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
10 changes: 10 additions & 0 deletions web/e2e/ui/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ test('base: VM detail opens with live facts and tabs', async ({ page }) => {
await expect(page.locator('main').getByText('10.128.0.10').first()).toBeVisible();
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.
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: 'Delete VM' })).toHaveCount(0);
await page.getByRole('button', { name: 'Actions' }).click();
await expect(page.getByRole('button', { name: 'Delete VM' })).toBeVisible();
await page.keyboard.press('Escape');
});

test('empty: onboarding CTA instead of a dead-end blank tree', async ({ page }) => {
Expand Down
8 changes: 7 additions & 1 deletion web/src/lib/components/ActionMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@
let {
vm,
onpick,
exclude = [],
}: {
vm: VM;
onpick: (a: VMAction) => void;
// Action ids the host already promotes as flat buttons - kept out of the
// menu so a verb never appears twice in one header.
exclude?: VMAction['id'][];
} = $props();

const actions = $derived(vmActions.filter((a) => !exclude.includes(a.id)));
</script>

<div class="w-48 rounded border border-line bg-panel py-1 text-xs shadow-lg">
{#each vmActions as a (a.id)}
{#each actions as a (a.id)}
{#if a.sep}
<div class="my-1 border-t border-line-soft"></div>
{/if}
Expand Down
134 changes: 87 additions & 47 deletions web/src/lib/components/VMDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@

<script lang="ts">
import { untrack } from 'svelte';
import { ChevronDown, Pencil, Trash2 } from 'lucide-svelte';
import {
ArrowRightLeft,
ChevronDown,
Monitor,
Pause,
Pencil,
Play,
RotateCw,
Server,
} from 'lucide-svelte';
import { api, Unauthorized, type Change, type DraftItem, type Network, type VM } from '$lib/api';
import { adoptVM, manifestURL, runRuntimeAction, type VMAction } from '$lib/actions';
import { adoptVM, manifestURL, runRuntimeAction, vmActions, type VMAction } from '$lib/actions';
import { type EditSection } from '$lib/editform';
import { action } from '$lib/resource.svelte';
import { ui, type DetailAction } from '$lib/state/ui.svelte';
import { duration, friendlyError } from '$lib/format';
import { phaseTone } from '$lib/status';
import ActionMenu from './ActionMenu.svelte';
import Banner from './Banner.svelte';
import CloneModal from './CloneModal.svelte';
Expand All @@ -37,10 +47,10 @@
import MetricsPanel from './MetricsPanel.svelte';
import PendingBanner from './PendingBanner.svelte';
import Permissions from './Permissions.svelte';
import PowerDot from './PowerDot.svelte';
import Snapshots from './Snapshots.svelte';
import StagedBadge from './StagedBadge.svelte';
import StatusDot from './StatusDot.svelte';
import StatusPill from './StatusPill.svelte';
import SyncBadge from './SyncBadge.svelte';
import TabBar from './TabBar.svelte';
import VMConfigure from './VMConfigure.svelte';
Expand Down Expand Up @@ -112,6 +122,27 @@
// surface as toasts - identical feedback to the right-click context menu.
let runtimeBusy = $state(false);

// The flat toolbar: the everyday imperative verbs, promoted out of the
// 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' },
{ id: 'restart', icon: RotateCw, label: 'Restart' },
{ id: 'pause', icon: Pause, label: 'Pause' },
{ id: 'unpause', icon: Play, label: 'Unpause' },
];
const toolbar = $derived.by(() => {
const v = vm;
if (!v) return [];
return TOOLBAR.filter((t) => (v.paused ? t.id !== 'pause' : t.id !== 'unpause')).map((t) => ({
...t,
action: vmActions.find((a) => a.id === t.id)!,
}));
});
const PROMOTED: VMAction['id'][] = ['console', 'migrate', 'restart', 'pause', 'unpause', 'edit'];

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 @@ -261,58 +292,67 @@
{#if vm}
<div class="flex h-full flex-col">
<div class="border-b border-line px-4 pt-4">
<div class="mb-3 flex items-center gap-2">
<PowerDot power={vm.power} paused={vm.paused} />
<div class="flex items-center gap-2.5">
<Server size={20} class="shrink-0 text-ink-muted" />
<h2 class="text-lg font-semibold text-ink">{vm.name}</h2>
<span class="rounded bg-line px-1.5 py-0.5 text-xs text-ink-soft">{vm.namespace}</span>
<StatusPill
tone={phaseTone(vm.phase, vm.paused)}
label={vm.paused ? 'Paused' : (vm.phase ?? String(vm.power))}
/>
<SyncBadge sync={vm.sync} error={vm.syncError} />
{#if stagedItem}
<StagedBadge item={stagedItem} onopen={() => onstagedopen?.()} />
{/if}
<div class="ml-auto flex items-center gap-2">
<HeaderMenu align="right" panel={false}>
{#snippet trigger({ toggle })}
<button
onclick={toggle}
disabled={runtimeBusy}
title="All VM actions — runtime ops act immediately; config changes go through a PR"
class="flex items-center gap-1.5 rounded border border-line-strong px-2.5 py-1 text-xs font-medium text-ink-soft hover:bg-inset disabled:opacity-50"
>
Actions <ChevronDown size={13} />
</button>
{/snippet}
{#snippet children({ close })}
<ActionMenu
{vm}
onpick={(a) => {
close();
handleAction(a);
}}
/>
{/snippet}
</HeaderMenu>
<button
onclick={() => openEdit()}
disabled={!vm.sourceFile}
title={vm.sourceFile ? 'Edit settings' : 'Not in git — adopt this VM first'}
class="flex items-center gap-1.5 rounded border border-line-strong px-2.5 py-1 text-xs font-medium text-ink-soft hover:bg-inset disabled:opacity-50 disabled:hover:bg-transparent"
>
<Pencil size={13} /> Edit Settings
</button>
<span class="ml-1 flex min-w-0 items-center gap-1.5 truncate text-xs text-ink-faint">
{vm.namespace}{#if vm.nodeName}<span class="text-line-strong">/</span
>{vm.nodeName}{/if}{#if vm.instancetype}<span class="text-line-strong">/</span
>{vm.instancetype}{/if}
</span>
</div>
<div class="mt-1.5 mb-1 flex flex-wrap items-center gap-0.5">
{#each toolbar as t (t.id)}
{@const Icon = t.icon}
<button
onclick={() => {
deleting = true;
delOp.clear();
}}
disabled={!vm.sourceFile}
title={vm.sourceFile
? 'Delete this VM (stages a removal into Changes)'
: 'Not in git — adopt this VM first'}
class="flex items-center gap-1.5 rounded border border-danger/50 px-2.5 py-1 text-xs font-medium text-danger-ink hover:bg-danger-soft/60 disabled:opacity-50 disabled:hover:bg-transparent"
onclick={() => handleAction(t.action)}
disabled={!t.action.enabled(vm) || runtimeBusy}
title={t.action.title ?? ''}
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"
>
<Trash2 size={13} /> Delete VM
<Icon size={13} />
{t.label}
</button>
</div>
{/each}
<span class="mx-1.5 h-4 w-px bg-line"></span>
<button
onclick={() => openEdit()}
disabled={!vm.sourceFile}
title={vm.sourceFile ? 'Edit settings' : 'Not in git — adopt this VM first'}
class="flex items-center gap-1.5 rounded border border-line-strong px-2.5 py-1 text-xs font-medium text-ink-soft hover:bg-inset disabled:opacity-50 disabled:hover:bg-transparent"
>
<Pencil size={13} /> Edit Settings
</button>
<HeaderMenu align="right" panel={false}>
{#snippet trigger({ toggle })}
<button
onclick={toggle}
disabled={runtimeBusy}
title="Everything else — snapshots, clone, adopt, delete; config changes go through a PR"
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-50"
>
Actions <ChevronDown size={13} />
</button>
{/snippet}
{#snippet children({ close })}
<ActionMenu
{vm}
exclude={PROMOTED}
onpick={(a) => {
close();
handleAction(a);
}}
/>
{/snippet}
</HeaderMenu>
</div>
<TabBar
tabs={[
Expand Down
Loading