Files
Claude-Code-Monitor/client/src/components/RemoteSources.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
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.
2026-07-31 10:54:31 +07:00

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>
);
}