b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
777 lines
31 KiB
TypeScript
777 lines
31 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* 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<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = {
|
|
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 (
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-0.5 rounded-full border ${s.cls}`}
|
|
>
|
|
<span
|
|
className={`w-1.5 h-1.5 rounded-full bg-current ${s.pulse ? "animate-pulse-dot" : ""}`}
|
|
/>
|
|
{s.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function RemoteSources() {
|
|
const { t } = useTranslation("settings");
|
|
const [sources, setSources] = useState<RemoteSource[]>([]);
|
|
const [facetSources, setFacetSources] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [scope, setScope] = useDataScope();
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [form, setForm] = useState<RemoteSourceInput>(EMPTY_FORM);
|
|
const [saving, setSaving] = useState(false);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const [syncingAll, setSyncingAll] = useState(false);
|
|
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
|
{}
|
|
);
|
|
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 (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
<Cloud className="w-4 h-4 text-fg-muted" />
|
|
{t("remoteSources.title", "Remote Data Sources")}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-4">
|
|
{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."
|
|
)}
|
|
</p>
|
|
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">
|
|
{t(
|
|
"cursorPathsNote",
|
|
"Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)."
|
|
)}
|
|
</p>
|
|
|
|
{/* Data scope selector */}
|
|
<div className="card p-5 mb-4">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Wifi className="w-4 h-4 text-accent" />
|
|
<span className="text-sm font-medium text-fg-secondary">
|
|
{t("remoteSources.scopeTitle", "Data scope")}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-fg-muted mb-3">
|
|
{t(
|
|
"remoteSources.scopeDesc",
|
|
"Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost."
|
|
)}
|
|
</p>
|
|
{/* Card selector — one card per scope mode, each with a short explanation
|
|
so the choice is self-describing rather than a bare radio label. */}
|
|
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
|
|
{(
|
|
[
|
|
{
|
|
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 (
|
|
<button
|
|
key={mode}
|
|
role="radio"
|
|
aria-checked={active}
|
|
onClick={() => setMode(mode)}
|
|
className={`relative text-left rounded-xl border p-3 transition-colors ${
|
|
active
|
|
? "border-accent bg-accent/10 ring-1 ring-accent/40"
|
|
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-2/70"
|
|
}`}
|
|
>
|
|
{active && (
|
|
<CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" />
|
|
)}
|
|
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-fg-secondary"}`} />
|
|
<div
|
|
className={`text-sm font-medium ${active ? "text-fg-primary" : "text-fg-secondary"}`}
|
|
>
|
|
{title}
|
|
</div>
|
|
<div className="text-[11px] text-fg-muted mt-0.5 leading-snug">{desc}</div>
|
|
{mode === "selected" && scope.mode === "selected" && (
|
|
<div className="text-[11px] text-accent mt-1">
|
|
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
|
|
n: scope.selected.filter((id) => scopeOptionIds.includes(id)).length,
|
|
total: scopeOptionIds.length,
|
|
})}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{scope.mode === "selected" && (
|
|
<div className="mt-3 pt-3 border-t border-border">
|
|
<div className="text-[11px] uppercase tracking-wider text-fg-muted mb-2">
|
|
{t("remoteSources.scopePickMachines", "Machines to include")}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{scopeOptionIds.map((id) => {
|
|
const on = scope.selected.includes(id);
|
|
return (
|
|
<button
|
|
key={id}
|
|
onClick={() => toggleSelected(id)}
|
|
aria-pressed={on}
|
|
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
|
on
|
|
? "bg-accent/15 border-accent/40 text-accent"
|
|
: "bg-surface-2 border-border text-fg-secondary hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />}
|
|
{labelFor(id)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sources list header + add button */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-medium text-fg-secondary uppercase tracking-wider">
|
|
{t("remoteSources.listTitle", "Configured sources")}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
{sources.some((s) => s.enabled) && (
|
|
<button
|
|
onClick={syncAll}
|
|
disabled={syncingAll}
|
|
className="btn-ghost text-xs disabled:opacity-40"
|
|
title={t("remoteSources.syncAll", "Sync all enabled sources")}
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${syncingAll ? "animate-spin" : ""}`} />
|
|
{t("remoteSources.syncAll", "Sync all")}
|
|
</button>
|
|
)}
|
|
<button onClick={openAdd} className="btn-primary text-xs">
|
|
<Plus className="w-3.5 h-3.5" /> {t("remoteSources.add", "Add source")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add/Edit form */}
|
|
{showForm && (
|
|
<div className="card p-5 mb-4 space-y-3 border-accent/30">
|
|
<div className="text-sm font-medium text-fg-secondary">
|
|
{editingId
|
|
? t("remoteSources.editTitle", "Edit source")
|
|
: t("remoteSources.addTitle", "Add a remote source")}
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">
|
|
{t("remoteSources.fieldLabel", "Label")} *
|
|
</label>
|
|
<input
|
|
className="input w-full"
|
|
placeholder="Dev box"
|
|
value={form.label}
|
|
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">
|
|
{t("remoteSources.fieldHost", "SSH host")} *
|
|
</label>
|
|
<input
|
|
className="input w-full font-mono"
|
|
placeholder="user@host or config-alias"
|
|
value={form.host}
|
|
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">
|
|
{t("remoteSources.fieldPort", "Port (optional)")}
|
|
</label>
|
|
<input
|
|
className="input w-full font-mono"
|
|
type="number"
|
|
placeholder="22"
|
|
value={form.ssh_port ?? ""}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
ssh_port: e.target.value ? Number(e.target.value) : null,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">
|
|
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
|
|
</label>
|
|
<input
|
|
className="input w-full font-mono"
|
|
placeholder="~/.ssh/id_ed25519"
|
|
value={form.identity_file ?? ""}
|
|
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label className="block text-xs text-fg-muted mb-1">
|
|
{t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")}
|
|
</label>
|
|
<input
|
|
className="input w-full font-mono"
|
|
placeholder="~/.claude or wsl:~/.claude"
|
|
value={form.remote_home ?? ""}
|
|
onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))}
|
|
/>
|
|
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
|
|
{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."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-accent"
|
|
checked={!!form.enabled}
|
|
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
|
/>
|
|
<span className="text-sm text-fg-secondary">
|
|
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
|
</span>
|
|
</label>
|
|
{formError && (
|
|
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
|
{formError}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={submitForm}
|
|
disabled={saving || !form.label.trim() || !form.host.trim()}
|
|
className="btn-primary text-xs disabled:opacity-40"
|
|
>
|
|
{saving ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Check className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("common:save", "Save")}
|
|
</button>
|
|
<button onClick={closeForm} className="btn-ghost text-xs">
|
|
<X className="w-3.5 h-3.5" /> {t("common:cancel", "Cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sources list */}
|
|
{loading ? (
|
|
<div className="text-xs text-fg-muted">{t("common:loading", "Loading…")}</div>
|
|
) : sources.length === 0 ? (
|
|
<div className="card p-6 text-center">
|
|
<Server className="w-6 h-6 text-fg-muted mx-auto mb-2" />
|
|
<p className="text-sm text-fg-secondary">
|
|
{t("remoteSources.empty", "No remote sources yet.")}
|
|
</p>
|
|
<p className="text-xs text-fg-muted mt-1">
|
|
{t(
|
|
"remoteSources.emptyHint",
|
|
"Add a machine you reach over SSH to pull its Claude Code usage in."
|
|
)}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{sources.map((s) => {
|
|
const test = testResults[s.id];
|
|
const busy = busyId === s.id;
|
|
return (
|
|
<div key={s.id} className="card p-4">
|
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Server className="w-4 h-4 text-accent shrink-0" />
|
|
<span className="text-sm font-medium text-fg-secondary">{s.label}</span>
|
|
<StatusPill status={s.status} />
|
|
{!s.enabled && (
|
|
<span className="text-[10px] text-fg-muted bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
|
|
{t("remoteSources.paused", "Auto-sync off")}
|
|
</span>
|
|
)}
|
|
{s.session_count != null && s.session_count > 0 && (
|
|
<span
|
|
className="text-[10px] text-fg-secondary bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
|
|
title={t(
|
|
"remoteSources.sessionCountHint",
|
|
"Sessions linked to this source (not every session visible when data scope is All)"
|
|
)}
|
|
>
|
|
{t("remoteSources.sessionCountLinked", "{{n}} linked", {
|
|
n: s.session_count,
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] text-fg-muted font-mono mt-1 truncate">
|
|
{s.host}
|
|
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
|
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
|
</div>
|
|
<div className="text-[11px] text-fg-muted mt-1">
|
|
{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,
|
|
})}`}
|
|
</div>
|
|
{s.status === "error" && s.last_error && (
|
|
<div className="text-[11px] text-status-danger mt-1 break-words">
|
|
{s.last_error}
|
|
</div>
|
|
)}
|
|
{test && test.message && (
|
|
<div
|
|
className={`flex items-start gap-1.5 text-[11px] mt-2 ${
|
|
test.ok ? "text-status-success" : "text-status-danger"
|
|
}`}
|
|
>
|
|
{test.ok ? (
|
|
<CheckCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
|
) : (
|
|
<XCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
|
)}
|
|
<span className="break-words">{test.message}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<button
|
|
onClick={() => testSource(s)}
|
|
disabled={busy}
|
|
className="btn-ghost text-xs disabled:opacity-40"
|
|
title={t("remoteSources.test", "Test connection")}
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Wifi className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("remoteSources.test", "Test")}
|
|
</button>
|
|
<button
|
|
onClick={() => syncNow(s)}
|
|
disabled={busy}
|
|
className="btn-ghost text-xs disabled:opacity-40"
|
|
title={t("remoteSources.syncNow", "Sync now")}
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${busy ? "animate-spin" : ""}`} />
|
|
{t("remoteSources.syncNow", "Sync")}
|
|
</button>
|
|
<button
|
|
onClick={() => toggleEnabled(s)}
|
|
disabled={busy}
|
|
className="btn-ghost text-xs disabled:opacity-40"
|
|
>
|
|
{s.enabled
|
|
? t("remoteSources.disable", "Disable")
|
|
: t("remoteSources.enable", "Enable")}
|
|
</button>
|
|
<button
|
|
onClick={() => openEdit(s)}
|
|
className="btn-ghost text-xs"
|
|
title={t("common:edit", "Edit")}
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmDelete({ id: s.id, purge: false })}
|
|
className="btn-ghost text-xs text-status-danger hover:text-status-danger"
|
|
title={t("common:delete", "Delete")}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inline delete confirmation */}
|
|
{confirmDelete?.id === s.id && (
|
|
<div className="mt-3 pt-3 border-t border-border">
|
|
<p className="text-xs text-fg-secondary mb-2">
|
|
{t("remoteSources.confirmDelete", "Remove this source?")}
|
|
</p>
|
|
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-red-500"
|
|
checked={confirmDelete.purge}
|
|
onChange={(e) =>
|
|
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
|
}
|
|
/>
|
|
<span className="text-xs text-fg-secondary">
|
|
{t(
|
|
"remoteSources.purgeData",
|
|
"Also delete the sessions imported from this source (cannot be undone)"
|
|
)}
|
|
</span>
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={doDelete}
|
|
disabled={busy}
|
|
className="btn-primary text-xs bg-status-danger hover:bg-status-danger disabled:opacity-40"
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
)}
|
|
{confirmDelete.purge
|
|
? t("remoteSources.deleteAndPurge", "Remove + delete data")
|
|
: t("remoteSources.removeKeep", "Remove (keep data)")}
|
|
</button>
|
|
<button onClick={() => setConfirmDelete(null)} className="btn-ghost text-xs">
|
|
{t("common:cancel", "Cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|