(DEFAULT_METRICS_SETTINGS);
+ const [isSaving, setIsSaving] = useState(false);
+ const saveSeqRef = useRef(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetchSystemMetricsSettings()
+ .then((res) => {
+ if (!cancelled) setSettings(res.settings);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const saveGlobal = async (next: SystemMetricsGlobalSettings) => {
+ const previous = settings;
+ const seq = saveSeqRef.current + 1;
+ saveSeqRef.current = seq;
+ setSettings(next);
+ setIsSaving(true);
+ try {
+ const res = await updateSystemMetricsSettings(next);
+ if (seq === saveSeqRef.current) setSettings(res.settings);
+ } catch {
+ if (seq === saveSeqRef.current) setSettings(previous);
+ } finally {
+ if (seq === saveSeqRef.current) setIsSaving(false);
+ }
+ };
+
+ const toggleMetric = (metric: SystemMetricId, checked: boolean) => {
+ const nextMetrics = checked
+ ? Array.from(new Set([...settings.metrics, metric]))
+ : settings.metrics.filter((id) => id !== metric);
+ if (nextMetrics.length > 0) void saveGlobal({ ...settings, metrics: nextMetrics });
+ };
+
+ const toggleDisplay = async (checked: boolean) => {
+ const current = storeApi.getState().userSettings;
+ const previous = current.systemMetricsDisplay;
+ try {
+ setUserSettings({ ...current, systemMetricsDisplay: { showInTopbar: checked } });
+ await updateUserSettings({
+ workspace_id: current.workspaceId || "",
+ repository_ids: current.repositoryIds || [],
+ system_metrics_display: { show_in_topbar: checked },
+ });
+ } catch {
+ setUserSettings({ ...storeApi.getState().userSettings, systemMetricsDisplay: previous });
+ }
+ };
+
+ return (
+
+
+ Resource Metrics
+
+
+
+ Useful when Kandev is self-hosted on a remote server and you want a lightweight view of
+ the machine resources from the kanban or task topbar.
+
+
+ void saveGlobal(next)}
+ onDraftSettings={setSettings}
+ />
+
+ void saveGlobal({ ...settings, collect_execution: checked })
+ }
+ />
+
+
+ );
+}
+
+function MetricsDisplayToggle({
+ checked,
+ onCheckedChange,
+}: {
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+
+
+ Collection starts only while at least one desktop or tablet client displays metrics.
+
+
+
+
+ );
+}
+
+function MetricsSamplerControls({
+ settings,
+ isSaving,
+ onToggleMetric,
+ onChangeSettings,
+ onDraftSettings,
+}: {
+ settings: SystemMetricsGlobalSettings;
+ isSaving: boolean;
+ onToggleMetric: (metric: SystemMetricId, checked: boolean) => void;
+ onChangeSettings: (settings: SystemMetricsGlobalSettings) => void;
+ onDraftSettings: (settings: SystemMetricsGlobalSettings) => void;
+}) {
+ return (
+
+
+
+
+
+ onChangeSettings({ ...settings, interval_seconds: clampInterval(event.target.value) })
+ }
+ />
+
+
+
+
+ onDraftSettings({ ...settings, backend_disk_path: event.target.value })
+ }
+ onBlur={() => onChangeSettings(settings)}
+ />
+
+
+ );
+}
+
+function MetricCheckboxes({
+ settings,
+ isSaving,
+ onToggleMetric,
+}: {
+ settings: SystemMetricsGlobalSettings;
+ isSaving: boolean;
+ onToggleMetric: (metric: SystemMetricId, checked: boolean) => void;
+}) {
+ return (
+
+
+
+ {METRIC_OPTIONS.map((metric) => (
+
+ ))}
+
+
+ );
+}
+
+function ExecutionMetricsToggle({
+ checked,
+ disabled,
+ onCheckedChange,
+}: {
+ checked: boolean;
+ disabled: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+
+
+ Adds agentctl values for Docker, SSH, Sprites, and remote executors.
+
+
+
+
+ );
+}
+
+function clampInterval(raw: string) {
+ return Math.min(300, Math.max(1, Number(raw) || 5));
+}
diff --git a/apps/web/components/state-provider.test.tsx b/apps/web/components/state-provider.test.tsx
new file mode 100644
index 0000000000..bbd5014a7e
--- /dev/null
+++ b/apps/web/components/state-provider.test.tsx
@@ -0,0 +1,57 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice";
+import { StateProvider, useAppStore } from "./state-provider";
+
+function ShowMetricsPreference({ label }: { label: string }) {
+ const enabled = useAppStore((state) => state.userSettings.systemMetricsDisplay.showInTopbar);
+ return (
+
+ {label}:{enabled ? "on" : "off"}
+
+ );
+}
+
+function EnableMetricsFromNestedProvider() {
+ const setUserSettings = useAppStore((state) => state.setUserSettings);
+ const userSettings = useAppStore((state) => state.userSettings);
+ return (
+
+ );
+}
+
+describe("StateProvider", () => {
+ it("reuses the parent store for nested route providers", async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("root:off")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Enable metrics" }));
+ expect(await screen.findByText("root:on")).toBeTruthy();
+ });
+});
diff --git a/apps/web/components/state-provider.tsx b/apps/web/components/state-provider.tsx
index 3d2f1dc68d..02201b37cb 100644
--- a/apps/web/components/state-provider.tsx
+++ b/apps/web/components/state-provider.tsx
@@ -1,6 +1,6 @@
"use client";
-import { createContext, useContext, useEffect, useState } from "react";
+import { createContext, useContext, useEffect, useLayoutEffect, useState } from "react";
import type { StoreApi } from "zustand";
import { useStore } from "zustand";
import { isDebug, registerSessionTaskResolver } from "@/lib/debug/log";
@@ -15,7 +15,14 @@ type E2EWindow = Window & {
};
export function StateProvider({ children, initialState }: StoreProviderProps) {
- const [store] = useState(() => createAppStore(initialState));
+ const parentStore = useContext(StoreContext);
+ const [ownStore] = useState(() => createAppStore(parentStore ? undefined : initialState));
+ const store = parentStore ?? ownStore;
+
+ useLayoutEffect(() => {
+ if (!parentStore || !initialState || Object.keys(initialState).length === 0) return;
+ store.getState().hydrate(initialState);
+ }, [initialState, parentStore, store]);
useEffect(() => {
const win = window as E2EWindow;
diff --git a/apps/web/components/system-metrics/topbar-metrics.tsx b/apps/web/components/system-metrics/topbar-metrics.tsx
new file mode 100644
index 0000000000..970c14cd2c
--- /dev/null
+++ b/apps/web/components/system-metrics/topbar-metrics.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import {
+ IconActivity,
+ IconCpu,
+ IconDatabase,
+ IconDeviceDesktopAnalytics,
+ IconFlame,
+ IconGauge,
+ IconDisc,
+ IconServer,
+} from "@tabler/icons-react";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
+import { formatDistanceToNow } from "date-fns";
+import { useAppStore } from "@/components/state-provider";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import { useSystemMetricsSubscription } from "@/hooks/use-system-metrics-subscription";
+import type { SystemMetricSample, SystemMetricsSource } from "@/lib/types/system";
+
+type TopbarMetricsProps = {
+ activeSessionId?: string | null;
+};
+
+export function TopbarMetrics({ activeSessionId }: TopbarMetricsProps) {
+ const enabled = useAppStore((s) => s.userSettings.systemMetricsDisplay.showInTopbar);
+ const snapshot = useAppStore((s) => s.system.metrics);
+ const { isMobile } = useResponsiveBreakpoint();
+ const shouldRender = enabled && !isMobile;
+ useSystemMetricsSubscription(shouldRender);
+
+ if (!shouldRender) return null;
+ const sources = selectSources(snapshot?.sources ?? [], activeSessionId);
+ if (sources.length === 0) {
+ return (
+
+
+ Metrics
+
+ );
+ }
+ return (
+
+ {sources.map((source) => (
+ 1}
+ />
+ ))}
+
+ );
+}
+
+function selectSources(sources: SystemMetricsSource[], activeSessionId?: string | null) {
+ if (!activeSessionId) return sources.slice(0, 2);
+ const backend = sources.find((source) => source.kind === "backend");
+ const execution = sources.find((source) => source.session_id === activeSessionId);
+ return [backend, execution].filter(Boolean) as SystemMetricsSource[];
+}
+
+function SourceMetrics({
+ source,
+ updatedAt,
+ showSource,
+}: {
+ source: SystemMetricsSource;
+ updatedAt?: string;
+ showSource: boolean;
+}) {
+ const metrics = source.metrics.slice(0, 4);
+
+ return (
+
+ {showSource ? : null}
+ {metrics.length > 0 ? (
+ metrics.map((metric) => (
+
+ ))
+ ) : (
+ -
+ )}
+
+ );
+}
+
+function SourceBadge({ source, updatedAt }: { source: SystemMetricsSource; updatedAt?: string }) {
+ const isHost = source.kind === "backend";
+ const label = isHost ? "Host" : "Executor";
+
+ return (
+
+
+
+ {isHost ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
{label}
+
{source.label}
+
{lastUpdatedText(updatedAt)}
+
+
+
+ );
+}
+
+function MetricChip({
+ metric,
+ source,
+ updatedAt,
+}: {
+ metric: SystemMetricSample;
+ source: SystemMetricsSource;
+ updatedAt?: string;
+}) {
+ return (
+
+
+
+ {metricIcon(metric.id)}
+ {formatMetric(metric)}
+
+
+
+
+
{metricLabel(metric.id)}
+
+ {source.kind === "backend" ? "Host" : "Executor"}: {source.label}
+
+
{formatMetric(metric)}
+ {metric.error ? (
+
{metric.error}
+ ) : null}
+
{lastUpdatedText(updatedAt)}
+
+
+
+ );
+}
+
+function metricLabel(id: string) {
+ switch (id) {
+ case "cpu_percent":
+ return "CPU";
+ case "memory_percent":
+ return "Memory";
+ case "disk_percent":
+ return "Disk";
+ case "cpu_temp":
+ return "CPU temperature";
+ case "io_load":
+ return "Load avg";
+ default:
+ return id;
+ }
+}
+
+function metricIcon(id: string) {
+ switch (id) {
+ case "cpu_percent":
+ return ;
+ case "memory_percent":
+ return ;
+ case "disk_percent":
+ return ;
+ case "cpu_temp":
+ return ;
+ case "io_load":
+ return ;
+ default:
+ return ;
+ }
+}
+
+function formatMetric(metric: SystemMetricSample) {
+ if (typeof metric.value !== "number") return "-";
+ const value = metric.unit === "%" ? Math.round(metric.value) : Math.round(metric.value * 10) / 10;
+ return `${value}${metric.unit ?? ""}`;
+}
+
+function metricColor(metric: SystemMetricSample) {
+ if (!metric.available) return "text-muted-foreground";
+ const thresholdValue = metricThresholdValue(metric);
+ if (thresholdValue === null) return "text-muted-foreground";
+ if (thresholdValue > 95) return "text-destructive";
+ if (thresholdValue >= 80) return "text-yellow-500 dark:text-yellow-400";
+ return "text-muted-foreground";
+}
+
+function metricThresholdValue(metric: SystemMetricSample) {
+ if (typeof metric.value !== "number") return null;
+ if (metric.unit === "%" || metric.id === "cpu_temp") return metric.value;
+ return null;
+}
+
+function lastUpdatedText(updatedAt?: string) {
+ if (!updatedAt) return "Last update unknown";
+ const date = new Date(updatedAt);
+ if (Number.isNaN(date.getTime())) return "Last update unknown";
+ return `Updated ${formatDistanceToNow(date, { addSuffix: true })}`;
+}
diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx
index f79e9e8a27..edaddfb4fb 100644
--- a/apps/web/components/task/task-top-bar.test.tsx
+++ b/apps/web/components/task/task-top-bar.test.tsx
@@ -1,5 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { StateProvider } from "@/components/state-provider";
import { TaskTopBar } from "./task-top-bar";
afterEach(() => cleanup());
@@ -90,14 +91,18 @@ vi.mock("@/components/task/branch-path-popover", () => ({
describe("TaskTopBar executor environment controls", () => {
it("hides the executor environment button for filesystem executors", () => {
- render();
+ renderTopBar();
expect(screen.queryByTestId("executor-settings-button")).toBeNull();
});
it("shows the executor environment button for Docker executors", () => {
- render();
+ renderTopBar();
expect(screen.getByTestId("executor-settings-button")).toBeTruthy();
});
});
+
+function renderTopBar(ui: React.ReactNode) {
+ return render({ui});
+}
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx
index e9190cee81..4a9d4325a5 100644
--- a/apps/web/components/task/task-top-bar.tsx
+++ b/apps/web/components/task/task-top-bar.tsx
@@ -20,6 +20,7 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
+import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
import { isDebugUI } from "@/lib/config";
type TaskTopBarProps = {
@@ -317,6 +318,7 @@ function TopBarRight({
}) {
return (
+
{officeTaskHref && (