mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +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:
@@ -273,3 +273,73 @@ describe('POST /api/fleet/role/reanchor', () => {
|
|||||||
expect(next.status).toBe(200);
|
expect(next.status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/fleet/role/demote', () => {
|
||||||
|
it('returns 401 without auth', async () => {
|
||||||
|
const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 without confirm:true', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/role/demote')
|
||||||
|
.set('Authorization', adminAuthHeader)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/confirmation/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demotes a replica back to control, drops mirrored rows, then re-allows local writes', async () => {
|
||||||
|
// Reanchor first so this test does not depend on whichever fingerprint
|
||||||
|
// an earlier test left in place; then self-promote into a replica via a
|
||||||
|
// sync push with at least one row so the demote has something to wipe.
|
||||||
|
const reset = await request(app)
|
||||||
|
.post('/api/fleet/role/reanchor')
|
||||||
|
.set('Authorization', adminAuthHeader)
|
||||||
|
.send({ override: true });
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
|
||||||
|
const push = await request(app)
|
||||||
|
.post('/api/fleet/sync/scan_policies')
|
||||||
|
.set('Authorization', nodeProxyAuthHeader)
|
||||||
|
.send({
|
||||||
|
rows: [
|
||||||
|
{ name: 'mirrored-policy', node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 0, enabled: 1 },
|
||||||
|
],
|
||||||
|
targetIdentity: 'https://me.example',
|
||||||
|
controlIdentity: 'fingerprint-demote-test',
|
||||||
|
});
|
||||||
|
expect(push.status).toBe(200);
|
||||||
|
|
||||||
|
const beforeRole = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||||
|
expect(beforeRole.body.role).toBe('replica');
|
||||||
|
|
||||||
|
const demote = await request(app)
|
||||||
|
.post('/api/fleet/role/demote')
|
||||||
|
.set('Authorization', adminAuthHeader)
|
||||||
|
.send({ confirm: true });
|
||||||
|
expect(demote.status).toBe(200);
|
||||||
|
expect(demote.body.role).toBe('control');
|
||||||
|
|
||||||
|
const afterRole = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||||
|
expect(afterRole.body.role).toBe('control');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 ALREADY_CONTROL when called on a control instance', async () => {
|
||||||
|
// Be explicit about the precondition so this test holds under --shuffle.
|
||||||
|
const role = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||||
|
if (role.body.role !== 'control') {
|
||||||
|
const cleanup = await request(app)
|
||||||
|
.post('/api/fleet/role/demote')
|
||||||
|
.set('Authorization', adminAuthHeader)
|
||||||
|
.send({ confirm: true });
|
||||||
|
expect(cleanup.status).toBe(200);
|
||||||
|
}
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/role/demote')
|
||||||
|
.set('Authorization', adminAuthHeader)
|
||||||
|
.send({ confirm: true });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe('ALREADY_CONTROL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const {
|
|||||||
mockGetLocalCveSuppressions,
|
mockGetLocalCveSuppressions,
|
||||||
mockReplaceReplicatedScanPolicies,
|
mockReplaceReplicatedScanPolicies,
|
||||||
mockReplaceReplicatedCveSuppressions,
|
mockReplaceReplicatedCveSuppressions,
|
||||||
|
mockClearOrphanPolicyEvaluations,
|
||||||
|
mockClearReplicatedRows,
|
||||||
mockRecordFleetSyncSuccess,
|
mockRecordFleetSyncSuccess,
|
||||||
mockRecordFleetSyncFailure,
|
mockRecordFleetSyncFailure,
|
||||||
mockGetSystemState,
|
mockGetSystemState,
|
||||||
@@ -25,6 +27,8 @@ const {
|
|||||||
mockGetLocalCveSuppressions: vi.fn().mockReturnValue([]),
|
mockGetLocalCveSuppressions: vi.fn().mockReturnValue([]),
|
||||||
mockReplaceReplicatedScanPolicies: vi.fn(),
|
mockReplaceReplicatedScanPolicies: vi.fn(),
|
||||||
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
||||||
|
mockClearOrphanPolicyEvaluations: vi.fn(),
|
||||||
|
mockClearReplicatedRows: vi.fn(),
|
||||||
mockRecordFleetSyncSuccess: vi.fn(),
|
mockRecordFleetSyncSuccess: vi.fn(),
|
||||||
mockRecordFleetSyncFailure: vi.fn(),
|
mockRecordFleetSyncFailure: vi.fn(),
|
||||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||||
@@ -42,6 +46,8 @@ vi.mock('../services/DatabaseService', () => ({
|
|||||||
getLocalCveSuppressions: mockGetLocalCveSuppressions,
|
getLocalCveSuppressions: mockGetLocalCveSuppressions,
|
||||||
replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies,
|
replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies,
|
||||||
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
||||||
|
clearOrphanPolicyEvaluations: mockClearOrphanPolicyEvaluations,
|
||||||
|
clearReplicatedRows: mockClearReplicatedRows,
|
||||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||||
getSystemState: mockGetSystemState,
|
getSystemState: mockGetSystemState,
|
||||||
@@ -474,7 +480,7 @@ describe('FleetSyncService.reanchor', () => {
|
|||||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_control_identity', '');
|
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_control_identity', '');
|
||||||
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:scan_policies', '');
|
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:scan_policies', '');
|
||||||
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:cve_suppressions', '');
|
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:cve_suppressions', '');
|
||||||
expect(mockReplaceReplicatedScanPolicies).toHaveBeenCalledWith([]);
|
expect(mockClearReplicatedRows).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,36 @@ fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Demote this replica back to a standalone control. Wipes all replicated
|
||||||
|
// security rules and the cached fingerprint, then flips `fleet_role` to
|
||||||
|
// 'control'. The local UI's write controls become available again.
|
||||||
|
// `{confirm: true}` body is required so a misclick cannot destroy mirrored
|
||||||
|
// state.
|
||||||
|
fleetRouter.post('/role/demote', authMiddleware, (req: Request, res: Response): void => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
const body = req.body ?? {};
|
||||||
|
if (body.confirm !== true) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Demote requires explicit confirmation. Send { "confirm": true } to proceed.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const demoted = FleetSyncService.getInstance().demote();
|
||||||
|
if (!demoted) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'This instance is already a control; nothing to demote.',
|
||||||
|
code: 'ALREADY_CONTROL',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, role: 'control' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[FleetSync] Demote failed:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to demote replica' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Reset the control anchor on this replica. An admin must opt in explicitly
|
// Reset the control anchor on this replica. An admin must opt in explicitly
|
||||||
// with `{override: true}` because reanchor wipes all replicated rows; the
|
// with `{override: true}` because reanchor wipes all replicated rows; the
|
||||||
// next push from a different control will re-populate them. Used when a
|
// next push from a different control will re-populate them. Used when a
|
||||||
|
|||||||
@@ -3872,6 +3872,38 @@ export class DatabaseService {
|
|||||||
txn(rows);
|
txn(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null out `vulnerability_scans.policy_evaluation` rows whose `$.policyId`
|
||||||
|
* no longer exists in `scan_policies`. Used after replicated rows are
|
||||||
|
* wiped (sync replace, demote) so cached scan banners do not reference
|
||||||
|
* deleted policies.
|
||||||
|
*/
|
||||||
|
public clearOrphanPolicyEvaluations(): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE vulnerability_scans
|
||||||
|
SET policy_evaluation = NULL
|
||||||
|
WHERE policy_evaluation IS NOT NULL
|
||||||
|
AND CAST(json_extract(policy_evaluation, '$.policyId') AS INTEGER)
|
||||||
|
NOT IN (SELECT id FROM scan_policies)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically delete every replicated_from_control row from both
|
||||||
|
* scan_policies and cve_suppressions, then null out any orphaned
|
||||||
|
* policy_evaluation cache. Used by the demote endpoint and any future
|
||||||
|
* "drop replicated state" operation.
|
||||||
|
*/
|
||||||
|
public clearReplicatedRows(): void {
|
||||||
|
this.transaction(() => {
|
||||||
|
this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1').run();
|
||||||
|
this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1').run();
|
||||||
|
this.clearOrphanPolicyEvaluations();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Stack Labels ---
|
// --- Stack Labels ---
|
||||||
|
|
||||||
public getLabels(nodeId: number): Label[] {
|
public getLabels(nodeId: number): Label[] {
|
||||||
|
|||||||
@@ -303,12 +303,44 @@ export class FleetSyncService {
|
|||||||
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
|
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
|
||||||
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
|
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
|
||||||
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
|
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
|
||||||
db.replaceReplicatedScanPolicies([]);
|
db.clearReplicatedRows();
|
||||||
db.replaceReplicatedCveSuppressions([]);
|
|
||||||
});
|
});
|
||||||
FleetSyncService.cachedControlIdentity = null;
|
FleetSyncService.cachedControlIdentity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demote this replica back to a standalone control. Atomic transaction:
|
||||||
|
* - 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
|
||||||
|
*
|
||||||
|
* After this returns, local writes to scan_policies and cve_suppressions
|
||||||
|
* succeed again (`blockIfReplica` no longer trips).
|
||||||
|
*
|
||||||
|
* Returns `false` if the instance is already a control (no work done);
|
||||||
|
* the route translates that to 409 so the UI doesn't show a misleading
|
||||||
|
* success toast.
|
||||||
|
*/
|
||||||
|
public demote(): boolean {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
if (FleetSyncService.getRole() === 'control') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
db.transaction(() => {
|
||||||
|
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'control');
|
||||||
|
db.setSystemState(SYNC_STATE_KEYS.fleetSelfIdentity, '');
|
||||||
|
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
|
||||||
|
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
|
||||||
|
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
|
||||||
|
db.clearReplicatedRows();
|
||||||
|
});
|
||||||
|
FleetSyncService.cachedControlIdentity = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chain a push behind any in-flight push for the same node. Different
|
* Chain a push behind any in-flight push for the same node. Different
|
||||||
* nodes still run in parallel via the outer Promise.all.
|
* nodes still run in parallel via the outer Promise.all.
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
|||||||
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
|
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
|
||||||
'POST /fleet/update-all': 'Triggered fleet-wide update',
|
'POST /fleet/update-all': 'Triggered fleet-wide update',
|
||||||
'POST /fleet/role/reanchor': 'Re-anchored fleet replica',
|
'POST /fleet/role/reanchor': 'Re-anchored fleet replica',
|
||||||
|
'POST /fleet/role/demote': 'Demoted fleet replica to control',
|
||||||
|
|
||||||
// Cloud backup
|
// Cloud backup
|
||||||
'PUT /cloud-backup/config': 'Updated cloud backup config',
|
'PUT /cloud-backup/config': 'Updated cloud backup config',
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
||||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||||
const [fleetRole, setFleetRole] = useState<FleetRole>('control');
|
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 isReplica = fleetRole === 'replica';
|
||||||
|
|
||||||
const runTrivyOp = async (
|
const runTrivyOp = async (
|
||||||
@@ -160,18 +163,48 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/fleet/role', { localOnly: true });
|
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();
|
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);
|
setFleetRole(data.role);
|
||||||
|
setFleetRoleProbeFailed(false);
|
||||||
|
} else {
|
||||||
|
setFleetRoleProbeFailed(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fallback: treat as control if the check fails */
|
if (!cancelled) setFleetRoleProbeFailed(true);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [isRemote]);
|
}, [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 = () => {
|
const openCreate = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
@@ -270,6 +303,33 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isRemote && isReplica && (
|
{!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
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
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" />
|
<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="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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
|
||||||
</p>
|
</p>
|
||||||
</ConfirmModal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user