diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5582747ee3a..ded98e8c619 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -68,6 +68,7 @@ import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { SkillStudio } from "./pages/SkillStudio"; import { Secrets } from "./pages/Secrets"; +import { BackupsVersionHistory } from "./pages/BackupsVersionHistory"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; @@ -115,6 +116,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index 833b3ca8379..738ed18c7ad 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -22,6 +22,26 @@ export type CloudInstanceHealthStatus = { cloudBaseUrl: string | null; }; +export type HealthWarning = { code: string; message: string }; + +export type DatabaseBackupHealth = { + enabled: boolean; + status: "ok" | "warning"; + backupDir?: string; + maxAgeHours?: number; + latestBackup?: { name: string; path?: string; mtime: string; ageHours: number; sizeBytes: number } | null; + lastFailure?: { path?: string; mtime: string; message: string } | null; + warnings: HealthWarning[]; +}; + +export type StateSnapshotHealth = { + enabled: boolean; + status: "ok" | "warning"; + markerDir?: string; + latestSnapshot?: Record | null; + warnings: HealthWarning[]; +}; + export type HealthStatus = { status: "ok"; version?: string; @@ -36,6 +56,9 @@ export type HealthStatus = { serverInfo?: ServerInfoSnapshot; devServer?: DevServerHealthStatus; cloud?: CloudInstanceHealthStatus; + databaseBackup?: DatabaseBackupHealth; + stateSnapshot?: StateSnapshotHealth; + warnings?: HealthWarning[]; }; export const healthApi = { diff --git a/ui/src/api/stateRepo.ts b/ui/src/api/stateRepo.ts new file mode 100644 index 00000000000..df0326b3c61 --- /dev/null +++ b/ui/src/api/stateRepo.ts @@ -0,0 +1,53 @@ +import { api } from "./client"; + +export type StateRepoCommit = { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + committer: string; + date: string; + subject: string; +}; + +export type StateRepoHealth = { + configured: boolean; + healthy: boolean; + success: { pushedAt: string } | null; + failure: string | null; +}; + +export type StateRepoRemoteConfig = { + companyId: string; + remoteUrl: string; + secretId: string | null; + secretVersion: string | null; + updatedAt: string; +}; + +export type SetStateRepoRemoteInput = { + remoteUrl: string; + secretId?: string | null; + secretVersion?: string | null; +}; + +export type RestoreResult = { restored: string[]; dryRun: boolean }; + +export const stateRepoApi = { + health: (companyId: string) => + api.get(`/companies/${companyId}/state-repo/health`), + log: (companyId: string, limit = 50) => + api.get<{ commits: StateRepoCommit[] }>(`/companies/${companyId}/state-repo/log?limit=${limit}`), + getRemote: (companyId: string) => + api.get<{ remote: StateRepoRemoteConfig | null }>(`/companies/${companyId}/state-repo/remote`), + setRemote: (companyId: string, data: SetStateRepoRemoteInput) => + api.put<{ remote: StateRepoRemoteConfig }>(`/companies/${companyId}/state-repo/remote`, data), + disconnectRemote: (companyId: string) => + api.delete<{ remote: null }>(`/companies/${companyId}/state-repo/remote`), + testMirror: (companyId: string) => + api.post(`/companies/${companyId}/state-repo/mirror/test`, {}), + restore: (companyId: string, source: string, ref = "main", dryRun = false) => + api.post(`/companies/${companyId}/state-repo/restore`, { source, ref, dryRun }), + // Bundle is a file download served with Content-Disposition; link to it directly. + bundleHref: (companyId: string) => `/api/companies/${companyId}/state-repo/bundle`, +}; diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 2d1cec667cf..e452c1897e6 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -5,6 +5,7 @@ import { Cpu, Download, FlaskConical, + History, KeyRound, MailPlus, MonitorCog, @@ -122,6 +123,7 @@ export function CompanySettingsSidebar() { ))} +
Instance settings diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 9bfaee76a7f..65686a9cefc 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -379,6 +379,11 @@ export const queryKeys = { cloud: { stacks: ["cloud", "stacks"] as const, }, + stateRepo: { + health: (companyId: string) => ["state-repo", "health", companyId] as const, + log: (companyId: string) => ["state-repo", "log", companyId] as const, + remote: (companyId: string) => ["state-repo", "remote", companyId] as const, + }, secrets: { list: (companyId: string) => ["secrets", companyId] as const, providers: (companyId: string) => ["secret-providers", companyId] as const, diff --git a/ui/src/pages/BackupsVersionHistory.tsx b/ui/src/pages/BackupsVersionHistory.tsx new file mode 100644 index 00000000000..c308672981d --- /dev/null +++ b/ui/src/pages/BackupsVersionHistory.tsx @@ -0,0 +1,507 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + CheckCircle2, + CircleSlash, + Cloud, + Download, + GitCommitHorizontal, + History, + Link2, + Link2Off, + Loader2, + RefreshCcw, + ShieldCheck, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { SecretBindingPicker, type SecretBindingValue } from "@/components/SecretBindingPicker"; +import { stateRepoApi } from "@/api/stateRepo"; +import { healthApi } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { useToastActions } from "@/context/ToastContext"; +import { timeAgo } from "@/lib/timeAgo"; +import { ApiError } from "@/api/client"; +import { cn } from "@/lib/utils"; + +type Tone = "ok" | "warn" | "danger" | "neutral"; + +const TONE_STYLES: Record = { + ok: { + badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + dot: "bg-emerald-500", + icon: "text-emerald-600 dark:text-emerald-400", + }, + warn: { + badge: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", + dot: "bg-amber-500", + icon: "text-amber-600 dark:text-amber-400", + }, + danger: { + badge: "border-destructive/30 bg-destructive/10 text-destructive", + dot: "bg-destructive", + icon: "text-destructive", + }, + neutral: { + badge: "border-border bg-muted/40 text-muted-foreground", + dot: "bg-muted-foreground/50", + icon: "text-muted-foreground", + }, +}; + +function StatusPill({ tone, label }: { tone: Tone; label: string }) { + return ( + + + {label} + + ); +} + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error) return error.message; + return fallback; +} + +export function BackupsVersionHistory() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + + const [remoteUrl, setRemoteUrl] = useState(""); + const [tokenBinding, setTokenBinding] = useState(null); + const [editingRemote, setEditingRemote] = useState(false); + + useEffect(() => { + setBreadcrumbs([ + { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, + { label: "Settings", href: "/company/settings" }, + { label: "Backups & version history" }, + ]); + }, [selectedCompany?.name, setBreadcrumbs]); + + const enabled = !!selectedCompanyId; + + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + }); + const logQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.stateRepo.log(selectedCompanyId) : ["state-repo", "log", "__none__"], + queryFn: () => stateRepoApi.log(selectedCompanyId!), + enabled, + }); + const mirrorHealthQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.stateRepo.health(selectedCompanyId) : ["state-repo", "health", "__none__"], + queryFn: () => stateRepoApi.health(selectedCompanyId!), + enabled, + }); + const remoteQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.stateRepo.remote(selectedCompanyId) : ["state-repo", "remote", "__none__"], + queryFn: () => stateRepoApi.getRemote(selectedCompanyId!), + enabled, + }); + + const remote = remoteQuery.data?.remote ?? null; + const mirrorHealth = mirrorHealthQuery.data ?? null; + const commits = logQuery.data?.commits ?? []; + + useEffect(() => { + if (remote && !editingRemote) { + setRemoteUrl(remote.remoteUrl); + setTokenBinding( + remote.secretId + ? { secretId: remote.secretId, version: remote.secretVersion === null ? "latest" : (remote.secretVersion as never) } + : null, + ); + } + }, [remote, editingRemote]); + + const invalidateMirror = async () => { + if (!selectedCompanyId) return; + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.stateRepo.health(selectedCompanyId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.stateRepo.remote(selectedCompanyId) }), + ]); + }; + + const saveRemoteMutation = useMutation({ + mutationFn: async () => { + if (!selectedCompanyId) throw new Error("No company selected"); + await stateRepoApi.setRemote(selectedCompanyId, { + remoteUrl: remoteUrl.trim(), + secretId: tokenBinding?.secretId ?? null, + secretVersion: + tokenBinding?.version === undefined || tokenBinding?.version === "latest" + ? "latest" + : String(tokenBinding.version), + }); + return stateRepoApi.testMirror(selectedCompanyId); + }, + onSuccess: async () => { + setEditingRemote(false); + await invalidateMirror(); + pushToast({ title: "Repository connected", body: "First mirror push succeeded.", tone: "success" }); + }, + onError: (error) => { + void invalidateMirror(); + pushToast({ title: "Connection test failed", body: errorMessage(error, "Push failed."), tone: "error" }); + }, + }); + + const testMutation = useMutation({ + mutationFn: () => stateRepoApi.testMirror(selectedCompanyId!), + onSuccess: async () => { + await invalidateMirror(); + pushToast({ title: "Push succeeded", body: "The mirror is up to date.", tone: "success" }); + }, + onError: (error) => { + void invalidateMirror(); + pushToast({ title: "Push failed", body: errorMessage(error, "Push failed."), tone: "error" }); + }, + }); + + const disconnectMutation = useMutation({ + mutationFn: () => stateRepoApi.disconnectRemote(selectedCompanyId!), + onSuccess: async () => { + setEditingRemote(false); + setRemoteUrl(""); + setTokenBinding(null); + await invalidateMirror(); + pushToast({ title: "Repository disconnected", tone: "info" }); + }, + onError: (error) => pushToast({ title: "Failed to disconnect", body: errorMessage(error, ""), tone: "error" }), + }); + + const snapshot = healthQuery.data?.stateSnapshot; + const dbBackup = healthQuery.data?.databaseBackup; + + const snapshotTone: Tone = !snapshot?.enabled + ? "neutral" + : snapshot.status === "ok" + ? "ok" + : "warn"; + const dbBackupTone: Tone = !dbBackup?.enabled ? "neutral" : dbBackup.status === "ok" ? "ok" : "warn"; + + const mirrorTone: Tone = !mirrorHealth?.configured + ? "neutral" + : mirrorHealth.failure + ? "danger" + : mirrorHealth.healthy + ? "ok" + : "warn"; + + if (!selectedCompanyId) { + return ( +
+ Select a company to view backups and version history. +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Backups & version history

+

+ Durability and change history for this company's agent instructions, skills, and memory. + Snapshots protect the whole instance; the state repository keeps an attributed, restorable + history you can mirror to your own git host. +

+
+ + + Restore runbook + +
+ + {/* Snapshot health */} +
+

+ Snapshot health +

+
+ + +
+
+ + {/* Version history */} +
+
+

+ Version history +

+ + + Export bundle + +
+ + {logQuery.isLoading ? ( +
+ Loading history… +
+ ) : commits.length === 0 ? ( +
+ +

No history yet

+

+ The state repository records a commit whenever an agent's instructions, skills, or memory + change. Edits will appear here with who changed what, and when. +

+
+ ) : ( +
    + {commits.map((commit) => ( +
  • + +
    +

    {commit.subject}

    +

    + {commit.author} + {" · "} + {commit.date ? timeAgo(commit.date) : "unknown time"} +

    +
    + {commit.shortHash} +
  • + ))} +
+ )} +
+
+ + {/* Connect your repo */} +
+

+ Connect your repository +

+ + +
+
+ + + Mirror to your own git host + + + Paperclip pushes a one-way mirror of the state repository to your remote after every + change. Your remote is a copy — Paperclip never force-pushes and never rewrites your + history. + +
+ {mirrorHealth?.configured && ( + + )} +
+
+ + {remote && !editingRemote ? ( + <> +
+
+ Remote URL + {remote.remoteUrl} +
+
+ Push token + + {remote.secretId ? "Company secret bound" : "None (public/anonymous remote)"} + +
+
+ {mirrorHealth?.failure ? ( +
+ +
+

Last push failed

+

{mirrorHealth.failure}

+
+
+ ) : mirrorHealth?.success ? ( +
+ + Last pushed {timeAgo(mirrorHealth.success.pushedAt)} — mirror is up to date. +
+ ) : null} +
+ + + +
+ + ) : ( + <> +
+ + setRemoteUrl(event.target.value)} + /> +

+ Must be an https:// URL. Create an empty repository first; + Paperclip pushes into it. +

+
+ +
+ + {remote && ( + + )} +
+ + )} +
+
+
+
+ ); +} + +function HealthCard({ + icon: Icon, + title, + tone, + statusLabel, + detail, + warnings, + loading, +}: { + icon: typeof ShieldCheck; + title: string; + tone: Tone; + statusLabel: string; + detail: string; + warnings: Array<{ code: string; message: string }>; + loading?: boolean; +}) { + return ( + + +
+ + + {title} + + {loading ? ( + + ) : tone === "neutral" ? ( + + + {statusLabel} + + ) : ( + + )} +
+
+ +

{detail}

+ {warnings.length > 0 && ( +
    + {warnings.map((warning) => ( +
  • + + {warning.message} +
  • + ))} +
+ )} +
+
+ ); +}