/** * @file RemoteSources.tsx * @description Settings UI for the Remote Data Sources feature: manage the SSH * machines this dashboard pulls Claude Code history from, and choose the global * "data scope" (which machines' data the whole app shows). * * Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the * global scope store ({@link useDataScope}). No secrets are entered or stored * here — authentication defers to the host's SSH stack (~/.ssh/config, agent, * keys, known_hosts); a source is just a label + ssh destination (+ optional * port / identity file / remote home). Live status/sync updates arrive over the * `remote_source.status` WebSocket message. * * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Supports federated dashboards: register SSH-backed or file-synced remote machines, health-check tunnels, and scope the entire UI to local vs all vs selected sources. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/dataScope` * * ## Public surface * - `RemoteSources` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **RemoteSources** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Cloud, Plus, Server, RefreshCw, Wifi, Trash2, Pencil, Check, X, CheckCircle, XCircle, Loader2, Globe, Monitor, ListChecks, } from "lucide-react"; import { api } from "../lib/api"; import type { RemoteSource, RemoteSourceInput } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import type { ScopeMode } from "../lib/dataScope"; const EMPTY_FORM: RemoteSourceInput = { label: "", host: "", ssh_port: null, identity_file: "", remote_home: "", enabled: true, }; /** Compact status pill for a source's last-known sync state. */ function StatusPill({ status }: { status: RemoteSource["status"] }) { const map: Record = { idle: { cls: "text-fg-secondary bg-surface-4/10 border-border-light/20", label: "Idle" }, syncing: { cls: "text-status-warning bg-status-warning/10 border-status-warning/25", label: "Syncing", pulse: true, }, ok: { cls: "text-status-success bg-status-success/10 border-status-success/25", label: "OK" }, error: { cls: "text-status-danger bg-status-danger/10 border-status-danger/25", label: "Error", }, }; const s = map[status] || map.idle; return ( {s.label} ); } export function RemoteSources() { const { t } = useTranslation("settings"); const [sources, setSources] = useState([]); const [facetSources, setFacetSources] = useState([]); const [loading, setLoading] = useState(true); const [scope, setScope] = useDataScope(); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [busyId, setBusyId] = useState(null); const [syncingAll, setSyncingAll] = useState(false); const [testResults, setTestResults] = useState>( {} ); const [confirmDelete, setConfirmDelete] = useState<{ id: string; purge: boolean } | null>(null); const load = useCallback(() => { Promise.all([api.remoteSources.list(), api.sessions.facets()]) .then(([srcRes, facetRes]) => { setSources(srcRes.sources); setFacetSources(facetRes.sources || []); }) .catch(() => undefined) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); // Refresh when a sync finishes or remote-imported rows change (session counts). useEffect(() => { return eventBus.subscribe((msg) => { if (msg.type === "remote_source.status" || isRemoteDataRefreshMessage(msg)) load(); }); }, [load]); // ── Scope selector ────────────────────────────────────────────────────────── // Union of origins that have data (facets) + all configured source ids, so a // freshly-added source is selectable before its first sync lands any rows. const configuredIds = sources.map((s) => s.id); const scopeOptionIds = ["local", ...new Set([...configuredIds, ...facetSources])].filter( (id, i, arr) => id === "local" || (arr.indexOf(id) === i && id !== "local") ); const labelFor = (id: string) => id === "local" ? t("remoteSources.thisMachine", "This machine") : sources.find((s) => s.id === id)?.label || id; function setMode(mode: ScopeMode) { if (mode === "selected") { const selected = scope.selected.length > 0 ? scope.selected : scopeOptionIds; setScope({ mode, selected }); } else { setScope({ mode, selected: scope.selected }); } } function toggleSelected(id: string) { const set = new Set(scope.selected); if (set.has(id)) set.delete(id); else set.add(id); setScope({ mode: "selected", selected: [...set] }); } // ── Form ──────────────────────────────────────────────────────────────────── function openAdd() { setForm(EMPTY_FORM); setEditingId(null); setFormError(null); setShowForm(true); } function openEdit(s: RemoteSource) { setForm({ label: s.label, host: s.host, ssh_port: s.ssh_port, identity_file: s.identity_file || "", remote_home: s.remote_home || "", enabled: s.enabled, }); setEditingId(s.id); setFormError(null); setShowForm(true); } function closeForm() { setShowForm(false); setEditingId(null); setFormError(null); } async function submitForm() { setSaving(true); setFormError(null); // Normalize optional empties to null so the server stores nothing rather // than empty strings (its validators treat absent as "use default"). const payload: RemoteSourceInput = { label: form.label.trim(), host: form.host.trim(), ssh_port: form.ssh_port ? Number(form.ssh_port) : null, identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null, remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null, enabled: form.enabled, }; try { if (editingId) await api.remoteSources.update(editingId, payload); else await api.remoteSources.create(payload); closeForm(); load(); } catch (err) { setFormError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } } // ── Per-source actions ──────────────────────────────────────────────────────── async function toggleEnabled(s: RemoteSource) { setBusyId(s.id); try { await api.remoteSources.update(s.id, { enabled: !s.enabled }); load(); } catch { /* surfaced via reload */ } finally { setBusyId(null); } } async function testSource(s: RemoteSource) { setBusyId(s.id); setTestResults((r) => ({ ...r, [s.id]: { ok: false, message: "" } })); try { const res = await api.remoteSources.test(s.id); setTestResults((r) => ({ ...r, [s.id]: { ok: res.ok, message: res.message } })); } catch (err) { setTestResults((r) => ({ ...r, [s.id]: { ok: false, message: err instanceof Error ? err.message : String(err) }, })); } finally { setBusyId(null); } } async function syncNow(s: RemoteSource) { setBusyId(s.id); try { await api.remoteSources.sync(s.id); load(); } catch (err) { setTestResults((r) => ({ ...r, [s.id]: { ok: false, message: err instanceof Error ? err.message : String(err) }, })); } finally { setBusyId(null); } } async function syncAll() { setSyncingAll(true); try { await api.remoteSources.syncAll(); load(); } catch { /* per-source errors surface via each source's status on reload */ } finally { setSyncingAll(false); } } async function doDelete() { if (!confirmDelete) return; const { id, purge } = confirmDelete; setBusyId(id); try { await api.remoteSources.remove(id, purge); setConfirmDelete(null); load(); } catch { /* surfaced via reload */ } finally { setBusyId(null); } } return (

{t("remoteSources.title", "Remote Data Sources")}

{t( "remoteSources.description", "Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here." )}

{t( "cursorPathsNote", "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)." )}

{/* Data scope selector */}
{t("remoteSources.scopeTitle", "Data scope")}

{t( "remoteSources.scopeDesc", "Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost." )}

{/* Card selector — one card per scope mode, each with a short explanation so the choice is self-describing rather than a bare radio label. */}
{( [ { mode: "all", Icon: Globe, title: t("remoteSources.scopeAll", "All sources"), desc: t( "remoteSources.scopeAllDesc", "This machine plus every configured remote source, combined." ), }, { mode: "local", Icon: Monitor, title: t("remoteSources.scopeLocal", "This machine only"), desc: t( "remoteSources.scopeLocalDesc", "Only sessions collected locally — hides all remote-source data." ), }, { mode: "selected", Icon: ListChecks, title: t("remoteSources.scopeSelected", "Selected sources"), desc: t( "remoteSources.scopeSelectedDesc", "Pick exactly which machines to include, below." ), }, ] as { mode: ScopeMode; Icon: typeof Globe; title: string; desc: string }[] ).map(({ mode, Icon, title, desc }) => { const active = scope.mode === mode; return ( ); })}
{scope.mode === "selected" && (
{t("remoteSources.scopePickMachines", "Machines to include")}
{scopeOptionIds.map((id) => { const on = scope.selected.includes(id); return ( ); })}
)}
{/* Sources list header + add button */}
{t("remoteSources.listTitle", "Configured sources")}
{sources.some((s) => s.enabled) && ( )}
{/* Add/Edit form */} {showForm && (
{editingId ? t("remoteSources.editTitle", "Edit source") : t("remoteSources.addTitle", "Add a remote source")}
setForm((f) => ({ ...f, label: e.target.value }))} />
setForm((f) => ({ ...f, host: e.target.value }))} />
setForm((f) => ({ ...f, ssh_port: e.target.value ? Number(e.target.value) : null, })) } />
setForm((f) => ({ ...f, identity_file: e.target.value }))} />
setForm((f) => ({ ...f, remote_home: e.target.value }))} />

