Skip to content

Commit ee736a3

Browse files
committed
fix: expose the compute-agent to a real user — it had no UI at all
Found by actually testing the app as a user: compute-agent.ts (fleet enrollment, heartbeat, lease lifecycle) was fully built and tested this session, but computeAgent:getIdentity/getStatus was never bridged through preload.ts, and no frontend page ever called it — the whole feature was completely inaccessible regardless of how correct the backend was. Added the preload bridge + electron.d.ts type, and a new Fleet tab in Runtime Manager (the compute-control-plane's own designated local node view) showing the device's real certificate fingerprint to hand to an org compute admin, a node-id field, and an enable toggle that saves through the existing generic settings:save channel and live-starts/stops the agent. Verified end-to-end in a real headless Electron session: a real fingerprint rendered, saving actually started the agent ("The agent is running and heartbeating regularly."), and a follow-up smoke pass across Patient Cases/Compare/Usage/Download Center/Audit/ Settings showed zero console errors.
1 parent 1001eab commit ee736a3

3 files changed

Lines changed: 83 additions & 2 deletions

File tree

‎app/src/preload.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,11 @@ export const api = {
573573
rollbackCaseMigration: (migrationId: string): Promise<MigrationSession> => ipcRenderer.invoke("sharedBackend:rollbackCaseMigration", migrationId),
574574
},
575575

576+
computeAgent: {
577+
getIdentity: (): Promise<{ fingerprint256: string }> => ipcRenderer.invoke("computeAgent:getIdentity"),
578+
getStatus: (): Promise<{ enabled: boolean; nodeId: string | null; running: boolean }> => ipcRenderer.invoke("computeAgent:getStatus"),
579+
},
580+
576581
imaging: {
577582
listStudies: (caseId: string): Promise<ImagingStudy[]> => ipcRenderer.invoke("imaging:listStudies", caseId),
578583
getStudy: (studyId: string): Promise<ImagingStudyDetail> => ipcRenderer.invoke("imaging:getStudy", studyId),

‎frontend/src/pages/RuntimeManager.tsx‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export default function RuntimeManager() {
6969
const [resourceTelemetry, setResourceTelemetry] = useState<ResourceTelemetry | null>(null);
7070
const [resourceSettings, setResourceSettings] = useState<{ mode: "balanced" | "performance" | "efficient" | "manual"; maxRamMB?: number; maxVramMB?: number; cpuThreadCeiling?: number }>({ mode: "balanced" });
7171
const [resourceSettingsSaving, setResourceSettingsSaving] = useState(false);
72+
const [fleetFingerprint, setFleetFingerprint] = useState<string | null>(null);
73+
const [fleetStatus, setFleetStatus] = useState<{ enabled: boolean; nodeId: string | null; running: boolean } | null>(null);
74+
const [fleetSaving, setFleetSaving] = useState(false);
7275
const [modelAssessments, setModelAssessments] = useState<GgufAssessment[]>([]);
7376
const [loadedModelPaths, setLoadedModelPaths] = useState<Set<string>>(new Set());
7477
const [modelAssessmentsLoading, setModelAssessmentsLoading] = useState(false);
@@ -107,6 +110,27 @@ export default function RuntimeManager() {
107110
setResourceSettingsSaving(false);
108111
}
109112
}, [hasApi, tr, toast]);
113+
const refreshFleet = useCallback(async () => {
114+
if (!hasApi || !window.api.computeAgent) return;
115+
try {
116+
const [identity, status] = await Promise.all([window.api.computeAgent.getIdentity(), window.api.computeAgent.getStatus()]);
117+
setFleetFingerprint(identity.fingerprint256);
118+
setFleetStatus(status);
119+
} catch { /* fleet enrollment is opt-in and best-effort to surface */ }
120+
}, [hasApi]);
121+
const saveFleetSettings = useCallback(async (next: { enabled: boolean; nodeId: string }) => {
122+
if (!hasApi) return;
123+
setFleetSaving(true);
124+
try {
125+
await window.api.settings.save({ computeAgentEnabled: next.enabled, computeNodeId: next.nodeId.trim() || undefined });
126+
await refreshFleet();
127+
toast.success(tr ? "Filo ayarları kaydedildi." : "Fleet settings saved.");
128+
} catch (reason) {
129+
toast.error((reason as Error).message);
130+
} finally {
131+
setFleetSaving(false);
132+
}
133+
}, [hasApi, refreshFleet, tr, toast]);
110134
const refreshGpuTelemetry = useCallback(async () => { if (!hasApi || !window.api.gpu) return; try { setGpuTelemetry(await window.api.gpu.getTelemetry()); } catch { /* telemetry is best-effort */ } }, [hasApi]);
111135
const refreshResourceTelemetry = useCallback(async () => { if (!hasApi || !window.api.resource) return; try { setResourceTelemetry(await window.api.resource.getTelemetry()); } catch { /* telemetry is best-effort */ } }, [hasApi]);
112136
// Item 6/7's "Models" panel: per-installed-model compatibility, reusing
@@ -145,7 +169,7 @@ export default function RuntimeManager() {
145169
}
146170
}, [hasApi, refreshGpuTelemetry, toast, tr]);
147171

