Files
sencho/backend/src/routes/dashboard.ts
T
Anso ca144f07d9 chore(dashboard): drop unused AgentStatus exports on both sides (#1222)
The Phase 5 dead-code sweep across the dashboard call graph found two
identically shaped findings: the AgentStatus interface is declared and
exported in both backend/src/routes/dashboard.ts and
frontend/src/components/dashboard/useConfigurationStatus.ts, but no other
file imports it. (The frontend StackAlertSheet component has a separate,
differently shaped private AgentStatus that does not refer to either of
these.)

Drop the export keyword on both. The interfaces stay alive as
file-internal types, the public surface shrinks by two names, and no
behaviour changes.

The broader payload-cleanup opportunities surfaced during the sweep
(unused requiredTier fields on individual row objects, unused top-level
tier and variant fields on ConfigurationStatus) are out of scope for this
PR and have been filed as Linear roadmap items.
2026-05-25 12:10:33 -04:00

186 lines
6.1 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService';
import { CloudBackupService } from '../services/CloudBackupService';
import { effectiveTier, effectiveVariant } from '../middleware/tierGates';
import type { LicenseTier, LicenseVariant } from '../services/license-types';
export const dashboardRouter = Router();
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: 'skipper' };
};
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(undefined, nodeId);
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: !isPaid,
requiredTier: 'skipper',
},
},
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: false,
requiredTier: 'admiral',
},
};
}
// 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;
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('/stack-restarts', (req: Request, res: Response): void => {
try {
const db = DatabaseService.getInstance();
const nodeId = req.nodeId ?? 0;
const rawDays = parseInt(String(req.query['days'] ?? '7'), 10);
const days = isNaN(rawDays) || rawDays < 1 ? 7 : Math.min(rawDays, 30);
const result: StackRestartSummary[] = db.getStackRestartSummary(nodeId, days);
res.json(result);
} catch (error) {
console.error('[Dashboard] Failed to fetch stack restarts:', error);
res.status(500).json({ error: 'Failed to fetch stack restarts' });
}
});