{t( "remoteSources.fieldRemoteHomeHint", "Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude." )}

{formError && (
{formError}
)}
)} {/* Sources list */} {loading ? (
{t("common:loading", "Loading…")}
) : sources.length === 0 ? (

{t("remoteSources.empty", "No remote sources yet.")}

{t( "remoteSources.emptyHint", "Add a machine you reach over SSH to pull its Claude Code usage in." )}

) : (
{sources.map((s) => { const test = testResults[s.id]; const busy = busyId === s.id; return (
{s.label} {!s.enabled && ( {t("remoteSources.paused", "Auto-sync off")} )} {s.session_count != null && s.session_count > 0 && ( {t("remoteSources.sessionCountLinked", "{{n}} linked", { n: s.session_count, })} )}
{s.host} {s.ssh_port ? `:${s.ssh_port}` : ""} {s.remote_home ? ` · ${s.remote_home}` : ""}
{s.last_sync_at ? t("remoteSources.lastSync", "Last sync: {{when}}", { when: new Date(s.last_sync_at).toLocaleString(), }) : t("remoteSources.neverSynced", "Never synced")} {s.last_sync_counts?.imported != null && ` · ${t("remoteSources.syncNew", "{{n}} new", { n: s.last_sync_counts.imported, })}`} {s.last_sync_counts?.sessions_tagged != null && ` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", { n: s.last_sync_counts.sessions_tagged, })}`}
{s.status === "error" && s.last_error && (
{s.last_error}
)} {test && test.message && (
{test.ok ? ( ) : ( )} {test.message}
)}
{/* Inline delete confirmation */} {confirmDelete?.id === s.id && (

{t("remoteSources.confirmDelete", "Remove this source?")}

)}
); })}
)}
); }