diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 8f6e5b8..1847d4d 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -311,3 +311,8 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result) -> Result { + data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b1db4f5..b632cf6 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -51,6 +51,7 @@ pub fn run() { bridge::focus, bridge::event_log, bridge::mark_read, + bridge::health, ]) .run(tauri::generate_context!()) .expect("error while running spacesh"); diff --git a/app/src/App.tsx b/app/src/App.tsx index 5aaac1f..939adb9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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([]); const [wizard, setWizard] = useState(false); const [eventsOpen, setEventsOpen] = useState(true); + const [health, setHealth] = useState(null); + const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); const activeRef = useRef(null); const wsRef = useRef([]); @@ -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() {
setEventsOpen((v) => !v)} unread={unread} />
- setWizard(true)} /> + setWizard(true)} health={health} connected={connected} />
{active && ( { if (active) void applyPreset(active.id, p, []); }} /> diff --git a/app/src/Sidebar.tsx b/app/src/Sidebar.tsx index ce3954a..2d44737 100644 --- a/app/src/Sidebar.tsx +++ b/app/src/Sidebar.tsx @@ -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>({}); + 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 &&
{ungrouped.map(row)}
}
- {/* Daemon status footer — uptime is mocked until the daemon reports it. */} -
- - spaceshd · live +
+ + {connected ? "spaceshd · live" : "spaceshd · offline"} - 3d 4h + {health ? fmtUptime(health.started_at_ms) : ""}
); diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index a6470ee..ab0afdf 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -175,3 +175,9 @@ export async function deleteGroup(groupId: string): Promise { export async function closeSurfaceCmd(surfaceId: string): Promise { await invoke("close_surface", { surfaceId }); } + +export interface DaemonHealth { version: string; pid: number; started_at_ms: number } + +export async function getHealth(): Promise { + return await invoke("health"); +}