feat(dashboard): replace 24h charts with Configuration Status and Recent Activity (#785)

* feat(dashboard): replace 24h charts with Configuration Status and Recent Activity

The 24-hour CPU/Memory area charts summed per-container metrics normalized
to each container's CPU quota, producing numbers that bore no honest
relationship to host load. The live ResourceGauges strip already shows
accurate host-level stats, making the historical charts both inaccurate
and redundant.

This commit replaces that row with two side-by-side cards:

- **Configuration Status**: aggregates every toggleable feature on the
  active node (notification agents, alert rules, routing rules, auto-heal,
  auto-update, webhooks, scheduled tasks, MFA, SSO, vulnerability scanning,
  cloud backup, and alert thresholds) into a single at-a-glance card.
  Tier-locked rows display an upgrade indicator instead of a value.
  Each row is clickable and navigates to the relevant settings section.
  Data refreshes every 60 s and immediately on state-invalidate events.

- **Recent Activity**: lists the ten most recent notification-history events
  for the active node (deployments, image updates, auto-heal actions, scan
  findings, cloud backup events, system notices) with category icons and
  relative timestamps. Refreshes every 30 s.

New backend endpoints:
- GET /api/dashboard/configuration - per-node feature status with locked/
  requiredTier markers so the frontend renders upgrade chips without extra
  calls. The endpoint sits after authGate and before the remote proxy so
  remote-node requests are transparently forwarded.
- GET /api/dashboard/recent-activity?limit=N - thin wrapper over
  DatabaseService.getNotificationHistory.
- GET /api/fleet/configuration - fleet-wide fan-out using the same
  Promise.allSettled dead-node-tolerant pattern as /fleet/overview.
  Exposed as the new "Status" tab on the Fleet page (after Snapshots).

Shared utilities:
- visibilityInterval and formatCount extracted to frontend/src/lib/utils.ts
  so the three polling hooks and two components share a single copy.

* docs(dashboard): fix stale alt text referencing removed historical charts
This commit is contained in:
Anso
2026-04-26 18:42:28 -04:00
committed by GitHub
parent fdab44fe07
commit d7d8f9bfe8
19 changed files with 1049 additions and 206 deletions
+2
View File
@@ -38,6 +38,7 @@ import { registriesRouter } from './routes/registries';
import { systemMaintenanceRouter } from './routes/systemMaintenance';
import { templatesRouter } from './routes/templates';
import { securityRouter } from './routes/security';
import { dashboardRouter } from './routes/dashboard';
import { containersRouter, portsRouter } from './routes/containers';
import { nodesRouter } from './routes/nodes';
import { stacksRouter } from './routes/stacks';
@@ -114,6 +115,7 @@ app.use('/api/templates', templatesRouter);
app.use('/api/security', securityRouter);
app.use('/api/containers', containersRouter);
app.use('/api/ports', portsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/stacks', stacksRouter);
+187
View File
@@ -0,0 +1,187 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService } 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/LicenseService';
export const dashboardRouter = Router();
export interface AgentStatus {
configured: boolean;
enabled: boolean;
}
export interface ConfigurationStatus {
tier: LicenseTier;
variant: LicenseVariant;
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'admiral' };
};
automation: {
autoHeal: { total: number; enabled: number };
autoUpdate: { enabled: number; total: number };
scheduledTasks: { total: number; enabled: number; locked: boolean; requiredTier: 'admiral' };
webhooks: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
};
security: {
mfaEnabled: boolean | null;
ssoEnabled: boolean;
ssoProvider: string | null;
scanPolicies: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
};
thresholds: {
cpuLimit: number;
ramLimit: number;
diskLimit: number;
dockerJanitorGb: number;
globalCrash: boolean;
};
backup: {
provider: 'disabled' | 'sencho' | 'custom';
autoUpload: boolean;
locked: boolean;
requiredTier: 'admiral';
};
}
export function buildLocalConfigurationStatus(
nodeId: number,
userId: number,
tier: LicenseTier,
variant: LicenseVariant,
): ConfigurationStatus {
const db = DatabaseService.getInstance();
const isPaid = tier === 'paid';
const isAdmiral = isPaid && variant === 'admiral';
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
const a = agents.find(ag => ag.type === type);
return { configured: !!a?.url, enabled: a?.enabled ?? false };
};
const alertRules = db.getStackAlerts().length;
const notifRoutes = db.getNotificationRoutes();
const healPolicies = db.getAutoHealPolicies();
const autoUpdateMap = db.getStackAutoUpdateSettingsForNode(nodeId);
const autoUpdateEnabled = Object.values(autoUpdateMap).filter(Boolean).length;
const autoUpdateTotal = Object.keys(autoUpdateMap).length;
const scheduledTasks = db.getScheduledTasks();
const webhooks = db.getWebhooks();
const mfaRow = userId ? db.getUserMfa(userId) : undefined;
const ssoConfigs = db.getSSOConfigs();
const enabledSso = ssoConfigs.find(c => c.enabled === 1);
const scanPolicies = db.getScanPolicies();
const settings = db.getGlobalSettings();
const cpuLimit = parseInt(settings['host_cpu_limit'] ?? '90', 10);
const ramLimit = parseInt(settings['host_ram_limit'] ?? '90', 10);
const diskLimit = parseInt(settings['host_disk_limit'] ?? '90', 10);
const dockerJanitorGb = parseFloat(settings['docker_janitor_gb'] ?? '5');
const globalCrash = settings['global_crash'] === '1';
const cloudSvc = CloudBackupService.getInstance();
const cloudProvider = cloudSvc.getProvider();
const cloudAutoUpload = cloudSvc.isAutoUploadOn();
return {
tier,
variant,
notifications: {
agents: {
discord: agentByType('discord'),
slack: agentByType('slack'),
webhook: agentByType('webhook'),
},
alertRules,
routingRules: {
count: notifRoutes.length,
enabledCount: notifRoutes.filter(r => r.enabled).length,
locked: !isAdmiral,
requiredTier: 'admiral',
},
},
automation: {
autoHeal: {
total: healPolicies.length,
enabled: healPolicies.filter(p => p.enabled === 1).length,
},
autoUpdate: {
enabled: autoUpdateEnabled,
total: autoUpdateTotal,
},
scheduledTasks: {
total: scheduledTasks.length,
enabled: scheduledTasks.filter(t => t.enabled === 1).length,
locked: !isAdmiral,
requiredTier: 'admiral',
},
webhooks: {
total: webhooks.length,
enabled: webhooks.filter(w => w.enabled).length,
locked: !isPaid,
requiredTier: 'skipper',
},
},
security: {
mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null,
ssoEnabled: !!enabledSso,
ssoProvider: enabledSso?.provider ?? null,
scanPolicies: {
total: scanPolicies.length,
enabled: scanPolicies.filter(p => p.enabled === 1).length,
locked: !isPaid,
requiredTier: 'skipper',
},
},
thresholds: {
cpuLimit,
ramLimit,
diskLimit,
dockerJanitorGb,
globalCrash,
},
backup: {
provider: cloudProvider,
autoUpload: cloudAutoUpload,
locked: !isAdmiral,
requiredTier: 'admiral',
},
};
}
// 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 => {
try {
const nodeId = req.nodeId ?? 0;
const userId = req.user?.userId ?? 0;
const tier = effectiveTier(req);
const variant = effectiveVariant(req);
res.json(buildLocalConfigurationStatus(nodeId, userId, tier, variant));
} catch (error) {
console.error('[Dashboard] Failed to build configuration status:', error);
res.status(500).json({ error: 'Failed to fetch configuration status' });
}
});
dashboardRouter.get('/recent-activity', authMiddleware, (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 items = db.getNotificationHistory(nodeId, limit);
res.json(items);
} catch (error) {
console.error('[Dashboard] Failed to fetch recent activity:', error);
res.status(500).json({ error: 'Failed to fetch recent activity' });
}
});
+66
View File
@@ -22,6 +22,8 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { CloudBackupService } from '../services/CloudBackupService';
import { NotificationService } from '../services/NotificationService';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { LicenseService } from '../services/LicenseService';
const updateTracker = FleetUpdateTrackerService.getInstance();
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -329,6 +331,70 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response
}
});
interface FleetNodeConfiguration {
id: number;
name: string;
type: 'local' | 'remote';
status: 'online' | 'offline';
configuration: ConfigurationStatus | null;
}
fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const userId = req.user?.userId ?? 0;
const localTier = LicenseService.getInstance().getTier();
const localVariant = LicenseService.getInstance().getVariant();
const results = await Promise.allSettled(
nodes.map(async (node: Node): Promise<FleetNodeConfiguration> => {
if (node.type === 'local') {
return {
id: node.id,
name: node.name,
type: 'local',
status: 'online',
configuration: buildLocalConfigurationStatus(node.id, userId, localTier, localVariant),
};
}
if (!node.api_url || !node.api_token) {
return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null };
}
try {
const resp = await fetch(
`${node.api_url.replace(/\/$/, '')}/api/dashboard/configuration`,
{ headers: { Authorization: `Bearer ${node.api_token}` }, signal: AbortSignal.timeout(10000) },
);
const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
return {
id: node.id,
name: node.name,
type: 'remote',
status: configuration ? 'online' : 'offline',
configuration,
};
} catch {
return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null };
}
}),
);
const fleet: FleetNodeConfiguration[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
console.error(`[Fleet] Configuration fetch failed for node ${nodes[i].name}:`, result.reason);
return { id: nodes[i].id, name: nodes[i].name, type: nodes[i].type, status: 'offline', configuration: null };
});
res.json(fleet);
} catch (error) {
console.error('[Fleet] Configuration overview error:', error);
res.status(500).json({ error: 'Failed to fetch fleet configuration' });
}
});
fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;