Files
sencho/backend/src/routes/dashboard.ts
T
Anso adcd04b01a refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)
* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only

The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.

The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.

Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
  accessors, the three routes under /api/stacks/*/auto-update, the per-stack
  skip in /api/auto-update/execute and SchedulerService.executeUpdate's
  fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
  Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
  rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
  optimistic toggle plumbing. Drop autoUpdateSettings state, the
  /stacks/auto-update-settings fetch, and the auto-update-settings-changed
  WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
  enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
  now means "a covering enabled action='update' schedule exists" (per-stack
  row or fleet row on this node, earliest next_run_at wins, per-stack row
  wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
  PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
  useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
  for the 60s poll. The broadcast is wrapped so a broken subscriber socket
  cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
  to describe the schedule-based model; update the matching troubleshooting
  entry. The misleading fleet-update help text in ScheduledOperationsView
  is corrected to reflect that every stack on the node is covered.

Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.

No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.

* docs: sweep remaining references to the per-stack auto-update toggle

The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:

- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
  group description, the matching screenshot alt-text, and the Skipper
  Note that listed it. Schedule task now carries the cross-link to
  Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
  refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
  "Auto-update stacks" to "Auto-update schedules" with the new value
  shape, and rewrite the troubleshooting accordion to describe the
  scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
  Stacks row and helper text to reflect that every stack on the node is
  covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
  derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
  All Stacks schedules instead of the removed per-stack policy.

The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.

No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.

* fix(stacks): drop orphaned requireAdmin import after auto-update route removal

CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.

tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
2026-05-26 11:08:33 -04:00

204 lines
6.9 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 { isDebugEnabled } from '../utils/debug';
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;
};
}
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 scheduledTasks = db.getScheduledTasks();
const nodeUpdateTasks = scheduledTasks.filter(t => t.action === 'update' && t.node_id === nodeId);
const autoUpdateTotal = nodeUpdateTasks.length;
const autoUpdateEnabled = nodeUpdateTasks.filter(t => t.enabled === 1).length;
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: {
// Cloud Backup has a per-provider tier: Custom S3 is open to every
// tier; Sencho Cloud Backup requires Admiral. The row is rendered for
// every tier because Custom S3 is universally configurable, so no
// dashboard-level lock is meaningful.
provider: cloudProvider,
autoUpload: cloudAutoUpload,
locked: false,
},
};
}
// 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 debug = isDebugEnabled();
const startedAt = debug ? Date.now() : 0;
const nodeId = req.nodeId ?? 0;
const userId = req.user?.userId ?? 0;
const tier = effectiveTier(req);
const variant = effectiveVariant(req);
const payload = buildLocalConfigurationStatus(nodeId, userId, tier, variant);
if (debug) {
console.debug(
`[Dashboard:debug] /configuration built in ${Date.now() - startedAt} ms (nodeId=${nodeId})`,
);
}
res.json(payload);
} 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 debug = isDebugEnabled();
const startedAt = debug ? Date.now() : 0;
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);
if (debug) {
console.debug(
`[Dashboard:debug] /stack-restarts returned ${result.length} rows for nodeId=${nodeId} over ${days}d in ${Date.now() - startedAt} ms`,
);
}
res.json(result);
} catch (error) {
console.error('[Dashboard] Failed to fetch stack restarts:', error);
res.status(500).json({ error: 'Failed to fetch stack restarts' });
}
});