mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +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:
@@ -1,7 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { effectiveTier, effectiveVariant } from '../middleware/tierGates';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
|
||||
@@ -155,9 +154,8 @@ export function buildLocalConfigurationStatus(
|
||||
};
|
||||
}
|
||||
|
||||
// Sits after authGate and before the remote proxy in index.ts so remote-node
|
||||
// requests are transparently forwarded to the target Sencho instance.
|
||||
dashboardRouter.get('/configuration', authMiddleware, (req: Request, res: Response): void => {
|
||||
// All routes below are protected by the global authGate mounted at app.use('/api', authGate)
|
||||
dashboardRouter.get('/configuration', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const userId = req.user?.userId ?? 0;
|
||||
@@ -171,17 +169,17 @@ dashboardRouter.get('/configuration', authMiddleware, (req: Request, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
dashboardRouter.get('/recent-activity', authMiddleware, (req: Request, res: Response): void => {
|
||||
dashboardRouter.get('/stack-restarts', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const rawLimit = parseInt(String(req.query['limit'] ?? '10'), 10);
|
||||
const limit = isNaN(rawLimit) || rawLimit < 1 ? 10 : Math.min(rawLimit, 50);
|
||||
const rawDays = parseInt(String(req.query['days'] ?? '7'), 10);
|
||||
const days = isNaN(rawDays) || rawDays < 1 ? 7 : Math.min(rawDays, 30);
|
||||
|
||||
const items = db.getNotificationHistory(nodeId, limit);
|
||||
res.json(items);
|
||||
const result: StackRestartSummary[] = db.getStackRestartSummary(nodeId, days);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to fetch recent activity:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch recent activity' });
|
||||
console.error('[Dashboard] Failed to fetch stack restarts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stack restarts' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ interface FleetNodeOverview {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
mode?: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
stats: {
|
||||
active: number;
|
||||
@@ -87,6 +88,9 @@ interface FleetNodeOverview {
|
||||
disk: { total: number; used: number; free: number; usagePercent: string } | null;
|
||||
} | null;
|
||||
stacks: string[] | null;
|
||||
latency_ms?: number;
|
||||
last_successful_contact?: number | null;
|
||||
pilot_last_seen?: number | null;
|
||||
}
|
||||
|
||||
/** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */
|
||||
@@ -154,26 +158,46 @@ async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
} : null,
|
||||
},
|
||||
stacks,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Fleet] Local node ${node.name} error:`, error);
|
||||
return {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise<FleetNodeOverview> {
|
||||
// Pilot-agent nodes: use pilot_last_seen as the contact signal; no HTTP fetch.
|
||||
if (node.mode === 'pilot_agent') {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
mode: node.mode,
|
||||
status: node.pilot_last_seen ? 'online' : 'offline',
|
||||
stats: null,
|
||||
systemStats: null,
|
||||
stacks: null,
|
||||
last_successful_contact: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null,
|
||||
pilot_last_seen: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!node.api_url || !node.api_token) {
|
||||
return {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
const headers = { Authorization: `Bearer ${node.api_token}` };
|
||||
const t0 = Date.now();
|
||||
|
||||
try {
|
||||
const [statsRes, systemStatsRes, stacksRes] = await Promise.allSettled([
|
||||
@@ -206,20 +230,34 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
} : null,
|
||||
} : null;
|
||||
|
||||
const completedAt = Date.now();
|
||||
const latency_ms = completedAt - t0;
|
||||
const isOnline = !!(stats || systemStats);
|
||||
|
||||
if (isOnline) {
|
||||
db.updateNodeLastContact(node.id);
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
status: stats || systemStats ? 'online' : 'offline',
|
||||
mode: node.mode,
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
stats,
|
||||
systemStats,
|
||||
stacks,
|
||||
latency_ms,
|
||||
last_successful_contact: isOnline
|
||||
? Math.floor(completedAt / 1000)
|
||||
: node.last_successful_contact ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Fleet] Remote node ${node.name} error:`, error);
|
||||
return {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
id: node.id, name: node.name, type: node.type, mode: node.mode, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -287,7 +325,7 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node): Promise<FleetNodeOverview> => {
|
||||
if (node.type === 'remote') {
|
||||
return fetchRemoteNodeOverview(node);
|
||||
return fetchRemoteNodeOverview(node, db);
|
||||
}
|
||||
return fetchLocalNodeOverview(node);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user