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:
2026-06-10 12:09:35 +07:00
parent f7763a84fc
commit defceb1169
5 changed files with 53 additions and 12 deletions
+5
View File
@@ -311,3 +311,8 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, S
let target: spacesh_proto::MarkReadTarget = serde_json::from_value(target).map_err(|e| format!("invalid mark_read target: {e}"))?; let target: spacesh_proto::MarkReadTarget = serde_json::from_value(target).map_err(|e| format!("invalid mark_read target: {e}"))?;
data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?) data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
} }
#[tauri::command]
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}
+1
View File
@@ -51,6 +51,7 @@ pub fn run() {
bridge::focus, bridge::focus,
bridge::event_log, bridge::event_log,
bridge::mark_read, bridge::mark_read,
bridge::health,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running spacesh"); .expect("error while running spacesh");
+18 -5
View File
@@ -7,8 +7,8 @@ import { Wizard } from "./Wizard";
import { EventCenter } from "./EventCenter"; import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify"; import { maybeNotify } from "./notify";
import { COLORS } from "./theme"; import { COLORS } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead } from "./socketBridge"; import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth } from "./socketBridge";
import type { EventRecord } from "./socketBridge"; import type { EventRecord, DaemonHealth } from "./socketBridge";
import { leafIds } from "./layoutTypes"; import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -21,6 +21,8 @@ export function App() {
const [events, setEvents] = useState<EventRecord[]>([]); const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false); const [wizard, setWizard] = useState(false);
const [eventsOpen, setEventsOpen] = useState(true); 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 [focusedId, setFocusedId] = useState<string | null>(null);
const activeRef = useRef<string | null>(null); const activeRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]); const wsRef = useRef<WorkspaceView[]>([]);
@@ -49,12 +51,18 @@ export function App() {
if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id); 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 => const wsOf = (surfaceId: string): WorkspaceView | undefined =>
wsRef.current.find((w) => surfaceId in w.surfaces); wsRef.current.find((w) => surfaceId in w.surfaces);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
void seedEvents(); void seedEvents();
void loadHealth();
const unlisten = onDaemonEvent((evt) => { const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "event") { if (evt.evt === "event") {
const rec = evt.data.record; const rec = evt.data.record;
@@ -74,9 +82,14 @@ export function App() {
void refresh(); 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()); }; 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 unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
const active = workspaces.find((w) => w.id === activeId) ?? null; 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 }}> <div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} /> <TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}> <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 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && ( {active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} /> <CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
+23 -7
View File
@@ -1,7 +1,16 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight } from "lucide-react"; import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { COLORS, FONT, STATE_COLOR } from "./theme"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; 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" { function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"]; const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
@@ -14,15 +23,22 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
} }
export function Sidebar({ export function Sidebar({
groups, workspaces, activeId, onSelect, onNew, groups, workspaces, activeId, onSelect, onNew, health, connected,
}: { }: {
groups: Group[]; groups: Group[];
workspaces: WorkspaceView[]; workspaces: WorkspaceView[];
activeId: string | null; activeId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onNew: () => void; onNew: () => void;
health: DaemonHealth | null;
connected: boolean;
}) { }) {
const [collapsed, setCollapsed] = useState<Record<string, 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 byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
const ungrouped = byGroup(null); 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>} {ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
</div> </div>
{/* Daemon status footer — uptime is mocked until the daemon reports it. */} <div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}> 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={{ 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 }}>spaceshd · live</span> <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
<span style={{ flex: 1 }} /> <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>
</div> </div>
); );
+6
View File
@@ -175,3 +175,9 @@ export async function deleteGroup(groupId: string): Promise<void> {
export async function closeSurfaceCmd(surfaceId: string): Promise<void> { export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
await invoke("close_surface", { surfaceId }); 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");
}