mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
e05099f2a1
Treat 409 CONTROL_IDENTITY_MISMATCH from a replica as a non-retriable
failure instead of looping the same 409 through the 5-minute retry
service forever and silently writing identical failure rows.
Backend
- DatabaseService: add `sticky_error_code`, `sticky_error_expected`,
`sticky_error_got` columns to `fleet_sync_status` via an idempotent
migration. New methods setFleetSyncSticky, getFleetSyncStickyCode,
clearFleetSyncStickyForNode. recordFleetSyncSuccess clears the sticky
flag on a clean push. getFailedSyncTargets SQL adds
`AND sticky_error_code IS NULL` so the retry loop skips sticky rows.
- FleetSyncService.executePushToNode: short-circuits at the top when
sticky is set (covers event-driven pushResourceAsync calls). On a 409
with code CONTROL_IDENTITY_MISMATCH, records the failure once and
pins sticky with the expected/got fingerprints carried in the 409 body.
- routes/nodes.ts: new POST /api/nodes/:id/fleet-sync/reset-anchor.
Admin + paid + node:manage. Proxies POST /api/fleet/role/reanchor to
the peer with `{override:true}` using the stored Bearer node_proxy
token. On peer 200, clears every sticky row for the node so the next
push re-anchors and resumes replication. Distinct 502 / 504 responses
for peer-rejected / peer-unreachable so the UI can show a useful toast.
Frontend
- New lib/fleetSyncApi.ts + hooks/useFleetSyncStatus.ts. Polling hook
(30s visibilityInterval) skips fetch when !isPaid.
- NodeManager.tsx: destructive banner per affected node listing both
fingerprints, with `Reset anchor on peer` and `Remove node` buttons.
Hidden for community-tier users via empty hook data.
- FleetConfiguration.tsx (Fleet -> Status): read-only `Policy sync`
SummaryRow per remote node card. In sync / degraded / paused with
a tooltip; no action buttons (the action lives in NodeManager).
Tests
- fleet-sync-service.test.ts: 4 new cases for sticky-set on first
mismatch, short-circuit on subsequent pushes, null fingerprints,
and non-mismatch failures not setting sticky.
- database-fleet-sync-sticky.test.ts (new): 6 cases pinning the DB
contract incl. retry-loop SQL filter and migration idempotency.
- nodes-fleet-sync-reset-anchor.test.ts (new): 6 cases covering
happy path, peer 401 -> 502, peer unreachable -> 504, local-node
rejection, unknown node id, and community-tier 403.
Gate parity (Directive 30): the new POST .../reset-anchor enforces
requireAdmin + requirePaid + node:manage (matches the existing read at
GET /api/fleet/sync-status). UI banner + SummaryRow only render when the
hook returns data, which it only does for paid-tier authed users. No
existing tier-gate file moved; this is greenfield parity.
Auth audit: the peer's POST /api/fleet/role/reanchor route already uses
requireAdmin, which accepts the central's stored node_proxy Bearer
token because authMiddleware maps `scope === 'node_proxy'` to
`req.user = { username: 'node-proxy', role: 'admin', userId: 0 }`.
No widening required.
Backend tsc clean. Frontend tsc -b clean. 59 fleet-sync tests pass; full
backend suite green minus the pre-existing Windows-only file-lock flake
on filesystem-backup.test.ts that reproduces unchanged on main.
266 lines
9.3 KiB
TypeScript
266 lines
9.3 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { formatCount } from '@/lib/utils';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import {
|
|
Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2, RefreshCw,
|
|
} from 'lucide-react';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
|
|
import { STICKY_CONTROL_IDENTITY_MISMATCH, type FleetSyncStatus } from '@/lib/fleetSyncApi';
|
|
import type { ConfigurationStatusPayload } from '@/components/dashboard';
|
|
|
|
interface FleetNodeConfiguration {
|
|
id: number;
|
|
name: string;
|
|
type: 'local' | 'remote';
|
|
status: 'online' | 'offline';
|
|
configuration: ConfigurationStatusPayload | null;
|
|
}
|
|
|
|
function SummaryRow({ icon: Icon, label, value }: {
|
|
icon: typeof Bell;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 py-0.5">
|
|
<Icon className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
|
|
<span className="text-xs flex-1 text-stat-subtitle">{label}</span>
|
|
<span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type PolicySyncState =
|
|
| { kind: 'in_sync' }
|
|
| { kind: 'degraded'; lastError: string | null }
|
|
| { kind: 'paused' };
|
|
|
|
function derivePolicySyncState(rows: FleetSyncStatus[]): PolicySyncState | null {
|
|
if (rows.length === 0) return null;
|
|
for (const row of rows) {
|
|
if (row.sticky_error_code === STICKY_CONTROL_IDENTITY_MISMATCH) {
|
|
return { kind: 'paused' };
|
|
}
|
|
}
|
|
let degradedError: string | null = null;
|
|
let hasSuccess = false;
|
|
for (const row of rows) {
|
|
if (row.last_success_at !== null) hasSuccess = true;
|
|
if (
|
|
row.last_failure_at !== null
|
|
&& (row.last_success_at === null || row.last_failure_at > row.last_success_at)
|
|
) {
|
|
degradedError = row.last_error;
|
|
}
|
|
}
|
|
if (degradedError !== null) return { kind: 'degraded', lastError: degradedError };
|
|
if (hasSuccess) return { kind: 'in_sync' };
|
|
return null;
|
|
}
|
|
|
|
function PolicySyncRow({ state }: { state: PolicySyncState }) {
|
|
if (state.kind === 'in_sync') {
|
|
return <SummaryRow icon={RefreshCw} label="Policy sync" value="In sync" />;
|
|
}
|
|
const tooltip = state.kind === 'paused'
|
|
? 'Anchored to another central. Open Settings → Nodes to reset the anchor or remove the node.'
|
|
: (state.lastError ?? 'Last push to this node failed.');
|
|
return (
|
|
<div className="flex items-center gap-2 py-0.5">
|
|
<RefreshCw className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
|
|
<span className="text-xs flex-1 text-stat-subtitle">Policy sync</span>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<span
|
|
className={
|
|
'inline-flex items-center rounded border px-1.5 py-0 text-[10px] leading-3 font-mono uppercase '
|
|
+ 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
|
}
|
|
>
|
|
{state.kind === 'paused' ? 'paused' : 'degraded'}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NodeCard({ node, isPaid, policySyncState }: {
|
|
node: FleetNodeConfiguration;
|
|
isPaid: boolean;
|
|
policySyncState: PolicySyncState | null;
|
|
}) {
|
|
const isRemote = node.type === 'remote';
|
|
if (!node.configuration) {
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
|
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
|
{isRemote ? 'Remote' : 'Local'}
|
|
</Badge>
|
|
<WifiOff className="h-3.5 w-3.5 text-stat-subtitle ml-auto" strokeWidth={1.5} />
|
|
<span className="text-xs text-stat-subtitle">Offline</span>
|
|
</div>
|
|
<p className="text-xs text-stat-subtitle/60">Node is unreachable. Configuration unavailable.</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const { notifications, automation, security, backup, thresholds } = node.configuration;
|
|
|
|
const agentCount = [
|
|
notifications.agents.discord.enabled,
|
|
notifications.agents.slack.enabled,
|
|
notifications.agents.webhook.enabled,
|
|
].filter(Boolean).length;
|
|
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
|
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
|
{isRemote ? 'Remote' : 'Local'}
|
|
</Badge>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-success" strokeWidth={1.5} />
|
|
<span className="text-xs text-success">Online</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
|
<SummaryRow icon={Bell} label="Agents"
|
|
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
|
|
<SummaryRow icon={Bell} label="Alert rules"
|
|
value={formatCount(notifications.alertRules, 'rule')} />
|
|
{isPaid && (
|
|
<SummaryRow icon={Zap} label="Auto-heal"
|
|
value={automation.autoHeal.total === 0
|
|
? 'None'
|
|
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
|
)}
|
|
{!automation.webhooks.locked && (
|
|
<SummaryRow icon={Zap} label="Webhooks"
|
|
value={formatCount(automation.webhooks.enabled, 'active')} />
|
|
)}
|
|
{!isRemote && (
|
|
<SummaryRow icon={Shield} label="MFA"
|
|
value={security.mfaEnabled === null ? 'Not set' : security.mfaEnabled ? 'On' : 'Off'} />
|
|
)}
|
|
{!security.scanPolicies.locked && (
|
|
<SummaryRow icon={Shield} label="Scanning"
|
|
value={formatCount(security.scanPolicies.enabled, 'policy')} />
|
|
)}
|
|
{!isRemote && !backup.locked && (
|
|
<SummaryRow icon={HardDrive} label="Backup"
|
|
value={backup.provider === 'disabled' ? 'Disabled' : 'Enabled'} />
|
|
)}
|
|
<SummaryRow icon={HardDrive} label="Crash detect"
|
|
value={thresholds.globalCrash ? 'On' : 'Off'} />
|
|
{policySyncState && <PolicySyncRow state={policySyncState} />}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function FleetConfiguration() {
|
|
const { isPaid } = useLicense();
|
|
const { statuses: syncStatuses } = useFleetSyncStatus();
|
|
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const syncStateByNode = useMemo(() => {
|
|
const byNode = new Map<number, FleetSyncStatus[]>();
|
|
for (const row of syncStatuses) {
|
|
const list = byNode.get(row.node_id);
|
|
if (list) list.push(row);
|
|
else byNode.set(row.node_id, [row]);
|
|
}
|
|
const out = new Map<number, PolicySyncState>();
|
|
for (const [nodeId, rows] of byNode) {
|
|
const state = derivePolicySyncState(rows);
|
|
if (state) out.set(nodeId, state);
|
|
}
|
|
return out;
|
|
}, [syncStatuses]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/fleet/configuration', { localOnly: true });
|
|
if (!res.ok) {
|
|
setError('Failed to fetch fleet configuration.');
|
|
return;
|
|
}
|
|
const data = await res.json() as FleetNodeConfiguration[];
|
|
setNodes(data);
|
|
setError(null);
|
|
} catch {
|
|
setError('Unable to reach the server.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchData();
|
|
}, [fetchData]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<Card key={i} className="bg-card shadow-card-bevel">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="h-4 w-32 rounded-sm bg-accent/10 animate-pulse" />
|
|
{Array.from({ length: 4 }).map((_, j) => (
|
|
<div key={j} className="h-3 rounded-sm bg-accent/10 animate-pulse" />
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
|
<p className="text-sm">{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (nodes.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
|
<p className="text-sm">No nodes configured.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
|
{nodes.map(node => (
|
|
<NodeCard
|
|
key={node.id}
|
|
node={node}
|
|
isPaid={isPaid}
|
|
policySyncState={syncStateByNode.get(node.id) ?? null}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|