feat(app): real daemon health footer (live, uptime, version)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+18
-5
@@ -7,8 +7,8 @@ import { Wizard } from "./Wizard";
|
||||
import { EventCenter } from "./EventCenter";
|
||||
import { maybeNotify } from "./notify";
|
||||
import { COLORS } from "./theme";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead } from "./socketBridge";
|
||||
import type { EventRecord } from "./socketBridge";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth } from "./socketBridge";
|
||||
import type { EventRecord, DaemonHealth } from "./socketBridge";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||
|
||||
@@ -21,6 +21,8 @@ export function App() {
|
||||
const [events, setEvents] = useState<EventRecord[]>([]);
|
||||
const [wizard, setWizard] = useState(false);
|
||||
const [eventsOpen, setEventsOpen] = useState(true);
|
||||
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const activeRef = useRef<string | null>(null);
|
||||
const wsRef = useRef<WorkspaceView[]>([]);
|
||||
@@ -49,12 +51,18 @@ export function App() {
|
||||
if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id);
|
||||
}, []);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
try { setHealth(await getHealth()); setConnected(true); }
|
||||
catch { setConnected(false); }
|
||||
}, []);
|
||||
|
||||
const wsOf = (surfaceId: string): WorkspaceView | undefined =>
|
||||
wsRef.current.find((w) => surfaceId in w.surfaces);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
void loadHealth();
|
||||
const unlisten = onDaemonEvent((evt) => {
|
||||
if (evt.evt === "event") {
|
||||
const rec = evt.data.record;
|
||||
@@ -74,9 +82,14 @@ export function App() {
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); void seedEvents(); });
|
||||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
|
||||
setConnected(false);
|
||||
void refresh();
|
||||
void seedEvents();
|
||||
void loadHealth();
|
||||
});
|
||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
|
||||
}, [refresh, seedEvents]);
|
||||
}, [refresh, seedEvents, loadHealth]);
|
||||
|
||||
const unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
|
||||
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
||||
@@ -93,7 +106,7 @@ export function App() {
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
||||
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} />
|
||||
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{active && (
|
||||
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
|
||||
|
||||
+23
-7
@@ -1,7 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||
import type { DaemonHealth } from "./socketBridge";
|
||||
|
||||
function fmtUptime(startedMs: number): string {
|
||||
const s = Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
||||
}
|
||||
|
||||
function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
|
||||
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
|
||||
@@ -14,15 +23,22 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
groups, workspaces, activeId, onSelect, onNew,
|
||||
groups, workspaces, activeId, onSelect, onNew, health, connected,
|
||||
}: {
|
||||
groups: Group[];
|
||||
workspaces: WorkspaceView[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
health: DaemonHealth | null;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick((n) => n + 1), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
|
||||
const ungrouped = byGroup(null);
|
||||
|
||||
@@ -76,12 +92,12 @@ export function Sidebar({
|
||||
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
|
||||
</div>
|
||||
|
||||
{/* Daemon status footer — uptime is mocked until the daemon reports it. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.stDone, flex: "0 0 7px" }} />
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>spaceshd · live</span>
|
||||
<div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 7px" }} />
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>3d 4h</span>
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>{health ? fmtUptime(health.started_at_ms) : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -175,3 +175,9 @@ export async function deleteGroup(groupId: string): Promise<void> {
|
||||
export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
|
||||
await invoke("close_surface", { surfaceId });
|
||||
}
|
||||
|
||||
export interface DaemonHealth { version: string; pid: number; started_at_ms: number }
|
||||
|
||||
export async function getHealth(): Promise<DaemonHealth> {
|
||||
return await invoke<DaemonHealth>("health");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user