mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat(dashboard): replace duplicate Recent Activity card with Fleet Heartbeat / Stack Restart Map (#932)
* feat: open security basics, manual fleet ops, and basic fleet management to Community
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
* feat: add node last-contact tracking, fleet latency, and stack-restart summary
- DatabaseService: add last_successful_contact column to nodes table via
idempotent migration; expose updateNodeLastContact() and getStackRestartSummary()
methods; include the column in NODE_COLUMNS so getNodes/getNode return it
- fleet.ts: record latency_ms and last_successful_contact on each remote
node overview fetch; pilot-agent nodes surface pilot_last_seen instead;
pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls
- dashboard.ts: replace /recent-activity with /stack-restarts endpoint that
groups notification_history events by stack and category (crash/autoheal/manual)
over a configurable window (default 7 days, max 30)
* refactor(dashboard): remove redundant per-route authMiddleware
All routes under /api/ are covered by the global auth gate in app.ts.
The inline authMiddleware arguments on /configuration and /stack-restarts
were redundant with that gate and inconsistent with every other route in
the file. Remove them and drop the now-unused import.
* refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping
- Capture a single completedAt timestamp in fetchRemoteNodeOverview to
eliminate two separate Date.now() calls and ensure latency_ms and
last_successful_contact are derived from the same instant
- Inline the redundant contactedAt variable; use completedAt directly
- Move stack-restart aggregation from JS into SQL (GROUP BY stack_name
with CASE/SUM counts), replacing the Map loop in the route handler
- Export StackRestartSummary interface from DatabaseService and remove
the duplicate local definition in dashboard.ts; handler now returns
the query result directly
- Add last_successful_contact normalization in decryptNodeRow, mirroring
the existing pilot_last_seen pattern
- Add authGate reliance comment above dashboardRouter route handlers
* feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map
- Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time
reachability, latency, and container count per registered node
- Local-only installs: shows Stack Restart Map — 7-day restart frequency
per stack grouped by crash / auto-heal / manual category
- Conditional wrapper (DashboardActivityCard) switches states automatically
when the node list changes, with no page reload required
- Deletes RecentActivity card and hook (duplicated data already in Recent Alerts)
- Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse
* fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel
* fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash
- Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview
interface and to both the pilot-agent and HTTP-proxy return paths in
fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch
can fire correctly
- Remove the private formatRelativeTime from RecentAlerts.tsx and use
the shared implementation from lib/utils, converting the millisecond
timestamp at the call site
- Replace the em dash in getLatencyLabel with 'n/a' per project rules
This commit is contained in:
@@ -71,6 +71,15 @@ export interface Node {
|
||||
api_token?: string;
|
||||
pilot_last_seen?: number | null;
|
||||
pilot_agent_version?: string | null;
|
||||
last_successful_contact?: number | null;
|
||||
}
|
||||
|
||||
export interface StackRestartSummary {
|
||||
stackName: string;
|
||||
crash: number;
|
||||
autoheal: number;
|
||||
manual: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PilotEnrollment {
|
||||
@@ -577,6 +586,7 @@ export class DatabaseService {
|
||||
this.migrateMeshTables();
|
||||
this.migrateNodeLabels();
|
||||
this.migrateBlueprints();
|
||||
this.migrateAddNodeLastContact();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1397,6 +1407,10 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAddNodeLastContact(): void {
|
||||
this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER');
|
||||
}
|
||||
|
||||
// --- Sencho Mesh ---
|
||||
|
||||
public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
|
||||
@@ -1789,6 +1803,25 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
|
||||
}
|
||||
|
||||
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
|
||||
const since = Date.now() - days * 86400 * 1000;
|
||||
return this.db.prepare(`
|
||||
SELECT
|
||||
stack_name AS stackName,
|
||||
SUM(CASE WHEN category = 'deploy_failure' THEN 1 ELSE 0 END) AS crash,
|
||||
SUM(CASE WHEN category = 'autoheal_triggered' THEN 1 ELSE 0 END) AS autoheal,
|
||||
SUM(CASE WHEN category = 'stack_restarted' THEN 1 ELSE 0 END) AS manual,
|
||||
COUNT(*) AS total
|
||||
FROM notification_history
|
||||
WHERE node_id = ?
|
||||
AND timestamp >= ?
|
||||
AND category IN ('deploy_failure', 'autoheal_triggered', 'stack_restarted')
|
||||
AND stack_name IS NOT NULL
|
||||
GROUP BY stack_name
|
||||
ORDER BY total DESC
|
||||
`).all(nodeId, since) as StackRestartSummary[];
|
||||
}
|
||||
|
||||
// --- Container Metrics ---
|
||||
|
||||
public addContainerMetric(metric: Omit<any, 'id'>): void {
|
||||
@@ -1854,11 +1887,12 @@ export class DatabaseService {
|
||||
api_token: row.api_token ? crypto.decrypt(row.api_token) : '',
|
||||
pilot_last_seen: row.pilot_last_seen ?? null,
|
||||
pilot_agent_version: row.pilot_agent_version ?? null,
|
||||
last_successful_contact: row.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly NODE_COLUMNS =
|
||||
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version';
|
||||
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact';
|
||||
|
||||
public getNodes(): Node[] {
|
||||
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`);
|
||||
@@ -1952,6 +1986,11 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
|
||||
}
|
||||
|
||||
public updateNodeLastContact(nodeId: number): void {
|
||||
this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
|
||||
.run(Math.floor(Date.now() / 1000), nodeId);
|
||||
}
|
||||
|
||||
// --- Pilot enrollments ---
|
||||
|
||||
public getPilotEnrollment(nodeId: number): PilotEnrollment | undefined {
|
||||
|
||||
Reference in New Issue
Block a user