mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 04:09:13 +02:00
Compare commits
1 Commits
agent/lamb
...
codex/runt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06242c7b77 |
@@ -4,7 +4,6 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
@@ -12,15 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,24 +23,13 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
* Desktop-only controls for the daemon embedded in this Electron app. The
|
||||
* shared runtimes page renders this inside the selected local machine header.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
export function DaemonRuntimeActions() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -57,14 +37,8 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
@@ -76,10 +50,6 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
@@ -108,9 +78,6 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
@@ -119,8 +86,6 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
@@ -136,9 +101,6 @@ export function DaemonRuntimeCard() {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
@@ -162,106 +124,64 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,9 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,42 @@
|
||||
"hint": "This usually takes a few seconds. Your daemon is registering with the workspace."
|
||||
}
|
||||
},
|
||||
"machine": {
|
||||
"search_placeholder": "Search machines…",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"online": "Online",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"section_local": "Local",
|
||||
"section_remote": "Remote",
|
||||
"section_cloud": "Cloud",
|
||||
"this_machine": "This Mac",
|
||||
"local_badge": "Local · this machine",
|
||||
"runtime_count_one": "{{count}} runtime",
|
||||
"runtime_count_other": "{{count}} runtimes",
|
||||
"busy_count_one": "{{count}} busy",
|
||||
"busy_count_other": "{{count}} busy",
|
||||
"no_matches_title": "No machines",
|
||||
"no_matches_hint": "No machines match the current search or filter.",
|
||||
"select_machine": "Select a machine to inspect its runtimes.",
|
||||
"metrics": {
|
||||
"runtimes": "Runtimes",
|
||||
"runtimes_hint_one": "{{count}} online",
|
||||
"runtimes_hint_other": "{{count}} online",
|
||||
"health": "Health",
|
||||
"health_clear": "No issues",
|
||||
"health_issues_one": "{{count}} issue",
|
||||
"health_issues_other": "{{count}} issues",
|
||||
"workload": "Workload",
|
||||
"workload_value_idle": "Idle",
|
||||
"workload_idle": "All idle",
|
||||
"workload_hint": "{{running}} running · {{queued}} queued",
|
||||
"cli": "CLI",
|
||||
"cloud_worker": "Cloud worker",
|
||||
"local_daemon": "Local daemon"
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"online": {
|
||||
"label": "Online",
|
||||
|
||||
@@ -27,6 +27,38 @@
|
||||
"hint": "通常需要几秒钟。守护进程正在向工作区注册。"
|
||||
}
|
||||
},
|
||||
"machine": {
|
||||
"search_placeholder": "搜索机器...",
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"online": "在线",
|
||||
"issues": "异常"
|
||||
},
|
||||
"section_local": "本机",
|
||||
"section_remote": "远程",
|
||||
"section_cloud": "云端",
|
||||
"this_machine": "本机",
|
||||
"local_badge": "本地 · 这台机器",
|
||||
"runtime_count_other": "{{count}} 个运行时",
|
||||
"busy_count_other": "{{count}} 个忙碌",
|
||||
"no_matches_title": "没有机器",
|
||||
"no_matches_hint": "没有机器匹配当前搜索或筛选。",
|
||||
"select_machine": "选择一台机器查看它的运行时。",
|
||||
"metrics": {
|
||||
"runtimes": "运行时",
|
||||
"runtimes_hint_other": "{{count}} 个在线",
|
||||
"health": "健康度",
|
||||
"health_clear": "无异常",
|
||||
"health_issues_other": "{{count}} 个异常",
|
||||
"workload": "工作负载",
|
||||
"workload_value_idle": "空闲",
|
||||
"workload_idle": "全部空闲",
|
||||
"workload_hint": "{{running}} 个运行中 · {{queued}} 个排队中",
|
||||
"cli": "CLI",
|
||||
"cloud_worker": "云端 worker",
|
||||
"local_daemon": "本地守护进程"
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"online": {
|
||||
"label": "在线",
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
isVersionNewer,
|
||||
pctChange,
|
||||
} from "../utils";
|
||||
import { splitRuntimeName } from "./runtime-machines";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Per-row data assembled at the page level. The columns reach into
|
||||
@@ -63,18 +64,19 @@ export interface RuntimeRow {
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
// Column widths in px. Runtime, Health, and CLI grow together until the
|
||||
// user resizes them. Their `size` values still flow into table.getTotalSize()
|
||||
// to set the table's min-width, giving each grow column a real floor below
|
||||
// which the container scrolls horizontally instead of shrinking further.
|
||||
// Column widths in px. Runtime is the primary scanning column, so it keeps
|
||||
// the only grow slot and receives the extra width until the user resizes it.
|
||||
// The size values still flow into table.getTotalSize() to set the table's
|
||||
// min-width, giving each column a real floor below which the container
|
||||
// scrolls horizontally instead of shrinking further.
|
||||
const COL_WIDTHS = {
|
||||
runtime: 240,
|
||||
health: 200,
|
||||
owner: 60,
|
||||
agents: 100,
|
||||
workload: 140,
|
||||
cost: 100,
|
||||
cli: 140,
|
||||
runtime: 340,
|
||||
health: 150,
|
||||
owner: 72,
|
||||
agents: 92,
|
||||
workload: 120,
|
||||
cost: 96,
|
||||
cli: 112,
|
||||
// 60 = 16 left padding + 28 kebab + 16 right padding. Keeps the
|
||||
// kebab's right edge 16px from the card so it lines up with the
|
||||
// toolbar's px-4 right inset.
|
||||
@@ -110,7 +112,6 @@ export function createRuntimeColumns({
|
||||
id: "health",
|
||||
header: () => t(($) => $.list.col_health),
|
||||
size: COL_WIDTHS.health,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => (
|
||||
<HealthCell runtime={row.original.runtime} now={now} />
|
||||
),
|
||||
@@ -175,7 +176,6 @@ export function createRuntimeColumns({
|
||||
id: "cli",
|
||||
header: () => t(($) => $.list.col_cli),
|
||||
size: COL_WIDTHS.cli,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => (
|
||||
<CliCell
|
||||
runtime={row.original.runtime}
|
||||
@@ -210,25 +210,12 @@ export function createRuntimeColumns({
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Backend formats `runtime.name` as `"<base> (<hostname>)"`. Every runtime on
|
||||
// the same machine repeats the hostname suffix, so it dominates column width
|
||||
// while carrying near-zero scan value once seen on the first row. Split it
|
||||
// so the base name stays emphasised and the hostname renders muted.
|
||||
export function splitRuntimeName(name: string): {
|
||||
base: string;
|
||||
hostname: string | null;
|
||||
} {
|
||||
const m = name.match(/^(.+?)\s+\(([^)]+)\)$/);
|
||||
if (!m || !m[1] || !m[2]) return { base: name, hostname: null };
|
||||
return { base: m[1], hostname: m[2] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cell renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { base: baseName, hostname } = splitRuntimeName(runtime.name);
|
||||
const { base: baseName } = splitRuntimeName(runtime.name);
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
@@ -238,18 +225,6 @@ function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
|
||||
<span className="block min-w-0 shrink truncate text-sm font-medium">
|
||||
{baseName}
|
||||
</span>
|
||||
{hostname && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="block min-w-0 flex-1 basis-0 truncate text-xs text-muted-foreground/70">
|
||||
({hostname})
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{hostname}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<VisibilityBadge runtime={runtime} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
116
packages/views/runtimes/components/runtime-machines.test.ts
Normal file
116
packages/views/runtimes/components/runtime-machines.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
import {
|
||||
buildRuntimeMachines,
|
||||
filterRuntimeMachines,
|
||||
runtimeMachineCounts,
|
||||
splitRuntimeName,
|
||||
} from "./runtime-machines";
|
||||
|
||||
const NOW = new Date("2026-05-17T12:00:00Z").getTime();
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "runtime-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Claude (dev-machine.local)",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "dev-machine.local · claude 1.0.0",
|
||||
metadata: { cli_version: "0.3.0" },
|
||||
owner_id: "user-1",
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: new Date(NOW - 10_000).toISOString(),
|
||||
created_at: "2026-05-17T11:00:00Z",
|
||||
updated_at: "2026-05-17T11:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runtime machine grouping", () => {
|
||||
it("groups multiple provider runtimes by daemon id", () => {
|
||||
const machines = buildRuntimeMachines(
|
||||
[
|
||||
makeRuntime({ id: "rt-claude", provider: "claude", name: "Claude (dev.local)" }),
|
||||
makeRuntime({ id: "rt-codex", provider: "codex", name: "Codex (dev.local)" }),
|
||||
],
|
||||
{ now: NOW, localDaemonId: "daemon-1" },
|
||||
);
|
||||
|
||||
expect(machines).toHaveLength(1);
|
||||
expect(machines[0]).toMatchObject({
|
||||
id: "local:daemon-1",
|
||||
title: "dev.local",
|
||||
section: "local",
|
||||
isCurrent: true,
|
||||
onlineCount: 2,
|
||||
issueCount: 0,
|
||||
providerNames: ["claude", "codex"],
|
||||
});
|
||||
});
|
||||
|
||||
it("counts machines with any offline runtime as issues", () => {
|
||||
const machines = buildRuntimeMachines(
|
||||
[
|
||||
makeRuntime({ id: "rt-online", provider: "claude" }),
|
||||
makeRuntime({
|
||||
id: "rt-offline",
|
||||
provider: "codex",
|
||||
status: "offline",
|
||||
last_seen_at: new Date(NOW - 10 * 60_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
{ now: NOW },
|
||||
);
|
||||
|
||||
expect(runtimeMachineCounts(machines)).toEqual({
|
||||
all: 1,
|
||||
online: 1,
|
||||
issues: 1,
|
||||
});
|
||||
expect(filterRuntimeMachines(machines, "", "issues")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps cloud runtimes as cloud workers when they have no daemon", () => {
|
||||
const machines = buildRuntimeMachines(
|
||||
[
|
||||
makeRuntime({
|
||||
id: "cloud-1",
|
||||
daemon_id: null,
|
||||
runtime_mode: "cloud",
|
||||
provider: "codex",
|
||||
name: "Codex cloud",
|
||||
device_info: "",
|
||||
}),
|
||||
],
|
||||
{ now: NOW },
|
||||
);
|
||||
|
||||
expect(machines[0]).toMatchObject({
|
||||
id: "cloud:runtime:cloud-1",
|
||||
title: "Codex cloud",
|
||||
subtitle: "Cloud worker",
|
||||
section: "cloud",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRuntimeName", () => {
|
||||
it("separates daemon host suffix from provider name", () => {
|
||||
expect(splitRuntimeName("Claude (build-server-01)")).toEqual({
|
||||
base: "Claude",
|
||||
hostname: "build-server-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the full name when no host suffix exists", () => {
|
||||
expect(splitRuntimeName("Codex cloud")).toEqual({
|
||||
base: "Codex cloud",
|
||||
hostname: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
315
packages/views/runtimes/components/runtime-machines.ts
Normal file
315
packages/views/runtimes/components/runtime-machines.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { deriveRuntimeHealth, type RuntimeHealth } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
import { formatDeviceInfo } from "../utils";
|
||||
|
||||
export type RuntimeMachineSection = "local" | "remote" | "cloud";
|
||||
export type RuntimeMachineFilter = "all" | "online" | "issues";
|
||||
|
||||
export interface RuntimeWorkloadSummary {
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}
|
||||
|
||||
export interface RuntimeMachine {
|
||||
id: string;
|
||||
daemonId: string | null;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
deviceInfo: string | null;
|
||||
cliVersion: string | null;
|
||||
mode: AgentRuntime["runtime_mode"];
|
||||
section: RuntimeMachineSection;
|
||||
isCurrent: boolean;
|
||||
health: RuntimeHealth;
|
||||
runtimes: AgentRuntime[];
|
||||
onlineCount: number;
|
||||
issueCount: number;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
providerNames: string[];
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
interface RuntimeMachineOptions {
|
||||
now: number;
|
||||
localDaemonId?: string | null;
|
||||
localMachineName?: string | null;
|
||||
workloadByRuntimeId?: Map<string, RuntimeWorkloadSummary>;
|
||||
}
|
||||
|
||||
interface RuntimeMachineDraft {
|
||||
id: string;
|
||||
daemonId: string | null;
|
||||
mode: AgentRuntime["runtime_mode"];
|
||||
runtimes: AgentRuntime[];
|
||||
}
|
||||
|
||||
const HEALTH_SEVERITY: Record<RuntimeHealth, number> = {
|
||||
online: 0,
|
||||
recently_lost: 1,
|
||||
offline: 2,
|
||||
about_to_gc: 3,
|
||||
};
|
||||
|
||||
export function splitRuntimeName(name: string): {
|
||||
base: string;
|
||||
hostname: string | null;
|
||||
} {
|
||||
const m = name.match(/^(.+?)\s+\(([^)]+)\)$/);
|
||||
if (!m || !m[1] || !m[2]) return { base: name, hostname: null };
|
||||
return { base: m[1], hostname: m[2] };
|
||||
}
|
||||
|
||||
export function buildRuntimeMachines(
|
||||
runtimes: AgentRuntime[],
|
||||
options: RuntimeMachineOptions,
|
||||
): RuntimeMachine[] {
|
||||
const drafts = new Map<string, RuntimeMachineDraft>();
|
||||
|
||||
for (const runtime of runtimes) {
|
||||
const id = runtimeMachineId(runtime);
|
||||
const draft =
|
||||
drafts.get(id) ??
|
||||
({
|
||||
id,
|
||||
daemonId: runtime.daemon_id,
|
||||
mode: runtime.runtime_mode,
|
||||
runtimes: [],
|
||||
} satisfies RuntimeMachineDraft);
|
||||
draft.runtimes.push(runtime);
|
||||
drafts.set(id, draft);
|
||||
}
|
||||
|
||||
return Array.from(drafts.values())
|
||||
.map((draft) => finalizeRuntimeMachine(draft, options))
|
||||
.sort(compareRuntimeMachines);
|
||||
}
|
||||
|
||||
export function filterRuntimeMachines(
|
||||
machines: RuntimeMachine[],
|
||||
query: string,
|
||||
filter: RuntimeMachineFilter,
|
||||
): RuntimeMachine[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return machines.filter((machine) => {
|
||||
if (filter === "online" && machine.onlineCount === 0) return false;
|
||||
if (filter === "issues" && machine.issueCount === 0) return false;
|
||||
if (!q) return true;
|
||||
|
||||
const haystack = [
|
||||
machine.title,
|
||||
machine.subtitle,
|
||||
machine.deviceInfo,
|
||||
machine.daemonId,
|
||||
machine.providerNames.join(" "),
|
||||
machine.runtimes.map((runtime) => runtime.name).join(" "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimeMachineCounts(machines: RuntimeMachine[]): {
|
||||
all: number;
|
||||
online: number;
|
||||
issues: number;
|
||||
} {
|
||||
return {
|
||||
all: machines.length,
|
||||
online: machines.filter((machine) => machine.onlineCount > 0).length,
|
||||
issues: machines.filter((machine) => machine.issueCount > 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeRuntimeMachine(
|
||||
draft: RuntimeMachineDraft,
|
||||
options: RuntimeMachineOptions,
|
||||
): RuntimeMachine {
|
||||
const runtimes = [...draft.runtimes].sort((a, b) =>
|
||||
a.provider.localeCompare(b.provider),
|
||||
);
|
||||
const first = runtimes[0];
|
||||
const providerNames = Array.from(new Set(runtimes.map((r) => r.provider))).sort();
|
||||
const isCurrent =
|
||||
!!options.localDaemonId && draft.daemonId === options.localDaemonId;
|
||||
const title = machineTitle(runtimes, {
|
||||
isCurrent,
|
||||
localMachineName: options.localMachineName,
|
||||
});
|
||||
const deviceInfo = first ? formatDeviceInfo(first.device_info ?? null) : null;
|
||||
const subtitle = machineSubtitle({
|
||||
title,
|
||||
deviceInfo,
|
||||
daemonId: draft.daemonId,
|
||||
mode: draft.mode,
|
||||
});
|
||||
const healthByRuntime = runtimes.map((runtime) =>
|
||||
deriveRuntimeHealth(runtime, options.now),
|
||||
);
|
||||
const onlineCount = healthByRuntime.filter((h) => h === "online").length;
|
||||
const issueCount = runtimes.length - onlineCount;
|
||||
const health =
|
||||
onlineCount > 0
|
||||
? "online"
|
||||
: healthByRuntime.reduce<RuntimeHealth>(
|
||||
(worst, current) =>
|
||||
HEALTH_SEVERITY[current] > HEALTH_SEVERITY[worst] ? current : worst,
|
||||
"recently_lost",
|
||||
);
|
||||
const workload = runtimes.reduce(
|
||||
(sum, runtime) => {
|
||||
const entry = options.workloadByRuntimeId?.get(runtime.id);
|
||||
return {
|
||||
runningCount: sum.runningCount + (entry?.runningCount ?? 0),
|
||||
queuedCount: sum.queuedCount + (entry?.queuedCount ?? 0),
|
||||
};
|
||||
},
|
||||
{ runningCount: 0, queuedCount: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
id: draft.id,
|
||||
daemonId: draft.daemonId,
|
||||
title,
|
||||
subtitle,
|
||||
deviceInfo,
|
||||
cliVersion: commonCliVersion(runtimes),
|
||||
mode: draft.mode,
|
||||
section: isCurrent ? "local" : draft.mode === "cloud" ? "cloud" : "remote",
|
||||
isCurrent,
|
||||
health,
|
||||
runtimes,
|
||||
onlineCount,
|
||||
issueCount,
|
||||
runningCount: workload.runningCount,
|
||||
queuedCount: workload.queuedCount,
|
||||
providerNames,
|
||||
lastSeenAt: latestLastSeenAt(runtimes),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeMachineId(runtime: AgentRuntime): string {
|
||||
if (runtime.daemon_id) return `${runtime.runtime_mode}:${runtime.daemon_id}`;
|
||||
const deviceName = runtimeDeviceName(runtime);
|
||||
if (deviceName) return `${runtime.runtime_mode}:device:${deviceName}`;
|
||||
return `${runtime.runtime_mode}:runtime:${runtime.id}`;
|
||||
}
|
||||
|
||||
function runtimeDeviceName(runtime: AgentRuntime): string | null {
|
||||
const host = splitRuntimeName(runtime.name).hostname;
|
||||
if (host) return host;
|
||||
|
||||
const raw = runtime.device_info?.trim();
|
||||
if (!raw) return null;
|
||||
return raw.split(" · ")[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function machineTitle(
|
||||
runtimes: AgentRuntime[],
|
||||
options: { isCurrent: boolean; localMachineName?: string | null },
|
||||
): string {
|
||||
if (options.isCurrent && options.localMachineName) {
|
||||
return options.localMachineName;
|
||||
}
|
||||
|
||||
const first = runtimes[0];
|
||||
if (!first) return "Unknown machine";
|
||||
|
||||
const deviceName = runtimeDeviceName(first);
|
||||
if (deviceName) return deviceName;
|
||||
|
||||
if (first.runtime_mode === "cloud") {
|
||||
return `${capitalize(first.provider)} cloud`;
|
||||
}
|
||||
return first.daemon_id ? shortDaemonId(first.daemon_id) : "Unknown machine";
|
||||
}
|
||||
|
||||
function machineSubtitle({
|
||||
title,
|
||||
deviceInfo,
|
||||
daemonId,
|
||||
mode,
|
||||
}: {
|
||||
title: string;
|
||||
deviceInfo: string | null;
|
||||
daemonId: string | null;
|
||||
mode: AgentRuntime["runtime_mode"];
|
||||
}): string | null {
|
||||
const compact = compactDeviceInfo(deviceInfo, title);
|
||||
if (compact) return compact;
|
||||
if (daemonId) return `daemon ${shortDaemonId(daemonId)}`;
|
||||
return mode === "cloud" ? "Cloud worker" : null;
|
||||
}
|
||||
|
||||
function compactDeviceInfo(
|
||||
deviceInfo: string | null,
|
||||
title: string,
|
||||
): string | null {
|
||||
if (!deviceInfo) return null;
|
||||
const parts = deviceInfo
|
||||
.split(" · ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.filter((part) => part !== title);
|
||||
const primary = parts[0];
|
||||
if (!primary) return null;
|
||||
|
||||
const runtimeVersion = primary.match(/^(.+?)\s+\(([^)]+)\)$/);
|
||||
if (runtimeVersion?.[1] && runtimeVersion[2]) {
|
||||
return `${runtimeVersion[2]} ${runtimeVersion[1]}`;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
function latestLastSeenAt(runtimes: AgentRuntime[]): string | null {
|
||||
let latest: string | null = null;
|
||||
for (const runtime of runtimes) {
|
||||
if (!runtime.last_seen_at) continue;
|
||||
if (!latest || new Date(runtime.last_seen_at) > new Date(latest)) {
|
||||
latest = runtime.last_seen_at;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function commonCliVersion(runtimes: AgentRuntime[]): string | null {
|
||||
const versions = new Set<string>();
|
||||
for (const runtime of runtimes) {
|
||||
const version = runtime.metadata?.cli_version;
|
||||
if (typeof version === "string" && version.trim()) {
|
||||
versions.add(version.trim());
|
||||
}
|
||||
}
|
||||
return versions.size === 1 ? Array.from(versions)[0] ?? null : null;
|
||||
}
|
||||
|
||||
function shortDaemonId(daemonId: string): string {
|
||||
return daemonId.length > 12 ? `${daemonId.slice(0, 8)}...` : daemonId;
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
if (!value) return "Runtime";
|
||||
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
||||
}
|
||||
|
||||
function compareRuntimeMachines(a: RuntimeMachine, b: RuntimeMachine): number {
|
||||
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
|
||||
const sectionDelta = sectionRank(a.section) - sectionRank(b.section);
|
||||
if (sectionDelta !== 0) return sectionDelta;
|
||||
if (a.onlineCount !== b.onlineCount) return b.onlineCount - a.onlineCount;
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
|
||||
function sectionRank(section: RuntimeMachineSection): number {
|
||||
switch (section) {
|
||||
case "local":
|
||||
return 0;
|
||||
case "remote":
|
||||
return 1;
|
||||
case "cloud":
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Search, Server } from "lucide-react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import {
|
||||
Cloud,
|
||||
Monitor,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { runtimeListOptions, runtimeKeys } from "@multica/core/runtimes/queries";
|
||||
import { useUpdatableRuntimeIds } from "@multica/core/runtimes/hooks";
|
||||
import { deriveRuntimeHealth } from "@multica/core/runtimes";
|
||||
import { useWSEvent } from "@multica/core/realtime";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@multica/ui/components/ui/resizable";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { ConnectRemoteDialog } from "./connect-remote-dialog";
|
||||
import { RuntimeList } from "./runtime-list";
|
||||
import { ProviderLogo } from "./provider-logo";
|
||||
import { RuntimeList, buildWorkloadIndex } from "./runtime-list";
|
||||
import {
|
||||
buildRuntimeMachines,
|
||||
filterRuntimeMachines,
|
||||
runtimeMachineCounts,
|
||||
type RuntimeMachine,
|
||||
type RuntimeMachineFilter,
|
||||
} from "./runtime-machines";
|
||||
import { HealthDot, HealthIcon, useHealthLabel } from "./shared";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
type HealthFilter = "all" | "online" | "recently_lost" | "offline" | "about_to_gc";
|
||||
|
||||
const HEALTH_ORDER: HealthFilter[] = [
|
||||
"all",
|
||||
"online",
|
||||
"recently_lost",
|
||||
"offline",
|
||||
"about_to_gc",
|
||||
];
|
||||
|
||||
// Dot tokens stay in code — labels/descriptions flow through useT.
|
||||
const HEALTH_DOT: Record<Exclude<HealthFilter, "all">, string> = {
|
||||
online: "bg-success",
|
||||
recently_lost: "bg-warning",
|
||||
offline: "bg-muted-foreground/40",
|
||||
about_to_gc: "bg-destructive",
|
||||
};
|
||||
const MACHINE_FILTERS: RuntimeMachineFilter[] = ["all", "online", "issues"];
|
||||
|
||||
interface RuntimesPageProps {
|
||||
/** Desktop-only slot rendered above the runtimes table (e.g. local daemon card) */
|
||||
topSlot?: React.ReactNode;
|
||||
/** Desktop-only daemon id used to mark the row for this Mac. */
|
||||
localDaemonId?: string | null;
|
||||
/** Desktop-only friendly device name for the local daemon. */
|
||||
localMachineName?: string | null;
|
||||
/** Desktop-only controls shown when the local machine is selected. */
|
||||
localMachineActions?: React.ReactNode;
|
||||
/**
|
||||
* Desktop-only signal: the bundled daemon is still booting / hasn't
|
||||
* registered with the server yet. Forwarded so the empty state can show
|
||||
@@ -64,23 +70,32 @@ function useNowTick(intervalMs = 30_000): number {
|
||||
return now;
|
||||
}
|
||||
|
||||
export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {}) {
|
||||
export function RuntimesPage({
|
||||
localDaemonId,
|
||||
localMachineName,
|
||||
localMachineActions,
|
||||
bootstrapping,
|
||||
}: RuntimesPageProps = {}) {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const [scope, setScope] = useState<RuntimeFilter>("mine");
|
||||
const [healthFilter, setHealthFilter] = useState<HealthFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [machineFilter, setMachineFilter] =
|
||||
useState<RuntimeMachineFilter>("all");
|
||||
const [machineSearch, setMachineSearch] = useState("");
|
||||
const [selectedMachineId, setSelectedMachineId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [showConnectDialog, setShowConnectDialog] = useState(false);
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_runtimes_layout",
|
||||
});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// One unified cache per workspace: scope (Mine/All) is a view filter, not
|
||||
// a fetch dimension. Splitting on owner used to give us two TanStack cache
|
||||
// slots holding independent snapshots of the same runtime — switching scope
|
||||
// surfaced stale `last_seen_at` from whichever slot was older.
|
||||
const { data: runtimes = [], isLoading: fetching } = useQuery(
|
||||
runtimeListOptions(wsId),
|
||||
);
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
const handleDaemonEvent = useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
@@ -90,46 +105,47 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
const updatableIds = useUpdatableRuntimeIds(wsId);
|
||||
const now = useNowTick();
|
||||
|
||||
// Apply scope first, then everything downstream (health counts, list filter)
|
||||
// operates on the post-scope set — so chip counts and filter results stay
|
||||
// consistent with what the user sees.
|
||||
const scopedRuntimes = useMemo(() => {
|
||||
if (scope !== "mine") return runtimes;
|
||||
if (!currentUserId) return [];
|
||||
return runtimes.filter((r) => r.owner_id === currentUserId);
|
||||
}, [runtimes, scope, currentUserId]);
|
||||
const workloadIndex = useMemo(
|
||||
() => buildWorkloadIndex(agents, snapshot),
|
||||
[agents, snapshot],
|
||||
);
|
||||
|
||||
const healthCounts = useMemo(() => {
|
||||
const counts: Record<Exclude<HealthFilter, "all">, number> = {
|
||||
online: 0,
|
||||
recently_lost: 0,
|
||||
offline: 0,
|
||||
about_to_gc: 0,
|
||||
};
|
||||
for (const r of scopedRuntimes) {
|
||||
counts[deriveRuntimeHealth(r, now)] += 1;
|
||||
const machines = useMemo(
|
||||
() =>
|
||||
buildRuntimeMachines(runtimes, {
|
||||
now,
|
||||
localDaemonId,
|
||||
localMachineName,
|
||||
workloadByRuntimeId: workloadIndex,
|
||||
}),
|
||||
[runtimes, now, localDaemonId, localMachineName, workloadIndex],
|
||||
);
|
||||
|
||||
const machineCounts = useMemo(() => runtimeMachineCounts(machines), [machines]);
|
||||
|
||||
const filteredMachines = useMemo(
|
||||
() => filterRuntimeMachines(machines, machineSearch, machineFilter),
|
||||
[machines, machineSearch, machineFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredMachines.length === 0) {
|
||||
if (selectedMachineId !== null) setSelectedMachineId(null);
|
||||
return;
|
||||
}
|
||||
return counts;
|
||||
}, [scopedRuntimes, now]);
|
||||
if (!selectedMachineId || !filteredMachines.some((m) => m.id === selectedMachineId)) {
|
||||
setSelectedMachineId(filteredMachines[0]?.id ?? null);
|
||||
}
|
||||
}, [filteredMachines, selectedMachineId]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return scopedRuntimes.filter((r) => {
|
||||
if (healthFilter !== "all") {
|
||||
if (deriveRuntimeHealth(r, now) !== healthFilter) return false;
|
||||
}
|
||||
if (q) {
|
||||
const haystack = `${r.name} ${r.provider} ${r.device_info ?? ""}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [scopedRuntimes, healthFilter, search, now]);
|
||||
const selectedMachine =
|
||||
machines.find((machine) => machine.id === selectedMachineId) ??
|
||||
filteredMachines[0] ??
|
||||
null;
|
||||
|
||||
if (isLoading || fetching) return <RuntimesPageSkeleton />;
|
||||
|
||||
const totalCount = runtimes.length;
|
||||
const scopedTotal = scopedRuntimes.length;
|
||||
const showEmpty = totalCount === 0 && !bootstrapping;
|
||||
|
||||
return (
|
||||
@@ -139,39 +155,76 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
onConnectRemote={() => setShowConnectDialog(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
|
||||
{topSlot}
|
||||
|
||||
{showEmpty ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
<CardToolbar
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
scope={scope}
|
||||
setScope={setScope}
|
||||
/>
|
||||
<FilterChipsRow
|
||||
healthFilter={healthFilter}
|
||||
setHealthFilter={setHealthFilter}
|
||||
healthCounts={healthCounts}
|
||||
total={scopedTotal}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<NoMatchesState search={search} healthFilter={healthFilter} scope={scope} bootstrapping={bootstrapping} />
|
||||
) : (
|
||||
<RuntimeList
|
||||
runtimes={filtered}
|
||||
{showEmpty ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col border-t bg-background">
|
||||
<MachineSidebar
|
||||
machines={filteredMachines}
|
||||
totalMachines={machines.length}
|
||||
counts={machineCounts}
|
||||
selectedMachineId={selectedMachine?.id ?? null}
|
||||
search={machineSearch}
|
||||
setSearch={setMachineSearch}
|
||||
filter={machineFilter}
|
||||
setFilter={setMachineFilter}
|
||||
onSelect={setSelectedMachineId}
|
||||
/>
|
||||
<MachineDetail
|
||||
machine={selectedMachine}
|
||||
updatableIds={updatableIds}
|
||||
now={now}
|
||||
bootstrapping={bootstrapping}
|
||||
actions={
|
||||
selectedMachine?.isCurrent ? localMachineActions : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 border-t bg-background">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="min-h-0 flex-1"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="machines"
|
||||
defaultSize={300}
|
||||
minSize={240}
|
||||
maxSize={420}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
>
|
||||
<MachineSidebar
|
||||
machines={filteredMachines}
|
||||
totalMachines={machines.length}
|
||||
counts={machineCounts}
|
||||
selectedMachineId={selectedMachine?.id ?? null}
|
||||
search={machineSearch}
|
||||
setSearch={setMachineSearch}
|
||||
filter={machineFilter}
|
||||
setFilter={setMachineFilter}
|
||||
onSelect={setSelectedMachineId}
|
||||
className="h-full border-b-0 border-r"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="detail" minSize="45%">
|
||||
<MachineDetail
|
||||
machine={selectedMachine}
|
||||
updatableIds={updatableIds}
|
||||
now={now}
|
||||
bootstrapping={bootstrapping}
|
||||
actions={
|
||||
selectedMachine?.isCurrent ? localMachineActions : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConnectDialog && (
|
||||
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
|
||||
@@ -223,194 +276,409 @@ function PageHeaderBar({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro block — sits between the page header and the table card. Mirrors
|
||||
// Skills' two-paragraph pattern: a one-liner plus a brand-accented callout
|
||||
// pinning down a single non-obvious fact about the surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card toolbar — search + scope toggle + live indicator. Skills puts its
|
||||
// search and filter buttons here; we follow the same convention so the card
|
||||
// owns its own interactions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CardToolbar({
|
||||
function MachineSidebar({
|
||||
machines,
|
||||
totalMachines,
|
||||
counts,
|
||||
selectedMachineId,
|
||||
search,
|
||||
setSearch,
|
||||
scope,
|
||||
setScope,
|
||||
filter,
|
||||
setFilter,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
machines: RuntimeMachine[];
|
||||
totalMachines: number;
|
||||
counts: { all: number; online: number; issues: number };
|
||||
selectedMachineId: string | null;
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
scope: RuntimeFilter;
|
||||
setScope: (v: RuntimeFilter) => void;
|
||||
setSearch: (value: string) => void;
|
||||
filter: RuntimeMachineFilter;
|
||||
setFilter: (value: RuntimeMachineFilter) => void;
|
||||
onSelect: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t(($) => $.page.search_placeholder)}
|
||||
className="h-8 w-64 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ScopeSegment value={scope} onChange={setScope} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="ml-auto inline-flex cursor-default select-none items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="relative inline-flex h-2 w-2 items-center justify-center">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success/60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
|
||||
</span>
|
||||
{t(($) => $.page.live)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{t(($) => $.page.live_tooltip)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const sections = [
|
||||
{
|
||||
key: "local" as const,
|
||||
label: t(($) => $.machine.section_local),
|
||||
machines: machines.filter((machine) => machine.section === "local"),
|
||||
},
|
||||
{
|
||||
key: "remote" as const,
|
||||
label: t(($) => $.machine.section_remote),
|
||||
machines: machines.filter((machine) => machine.section === "remote"),
|
||||
},
|
||||
{
|
||||
key: "cloud" as const,
|
||||
label: t(($) => $.machine.section_cloud),
|
||||
machines: machines.filter((machine) => machine.section === "cloud"),
|
||||
},
|
||||
].filter((section) => section.machines.length > 0);
|
||||
|
||||
function ScopeSegment({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RuntimeFilter;
|
||||
onChange: (v: RuntimeFilter) => void;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
onClick={() => onChange("mine")}
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
value === "mine"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(($) => $.page.scope_mine)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange("all")}
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
value === "all"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(($) => $.page.scope_all)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter chips — 4 health states + "All", each with a tooltip explaining
|
||||
// what the state actually means in operational terms. Counts come from the
|
||||
// pre-filter set so users can see "what would happen" before clicking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FilterChipsRow({
|
||||
healthFilter,
|
||||
setHealthFilter,
|
||||
healthCounts,
|
||||
total,
|
||||
}: {
|
||||
healthFilter: HealthFilter;
|
||||
setHealthFilter: (v: HealthFilter) => void;
|
||||
healthCounts: Record<Exclude<HealthFilter, "all">, number>;
|
||||
total: number;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
||||
{HEALTH_ORDER.map((key) => {
|
||||
const count = key === "all" ? total : healthCounts[key];
|
||||
const label =
|
||||
key === "all"
|
||||
? t(($) => $.page.filter_all)
|
||||
: t(($) => $.health[key].label);
|
||||
const description =
|
||||
key === "all"
|
||||
? t(($) => $.page.filter_all_description)
|
||||
: t(($) => $.health[key].description);
|
||||
return (
|
||||
<HealthChip
|
||||
key={key}
|
||||
active={healthFilter === key}
|
||||
onClick={() => setHealthFilter(key)}
|
||||
label={label}
|
||||
count={count}
|
||||
dotClass={key === "all" ? undefined : HEALTH_DOT[key]}
|
||||
description={description}
|
||||
<aside
|
||||
className={cn(
|
||||
"flex min-h-0 shrink-0 flex-col border-b bg-muted/20",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 border-b bg-background p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t(($) => $.machine.search_placeholder)}
|
||||
className="h-9 pl-8 text-sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 overflow-x-auto">
|
||||
{MACHINE_FILTERS.map((key) => (
|
||||
<MachineFilterChip
|
||||
key={key}
|
||||
active={filter === key}
|
||||
onClick={() => setFilter(key)}
|
||||
label={t(($) => $.machine.filters[key])}
|
||||
count={counts[key]}
|
||||
tone={key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto py-2">
|
||||
{sections.length > 0 ? (
|
||||
sections.map((section) => (
|
||||
<div key={section.key} className="mb-3 last:mb-0">
|
||||
<div className="mb-1 flex items-center gap-2 px-4 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{section.label}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div>
|
||||
{section.machines.map((machine) => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
machine={machine}
|
||||
active={machine.id === selectedMachineId}
|
||||
onClick={() => onSelect(machine.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{t(($) => $.machine.no_matches_title)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{totalMachines > 0
|
||||
? t(($) => $.machine.no_matches_hint)
|
||||
: t(($) => $.page.bootstrapping.hint)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors Agents' `PresenceChip` — same `Button outline + size sm` shell so
|
||||
// any future polish to the chip token cascades to both surfaces. The active
|
||||
// state uses `bg-accent text-accent-foreground hover:bg-accent/80`, matching
|
||||
// Skills' filter chip selection.
|
||||
function HealthChip({
|
||||
function MachineFilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
dotClass,
|
||||
description,
|
||||
tone,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
dotClass?: string;
|
||||
description: string;
|
||||
tone: RuntimeMachineFilter;
|
||||
}) {
|
||||
const dotClass =
|
||||
tone === "online"
|
||||
? "bg-success"
|
||||
: tone === "issues"
|
||||
? "bg-warning"
|
||||
: "bg-muted-foreground/40";
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"h-7 gap-1.5 px-2 text-xs",
|
||||
active
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tone !== "all" && <span className={cn("h-1.5 w-1.5 rounded-full", dotClass)} />}
|
||||
<span>{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground/70">
|
||||
{count}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineRow({
|
||||
machine,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
machine: RuntimeMachine;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
const Icon = machine.section === "cloud" ? Cloud : Monitor;
|
||||
const busyCount = machine.runningCount + machine.queuedCount;
|
||||
const runtimeCount = t(($) => $.machine.runtime_count, {
|
||||
count: machine.runtimes.length,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex w-full min-w-0 items-start gap-3 px-4 py-2.5 text-left transition-colors",
|
||||
active ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span className="relative mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<HealthDot
|
||||
health={machine.health}
|
||||
className="absolute -bottom-0.5 -right-0.5 ring-2 ring-background"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{machine.title}</span>
|
||||
{machine.isCurrent && (
|
||||
<span className="shrink-0 rounded bg-foreground px-1.5 py-0.5 text-[10px] font-medium text-background">
|
||||
{t(($) => $.machine.this_machine)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{machine.subtitle && (
|
||||
<span className="mt-0.5 block truncate font-mono text-xs text-muted-foreground">
|
||||
{machine.subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-2 flex min-w-0 items-center gap-1.5">
|
||||
<ProviderIconStack providers={machine.providerNames} />
|
||||
{busyCount > 0 ? (
|
||||
<span className="ml-auto shrink-0 text-xs font-medium text-primary">
|
||||
{t(($) => $.machine.busy_count, { count: busyCount })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{runtimeCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderIconStack({ providers }: { providers: string[] }) {
|
||||
const visible = providers.slice(0, 4);
|
||||
const extra = providers.length - visible.length;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center -space-x-1">
|
||||
{visible.map((provider) => (
|
||||
<span
|
||||
key={provider}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded bg-background ring-1 ring-border"
|
||||
>
|
||||
<ProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground ring-1 ring-border">
|
||||
+{extra}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineDetail({
|
||||
machine,
|
||||
updatableIds,
|
||||
now,
|
||||
bootstrapping,
|
||||
actions,
|
||||
}: {
|
||||
machine: RuntimeMachine | null;
|
||||
updatableIds: Set<string>;
|
||||
now: number;
|
||||
bootstrapping?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
const healthLabel = useHealthLabel();
|
||||
|
||||
if (!machine) {
|
||||
return (
|
||||
<main className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
{bootstrapping ? (
|
||||
<>
|
||||
<Server className="h-8 w-8 animate-pulse text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t(($) => $.page.bootstrapping.title)}
|
||||
</p>
|
||||
<p className="mt-1 max-w-xs text-xs text-muted-foreground/70">
|
||||
{t(($) => $.page.bootstrapping.hint)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Monitor className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t(($) => $.machine.select_machine)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineRuntimeCount = machine.onlineCount;
|
||||
const issueCount = machine.issueCount;
|
||||
const workloadLabel =
|
||||
machine.runningCount > 0 || machine.queuedCount > 0
|
||||
? t(($) => $.machine.metrics.workload_hint, {
|
||||
running: machine.runningCount,
|
||||
queued: machine.queuedCount,
|
||||
})
|
||||
: t(($) => $.machine.metrics.workload_idle);
|
||||
const runtimeTotal = machine.runtimes.length;
|
||||
const metaItems = [machine.subtitle].filter(Boolean);
|
||||
|
||||
return (
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b bg-background px-5 py-5">
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="truncate text-xl font-semibold tracking-tight">
|
||||
{machine.title}
|
||||
</h2>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1 text-xs text-muted-foreground">
|
||||
<HealthIcon health={machine.health} />
|
||||
{healthLabel(machine.health)}
|
||||
</span>
|
||||
{machine.isCurrent && (
|
||||
<span className="rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background">
|
||||
{t(($) => $.machine.local_badge)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-md border bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
{machine.section === "cloud"
|
||||
? t(($) => $.machine.section_cloud)
|
||||
: machine.section === "local"
|
||||
? t(($) => $.machine.section_local)
|
||||
: t(($) => $.machine.section_remote)}
|
||||
</span>
|
||||
</div>
|
||||
{metaItems.length > 0 && (
|
||||
<p className="mt-2 max-w-4xl truncate text-xs text-muted-foreground">
|
||||
{metaItems.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="shrink-0">{actions}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid overflow-hidden rounded-lg border bg-muted/20 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MachineMetric
|
||||
label={t(($) => $.machine.metrics.runtimes)}
|
||||
value={String(runtimeTotal)}
|
||||
hint={t(($) => $.machine.metrics.runtimes_hint, {
|
||||
count: onlineRuntimeCount,
|
||||
})}
|
||||
/>
|
||||
<MachineMetric
|
||||
label={t(($) => $.machine.metrics.health)}
|
||||
value={healthLabel(machine.health)}
|
||||
hint={
|
||||
issueCount > 0
|
||||
? t(($) => $.machine.metrics.health_issues, { count: issueCount })
|
||||
: t(($) => $.machine.metrics.health_clear)
|
||||
}
|
||||
/>
|
||||
<MachineMetric
|
||||
label={t(($) => $.machine.metrics.workload)}
|
||||
value={
|
||||
machine.runningCount > 0 || machine.queuedCount > 0
|
||||
? String(machine.runningCount + machine.queuedCount)
|
||||
: t(($) => $.machine.metrics.workload_value_idle)
|
||||
}
|
||||
hint={workloadLabel}
|
||||
/>
|
||||
<MachineMetric
|
||||
label={t(($) => $.machine.metrics.cli)}
|
||||
value={machine.cliVersion ?? "—"}
|
||||
hint={
|
||||
machine.mode === "cloud"
|
||||
? t(($) => $.machine.metrics.cloud_worker)
|
||||
: t(($) => $.machine.metrics.local_daemon)
|
||||
}
|
||||
mono={!!machine.cliVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuntimeList
|
||||
runtimes={machine.runtimes}
|
||||
updatableIds={updatableIds}
|
||||
now={now}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineMetric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={
|
||||
active
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{dotClass && (
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground/70">
|
||||
{count}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{description}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0 border-b px-4 py-3 last:border-b-0 sm:odd:border-r lg:border-b-0 lg:border-r lg:last:border-r-0">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 truncate text-base font-semibold tabular-nums",
|
||||
mono && "font-mono text-sm",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state — shown when zero runtimes have ever registered in this
|
||||
// workspace. Different from "filter matches nothing" (NoMatchesState).
|
||||
// workspace.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
|
||||
@@ -438,57 +706,8 @@ function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No matches state — runtimes exist but the current filter combination
|
||||
// hides all of them. Keeps the user oriented by reflecting *which* filters
|
||||
// are in play.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoMatchesState({
|
||||
search,
|
||||
healthFilter,
|
||||
scope,
|
||||
bootstrapping,
|
||||
}: {
|
||||
search: string;
|
||||
healthFilter: HealthFilter;
|
||||
scope: RuntimeFilter;
|
||||
bootstrapping?: boolean;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
if (bootstrapping) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center">
|
||||
<Server className="h-8 w-8 animate-pulse text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.page.bootstrapping.title)}</p>
|
||||
<p className="max-w-xs text-xs text-muted-foreground/70">
|
||||
{t(($) => $.page.bootstrapping.hint)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasSearch = search.length > 0;
|
||||
const hasHealthFilter = healthFilter !== "all";
|
||||
const hasScope = scope === "mine";
|
||||
const filterSuffix = hasHealthFilter || hasScope ? t(($) => $.page.no_matches.with_query_filter_suffix) : "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm">{t(($) => $.page.no_matches.title)}</p>
|
||||
<p className="max-w-xs text-xs">
|
||||
{hasSearch
|
||||
? t(($) => $.page.no_matches.with_query, { query: search, filterSuffix })
|
||||
: t(($) => $.page.no_matches.no_query)}
|
||||
{t(($) => $.page.no_matches.try_widening)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton — laid out the same as the real page (header + intro
|
||||
// + card) so the layout doesn't jump on first paint.
|
||||
// Loading skeleton — laid out like the split runtime page so the layout
|
||||
// does not jump on first paint.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RuntimesPageSkeleton() {
|
||||
@@ -497,18 +716,35 @@ function RuntimesPageSkeleton() {
|
||||
<PageHeader className="justify-between px-5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</PageHeader>
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
|
||||
<div className="space-y-3 pl-4">
|
||||
<Skeleton className="h-5 w-full max-w-2xl rounded-md" />
|
||||
<Skeleton className="h-14 w-full max-w-3xl rounded-md" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-8 w-64 rounded-md" />
|
||||
<div className="flex min-h-0 flex-1 border-t">
|
||||
<div className="hidden w-[300px] shrink-0 border-r p-3 md:block">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Skeleton className="h-7 w-16 rounded-md" />
|
||||
<Skeleton className="h-7 w-20 rounded-md" />
|
||||
<Skeleton className="h-7 w-20 rounded-md" />
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b p-5">
|
||||
<Skeleton className="h-6 w-64 rounded-md" />
|
||||
<Skeleton className="mt-3 h-4 w-full max-w-2xl rounded-md" />
|
||||
<div className="mt-5 grid gap-px overflow-hidden rounded-lg border sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-none" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 border-b px-4 py-2">
|
||||
<Skeleton className="h-8 w-40 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2 p-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user