Files
OPNexus/frontend/app/hosts/[id]/page.tsx
T
Julian Boldt 295555cfd6
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.22.0
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
  (Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
  Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
  Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
2026-10-20 01:42:30 +02:00

474 lines
23 KiB
TypeScript

"use client";
import { HostMaintenance } from "@/components/HostMaintenance";
import { HostResourceUsage } from "@/components/HostResourceUsage";
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 { 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 items-center gap-4 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>
</div>
<HostResourceUsage 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-5 py-3 text-slate-900">{new Date(backup.created_at).toLocaleString(locale)}</td>
<td className="px-5 py-3 text-slate-500">{formatSize(backup.size)}</td>
<td className="px-5 py-3 text-right">
{index + 1 < backups.length && (
<Button variant="ghost" onClick={() => setDiffTarget({ older: backups[index + 1].id, newer: backup.id })}>
<GitCompare size={14} />
{t("hostDetail.changes")}
</Button>
)}
{!readOnly && (
<Button variant="ghost" onClick={() => handleDownloadBackup(backup.id)}>
<Download size={14} />
{t("hostDetail.download")}
</Button>
)}
<Button variant="ghost" onClick={() => setRestoreTarget(backup.id)}>
<RotateCcw size={14} />
{t("hostDetail.restore")}
</Button>
</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>
);
}