148-
useEffect(() => { const timer = window.setTimeout(() => { void refreshStatuses(); void refreshEnvironments(); void refreshModels(); void refreshGpuTelemetry(); }, 0); return () => clearTimeout(timer); }, [refreshStatuses, refreshEnvironments, refreshModels, refreshGpuTelemetry]);
172+
useEffect(() => { const timer = window.setTimeout(() => { void refreshStatuses(); void refreshEnvironments(); void refreshModels(); void refreshGpuTelemetry(); void refreshFleet(); }, 0); return () => clearTimeout(timer); }, [refreshStatuses, refreshEnvironments, refreshModels, refreshGpuTelemetry, refreshFleet]);
149173
useEffect(() => { const timer = window.setInterval(() => void refreshStatuses(), 5_000); const visible = () => { if (document.visibilityState === "visible") void refreshStatuses(); }; document.addEventListener("visibilitychange", visible); return () => { clearInterval(timer); document.removeEventListener("visibilitychange", visible); }; }, [refreshStatuses]);
150174
// The main process already caches/dedupes/pauses the underlying nvidia-smi
151175
// / rocm-smi probes (gpu-telemetry.ts) — this just re-reads that shared
@@ -221,7 +245,7 @@ export default function RuntimeManager() {
221245

222246
return <main className="min-h-full bg-background p-4 md:p-6"><div className="mx-auto max-w-7xl space-y-5">
223247
<header className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => navigate(-1)} aria-label={tr ? "Geri" : "Back"}><ArrowLeft className="size-4" /></Button><div><h1 className="text-2xl font-semibold tracking-tight">{tr ? "Çalışma Zamanı Yöneticisi" : "Runtime Manager"}</h1><p className="text-sm text-muted-foreground">{tr ? "Yerel çıkarım süreçleri, ortamlar ve tanılama" : "Local inference processes, environments, and diagnostics"}</p></div></div><Button variant="outline" disabled={hardwareRefreshing} onClick={() => void Promise.all([refreshStatuses(), refreshEnvironments(), refreshModels(), refreshGpuTopology()])}><RefreshCw className={`mr-2 size-4 ${environmentLoading || hardwareRefreshing ? "animate-spin motion-reduce:animate-none" : ""}`} />{tr ? "Yenile" : "Refresh"}</Button></header>
224-
<Tabs value={tab} onValueChange={(value) => setTab(String(value))}><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="overview">{tr ? "Genel Bakış" : "Overview"}</TabsTrigger><TabsTrigger value="runtimes">{tr ? "Çalışma Zamanları" : "Runtimes"}</TabsTrigger><TabsTrigger value="workloads">{tr ? "İş yükleri" : "Workloads"}</TabsTrigger><TabsTrigger value="models">{tr ? "Modeller" : "Models"}</TabsTrigger><TabsTrigger value="environments">{tr ? "Ortamlar" : "Environments"}</TabsTrigger><TabsTrigger value="resource-settings">{tr ? "Kaynak ayarları" : "Resource settings"}</TabsTrigger><TabsTrigger value="logs">{tr ? "Günlükler ve tanılama" : "Logs & diagnostics"}</TabsTrigger></TabsList>
248+
<Tabs value={tab} onValueChange={(value) => setTab(String(value))}><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="overview">{tr ? "Genel Bakış" : "Overview"}</TabsTrigger><TabsTrigger value="runtimes">{tr ? "Çalışma Zamanları" : "Runtimes"}</TabsTrigger><TabsTrigger value="workloads">{tr ? "İş yükleri" : "Workloads"}</TabsTrigger><TabsTrigger value="models">{tr ? "Modeller" : "Models"}</TabsTrigger><TabsTrigger value="environments">{tr ? "Ortamlar" : "Environments"}</TabsTrigger><TabsTrigger value="resource-settings">{tr ? "Kaynak ayarları" : "Resource settings"}</TabsTrigger><TabsTrigger value="fleet">{tr ? "Filo" : "Fleet"}</TabsTrigger><TabsTrigger value="logs">{tr ? "Günlükler ve tanılama" : "Logs & diagnostics"}</TabsTrigger></TabsList>
225249
<TabsContent value="overview" className="space-y-5 pt-4">
226250
<section className="grid overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4"><Metric label={tr ? "Çalışan" : "Running"} value={`${running.length} / ${statuses.length}`} detail={unhealthy.length ? `${unhealthy.length} ${tr ? "sağlıksız" : "unhealthy"}` : tr ? "Tümü sağlıklı" : "All healthy"} /><Metric label={tr ? "Etkin istekler" : "Active requests"} value={String(activeRequests)} detail={running.map((item) => item.model).filter(Boolean).join(", ") || "—"} /><Metric label="RAM / VRAM" value={`${memory(totalRam)} / ${memory(totalVram)}`} detail={accelerator} /><Metric label={tr ? "İşlem gerekli" : "Needs attention"} value={String(repairCount)} detail={`${tr ? "Son denetim" : "Last check"}: ${fmtTime(statuses.map((item) => item.lastHealthCheckAt).filter(Boolean).sort().at(-1) ?? null)}`} /></section>
227251
<section className="rounded-xl border bg-card"><div className="border-b px-4 py-3"><h2 className="font-medium">{tr ? "Çalışma zamanı özeti" : "Runtime summary"}</h2></div><div className="divide-y">{statuses.map((status) => <button key={status.backend} className="grid w-full gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:grid-cols-[1.2fr_.8fr_1fr_auto] sm:items-center" onClick={() => { setTab("runtimes"); }}><span><span className="font-medium">{RUNTIMES[status.backend].name}</span><span className="block text-xs text-muted-foreground">{status.model || RUNTIMES[status.backend].purpose}</span></span><StatusBadge state={displayState(status)} /><span className="text-xs text-muted-foreground">{status.activeRequests} {tr ? "istek" : "requests"} · {memory(status.ramMB)} RAM</span><span className="text-xs text-muted-foreground">{fmtTime(status.lastHealthCheckAt)}</span></button>)}</div></section>
@@ -232,6 +256,7 @@ export default function RuntimeManager() {
232256
<TabsContent value="models" className="space-y-4 pt-4"><ModelsPanel assessments={modelAssessments} loadedPaths={loadedModelPaths} loading={modelAssessmentsLoading} tr={tr} /></TabsContent>
233257
<TabsContent value="environments" className="space-y-3 pt-4"><div className="rounded-xl border bg-card"><div className="border-b px-4 py-3"><h2 className="font-medium">{tr ? "Yönetilen Python ortamları" : "Managed Python environments"}</h2><p className="text-xs text-muted-foreground">{tr ? "Hiçbir kurulum komutu açık onay olmadan çalıştırılmaz." : "No installation command runs without explicit approval."}</p></div><div className="divide-y">{environments.map((environment) => <EnvironmentRow key={environment.family} environment={environment} tr={tr} onPlan={() => setConfirmation({ kind: "environment", environment })} />)}</div></div></TabsContent>
234258
<TabsContent value="resource-settings" className="space-y-4 pt-4"><ResourceSettingsPanel settings={resourceSettings} saving={resourceSettingsSaving} onSave={(next) => void saveResourceSettings(next)} tr={tr} /></TabsContent>
259+
<TabsContent value="fleet" className="space-y-4 pt-4"><FleetAgentPanel fingerprint={fleetFingerprint} status={fleetStatus} saving={fleetSaving} onSave={(next) => void saveFleetSettings(next)} tr={tr} /></TabsContent>
235260
<TabsContent value="logs" className="space-y-3 pt-4"><section className="overflow-hidden rounded-xl border bg-card"><div className="flex flex-wrap items-center gap-2 border-b p-3"><label className="sr-only" htmlFor="runtime-log-selector">Runtime</label><select id="runtime-log-selector" className="h-8 rounded-lg border bg-background px-2 text-sm" value={logBackend} onChange={(event) => setLogBackend(event.target.value as Backend)}>{Object.values(RUNTIMES).map((runtime) => <option key={runtime.id} value={runtime.id}>{runtime.name}</option>)}</select><select aria-label={tr ? "Günlük kaynağı" : "Log source"} className="h-8 rounded-lg border bg-background px-2 text-sm" value={logSource} onChange={(event) => setLogSource(event.target.value)}><option value="all">{tr ? "Tüm kaynaklar" : "All sources"}</option><option value="manager">Manager</option><option value="stdout">stdout</option><option value="stderr">stderr</option></select><div className="relative min-w-48 flex-1"><Search className="absolute left-2.5 top-2 size-4 text-muted-foreground" /><Input className="h-8 pl-8" value={logSearch} onChange={(event) => setLogSearch(event.target.value)} placeholder={tr ? "Günlüklerde ara" : "Search logs"} /></div><label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={follow} onChange={(event) => setFollow(event.target.checked)} />{tr ? "Çıktıyı izle" : "Follow output"}</label><Button size="sm" variant="outline" onClick={() => { void navigator.clipboard.writeText(visibleLogs.join("\n")); toast.success(tr ? "Günlükler kopyalandı." : "Logs copied."); }}><Clipboard className="mr-1.5 size-3.5" />{tr ? "Kopyala" : "Copy"}</Button><Button size="sm" variant="outline" onClick={() => window.api.localBackends.exportLogs(logBackend)}><Download className="mr-1.5 size-3.5" />{tr ? "Dışa aktar" : "Export"}</Button><Button size="sm" variant="ghost" onClick={() => setConfirmation({ kind: "clear-logs", backend: logBackend })}><Trash2 className="mr-1.5 size-3.5" />{tr ? "Temizle" : "Clear"}</Button></div><div className="h-[min(60vh,34rem)] overflow-auto bg-zinc-950 p-4 font-mono text-xs leading-relaxed text-zinc-200" role="log" aria-live="polite">{visibleLogs.length ? visibleLogs.map((line, index) => <div key={`${index}-${line.slice(0, 30)}`} className={line.includes("[stderr]") ? "text-amber-300" : ""}>{line}</div>) : <div className="flex h-full items-center justify-center text-zinc-500">{tr ? "Bu filtre için günlük yok." : "No logs match this filter."}</div>}<div ref={logEnd} /></div></section></TabsContent>
236261
</Tabs>
237262
{!hasApi && <div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">{tr ? "Çalışma zamanı yönetimi masaüstü uygulamasında kullanılabilir." : "Runtime management is available in the desktop application."}</div>}
@@ -428,6 +453,51 @@ function ResourceSettingsPanel({ settings, saving, onSave, tr }: { settings: Res
428453
</section>;
429454
}
430455

456+
// Requires an already-connected shared backend (Audit & Privacy's own
457+
// enrollment panel) — enabling this without one configured saves cleanly
458+
// (compute-agent.ts's own runCycle() just no-ops each cycle until then)
459+
// but never actually starts heartbeating, so the copy below says so up
460+
// front rather than let a user wonder why nothing happens.
461+
function FleetAgentPanel({ fingerprint, status, saving, onSave, tr }: { fingerprint: string | null; status: { enabled: boolean; nodeId: string | null; running: boolean } | null; saving: boolean; onSave(next: { enabled: boolean; nodeId: string }): void; tr: boolean }) {
462+
const [enabled, setEnabled] = useState(status?.enabled ?? false);
463+
const [nodeId, setNodeId] = useState(status?.nodeId ?? "");
464+
useEffect(() => { setEnabled(status?.enabled ?? false); setNodeId(status?.nodeId ?? ""); }, [status]);
465+
const dirty = enabled !== (status?.enabled ?? false) || nodeId !== (status?.nodeId ?? "");
466+
const [copied, setCopied] = useState(false);
467+
468+
return <section className="max-w-2xl space-y-4 overflow-hidden rounded-xl border bg-card p-4">
469+
<div>
470+
<h2 className="font-medium">{tr ? "Kurumsal işlem filosu" : "Enterprise compute fleet"}</h2>
471+
<p className="mt-1 text-xs text-muted-foreground">{tr ? "Bu cihazı, kurumun paylaşılan arka ucuna zaten bağlıysa (Denetim ve Gizlilik) bir işlem havuzuna kaydedin. Yalnızca havuzun taahhüt ettiği kapasiteyi korur — burada hiçbir iş çalıştırılmaz." : "Enroll this device into a compute pool, if it's already connected to your organization's shared backend (Audit & Privacy). This only protects the capacity the fleet has committed — no work is dispatched to run here yet."}</p>
472+
</div>
473+
<div className="space-y-1.5">
474+
<p className="text-xs font-medium text-muted-foreground">{tr ? "Cihaz parmak izi" : "Device fingerprint"}</p>
475+
<div className="flex items-center gap-2">
476+
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-lg border bg-background px-3 py-2 font-mono text-[11px]">{fingerprint ?? "…"}</code>
477+
<Button variant="outline" size="sm" disabled={!fingerprint} onClick={() => { if (fingerprint) { void navigator.clipboard.writeText(fingerprint); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } }}>
478+
<Clipboard className="mr-1.5 size-3.5" />{copied ? (tr ? "Kopyalandı" : "Copied") : (tr ? "Kopyala" : "Copy")}
479+
</Button>
480+
</div>
481+
<p className="text-[11px] text-muted-foreground">{tr ? "Bu cihazı bir işlem havuzuna eklemesi için kuruluşunuzun işlem yöneticisine verin." : "Give this to your organization's compute admin to register this device to a pool."}</p>
482+
</div>
483+
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
484+
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
485+
{tr ? "Düğüm kimliği (yönetici tarafından verilir)" : "Node ID (given by your admin)"}
486+
<Input value={nodeId} onChange={(event) => setNodeId(event.target.value)} placeholder={tr ? "kayıttan sonra buraya yapıştırın" : "paste it here after registration"} />
487+
</label>
488+
<label className="flex items-end gap-2 pb-0.5 text-sm">
489+
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
490+
{tr ? "Filo aracısını etkinleştir" : "Enable fleet agent"}
491+
</label>
492+
</div>
493+
{status && <p className="text-[11px] text-muted-foreground">{status.running ? (tr ? "Aracı çalışıyor ve düzenli olarak nabız gönderiyor." : "The agent is running and heartbeating regularly.") : status.enabled ? (tr ? "Etkin ancak henüz çalışmıyor — bir düğüm kimliği gerekiyor olabilir." : "Enabled but not running yet — a node ID may still be needed.") : (tr ? "Şu anda devre dışı." : "Currently disabled.")}</p>}
494+
<div className="flex justify-end gap-2 border-t pt-3">
495+
<Button variant="outline" disabled={!dirty || saving} onClick={() => { setEnabled(status?.enabled ?? false); setNodeId(status?.nodeId ?? ""); }}>{tr ? "Sıfırla" : "Reset"}</Button>
496+
<Button disabled={!dirty || saving} onClick={() => onSave({ enabled, nodeId })}>{saving ? (tr ? "Kaydediliyor…" : "Saving…") : (tr ? "Kaydet" : "Save")}</Button>
497+
</div>
498+
</section>;
499+
}
500+
431501
function RuntimeCard({ status, descriptor, model, setModel, config, setConfig, ggufModels, recentModels, tensorParallelAvailable, gpus, operation, error, tr, onAction, onLogs }: { status: LocalRuntimeStatus; descriptor: RuntimeDescriptor; model: string; setModel(value: string): void; config: RuntimeStartupConfig; setConfig(value: RuntimeStartupConfig): void; ggufModels: LocalGgufModel[]; recentModels: string[]; tensorParallelAvailable: boolean; gpus: GpuInfo[]; operation?: string; error?: string; tr: boolean; onAction(backend: Backend, action: "start" | "stop" | "restart"): void; onLogs(): void }) {
432502
const state = displayState(status); const busy = !!operation || !!status.operation; const primary = !status.compatible || status.startupError ? "error" : !status.installed ? "install" : status.state === "running" || status.state === "unhealthy" ? "stop" : "start";
433503
const candidates = selectableGpus(gpus, status.backend);

‎frontend/src/types/electron.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,8 @@ export interface AppSettings {
683683
resourceMaxRamMB?: number;
684684
resourceMaxVramMB?: number;
685685
resourceCpuThreadCeiling?: number;
686+
computeAgentEnabled?: boolean;
687+
computeNodeId?: string;
686688
agentMaxSteps?: number;
687689
llamaCppMaxCachedModels?: number;
688690
llamaCppMaxThreads?: number;
@@ -1344,6 +1346,10 @@ export interface ElectronApi {
13441346
activateCaseMigration: (migrationId: string) => Promise<MigrationSession>;
13451347
rollbackCaseMigration: (migrationId: string) => Promise<MigrationSession>;
13461348
};
1349+
computeAgent: {
1350+
getIdentity: () => Promise<{ fingerprint256: string }>;
1351+
getStatus: () => Promise<{ enabled: boolean; nodeId: string | null; running: boolean }>;
1352+
};
13471353
imaging: {
13481354
listStudies: (caseId: string) => Promise<ImagingStudy[]>;
13491355
getStudy: (studyId: string) => Promise<ImagingStudyDetail>;

0 commit comments

Comments
 (0)