mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user