mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
MUL-4781: keep machine CLI status visible
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -21,6 +21,7 @@ function makeMachine(
|
||||
subtitle: "x86_64 macOS",
|
||||
deviceInfo: "dev.local · x86_64 macOS",
|
||||
cliVersion: "1.0.0",
|
||||
launchedBy: null,
|
||||
mode: "local",
|
||||
section: "local",
|
||||
isCurrent: true,
|
||||
|
||||
@@ -10,7 +10,11 @@ const mockUpdateSection = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./update-section", () => ({
|
||||
UpdateSection: (props: Record<string, unknown>) => {
|
||||
mockUpdateSection(props);
|
||||
return <button type="button">Update</button>;
|
||||
return props.runtimeId ? (
|
||||
<button type="button">Update</button>
|
||||
) : (
|
||||
<span>CLI status</span>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,6 +42,11 @@ function runtime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
}
|
||||
|
||||
function machine(runtimes: AgentRuntime[]): RuntimeMachine {
|
||||
const launchedBy = runtimes
|
||||
.filter((item) => item.status === "online")
|
||||
.map((item) => item.metadata?.launched_by)
|
||||
.find((value): value is string => typeof value === "string");
|
||||
|
||||
return {
|
||||
id: "local:daemon-1",
|
||||
daemonId: "daemon-1",
|
||||
@@ -45,6 +54,7 @@ function machine(runtimes: AgentRuntime[]): RuntimeMachine {
|
||||
subtitle: null,
|
||||
deviceInfo: "dev.local",
|
||||
cliVersion: "0.3.17",
|
||||
launchedBy: launchedBy ?? null,
|
||||
mode: "local",
|
||||
section: "remote",
|
||||
isCurrent: false,
|
||||
@@ -92,18 +102,31 @@ describe("MachineCliSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows read-only machine CLI version when the viewer owns no runtime", () => {
|
||||
it("shows read-only machine CLI status when the viewer owns no runtime", () => {
|
||||
render(
|
||||
<MachineCliSection
|
||||
machine={machine([runtime({ owner_id: "user-2" })])}
|
||||
machine={machine([
|
||||
runtime({
|
||||
owner_id: "user-2",
|
||||
metadata: {
|
||||
cli_version: "0.3.17",
|
||||
launched_by: "desktop",
|
||||
},
|
||||
}),
|
||||
])}
|
||||
currentUserId="user-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("CLI 0.3.17")).toBeInTheDocument();
|
||||
expect(screen.getByText("CLI status")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Update" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockUpdateSection).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSection).toHaveBeenCalledWith({
|
||||
runtimeId: null,
|
||||
currentVersion: "0.3.17",
|
||||
isOnline: false,
|
||||
launchedBy: "desktop",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,6 @@ import type { AgentRuntime } from "@multica/core/types";
|
||||
import type { RuntimeMachine } from "./runtime-machines";
|
||||
import { UpdateSection } from "./update-section";
|
||||
|
||||
function launchedBy(runtime: AgentRuntime): string | null {
|
||||
const value = runtime.metadata?.launched_by;
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one viewer-owned runtime as the command channel for a machine-wide
|
||||
* daemon update. An online runtime wins so the daemon can receive the request
|
||||
@@ -34,18 +29,30 @@ export function MachineCliSection({
|
||||
}) {
|
||||
const updateRuntime = machineUpdateRuntime(machine, currentUserId);
|
||||
|
||||
if (!updateRuntime) {
|
||||
if (machine.mode !== "local") {
|
||||
return machine.cliVersion ? (
|
||||
<span className="font-mono">CLI {machine.cliVersion}</span>
|
||||
) : null;
|
||||
}
|
||||
|
||||
// A viewer's ability to send an update command must not gate the
|
||||
// machine-level version and manager information. The only local machine
|
||||
// without anything to report is Desktop's synthesized stopped-daemon row.
|
||||
if (
|
||||
!updateRuntime &&
|
||||
machine.runtimes.length === 0 &&
|
||||
!machine.cliVersion &&
|
||||
!machine.launchedBy
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdateSection
|
||||
runtimeId={updateRuntime.id}
|
||||
runtimeId={updateRuntime?.id ?? null}
|
||||
currentVersion={machine.cliVersion}
|
||||
isOnline={updateRuntime.status === "online"}
|
||||
launchedBy={launchedBy(updateRuntime)}
|
||||
isOnline={updateRuntime?.status === "online"}
|
||||
launchedBy={machine.launchedBy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,30 @@ describe("runtime machine grouping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the online daemon CLI version instead of a stale offline report", () => {
|
||||
const machines = buildRuntimeMachines(
|
||||
[
|
||||
makeRuntime({
|
||||
id: "rt-online",
|
||||
provider: "claude",
|
||||
metadata: { cli_version: "0.4.0", launched_by: "desktop" },
|
||||
}),
|
||||
makeRuntime({
|
||||
id: "rt-stale",
|
||||
provider: "copilot",
|
||||
status: "offline",
|
||||
last_seen_at: new Date(NOW - 4 * 24 * 60 * 60_000).toISOString(),
|
||||
metadata: { cli_version: "0.3.17", launched_by: "desktop" },
|
||||
}),
|
||||
],
|
||||
{ now: NOW },
|
||||
);
|
||||
|
||||
expect(machines).toHaveLength(1);
|
||||
expect(machines[0]?.cliVersion).toBe("0.4.0");
|
||||
expect(machines[0]?.launchedBy).toBe("desktop");
|
||||
});
|
||||
|
||||
it("uses a machine-wide custom name as the machine title, over the local name", () => {
|
||||
const machines = buildRuntimeMachines(
|
||||
[
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface RuntimeMachine {
|
||||
subtitle: string | null;
|
||||
deviceInfo: string | null;
|
||||
cliVersion: string | null;
|
||||
launchedBy: string | null;
|
||||
mode: AgentRuntime["runtime_mode"];
|
||||
section: RuntimeMachineSection;
|
||||
isCurrent: boolean;
|
||||
@@ -133,6 +134,7 @@ function placeholderLocalMachine(
|
||||
subtitle: null,
|
||||
deviceInfo: null,
|
||||
cliVersion: null,
|
||||
launchedBy: null,
|
||||
mode: "local",
|
||||
section: "local",
|
||||
isCurrent: true,
|
||||
@@ -252,7 +254,8 @@ function finalizeRuntimeMachine(
|
||||
title,
|
||||
subtitle,
|
||||
deviceInfo,
|
||||
cliVersion: commonCliVersion(runtimes),
|
||||
cliVersion: currentMachineMetadata(runtimes, "cli_version"),
|
||||
launchedBy: currentMachineMetadata(runtimes, "launched_by"),
|
||||
mode: draft.mode,
|
||||
section: isCurrent ? "local" : draft.mode === "cloud" ? "cloud" : "remote",
|
||||
isCurrent,
|
||||
@@ -383,15 +386,28 @@ function latestLastSeenAt(runtimes: AgentRuntime[]): string | null {
|
||||
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());
|
||||
}
|
||||
function currentMachineMetadata(
|
||||
runtimes: AgentRuntime[],
|
||||
key: "cli_version" | "launched_by",
|
||||
): string | null {
|
||||
const online = runtimes.filter((runtime) => runtime.status === "online");
|
||||
const candidates = online.length > 0 ? online : runtimes;
|
||||
|
||||
for (const runtime of candidates.toSorted(compareRuntimeReports)) {
|
||||
const value = runtime.metadata?.[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return versions.size === 1 ? Array.from(versions)[0] ?? null : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareRuntimeReports(a: AgentRuntime, b: AgentRuntime): number {
|
||||
return runtimeReportTime(b) - runtimeReportTime(a);
|
||||
}
|
||||
|
||||
function runtimeReportTime(runtime: AgentRuntime): number {
|
||||
const reportedAt = runtime.last_seen_at ?? runtime.updated_at;
|
||||
const timestamp = Date.parse(reportedAt);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function shortDaemonId(daemonId: string): string {
|
||||
|
||||
92
packages/views/runtimes/components/update-section.test.tsx
Normal file
92
packages/views/runtimes/components/update-section.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enRuntimes from "../../locales/en/runtimes.json";
|
||||
import { UpdateSection } from "./update-section";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, runtimes: enRuntimes } };
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
initiateUpdate: vi.fn(),
|
||||
getUpdateResult: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderSection(props: {
|
||||
runtimeId: string | null;
|
||||
launchedBy?: string | null;
|
||||
currentVersion?: string;
|
||||
}) {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<UpdateSection
|
||||
runtimeId={props.runtimeId}
|
||||
currentVersion={props.currentVersion ?? "v0.4.0"}
|
||||
isOnline
|
||||
launchedBy={props.launchedBy}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("UpdateSection read-only status", () => {
|
||||
it("shows Latest without exposing an update action", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v0.4.0" }),
|
||||
}),
|
||||
);
|
||||
|
||||
renderSection({ runtimeId: null });
|
||||
|
||||
expect(await screen.findByText("Latest")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Update" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Desktop manager without exposing an update action", () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v0.4.0" }),
|
||||
}),
|
||||
);
|
||||
|
||||
renderSection({ runtimeId: null, launchedBy: "desktop" });
|
||||
|
||||
expect(screen.getByText("Managed by Desktop")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Update" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an available version without an action for a read-only viewer", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v0.4.0" }),
|
||||
}),
|
||||
);
|
||||
|
||||
renderSection({ runtimeId: null, currentVersion: "v0.3.17" });
|
||||
|
||||
expect(await screen.findByText("available")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Update" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,8 @@ const statusConfig: Record<
|
||||
};
|
||||
|
||||
interface UpdateSectionProps {
|
||||
runtimeId: string;
|
||||
/** Null for a read-only viewer who cannot use a runtime as the command channel. */
|
||||
runtimeId: string | null;
|
||||
currentVersion: string | null;
|
||||
isOnline: boolean;
|
||||
/**
|
||||
@@ -128,7 +129,7 @@ export function UpdateSection({
|
||||
}, [currentVersion, markCompleted, targetVersion, updating]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!latestVersion) return;
|
||||
if (!latestVersion || !runtimeId) return;
|
||||
cleanup();
|
||||
setUpdating(true);
|
||||
setTargetVersion(latestVersion);
|
||||
@@ -212,7 +213,7 @@ export function UpdateSection({
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasUpdate && isOnline && !status && (
|
||||
{hasUpdate && runtimeId && isOnline && !status && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
|
||||
Reference in New Issue
Block a user