mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(auto-update): per-stack auto-update enable/disable toggle (#771)
* feat(auto-update): add per-stack auto-update enable/disable toggle Paid users (Skipper and Admiral) can now opt individual stacks out of scheduled auto-updates from the stack context menu without disabling the global feature. - Add stack_auto_update_settings table (node_id, stack_name) with default enabled=true; four typed DatabaseService accessors with parameterized queries. - Add GET /stacks/auto-update-settings, GET /stacks/:name/auto-update, and PUT /stacks/:name/auto-update (requirePaid + requireAdmin). PUT broadcasts state-invalidate with action auto-update-settings-changed so all open tabs refresh immediately. - Stack DELETE clears the auto-update setting row alongside stack_update_status. - autoUpdateRouter /execute skips disabled stacks before any registry call; skip is recorded in the results array. Manual Update actions are not affected. - Add Auto-update: Enabled/Disabled toggle in the stack inspect group (paid tiers only, hidden for Community, consistent with Auto-Heal). Toggle uses optimistic update with revert-on-error toast. - AutoUpdateReadinessView shows an Auto: Off pill and disables the Apply now button for stacks with auto-updates off. Detection still runs so the readiness card remains visible. - Add 21 backend Vitest tests covering DB round-trips, endpoint auth and tier gates, execute skip for both wildcard and named targets. Add 3 frontend hook tests for toggle visibility and callback behavior. * docs(auto-update): document per-stack auto-update control Add a Per-stack control section to the auto-update readiness page explaining how to disable and re-enable auto-updates for individual stacks, what disabling means (scheduled apply skipped; detection still runs; manual update unaffected), and a troubleshooting entry for scheduled runs not applying to a specific stack.
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Tests for per-stack auto-update settings:
|
||||
* - DatabaseService accessors (round-trip, defaults)
|
||||
* - GET/PUT /api/stacks/auto-update-settings and /api/stacks/:name/auto-update
|
||||
* - /api/auto-update/execute skips disabled stacks
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass2', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'aus-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'aus-viewer', password: 'viewerpass2' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('DatabaseService - stack auto-update settings', () => {
|
||||
it('returns true by default when no row exists', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const result = db.getStackAutoUpdateEnabled(0, 'no-such-stack');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('upsert → get round-trip (disable)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertStackAutoUpdateEnabled(0, 'test-stack', false);
|
||||
expect(db.getStackAutoUpdateEnabled(0, 'test-stack')).toBe(false);
|
||||
});
|
||||
|
||||
it('upsert → get round-trip (re-enable)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertStackAutoUpdateEnabled(0, 'test-stack', true);
|
||||
expect(db.getStackAutoUpdateEnabled(0, 'test-stack')).toBe(true);
|
||||
});
|
||||
|
||||
it('getStackAutoUpdateSettingsForNode only returns stacks with explicit rows', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertStackAutoUpdateEnabled(0, 'explicit-stack', false);
|
||||
const settings = db.getStackAutoUpdateSettingsForNode(0);
|
||||
expect('explicit-stack' in settings).toBe(true);
|
||||
expect(settings['explicit-stack']).toBe(false);
|
||||
});
|
||||
|
||||
it('clearStackAutoUpdateSetting removes the row (reverts to default true)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertStackAutoUpdateEnabled(0, 'to-clear', false);
|
||||
db.clearStackAutoUpdateSetting(0, 'to-clear');
|
||||
expect(db.getStackAutoUpdateEnabled(0, 'to-clear')).toBe(true);
|
||||
const settings = db.getStackAutoUpdateSettingsForNode(0);
|
||||
expect('to-clear' in settings).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/auto-update-settings', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/stacks/auto-update-settings');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns an object for authenticated admin', async () => {
|
||||
const res = await request(app).get('/api/stacks/auto-update-settings').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
|
||||
it('returns an object for authenticated viewer (read-only)', async () => {
|
||||
const res = await request(app).get('/api/stacks/auto-update-settings').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/:stackName/auto-update', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/stacks/mystack/auto-update');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns enabled:true by default', async () => {
|
||||
const res = await request(app).get('/api/stacks/nonexistent/auto-update').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid stack names with 400', async () => {
|
||||
// Dots are rejected by isValidStackName; unlike path-traversal sequences
|
||||
// they are not normalised away by Express routing.
|
||||
const res = await request(app).get('/api/stacks/my.stack/auto-update').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/stacks/:stackName/auto-update', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/mystack/auto-update')
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects viewer with 403', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/mystack/auto-update')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects Community tier with 403', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/mystack/auto-update')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(403);
|
||||
} finally {
|
||||
// Use mockReturnValue rather than mockRestore: restoring would bypass the
|
||||
// beforeAll spy that sets the tier to 'paid' for the rest of the suite.
|
||||
spy.mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-boolean enabled with 400', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/mystack/auto-update')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ enabled: 'yes' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects invalid stack names with 400', async () => {
|
||||
// Dots are rejected by isValidStackName; unlike path-traversal sequences
|
||||
// they are not normalised away by Express routing.
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/my.stack/auto-update')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts Skipper/Admiral admin and persists the setting', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/my-app/auto-update')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNodes().find(n => n.type === 'local');
|
||||
expect(db.getStackAutoUpdateEnabled(node!.id, 'my-app')).toBe(false);
|
||||
});
|
||||
|
||||
it('can re-enable a disabled stack', async () => {
|
||||
await request(app)
|
||||
.put('/api/stacks/my-app/auto-update')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ enabled: false });
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/my-app/auto-update')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auto-update/execute - per-stack disable gate', () => {
|
||||
it('skips stacks with auto-updates disabled', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNodes().find(n => n.type === 'local')!;
|
||||
|
||||
db.upsertStackAutoUpdateEnabled(node.id, 'disabled-stack', false);
|
||||
|
||||
// Mock FileSystemService so target='*' returns our test stack
|
||||
const fsMod = await import('../services/FileSystemService');
|
||||
const getStacksSpy = vi.spyOn(fsMod.FileSystemService.prototype, 'getStacks')
|
||||
.mockResolvedValue(['disabled-stack']);
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: '*' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toContain('auto-updates disabled; skipped');
|
||||
} finally {
|
||||
getStacksSpy.mockRestore();
|
||||
db.clearStackAutoUpdateSetting(node.id, 'disabled-stack');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips a named disabled stack', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNodes().find(n => n.type === 'local')!;
|
||||
db.upsertStackAutoUpdateEnabled(node.id, 'named-disabled', false);
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: 'named-disabled' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toContain('auto-updates disabled; skipped');
|
||||
} finally {
|
||||
db.clearStackAutoUpdateSetting(node.id, 'named-disabled');
|
||||
}
|
||||
});
|
||||
|
||||
it('allows enabled stacks to proceed (may fail at image check, but not at disable gate)', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNodes().find(n => n.type === 'local')!;
|
||||
db.upsertStackAutoUpdateEnabled(node.id, 'enabled-stack', true);
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: 'enabled-stack' });
|
||||
expect(res.status).toBe(200);
|
||||
// Should NOT contain "auto-updates disabled" in result
|
||||
expect(res.body.result).not.toContain('auto-updates disabled; skipped');
|
||||
} finally {
|
||||
db.clearStackAutoUpdateSetting(node.id, 'enabled-stack');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,11 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
try {
|
||||
if (!db.getStackAutoUpdateEnabled(req.nodeId, stackName)) {
|
||||
results.push(`Stack "${stackName}": auto-updates disabled; skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const containers = await docker.getContainersByStack(stackName);
|
||||
if (!containers || containers.length === 0) {
|
||||
results.push(`Stack "${stackName}": no containers found; skipped.`);
|
||||
|
||||
@@ -11,7 +11,8 @@ import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { requirePaid } from '../middleware/tierGates';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { isValidStackName, isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -128,6 +129,61 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/auto-update-settings', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getStackAutoUpdateSettingsForNode(req.nodeId);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to fetch auto-update settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch auto-update settings' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/auto-update', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
const enabled = DatabaseService.getInstance().getStackAutoUpdateEnabled(req.nodeId, stackName);
|
||||
res.json({ enabled });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to fetch auto-update setting:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch auto-update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName/auto-update', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
const { enabled } = req.body as { enabled?: unknown };
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({ error: '"enabled" must be a boolean' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().upsertStackAutoUpdateEnabled(req.nodeId, stackName, enabled);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'stack',
|
||||
nodeId: req.nodeId,
|
||||
stackName,
|
||||
action: 'auto-update-settings-changed',
|
||||
ts: Date.now(),
|
||||
});
|
||||
res.json({ enabled });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to update auto-update setting:', error);
|
||||
res.status(500).json({ error: 'Failed to update auto-update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
@@ -469,6 +525,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
|
||||
|
||||
@@ -559,6 +559,14 @@ export class DatabaseService {
|
||||
PRIMARY KEY (node_id, stack_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_auto_update_settings (
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
stack_name TEXT NOT NULL,
|
||||
auto_update_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (node_id, stack_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -1722,6 +1730,40 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Stack Auto-Update Settings ---
|
||||
|
||||
public getStackAutoUpdateEnabled(nodeId: number, stackName: string): boolean {
|
||||
const row = this.db.prepare(
|
||||
'SELECT auto_update_enabled FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?'
|
||||
).get(nodeId, stackName) as { auto_update_enabled: number } | undefined;
|
||||
return row === undefined ? true : row.auto_update_enabled === 1;
|
||||
}
|
||||
|
||||
// Returns only stacks with an explicit row. Missing keys default to true
|
||||
// (auto-update enabled); callers must not treat absence as false.
|
||||
public getStackAutoUpdateSettingsForNode(nodeId: number): Record<string, boolean> {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT stack_name, auto_update_enabled FROM stack_auto_update_settings WHERE node_id = ?'
|
||||
).all(nodeId) as Array<{ stack_name: string; auto_update_enabled: number }>;
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const row of rows) {
|
||||
result[row.stack_name] = row.auto_update_enabled === 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public upsertStackAutoUpdateEnabled(nodeId: number, stackName: string, enabled: boolean): void {
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_auto_update_settings (node_id, stack_name, auto_update_enabled, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name) DO UPDATE SET auto_update_enabled = excluded.auto_update_enabled, updated_at = excluded.updated_at`
|
||||
).run(nodeId, stackName, enabled ? 1 : 0, Date.now());
|
||||
}
|
||||
|
||||
public clearStackAutoUpdateSetting(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
|
||||
return this.db.prepare(
|
||||
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
|
||||
|
||||
@@ -47,6 +47,30 @@ Cards are grouped by node, with a section header for each node that has at least
|
||||
|
||||
Blocked updates still schedule check runs, but the apply button is disabled until you review them manually.
|
||||
|
||||
## Per-stack control
|
||||
|
||||
Auto-updates can be disabled on a per-stack basis from the stack's context menu. This lets you keep the global schedule active while opting specific stacks out of unattended updates, such as databases, self-built images, or any stack pinned to a fixed tag.
|
||||
|
||||
### Disabling auto-updates for a stack
|
||||
|
||||
1. Right-click the stack in the sidebar, or open the kebab menu (three dots).
|
||||
2. In the **Inspect** group, click **Auto-update: Enabled** to toggle it off. The label changes to **Auto-update: Disabled** and the icon switches to a slash-circle.
|
||||
3. The setting persists across restarts. The readiness board shows an **Auto: Off** pill on that card, and the **Apply now** button is disabled with the tooltip "Auto-updates are disabled for this stack. Update it from its actions menu."
|
||||
|
||||
<Note>
|
||||
Per-stack auto-update control requires a **Skipper** or **Admiral** license. The toggle does not appear on Community.
|
||||
</Note>
|
||||
|
||||
### What disabling means
|
||||
|
||||
- **Scheduled and fleet-wide auto-update runs skip the stack entirely.** No registry call is made, and no image update is applied automatically.
|
||||
- **Image update detection still runs.** The sidebar dot and the readiness card still reflect whether an update is available. You are informed; the system just does not apply it for you.
|
||||
- **Manual updates are unaffected.** You can still click **Update** in the lifecycle menu (or **Deploy**) to apply an update on demand. Per-stack control governs only the automated path.
|
||||
|
||||
### Re-enabling
|
||||
|
||||
Open the same menu and click **Auto-update: Disabled** to toggle it back on. The next scheduled run will include the stack again.
|
||||
|
||||
## Scheduling auto-updates
|
||||
|
||||
Auto-update is a first-class action in the Schedules view. To create a recurring check for a stack:
|
||||
@@ -93,6 +117,10 @@ The registry call is either still pending or failed. Click **Recheck** in the he
|
||||
|
||||
Image update detection runs every six hours on each node. The readiness board uses the same cached status. Trigger **Recheck** to force a fresh check across every reachable node, or see [Image update detection](/features/image-update-detection) for details on the refresh cycle.
|
||||
|
||||
### Scheduled auto-update runs are not applying to a specific stack
|
||||
|
||||
The stack likely has auto-updates disabled. Open the stack's kebab menu or right-click context menu and check the **Inspect** group. If the item reads **Auto-update: Disabled**, click it to re-enable. Once re-enabled, the next scheduled run will include the stack, or you can trigger an immediate run from the Auto-Update view.
|
||||
|
||||
### Banner says "X of Y nodes reachable"
|
||||
|
||||
One or more nodes that are marked online in your fleet did not respond within the request timeout. Pending updates from those nodes are not shown until they come back. Check the node's status from the Fleet view and the network path between this Sencho instance and the unreachable node.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
|
||||
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
@@ -46,6 +46,7 @@ interface StackCard {
|
||||
previewLoaded: boolean;
|
||||
scheduledTask: ScheduledTask | null;
|
||||
applying: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
}
|
||||
|
||||
interface NodeGroup {
|
||||
@@ -142,7 +143,7 @@ function StackReadinessCard({
|
||||
card: StackCard;
|
||||
onApply: (stack: string, nodeId: number) => void;
|
||||
}) {
|
||||
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying } = card;
|
||||
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, autoUpdateEnabled } = card;
|
||||
const loading = !previewLoaded;
|
||||
const failed = previewLoaded && preview === null;
|
||||
const blocked = preview?.summary.blocked ?? false;
|
||||
@@ -161,7 +162,15 @@ function StackReadinessCard({
|
||||
{stack}
|
||||
</span>
|
||||
</div>
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!autoUpdateEnabled && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />
|
||||
Auto: Off
|
||||
</span>
|
||||
)}
|
||||
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -227,8 +236,12 @@ function StackReadinessCard({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApply(stack, nodeId)}
|
||||
disabled={blocked || applying}
|
||||
title={blocked ? (blockedReason ?? undefined) : undefined}
|
||||
disabled={blocked || applying || !autoUpdateEnabled}
|
||||
title={
|
||||
!autoUpdateEnabled
|
||||
? 'Auto-updates are disabled for this stack. Update it from its actions menu.'
|
||||
: (blocked ? (blockedReason ?? undefined) : undefined)
|
||||
}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
@@ -382,6 +395,8 @@ function AutoUpdateReadinessContent() {
|
||||
apiFetch('/image-updates/fleet', { localOnly: true }),
|
||||
apiFetch('/scheduled-tasks?action=update', { localOnly: true }),
|
||||
]);
|
||||
// Auto-update settings are per-node; fetch lazily after we know which nodes have updates.
|
||||
// Collected into a map keyed by nodeId once we know the fleet topology.
|
||||
if (token !== loadTokenRef.current) return;
|
||||
|
||||
if (!statusRes.ok) {
|
||||
@@ -404,6 +419,20 @@ function AutoUpdateReadinessContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch auto-update settings for all nodes that have pending updates.
|
||||
const nodeIdsWithUpdates = [...new Set(
|
||||
Object.keys(fleetStatus).map(Number).filter(id => Object.values(fleetStatus[String(id)]).some(Boolean))
|
||||
)];
|
||||
const autoUpdateByNode = new Map<number, Record<string, boolean>>();
|
||||
await Promise.all(nodeIdsWithUpdates.map(async (nodeId) => {
|
||||
try {
|
||||
const res = await fetchForNode('/stacks/auto-update-settings', nodeId);
|
||||
if (res.ok) autoUpdateByNode.set(nodeId, await res.json() as Record<string, boolean>);
|
||||
} catch {
|
||||
// If the fetch fails, default all stacks on that node to enabled.
|
||||
}
|
||||
}));
|
||||
|
||||
const flatPairs: { nodeId: number; stack: string }[] = [];
|
||||
const initialGroups: NodeGroup[] = [];
|
||||
const currentNodes = nodesRef.current;
|
||||
@@ -416,6 +445,7 @@ function AutoUpdateReadinessContent() {
|
||||
.map(([stack]) => stack)
|
||||
.sort();
|
||||
if (stacks.length === 0) continue;
|
||||
const nodeAutoUpdateSettings = autoUpdateByNode.get(nodeId) ?? {};
|
||||
const cards: StackCard[] = stacks.map(stack => {
|
||||
flatPairs.push({ nodeId, stack });
|
||||
return {
|
||||
@@ -425,6 +455,7 @@ function AutoUpdateReadinessContent() {
|
||||
previewLoaded: false,
|
||||
scheduledTask: taskByNodeStack.get(`${nodeId}::${stack}`) ?? null,
|
||||
applying: false,
|
||||
autoUpdateEnabled: nodeAutoUpdateSettings[stack] ?? true,
|
||||
};
|
||||
});
|
||||
initialGroups.push({
|
||||
|
||||
@@ -347,6 +347,8 @@ export default function EditorLayout() {
|
||||
|
||||
// Image update checker state
|
||||
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
|
||||
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
|
||||
const isAdmiral = license?.variant === 'admiral';
|
||||
|
||||
// Notifications & Settings state
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
@@ -667,7 +669,11 @@ export default function EditorLayout() {
|
||||
// sidebar, etc.) can refetch on the same trigger without prop
|
||||
// drilling. Refresh stack statuses on this layer too.
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
|
||||
scheduleStateInvalidateRefresh();
|
||||
if (msg.action === 'auto-update-settings-changed') {
|
||||
fetchAutoUpdateSettings();
|
||||
} else {
|
||||
scheduleStateInvalidateRefresh();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WS notifications] parse error', e);
|
||||
@@ -811,6 +817,7 @@ export default function EditorLayout() {
|
||||
|
||||
refreshStacks();
|
||||
fetchImageUpdates();
|
||||
fetchAutoUpdateSettings();
|
||||
refreshGitSourcePending();
|
||||
|
||||
// Poll for image update results every 5 minutes so background checks are picked up
|
||||
@@ -875,6 +882,20 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAutoUpdateSettings = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks/auto-update-settings');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAutoUpdateSettings(data as Record<string, boolean>);
|
||||
} else {
|
||||
console.error('[AutoUpdateSettings] fetch returned', res.status);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('[AutoUpdateSettings] fetch failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
@@ -1899,11 +1920,13 @@ export default function EditorLayout() {
|
||||
hasPort: Boolean(stackPorts[file]),
|
||||
isBusy: isStackBusy(file),
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
canDelete: can('stack:delete', 'stack', stackName),
|
||||
isPinned: isPinned(file),
|
||||
labels,
|
||||
assignedLabelIds: (stackLabelMap[file] ?? []).map(l => l.id),
|
||||
menuVisibility: getStackMenuVisibility(file),
|
||||
autoUpdateEnabled: autoUpdateSettings[stackName] ?? true,
|
||||
openAlertSheet: () => openAlertSheet(file),
|
||||
openAutoHeal: () => setAutoHealStackName(file),
|
||||
checkUpdates: () => checkUpdatesForStack(),
|
||||
@@ -1915,6 +1938,22 @@ export default function EditorLayout() {
|
||||
remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); },
|
||||
pin: () => pin(file),
|
||||
unpin: () => unpin(file),
|
||||
setAutoUpdateEnabled: async (enabled: boolean) => {
|
||||
setAutoUpdateSettings(prev => ({ ...prev, [stackName]: enabled }));
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/auto-update`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setAutoUpdateSettings(prev => ({ ...prev, [stackName]: !enabled }));
|
||||
toast.error((err as Error)?.message || 'Failed to update auto-update setting.');
|
||||
}
|
||||
},
|
||||
toggleLabel: async (labelId: number) => {
|
||||
const currentIds = (stackLabelMap[file] ?? []).map(l => l.id);
|
||||
const assigned = currentIds.includes(labelId);
|
||||
@@ -1968,8 +2007,8 @@ export default function EditorLayout() {
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
stackStatuses, stackPorts, isPaid, isPinned, labels, stackLabelMap,
|
||||
pin, unpin,
|
||||
stackStatuses, stackPorts, isPaid, isAdmiral, isPinned, labels, stackLabelMap,
|
||||
autoUpdateSettings, pin, unpin,
|
||||
]);
|
||||
|
||||
const createStackSlot = can('stack:create') ? (
|
||||
|
||||
@@ -26,11 +26,16 @@ export interface StackMenuCtx {
|
||||
hasPort: boolean;
|
||||
isBusy: boolean;
|
||||
isPaid: boolean;
|
||||
// isAdmiral: plumbed now so Admiral-specific menu items can be added in a
|
||||
// follow-up PR without changing this interface. No Admiral-only items ship
|
||||
// in the current PR; the auto-update toggle is Skipper+, gated on isPaid.
|
||||
isAdmiral: boolean;
|
||||
canDelete: boolean;
|
||||
isPinned: boolean;
|
||||
labels: Label[];
|
||||
assignedLabelIds: number[];
|
||||
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
|
||||
autoUpdateEnabled: boolean;
|
||||
openAlertSheet: () => void;
|
||||
openAutoHeal: () => void;
|
||||
checkUpdates: () => void;
|
||||
@@ -45,6 +50,7 @@ export interface StackMenuCtx {
|
||||
toggleLabel: (labelId: number) => void;
|
||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
|
||||
openLabelManager: () => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled';
|
||||
|
||||
@@ -10,11 +10,13 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
hasPort: true,
|
||||
isBusy: false,
|
||||
isPaid: true,
|
||||
isAdmiral: false,
|
||||
canDelete: true,
|
||||
isPinned: false,
|
||||
labels: [],
|
||||
assignedLabelIds: [],
|
||||
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
|
||||
autoUpdateEnabled: true,
|
||||
openAlertSheet: vi.fn(),
|
||||
openAutoHeal: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
@@ -29,6 +31,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
toggleLabel: vi.fn(),
|
||||
createAndAssignLabel: vi.fn(),
|
||||
openLabelManager: vi.fn(),
|
||||
setAutoUpdateEnabled: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -88,6 +91,28 @@ describe('useStackMenuItems', () => {
|
||||
expect(ids).toEqual(['deploy', 'update']);
|
||||
});
|
||||
|
||||
it('shows auto-update toggle in inspect when isPaid', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'auto-update')).toBeDefined();
|
||||
});
|
||||
|
||||
it('hides auto-update toggle when !isPaid', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'auto-update')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('auto-update toggle calls setAutoUpdateEnabled with toggled value', () => {
|
||||
const setAutoUpdateEnabled = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true, setAutoUpdateEnabled }))
|
||||
);
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
inspect.items.find(i => i.id === 'auto-update')!.onSelect();
|
||||
expect(setAutoUpdateEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('disables every lifecycle item when isBusy', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||
isBusy: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
BellRing,
|
||||
CircleSlash,
|
||||
Download,
|
||||
Pin,
|
||||
PinOff,
|
||||
@@ -20,7 +21,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
|
||||
menuVisibility,
|
||||
menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled,
|
||||
} = ctx;
|
||||
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
|
||||
|
||||
@@ -32,6 +33,12 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
];
|
||||
if (isPaid) {
|
||||
inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal });
|
||||
inspect.push({
|
||||
id: 'auto-update',
|
||||
label: autoUpdateEnabled ? 'Auto-update: Enabled' : 'Auto-update: Disabled',
|
||||
icon: autoUpdateEnabled ? RefreshCw : CircleSlash,
|
||||
onSelect: () => setAutoUpdateEnabled(!autoUpdateEnabled),
|
||||
});
|
||||
}
|
||||
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
|
||||
if (stackStatus === 'running' && hasPort) {
|
||||
@@ -80,6 +87,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
}, [
|
||||
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
|
||||
showDeploy, showStop, showRestart, showUpdate,
|
||||
autoUpdateEnabled, setAutoUpdateEnabled,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user