mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
feat(fleet-sync): replica self-demote endpoint and role UX (#969)
A replica admin can now demote the instance back to a standalone
control without raw SQLite access. The Settings → Security UI surfaces
a confirm-gated button when the role is replica; the role probe also
surfaces a soft banner when it cannot determine fleet role rather
than silently defaulting to control.
Backend:
- POST /api/fleet/role/demote (admin, requires `{confirm: true}`):
flips fleet_role to 'control', clears fleet_self_identity,
fleet_control_identity, and both received_pushed_at:* watermarks,
drops every replicated_from_control row from scan_policies and
cve_suppressions, nulls out any orphaned policy_evaluation cache.
Returns 409 ALREADY_CONTROL when invoked on a control.
- DatabaseService gains `clearOrphanPolicyEvaluations()` and
`clearReplicatedRows()` helpers. Reanchor consolidates onto
clearReplicatedRows so it shares the same code path.
- `FleetSyncService.demote()` returns boolean for the route to
translate into 200 or 409.
Frontend:
- SecuritySection probes /fleet/role and now records explicit success
vs failure rather than silently treating an error as control. A
soft banner appears when probe fails.
- Replica banner gains a "Demote to control" button and a destructive
ConfirmModal explaining the wipe.
Tests:
- 4 new route-level vitest cases (401, 400 without confirm,
end-to-end demote with replica setup, 409 ALREADY_CONTROL with
explicit precondition).
- Service unit test asserts the consolidated clearReplicatedRows path.
- Full backend suite: 1773 pass / 5 skipped. Frontend: 185 pass.
This commit is contained in:
@@ -77,6 +77,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||
const [fleetRole, setFleetRole] = useState<FleetRole>('control');
|
||||
const [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false);
|
||||
const [demoteConfirm, setDemoteConfirm] = useState(false);
|
||||
const [demoteBusy, setDemoteBusy] = useState(false);
|
||||
const isReplica = fleetRole === 'replica';
|
||||
|
||||
const runTrivyOp = async (
|
||||
@@ -160,18 +163,48 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/role', { localOnly: true });
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setFleetRoleProbeFailed(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) {
|
||||
if (cancelled) return;
|
||||
if (data?.role === 'control' || data?.role === 'replica') {
|
||||
setFleetRole(data.role);
|
||||
setFleetRoleProbeFailed(false);
|
||||
} else {
|
||||
setFleetRoleProbeFailed(true);
|
||||
}
|
||||
} catch {
|
||||
/* fallback: treat as control if the check fails */
|
||||
if (!cancelled) setFleetRoleProbeFailed(true);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isRemote]);
|
||||
|
||||
const handleDemote = async () => {
|
||||
setDemoteBusy(true);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/role/demote', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Demote failed');
|
||||
}
|
||||
toast.success('Replica demoted to control');
|
||||
setFleetRole('control');
|
||||
setDemoteConfirm(false);
|
||||
fetchPolicies();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Demote failed');
|
||||
} finally {
|
||||
setDemoteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
@@ -270,6 +303,33 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
)}
|
||||
|
||||
{!isRemote && isReplica && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Managed by control node</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setDemoteConfirm(true)}
|
||||
disabled={demoteBusy}
|
||||
>
|
||||
Demote to control
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemote && fleetRoleProbeFailed && !isReplica && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -277,9 +337,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
>
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Managed by control node</div>
|
||||
<div className="font-medium">Fleet role could not be determined</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
|
||||
Treating this instance as a control. Refresh the page to retry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -547,6 +607,20 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
|
||||
<ConfirmModal
|
||||
open={demoteConfirm}
|
||||
onOpenChange={setDemoteConfirm}
|
||||
variant="destructive"
|
||||
kicker="FLEET · DEMOTE · IRREVERSIBLE"
|
||||
title="Demote replica to control"
|
||||
confirmLabel={demoteBusy ? 'Demoting...' : 'Demote'}
|
||||
onConfirm={handleDemote}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user