Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions web/e2e/ui/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ test('base: VM detail opens with live facts and tabs', async ({ page }) => {
await expect(page.getByRole('link', { name: 'Snapshots', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'Console', exact: true })).toBeVisible();

// The flat toolbar: imperative verbs promoted, power deliberately absent
// (declarative here), delete demoted into the Actions menu.
// The flat toolbar: power first (declarative - the button STAGES a change),
// then the imperative verbs; delete demoted into the Actions menu.
await expect(page.getByRole('button', { name: 'Power off', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Restart', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Migrate', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Power (on|off)/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Migrate', exact: true }).first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Delete VM' })).toHaveCount(0);
await page.getByRole('button', { name: 'Power off', exact: true }).click();
// The stage lands as a toast + staged badge, never an immediate power change.
await expect(page.getByText(/Power Off staged for web-1/)).toBeVisible();
await expect(page.getByRole('button', { name: 'Staged' }).first()).toBeVisible();
await page.getByRole('button', { name: 'Actions' }).click();
await expect(page.getByRole('button', { name: 'Delete VM' })).toBeVisible();
await page.keyboard.press('Escape');
Expand Down
39 changes: 22 additions & 17 deletions web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,20 @@
:root[data-theme='dark'] {
color-scheme: dark;

/* surfaces */
--color-canvas: #0b1120; /* between slate-900 and slate-950 */
--color-panel: oklch(20.8% 0.042 265.755); /* slate-900 */
--color-inset: oklch(23.9% 0.041 262.5); /* a step above panel */
--color-inset-strong: oklch(27.9% 0.041 260.031); /* slate-800 */
--color-bar: oklch(12.9% 0.042 264.695); /* slate-950: the header stays darkest */

/* hairlines */
--color-line: oklch(27.9% 0.041 260.031); /* slate-800 */
--color-line-strong: oklch(37.2% 0.044 257.287); /* slate-700 */
--color-line-soft: oklch(24.5% 0.041 262);
/* surfaces: near-neutral darks (chroma <=0.016) - at slate's 0.04 every
surface reads navy and drowns the accent; color belongs to accent and
status, not the ground. Panel sits a clear step above canvas so cards
separate without shadows. */
--color-canvas: oklch(14.5% 0.012 262);
--color-panel: oklch(20% 0.014 262);
--color-inset: oklch(23.5% 0.015 262);
--color-inset-strong: oklch(27.5% 0.016 262);
--color-bar: oklch(12.5% 0.014 262); /* the header stays darkest */

/* hairlines: same near-neutral family, one step more visible than before */
--color-line: oklch(29% 0.018 262);
--color-line-strong: oklch(38% 0.02 260);
--color-line-soft: oklch(25% 0.016 262);

/* ink */
--color-ink: oklch(96.8% 0.007 247.896); /* slate-100 */
Expand All @@ -128,9 +131,10 @@
--color-accent-hover: oklch(62.3% 0.214 259.815); /* blue-500 */
--color-accent-ink: oklch(70.7% 0.165 254.624); /* blue-400 */

/* selection */
--color-select: oklch(37.9% 0.146 265.522 / 0.45); /* blue-800 @ 45% */
--color-select-soft: oklch(37.9% 0.146 265.522 / 0.25);
/* selection: solid tokens (alpha over varying grounds was unpredictable),
bright enough to read as the one blue plane on the neutral darks */
--color-select: oklch(32% 0.085 262);
--color-select-soft: oklch(25.5% 0.05 262);

/* accent tint for informational badges */
--color-accent-soft: oklch(28.2% 0.091 267.935); /* blue-950 */
Expand All @@ -139,9 +143,10 @@
--color-ok: oklch(76.5% 0.177 163.223); /* emerald-400 */
--color-warn: oklch(82.8% 0.189 84.429); /* amber-400 */
--color-danger: oklch(63.7% 0.237 25.331); /* red-500 */
--color-ok-soft: oklch(26.6% 0.065 152.934); /* green-950 */
--color-warn-soft: oklch(27.9% 0.077 45.635); /* amber-950 */
--color-danger-soft: oklch(25.8% 0.092 26.042); /* red-950 */
/* badge tints one step above the -950s, which vanished on the panels */
--color-ok-soft: oklch(31% 0.07 153);
--color-warn-soft: oklch(32% 0.08 60);
--color-danger-soft: oklch(30% 0.09 26);
--color-ok-ink: oklch(87.1% 0.15 154.449); /* green-300 */
--color-warn-ink: oklch(87.9% 0.169 91.605); /* amber-300 */
--color-danger-ink: oklch(80.8% 0.114 19.571); /* red-300 */
Expand Down
46 changes: 43 additions & 3 deletions web/src/lib/components/VMDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Play,
RotateCw,
Server,
Square,
} from 'lucide-svelte';
import { api, Unauthorized, type Change, type DraftItem, type Network, type VM } from '$lib/api';
import { adoptVM, manifestURL, runRuntimeAction, vmActions, type VMAction } from '$lib/actions';
Expand Down Expand Up @@ -126,12 +127,12 @@
// Actions menu. Power is deliberately absent - it is a declarative
// (staged, PR-gated) runStrategy change here, and a flat button would read
// as immediate. Pause is the instant containment verb instead.
const TOOLBAR: { id: VMAction['id']; icon: typeof Monitor; label: string }[] = [
{ id: 'console', icon: Monitor, label: 'Console' },
{ id: 'migrate', icon: ArrowRightLeft, label: 'Migrate' },
const TOOLBAR: { id: VMAction['id']; icon: typeof Monitor; label: string; sep?: boolean }[] = [
{ id: 'restart', icon: RotateCw, label: 'Restart' },
{ id: 'pause', icon: Pause, label: 'Pause' },
{ id: 'unpause', icon: Play, label: 'Unpause' },
{ id: 'console', icon: Monitor, label: 'Console', sep: true },
{ id: 'migrate', icon: ArrowRightLeft, label: 'Migrate' },
];
const toolbar = $derived.by(() => {
const v = vm;
Expand All @@ -143,6 +144,33 @@
});
const PROMOTED: VMAction['id'][] = ['console', 'migrate', 'restart', 'pause', 'unpause', 'edit'];

// Power is declarative here (a staged runStrategy change), but it still
// deserves a first-class button: hiding it inside Edit Settings made the
// most basic verb the hardest to find. The button stages and says so.
let powerBusy = $state(false);
async function stagePower() {
const target = vm;
if (!target?.sourceFile || powerBusy) return;
const to = target.power === 'On' ? 'Off' : 'On';
powerBusy = true;
try {
await api.stageEdit(target.namespace, target.name, {
sourceFile: target.sourceFile,
power: to,
});
onstaged?.();
ui.showToast(`Power ${to} staged for ${target.name} — applies when the PR merges.`, {
kind: 'success',
action: { label: 'Review & propose', run: () => ui.openChanges() },
});
} catch (e) {
if (e instanceof Unauthorized) return;
ui.showToast(friendlyError(e), { kind: 'error' });
} finally {
powerBusy = false;
}
}

function loadDrift(ns: string, name: string) {
// Drop a stale response if the selection moved while it was in flight -
// VM A's drift must never render under VM B.
Expand Down Expand Up @@ -310,8 +338,19 @@
</span>
</div>
<div class="mt-1.5 mb-1 flex flex-wrap items-center gap-0.5">
<button
onclick={stagePower}
disabled={!vm.sourceFile || powerBusy}
title={vm.sourceFile
? 'Stages a power change into a PR — nothing happens until it merges'
: 'Not in git — adopt this VM first'}
class="flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium text-ink-soft hover:bg-inset disabled:opacity-45 disabled:hover:bg-transparent"
>
{#if vm.power === 'On'}<Square size={13} /> Power off{:else}<Play size={13} /> Power on{/if}
</button>
{#each toolbar as t (t.id)}
{@const Icon = t.icon}
{#if t.sep}<span class="mx-1.5 h-4 w-px bg-line"></span>{/if}
<button
onclick={() => handleAction(t.action)}
disabled={!t.action.enabled(vm) || runtimeBusy}
Expand Down Expand Up @@ -393,6 +432,7 @@
onconsole={() => ontab?.('console')}
onmonitor={() => ontab?.('monitor')}
onedit={() => openEdit()}
onmigrate={() => (migrating = true)}
/>
{:else if tab === 'monitor'}
<!-- Monitor sub-rail: events + performance. -->
Expand Down
Loading
Loading