- **Zum Update:** Das erste Update auf diese Version übernimmt auch die `docker-compose.yml` (siehe unten; braucht wie die
CLI-Aktualisierung `cosign` und `cosign.pub`), migriert die Datenbank (Revision 0032; ein Rollback spielt die Sicherung davor
zurück) und stellt das Backend auf einen unprivilegierten Benutzer um. Nichts davon braucht einen Handgriff.
- **Nur ein Backend führt die Hintergrunddienste aus (HA, Stufe H0):** Laufen zwei Backends an derselben Datenbank, verschickten
beide jeden Alarm und brachen Zyklen mit `UniqueViolation` ab. Jetzt führt nur der Prozess mit der Leader-Sperre in der
Datenbank die neun Hintergrundschleifen aus; die anderen warten in Bereitschaft und übernehmen innerhalb einer halben Minute,
wenn der Leader ausfällt. Verliert der Leader seine Datenbankverbindung, hält er alle Schleifen sofort an (geprüft alle 5 Sekunden
und vor jedem Durchlauf). An einer Replica startet kein Leader. Rolle in `GET /api/health` und als Metrik `opnexus_leader`.
Zwei gleichzeitige Erststarts legen das Admin-Konto nur einmal an.
- **Umzug und Kaltreserve (HA, Stufe H1):** `opnexus backup --full --with-env --with-ca` nimmt zusätzlich die `.env` und die
interne CA des Proxys mit, `opnexus restore DATEI --with-env --with-ca` spielt sie zurück (ohne Datenbank-Zugang, Version und
Adressen des Zielservers; die CA bleibt dieselbe, Browser vertrauen weiter). Neu: `opnexus migrate-host BENUTZER@ZIEL` zieht
eine Installation in einem Schritt auf einen anderen Server um (Vorprüfung, Quelle anhalten, Sicherung mit Zufalls-Passphrase,
Restore, Kontrolle). Im Lab 90 Sekunden mit drei Firewalls. `opnexus status` zeigt das Alter der letzten Vollsicherung.
Handbuch: „Umzug und Notfall“.
- **Betriebseinstellungen im Webinterface:** Was bisher nur in der `.env` oder gar nur im Code stand, lässt sich unter
Einstellungen › Betriebseinstellungen ändern: Sitzungsdauer und Anmeldesperren, Bestätigungsfrist beim Ausrollen, Zeitlimit für
Firewall-Anfragen, Messintervalle und Aufbewahrung, Intervall der Versionsprüfung, Heartbeat-Adresse, CA des Mailservers (PEM
einfügen statt Datei einbinden), Zeitlimit Mailversand. Mit Grenzen, Standardwert, Zurücksetzen und Audit-Eintrag. Ein Wert in der
`.env` hat weiter Vorrang und wird als „vom Server“ angezeigt. Adressen, TLS und Datenbank-Zugang bleiben bewusst in der `.env`.
- **Heartbeat für einen externen Überwacher:** Die Heartbeat-Adresse (nur `https://`) wird nach jedem erfolgreichen Prüfzyklus
aufgerufen; bleibt der Aufruf aus, schlägt der Überwacher Alarm. Die Adresse wird nie angezeigt oder protokolliert.
- **Updates bringen die Compose-Datei mit:** Bisher tauschte `opnexus update` nur die Images; Änderungen an der
`docker-compose.yml` (neue Variablen, Härtung) kamen bei bestehenden Installationen nie an. Jetzt übernimmt das Update die Datei
des Zielreleases aus dem signierten Updater-Image, sichert die alte als `docker-compose.yml.bak-<version>` und legt sie beim
Rollback zurück. Eine hier angepasste Datei wird nicht überschrieben (neue Fassung als `docker-compose.yml.new`); eigene
Anpassungen gehören in `docker-compose.override.yml`.
- **Alarm „Platte des OPNexus-Servers fast voll“** (standardmäßig an, ab 90 %, einstellbar): eine volle Platte hält die Datenbank
an und damit jeden anderen Alarm. Auch im Dashboard und als Metrik `opnexus_server_disk_used_percent`.
- **Härtung aus einem externen Audit:**
- **Kein root mehr in den Containern:** Backend (uid 10001) und Frontend (`node`) laufen unprivilegiert, ohne neue Rechte (`no-new-privileges`), das Frontend zusätzlich
ohne Capabilities. Das Backend startet über `backend/scripts/start.sh` noch kurz als root, stellt die vorhandenen Datenvolumes einmalig um und gibt dann alle Rechte ab:
ein Update braucht keinen Handgriff, ein Rückweg auf eine ältere Version bleibt möglich. Notausgang für Sonderfälle: `OPNEXUS_RUN_AS_ROOT=1` in der `.env`.
`wg-hub` (braucht root für WireGuard) und `gui-gateway` (läuft schon als nobody) sind unverändert. Der Proxy (Caddy) und der optionale Updater (Docker-Socket) laufen weiter wie bisher.
- **Vollsicherung konsistent:** Ändern sich Geheimnisse, während die Sicherung läuft (jemand legt gerade ein Objekt an oder löscht eines), wird sie bis zu dreimal wiederholt;
gelingt das nicht, bricht sie mit einer klaren Meldung ab, statt ein unstimmiges Archiv zu schreiben.
- **Zieladress-Schutz überall:** Audit-Export (Webhook und Syslog), Offsite-S3 und der Update-Feed verbinden nur noch zu Adressen, die die Prüfung bestanden haben
(kein Loopback, Link-Local, Metadaten-Dienst; auch nicht über DNS-Rebinding oder Weiterleitungen). Adressen im eigenen Netz bleiben erlaubt. Der Update-Feed wird beim Lesen auf 1 MB begrenzt, nicht erst danach.
- **Fehlende Basis-Rechte werden sichtbar:** Fehlen dem API-Schlüssel schon die Rechte für die Grundmessungen (Speicher, Last, Platte), schlug das ganze Sampling still fehl und der
Rechte-Alarm konnte nie auslösen. Jetzt entsteht ein Befund „Grundwerte“ (Recht „Lobby: Dashboard“), der sich nach Korrektur von selbst schließt.
- **Installation:** Die `INSTALL.txt` im Bundle und die Anleitung auf der Website legen die `.env` jetzt mit `install -m 600` an (nur für dich lesbar), statt mit `cp`.
- **Oberfläche:** Die Aktionsknöpfe der Backup-Tabelle auf der Host-Seite werden auf schmalen Bildschirmen als Symbole gezeigt, statt abgeschnitten zu werden.
481 lines
23 KiB
TypeScript
481 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { HostMaintenance } from "@/components/HostMaintenance";
|
|
import { HostResourceUsage } from "@/components/HostResourceUsage";
|
|
import { HostGateways } from "@/components/GatewayStatus";
|
|
import { HostServices } from "@/components/HostServices";
|
|
import { RestoreWizard } from "@/components/RestoreWizard";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { Skeleton } from "@/components/ui/Skeleton";
|
|
import { Spinner } from "@/components/ui/Spinner";
|
|
import { StatusDot } from "@/components/ui/StatusDot";
|
|
import { api, AliasOption, Host, HostBackup, LiveRule, PolicyAlias, RestoreOperation, Rule } from "@/lib/api";
|
|
import { ArrowLeft, Download, ExternalLink, GitCompare, RotateCcw, ShieldCheck, ShieldOff } from "lucide-react";
|
|
import { OpenFirewallUi } from "@/components/OpenFirewallUi";
|
|
import { HostVariables } from "@/components/HostVariables";
|
|
import { BackupDiffModal } from "@/components/BackupDiffModal";
|
|
import Link from "next/link";
|
|
import { useParams } from "next/navigation";
|
|
import { Collapsible } from "@/components/ui/Collapsible";
|
|
import { useEffect, useState } from "react";
|
|
import { useLanguage } from "@/components/LanguageProvider";
|
|
import { useNotAdmin } from "@/lib/useReadOnly";
|
|
|
|
export default function HostDetailPage() {
|
|
const { t, ts, errorMessage, locale } = useLanguage();
|
|
const { id } = useParams<{ id: string }>();
|
|
// The raw config.xml contains secrets: its download is admin-only on the server too.
|
|
const readOnly = useNotAdmin();
|
|
const [diffTarget, setDiffTarget] = useState<{ older: string; newer: string } | null>(null);
|
|
const [host, setHost] = useState<Host | null>(null);
|
|
const [rules, setRules] = useState<Rule[] | null>(null);
|
|
const [liveRules, setLiveRules] = useState<LiveRule[] | null>(null);
|
|
const [liveRulesError, setLiveRulesError] = useState<string | null>(null);
|
|
const [aliases, setAliases] = useState<PolicyAlias[] | null>(null);
|
|
const [liveAliases, setLiveAliases] = useState<AliasOption[] | null>(null);
|
|
const [liveAliasesError, setLiveAliasesError] = useState<string | null>(null);
|
|
const [backups, setBackups] = useState<HostBackup[] | null>(null);
|
|
const [backupsError, setBackupsError] = useState<string | null>(null);
|
|
const [triggeringBackup, setTriggeringBackup] = useState(false);
|
|
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
|
|
const [restoreHistory, setRestoreHistory] = useState<RestoreOperation[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
function loadRestoreHistory() {
|
|
api.listRestoreOperations(id).then(setRestoreHistory).catch(() => {});
|
|
}
|
|
|
|
useEffect(() => {
|
|
api.getHost(id).then(setHost).catch((e) => setError(errorMessage(e, "hostDetail.notFound")));
|
|
api.listRules().then(setRules).catch(() => {});
|
|
api.listAliases().then(setAliases).catch(() => {});
|
|
api
|
|
.getLiveRules(id)
|
|
.then(setLiveRules)
|
|
.catch((e) => setLiveRulesError(errorMessage(e, "hostDetail.liveRulesFailed")));
|
|
api
|
|
.getHostAliases(id)
|
|
.then(setLiveAliases)
|
|
.catch((e) => setLiveAliasesError(errorMessage(e, "hostDetail.liveAliasesFailed")));
|
|
api
|
|
.listHostBackups(id)
|
|
.then(setBackups)
|
|
.catch((e) => setBackupsError(errorMessage(e, "hostDetail.backupsFailed")));
|
|
loadRestoreHistory();
|
|
}, [id]);
|
|
|
|
async function handleTriggerBackup() {
|
|
setTriggeringBackup(true);
|
|
setBackupsError(null);
|
|
try {
|
|
const created = await api.triggerHostBackup(id);
|
|
setBackups((prev) => [created, ...(prev ?? [])]);
|
|
} catch (e) {
|
|
setBackupsError(errorMessage(e, "hostDetail.backupFailed"));
|
|
} finally {
|
|
setTriggeringBackup(false);
|
|
}
|
|
}
|
|
|
|
async function handleDownloadBackup(backupId: string) {
|
|
try {
|
|
const { blob, filename } = await api.downloadHostBackup(id, backupId);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
setBackupsError(errorMessage(e, "hostDetail.downloadFailed"));
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
}
|
|
|
|
// effective_host_ids (not target_host_ids) so this also picks up rules/
|
|
// aliases that reach this host only via a device group, not just ones
|
|
// directly assigned to it.
|
|
const managedRules = rules?.filter((r) => r.effective_host_ids.includes(id)) ?? [];
|
|
const managedAliases = aliases?.filter((a) => a.effective_host_ids.includes(id)) ?? [];
|
|
|
|
return (
|
|
<div>
|
|
<Link href="/hosts" className="mb-4 inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700">
|
|
<ArrowLeft size={14} />
|
|
{t("hostDetail.backToHosts")}
|
|
</Link>
|
|
|
|
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
|
|
|
{!host && !error && (
|
|
<div className="mb-8">
|
|
<div className="mb-3 flex items-center gap-3">
|
|
<Skeleton className="h-7 w-40" />
|
|
<Skeleton className="h-5 w-16 rounded-full" />
|
|
</div>
|
|
<Skeleton className="mb-6 h-4 w-56" />
|
|
<Skeleton className="h-5 w-56" />
|
|
</div>
|
|
)}
|
|
|
|
{host && (
|
|
<>
|
|
<div className="mb-6 flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold text-slate-900">{host.name}</h1>
|
|
{host.carp_status && (
|
|
<span
|
|
className={
|
|
host.carp_status === "MASTER"
|
|
? "rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-700"
|
|
: "rounded-full bg-flame-100 px-2.5 py-0.5 text-xs font-medium text-flame-700"
|
|
}
|
|
>
|
|
{host.carp_status}
|
|
</span>
|
|
)}
|
|
{host.cluster_name && (
|
|
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">
|
|
{host.cluster_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mb-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-slate-500">
|
|
<span>{host.base_url}</span>
|
|
<StatusDot status={host.status} />
|
|
<Button href={host.base_url} target="_blank" rel="noopener noreferrer" variant="ghost">
|
|
<ExternalLink size={14} />
|
|
{t("hosts.openInOpnsense")}
|
|
</Button>
|
|
<OpenFirewallUi host={host} />
|
|
</div>
|
|
|
|
<HostResourceUsage host={host} />
|
|
|
|
<HostGateways host={host} />
|
|
|
|
<HostMaintenance host={host} />
|
|
|
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">{t("hostDetail.managedRules")}</h2>
|
|
<Card className="mb-8 overflow-x-auto">
|
|
{!rules ? (
|
|
<div className="space-y-3 p-5">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-5/6" />
|
|
</div>
|
|
) : managedRules.length === 0 ? (
|
|
<p className="px-5 py-6 text-center text-slate-400">{t("hostDetail.noManagedRules")}</p>
|
|
) : (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">{t("common.description")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colRule")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colStatusOnHost")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{managedRules.map((rule) => {
|
|
const dep = rule.deployments.find((d) => d.host_id === id);
|
|
return (
|
|
<tr key={rule.id} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-5 py-3 font-medium text-slate-900">{rule.description}</td>
|
|
<td className="px-5 py-3 text-slate-500">
|
|
{rule.interface} · {rule.direction === "in" ? t("rules.dirIn") : t("rules.dirOut")} · {rule.protocol} ·{" "}
|
|
{rule.source_net} → {rule.destination_net}
|
|
{rule.destination_port ? `:${rule.destination_port}` : ""}
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
{dep ? (
|
|
<span className="text-slate-600">
|
|
{dep.status}
|
|
{dep.message && <span className="text-slate-400"> — {ts(dep.message)}</span>}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-300">{t("hostDetail.notDeployed")}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
|
|
<Collapsible title={t("hostDetail.liveRulesTitle")} count={liveRules?.length ?? null} className="mb-8">
|
|
<Card className="max-h-[70vh] overflow-auto">
|
|
{liveRulesError && <p className="px-5 py-6 text-sm text-red-600">{liveRulesError}</p>}
|
|
{!liveRulesError && !liveRules && (
|
|
<div className="space-y-3 p-5">
|
|
<div className="mb-2 flex items-center gap-2 text-sm text-slate-400">
|
|
<Spinner size={14} />
|
|
{t("hostDetail.fetchingRules")}
|
|
</div>
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-4 w-full" />
|
|
))}
|
|
</div>
|
|
)}
|
|
{liveRules && (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
|
|
<th className="px-5 py-3 font-medium">{t("common.description")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("logs.interface")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("fields.direction")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("nat.protocol")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("drift.colSourceDest")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("logs.action")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colOrigin")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{liveRules.map((rule) => (
|
|
<tr key={rule.uuid} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-5 py-3">
|
|
{rule.enabled ? (
|
|
<ShieldCheck size={15} className="text-emerald-500" />
|
|
) : (
|
|
<ShieldOff size={15} className="text-slate-300" />
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-3 text-slate-900">{rule.description}</td>
|
|
<td className="px-5 py-3 text-slate-500">{rule.interface_label ?? "—"}</td>
|
|
<td className="px-5 py-3 text-slate-500">{rule.direction_label}</td>
|
|
<td className="px-5 py-3 text-slate-500">{rule.protocol_label ?? "any"}</td>
|
|
<td className="px-5 py-3 text-slate-500">
|
|
{rule.source_net}
|
|
{rule.source_port ? `:${rule.source_port}` : ""} → {rule.destination_net}
|
|
{rule.destination_port ? `:${rule.destination_port}` : ""}
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<span
|
|
className={
|
|
rule.action === "pass"
|
|
? "rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700"
|
|
: "rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700"
|
|
}
|
|
>
|
|
{rule.action_label}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
{rule.is_automatic ? (
|
|
<span className="text-xs text-slate-400">{t("hostDetail.automatic")}</span>
|
|
) : (
|
|
<span className="text-xs text-slate-500">{t("hostDetail.custom")}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
</Collapsible>
|
|
|
|
<h2 className="mb-3 mt-8 text-lg font-semibold text-slate-900">{t("hostDetail.managedAliases")}</h2>
|
|
<Card className="mb-8 overflow-x-auto">
|
|
{!aliases ? (
|
|
<div className="space-y-3 p-5">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-5/6" />
|
|
</div>
|
|
) : managedAliases.length === 0 ? (
|
|
<p className="px-5 py-6 text-center text-slate-400">{t("hostDetail.noManagedAliases")}</p>
|
|
) : (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">{t("hosts.name")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("aliases.content")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colStatusOnHost")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{managedAliases.map((alias) => {
|
|
const dep = alias.deployments.find((d) => d.host_id === id);
|
|
return (
|
|
<tr key={alias.id} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-5 py-3 font-mono font-medium text-slate-900">{alias.name}</td>
|
|
<td className="px-5 py-3 whitespace-pre-wrap text-slate-500">{alias.content || t("aliases.empty")}</td>
|
|
<td className="px-5 py-3">
|
|
{dep ? (
|
|
<span className="text-slate-600">
|
|
{dep.status}
|
|
{dep.message && <span className="text-slate-400"> — {ts(dep.message)}</span>}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-300">{t("hostDetail.notDeployed")}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
|
|
<Collapsible title={t("hostDetail.liveAliasesTitle")} count={liveAliases?.length ?? null} className="mb-8">
|
|
<Card className="max-h-[70vh] overflow-auto">
|
|
{liveAliasesError && <p className="px-5 py-6 text-sm text-red-600">{liveAliasesError}</p>}
|
|
{!liveAliasesError && !liveAliases && (
|
|
<div className="space-y-3 p-5">
|
|
<div className="mb-2 flex items-center gap-2 text-sm text-slate-400">
|
|
<Spinner size={14} />
|
|
{t("hostDetail.fetchingAliases")}
|
|
</div>
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-4 w-full" />
|
|
))}
|
|
</div>
|
|
)}
|
|
{liveAliases && (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
|
|
<th className="px-5 py-3 font-medium">{t("hosts.name")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("aliases.typeLabel")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("aliases.content")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("common.description")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{liveAliases.map((alias) => (
|
|
<tr key={alias.name} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-5 py-3">
|
|
{alias.enabled ? (
|
|
<ShieldCheck size={15} className="text-emerald-500" />
|
|
) : (
|
|
<ShieldOff size={15} className="text-slate-300" />
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-3 font-mono text-slate-900">{alias.name}</td>
|
|
<td className="px-5 py-3 text-slate-500">{alias.type}</td>
|
|
<td className="px-5 py-3 whitespace-pre-wrap text-slate-500">{alias.content || t("aliases.empty")}</td>
|
|
<td className="px-5 py-3 text-slate-500">{alias.description || "—"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
</Collapsible>
|
|
|
|
<div className="mb-3 mt-8 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-slate-900">{t("hostDetail.backups")}</h2>
|
|
<Button onClick={handleTriggerBackup} disabled={triggeringBackup}>
|
|
{triggeringBackup && <Spinner size={14} />}
|
|
{t("hostDetail.backupNow")}
|
|
</Button>
|
|
</div>
|
|
<Card className="overflow-x-auto">
|
|
{backupsError && <p className="px-5 py-4 text-sm text-red-600">{backupsError}</p>}
|
|
{!backupsError && !backups && (
|
|
<div className="space-y-3 p-5">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-5/6" />
|
|
</div>
|
|
)}
|
|
{backups && backups.length === 0 && (
|
|
<p className="px-5 py-6 text-center text-slate-400">{t("hostDetail.noBackups")}</p>
|
|
)}
|
|
{backups && backups.length > 0 && (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colTime")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colSize")}</th>
|
|
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{backups.map((backup, index) => (
|
|
<tr key={backup.id} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-3 py-3 text-slate-900 sm:px-5">{new Date(backup.created_at).toLocaleString(locale)}</td>
|
|
<td className="px-3 py-3 text-slate-500 sm:px-5">{formatSize(backup.size)}</td>
|
|
<td className="px-3 py-3 sm:px-5">
|
|
<div className="flex justify-end gap-x-1">
|
|
{index + 1 < backups.length && (
|
|
<Button variant="ghost" className="!px-2 sm:!px-3.5" title={t("hostDetail.changes")} aria-label={t("hostDetail.changes")} onClick={() => setDiffTarget({ older: backups[index + 1].id, newer: backup.id })}>
|
|
<GitCompare size={14} />
|
|
<span className="hidden sm:inline">{t("hostDetail.changes")}</span>
|
|
</Button>
|
|
)}
|
|
{!readOnly && (
|
|
<Button variant="ghost" className="!px-2 sm:!px-3.5" title={t("hostDetail.download")} aria-label={t("hostDetail.download")} onClick={() => handleDownloadBackup(backup.id)}>
|
|
<Download size={14} />
|
|
<span className="hidden sm:inline">{t("hostDetail.download")}</span>
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" className="!px-2 sm:!px-3.5" title={t("hostDetail.restore")} aria-label={t("hostDetail.restore")} onClick={() => setRestoreTarget(backup.id)}>
|
|
<RotateCcw size={14} />
|
|
<span className="hidden sm:inline">{t("hostDetail.restore")}</span>
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
|
|
<HostServices host={host} />
|
|
|
|
<HostVariables host={host} />
|
|
|
|
{restoreHistory && restoreHistory.length > 0 && (
|
|
<>
|
|
<h2 className="mb-3 mt-8 text-lg font-semibold text-slate-900">{t("hostDetail.restoreHistory")}</h2>
|
|
<Card className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colTime")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hostDetail.colBackup")}</th>
|
|
<th className="px-5 py-3 font-medium">{t("hosts.status")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{restoreHistory.map((op) => (
|
|
<tr key={op.id} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-5 py-3 text-slate-900">{new Date(op.created_at).toLocaleString(locale)}</td>
|
|
<td className="px-5 py-3 text-slate-500">{op.backup_id}</td>
|
|
<td className="px-5 py-3">
|
|
{op.status === "verified" && <span className="text-emerald-700">{t("hostDetail.restoreVerified")}</span>}
|
|
{op.status === "handed_off" && <span className="text-flame-700">{t("hostDetail.restoreHandedOff")}</span>}
|
|
{op.status === "cancelled" && <span className="text-slate-500">{t("hostDetail.restoreCancelled")}</span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{diffTarget && host && <BackupDiffModal hostId={host.id} older={diffTarget.older} newer={diffTarget.newer} onClose={() => setDiffTarget(null)} />}
|
|
{restoreTarget && (
|
|
<RestoreWizard
|
|
hostId={id}
|
|
hostName={host?.name ?? ""}
|
|
backupId={restoreTarget}
|
|
onClose={() => setRestoreTarget(null)}
|
|
onChanged={loadRestoreHistory}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|