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
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:
- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
`/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
`cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
`/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
(VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
`SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
(„instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
(„Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
„Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
allen Seiten, hell und dunkel, nichts mehr.
- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
(Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
354 lines
18 KiB
TypeScript
354 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { Tooltip, InfoTip } from "@/components/ui/Tooltip";
|
|
import { WorkflowBadge } from "@/components/WorkflowBadge";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { WireguardTunnelReview } from "@/components/WireguardTunnelReview";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
import { FieldGroup, Input } from "@/components/ui/Field";
|
|
import { Modal } from "@/components/ui/Modal";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import {
|
|
api,
|
|
DeviceGroup,
|
|
Host,
|
|
WireguardDeploymentResult,
|
|
WireguardTunnel,
|
|
WireguardTunnelBase,
|
|
WireguardTunnelCreate,
|
|
} from "@/lib/api";
|
|
import { AlertTriangle, CheckCircle2, History, KeyRound, Pencil, Rocket, Trash2, Plus } from "lucide-react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { Suspense, useEffect, useState } from "react";
|
|
import { useReadOnly } from "@/lib/useReadOnly";
|
|
import { useLanguage } from "@/components/LanguageProvider";
|
|
|
|
import { Loading } from "@/components/ui/Loading";
|
|
const emptyForm: WireguardTunnelCreate = {
|
|
server_name: "",
|
|
server_public_key: "",
|
|
server_private_key: "",
|
|
listen_port: "51820",
|
|
server_tunnel_address: "",
|
|
dns: "",
|
|
mtu: "",
|
|
peer_name: "",
|
|
peer_public_key: "",
|
|
peer_psk_enabled: false,
|
|
peer_allowed_ips: "",
|
|
peer_endpoint_address: "",
|
|
peer_endpoint_port: "",
|
|
peer_keepalive: "",
|
|
enabled: true,
|
|
target_host_ids: [],
|
|
target_group_ids: [],
|
|
peer_psk_material: "",
|
|
};
|
|
|
|
export default function WireguardPage() {
|
|
const { t } = useLanguage();
|
|
return (
|
|
<Suspense fallback={<Loading>{t("ipsec.loading")}</Loading>}>
|
|
<WireguardContent />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function WireguardContent() {
|
|
const { t, errorMessage } = useLanguage();
|
|
const readOnly = useReadOnly();
|
|
const router = useRouter();
|
|
const params = useSearchParams();
|
|
const tunnelFilter = params.get("tunnel") ?? "";
|
|
const [tunnels, setTunnels] = useState<WireguardTunnel[] | null>(null);
|
|
const [hosts, setHosts] = useState<Host[]>([]);
|
|
const [groups, setGroups] = useState<DeviceGroup[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [form, setForm] = useState<WireguardTunnelCreate>(emptyForm);
|
|
const [saving, setSaving] = useState(false);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [editing, setEditing] = useState<WireguardTunnel | null>(null);
|
|
const [review, setReview] = useState<{ tunnel: WireguardTunnel; mode: "preview" | "history" } | null>(null);
|
|
const toast = useToast();
|
|
|
|
async function load() {
|
|
try {
|
|
const [tunnelList, hostList, groupList] = await Promise.all([api.listWireguardTunnels(), api.listHosts(), api.listGroups()]);
|
|
setTunnels(tunnelList);
|
|
setHosts(hostList);
|
|
setGroups(groupList);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(errorMessage(e, "ipsec.loadFailed"));
|
|
}
|
|
}
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
function openEditor(tunnel: WireguardTunnel | null, restored?: WireguardTunnelBase) {
|
|
setEditing(tunnel);
|
|
const base = restored ?? tunnel ?? emptyForm;
|
|
setForm({ ...base, server_private_key: "", peer_psk_material: "" });
|
|
setFormError(null);
|
|
setReview(null);
|
|
setShowModal(true);
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setFormError(null);
|
|
try {
|
|
if (editing) {
|
|
const { server_private_key, ...rest } = form;
|
|
await api.updateWireguardTunnel(editing.id, server_private_key ? form : rest, editing.updated_at);
|
|
toast.show(t("wg.saved", { name: `${form.server_name} → ${form.peer_name}` }), "success");
|
|
} else {
|
|
await api.createWireguardTunnel(form);
|
|
toast.show(t("wg.created", { name: `${form.server_name} → ${form.peer_name}` }), "success");
|
|
}
|
|
setShowModal(false);
|
|
setForm(emptyForm);
|
|
await load();
|
|
} catch (err) {
|
|
setFormError(errorMessage(err, "ipsec.saveFailed"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
if (!confirm(t("wg.deleteConfirm"))) return;
|
|
const target = tunnels?.find((tn) => tn.id === id);
|
|
const name = target ? `${target.server_name} → ${target.peer_name}` : t("ipsec.tunnelFallback");
|
|
try { await api.deleteWireguardTunnel(id); toast.show(t("ipsec.deleted", { name }), "success"); await load(); }
|
|
catch (e) { setError(errorMessage(e, "common.deleteFailed")); }
|
|
}
|
|
|
|
function toggleTarget(id: string) {
|
|
setForm((f) => ({
|
|
...f,
|
|
target_host_ids: f.target_host_ids.includes(id) ? f.target_host_ids.filter((x) => x !== id) : [...f.target_host_ids, id],
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
<h1 className="text-2xl font-semibold text-slate-900 flex items-center gap-2">{t("wg.title")}<InfoTip text={t("wg.intro")} size={18} /></h1>
|
|
<Button disabled={readOnly} onClick={() => openEditor(null)}>
|
|
<Plus size={16} />
|
|
{t("ipsec.new")}
|
|
</Button>
|
|
</div>
|
|
|
|
|
|
{error && <div className="mb-4"><p role="alert" className="text-sm text-red-600">{error}</p><Button variant="ghost" onClick={load}>{t("rules.retry")}</Button></div>}
|
|
{tunnelFilter && <Button variant="ghost" onClick={() => router.push("/wireguard")}>{t("ipsec.showAll")}</Button>}
|
|
|
|
<fieldset disabled={readOnly} className="contents">
|
|
<div className="space-y-4">
|
|
{tunnels === null && !error && <Card className="p-6"><Loading>{t("ipsec.loading")}</Loading></Card>}
|
|
{tunnels !== null && tunnels.length === 0 && (
|
|
<EmptyState icon={KeyRound} title={t("wg.none")} action={<Button onClick={() => openEditor(null)}>{t("ipsec.createFirst")}</Button>} />
|
|
)}
|
|
{(tunnelFilter ? tunnels?.filter((tn) => tn.id === tunnelFilter) : tunnels)?.map((tunnel) => (
|
|
<Card key={tunnel.id} className="p-5">
|
|
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h2 className="font-medium text-slate-900">{tunnel.server_name} → {tunnel.peer_name}</h2>
|
|
<WorkflowBadge state={tunnel.workflow_state} />
|
|
{!tunnel.server_private_key_set && (
|
|
<Badge tone="warning">
|
|
<KeyRound size={12} />{t("wg.noPrivateKey")}
|
|
</Badge>
|
|
)}
|
|
{tunnel.peer_psk_enabled && !tunnel.peer_psk_set && (
|
|
<Badge tone="warning">
|
|
<KeyRound size={12} />{t("wg.pskMissing")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
{t("wg.summary", { address: tunnel.server_tunnel_address, port: tunnel.listen_port, ips: tunnel.peer_allowed_ips })}
|
|
{tunnel.peer_psk_enabled ? t("wg.peerPskActive") : ""}
|
|
</p>
|
|
<p className="mt-2 text-xs text-slate-500">
|
|
{t("rules.targets", { active: tunnel.workflow_counts.active_targets, total: tunnel.workflow_counts.targets })}
|
|
{tunnel.workflow_counts.retained > 0 ? t("rules.retainedTargets", { count: tunnel.workflow_counts.retained }) : ""}
|
|
{!tunnel.enabled ? t("ipsec.disabledSuffix") : ""}
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{tunnel.target_group_ids.map((id) => <span key={id} className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs text-blue-800">{t("rules.groupTag", { name: groups.find(g => g.id === id)?.name ?? id.slice(0, 8) })}</span>)}
|
|
{tunnel.target_host_ids.map((id) => <span key={id} className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{hosts.find(h => h.id === id)?.name ?? id.slice(0, 8)}</span>)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="outline" onClick={() => openEditor(tunnel)}><Pencil size={14} />{t("rules.edit")}</Button>
|
|
<Button onClick={() => setReview({ tunnel, mode: "preview" })}><Rocket size={14} />{t("rules.reviewPush")}</Button>
|
|
<Tooltip content={t("ipsec.historyTitle")}><Button variant="ghost" onClick={() => setReview({ tunnel, mode: "history" })} aria-label={t("ipsec.historyTitle")}><History size={14} /></Button></Tooltip>
|
|
<Button variant="dangerSubtle" onClick={() => handleDelete(tunnel.id)}><Trash2 size={14} />{t("rules.delete")}</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{tunnel.deployments.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-slate-100 pt-3">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">{t("rules.deployResult")}</p>
|
|
<ul className="flex flex-wrap gap-x-5 gap-y-1.5">
|
|
{tunnel.deployments.map((d) => <DeploymentRow key={d.host_id} deployment={d} />)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset disabled={readOnly} className="contents">
|
|
{review && <WireguardTunnelReview
|
|
tunnel={review.tunnel} mode={review.mode}
|
|
onClose={() => setReview(null)}
|
|
onDeployed={() => { setReview(null); load(); }}
|
|
onChanged={load}
|
|
onRestore={(snapshot) => openEditor(review.tunnel, snapshot)}
|
|
/>}
|
|
|
|
{showModal && (
|
|
<Modal title={editing ? t("ipsec.editTitle") : t("wg.newTitle")} onClose={() => setShowModal(false)}>
|
|
<form onSubmit={handleSubmit} className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">{t("wg.serverSection")}</p>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FieldGroup label={t("wg.interfaceName")}>
|
|
<Input required value={form.server_name} onChange={(e) => setForm({ ...form, server_name: e.target.value })} placeholder="fw1-wg0" />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("wg.listenPort")}>
|
|
<Input required value={form.listen_port} onChange={(e) => setForm({ ...form, listen_port: e.target.value })} placeholder="51820" />
|
|
</FieldGroup>
|
|
</div>
|
|
<FieldGroup label={t("ovpn.tunnelNetwork")}>
|
|
<Input required value={form.server_tunnel_address} onChange={(e) => setForm({ ...form, server_tunnel_address: e.target.value })} placeholder="10.9.0.1/24" />
|
|
</FieldGroup>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FieldGroup label={t("wg.dns")}>
|
|
<Input value={form.dns} onChange={(e) => setForm({ ...form, dns: e.target.value })} placeholder={t("wg.dnsPlaceholder")} />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("wg.mtu")}>
|
|
<Input value={form.mtu} onChange={(e) => setForm({ ...form, mtu: e.target.value })} placeholder={t("wg.mtuPlaceholder")} />
|
|
</FieldGroup>
|
|
</div>
|
|
<FieldGroup label={t("wg.serverPublicKey")}>
|
|
<Input required value={form.server_public_key} onChange={(e) => setForm({ ...form, server_public_key: e.target.value })} placeholder={t("wg.serverPublicKeyPlaceholder")} />
|
|
</FieldGroup>
|
|
<FieldGroup label={editing ? t("wg.serverPrivateKeyEditing", { state: editing.server_private_key_set ? t("ipsec.pskSet") : t("ipsec.pskNotSet") }) : t("wg.serverPrivateKey")}>
|
|
<Input
|
|
type="password"
|
|
required={!editing}
|
|
value={form.server_private_key ?? ""}
|
|
onChange={(e) => setForm({ ...form, server_private_key: e.target.value })}
|
|
placeholder={editing ? t("ipsec.pskKeep") : t("wg.privateKeyPlaceholder")}
|
|
autoComplete="new-password"
|
|
/>
|
|
</FieldGroup>
|
|
<p className="-mt-2 text-xs text-slate-500">
|
|
{t("wg.keyNote", { cmd: "wg genkey" })}
|
|
</p>
|
|
|
|
<p className="pt-2 text-xs font-medium uppercase tracking-wide text-slate-400">{t("wg.peerSection")}</p>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FieldGroup label={t("wg.peerName")}>
|
|
<Input required value={form.peer_name} onChange={(e) => setForm({ ...form, peer_name: e.target.value })} placeholder="fw2-peer" />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("wg.allowedIps")}>
|
|
<Input required value={form.peer_allowed_ips} onChange={(e) => setForm({ ...form, peer_allowed_ips: e.target.value })} placeholder="10.9.0.2/32" />
|
|
</FieldGroup>
|
|
</div>
|
|
<FieldGroup label={t("wg.peerPublicKey")}>
|
|
<Input required value={form.peer_public_key} onChange={(e) => setForm({ ...form, peer_public_key: e.target.value })} placeholder={t("wg.peerPublicKeyPlaceholder")} />
|
|
</FieldGroup>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FieldGroup label={t("wg.endpointAddress")}>
|
|
<Input value={form.peer_endpoint_address} onChange={(e) => setForm({ ...form, peer_endpoint_address: e.target.value })} placeholder={t("wg.endpointAddressPlaceholder")} />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("wg.endpointPort")}>
|
|
<Input value={form.peer_endpoint_port} onChange={(e) => setForm({ ...form, peer_endpoint_port: e.target.value })} placeholder="51820" />
|
|
</FieldGroup>
|
|
</div>
|
|
<FieldGroup label={t("wg.keepalive")}>
|
|
<Input value={form.peer_keepalive} onChange={(e) => setForm({ ...form, peer_keepalive: e.target.value })} placeholder={t("wg.keepalivePlaceholder")} />
|
|
</FieldGroup>
|
|
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={form.peer_psk_enabled} onChange={(e) => setForm({ ...form, peer_psk_enabled: e.target.checked })} />
|
|
{t("wg.pskLabel")}
|
|
</label>
|
|
{form.peer_psk_enabled && <FieldGroup label={editing ? t("wg.peerPskEditing", { state: editing.peer_psk_set ? t("ipsec.pskSet") : t("ipsec.pskNotSet") }) : t("wg.peerPsk")}>
|
|
<Input
|
|
type="password"
|
|
value={form.peer_psk_material ?? ""}
|
|
onChange={(e) => setForm({ ...form, peer_psk_material: e.target.value })}
|
|
placeholder={editing ? t("ipsec.pskKeep") : t("wg.pskPlaceholder")}
|
|
autoComplete="new-password"
|
|
/>
|
|
</FieldGroup>}
|
|
|
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />{t("ipsec.active")}</label>
|
|
|
|
<FieldGroup label={t("rules.deviceGroups")}>
|
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
|
{groups.length === 0 && <p className="text-sm text-slate-500">{t("rules.createGroupsHint")}</p>}
|
|
{groups.map((group) => (
|
|
<label key={group.id} className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.target_group_ids.includes(group.id)}
|
|
onChange={() => setForm((f) => ({
|
|
...f,
|
|
target_group_ids: f.target_group_ids.includes(group.id) ? f.target_group_ids.filter((id) => id !== group.id) : [...f.target_group_ids, group.id],
|
|
}))}
|
|
/>
|
|
{t("rules.groupHosts", { name: group.name, count: group.host_ids.length })}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</FieldGroup>
|
|
<FieldGroup label={t("rules.additionalTargets")}>
|
|
<div className="space-y-1.5 rounded-lg border border-slate-200 p-3">
|
|
{hosts.length === 0 && <p className="text-sm text-slate-400">{t("rules.noHosts")}</p>}
|
|
{hosts.map((host) => (
|
|
<label key={host.id} className="flex items-center gap-2 text-sm text-slate-700">
|
|
<input type="checkbox" checked={form.target_host_ids.includes(host.id)} onChange={() => toggleTarget(host.id)} />
|
|
{host.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</FieldGroup>
|
|
|
|
{formError && <p className="text-sm text-red-600">{formError}</p>}
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="ghost" onClick={() => setShowModal(false)}>{t("common.cancel")}</Button>
|
|
<Button type="submit" disabled={saving} loading={saving}>{saving ? t("common.saving") : t("rules.saveDraft")}</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</fieldset>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeploymentRow({ deployment }: { deployment: WireguardDeploymentResult }) {
|
|
const { ts } = useLanguage();
|
|
const d = deployment;
|
|
return (
|
|
<li className="flex items-center gap-2 text-sm">
|
|
{d.status === "success" ? <CheckCircle2 size={15} className="text-emerald-500" /> : <AlertTriangle size={15} className="text-red-500" />}
|
|
<span className="font-medium text-slate-700">{d.host_name}</span>
|
|
{d.message && <span className="text-slate-400">— {ts(d.message)}</span>}
|
|
</li>
|
|
);
|
|
}
|