diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index f28494fd..a0f4e09c 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -171,6 +171,45 @@ describe('SchedulerService - calculateNextRun', () => { }); }); +describe('SchedulerService - calculateRunsWithin', () => { + it('expands hourly cron into every firing within a 24h window when limit allows', () => { + const svc = SchedulerService.getInstance(); + const from = Date.now(); + const to = from + 24 * 60 * 60 * 1000; + const runs = svc.calculateRunsWithin('0 * * * *', from, to, 32); + expect(runs.length).toBeGreaterThanOrEqual(23); + expect(runs.length).toBeLessThanOrEqual(24); + for (const run of runs) { + expect(run).toBeGreaterThanOrEqual(from); + expect(run).toBeLessThanOrEqual(to); + } + }); + + it('returns a single firing for a daily cron', () => { + const svc = SchedulerService.getInstance(); + const from = Date.now(); + const to = from + 24 * 60 * 60 * 1000; + const runs = svc.calculateRunsWithin('0 3 * * *', from, to); + expect(runs.length).toBeLessThanOrEqual(2); + expect(runs.length).toBeGreaterThanOrEqual(1); + }); + + it('honours the limit parameter to avoid runaway expansions', () => { + const svc = SchedulerService.getInstance(); + const from = Date.now(); + const to = from + 60 * 60 * 1000; + const runs = svc.calculateRunsWithin('* * * * *', from, to, 5); + expect(runs.length).toBe(5); + }); + + it('returns empty array for invalid cron instead of throwing', () => { + const svc = SchedulerService.getInstance(); + const from = Date.now(); + const to = from + 60 * 60 * 1000; + expect(svc.calculateRunsWithin('not a cron', from, to)).toEqual([]); + }); +}); + // ── License gating ───────────────────────────────────────────────────── describe('SchedulerService - license gating', () => { diff --git a/backend/src/__tests__/update-preview-service.test.ts b/backend/src/__tests__/update-preview-service.test.ts new file mode 100644 index 00000000..d8a9ffde --- /dev/null +++ b/backend/src/__tests__/update-preview-service.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + parseSemverTag, + findNextTag, + computeSemverBump, + computeImagePreview, + buildSummary, + type ComputePreviewDeps, +} from '../services/UpdatePreviewService'; + +describe('parseSemverTag', () => { + it('parses bare semver', () => { + expect(parseSemverTag('1.2.3')).toMatchObject({ prefix: '', major: 1, minor: 2, patch: 3, suffix: '' }); + }); + it('parses v-prefixed semver', () => { + expect(parseSemverTag('v1.2.3')).toMatchObject({ prefix: 'v', major: 1, minor: 2, patch: 3 }); + }); + it('parses suffixed semver (alpine, slim)', () => { + expect(parseSemverTag('27.1.4-alpine')).toMatchObject({ major: 27, minor: 1, patch: 4, suffix: 'alpine' }); + }); + it('rejects non-semver', () => { + expect(parseSemverTag('latest')).toBeNull(); + expect(parseSemverTag('main')).toBeNull(); + expect(parseSemverTag('1.2')).toBeNull(); + }); +}); + +describe('findNextTag', () => { + it('picks highest semver greater than current', () => { + const tags = ['27.1.3', '27.1.4', '27.1.5', '27.2.0', '27.1.5-alpine']; + expect(findNextTag('27.1.4', tags)).toBe('27.2.0'); + }); + it('keeps prefix style (v vs bare)', () => { + const tags = ['1.2.3', '1.2.4', 'v1.2.4', 'v1.3.0']; + expect(findNextTag('v1.2.3', tags)).toBe('v1.3.0'); + expect(findNextTag('1.2.3', tags)).toBe('1.2.4'); + }); + it('keeps suffix style (alpine)', () => { + const tags = ['1.2.3', '1.2.4', '1.2.3-alpine', '1.2.4-alpine']; + expect(findNextTag('1.2.3-alpine', tags)).toBe('1.2.4-alpine'); + }); + it('returns null when current tag is not semver', () => { + expect(findNextTag('latest', ['latest', '1.2.3'])).toBeNull(); + }); + it('returns null when no higher semver exists', () => { + expect(findNextTag('1.2.3', ['1.2.0', '1.2.1', '1.2.2'])).toBeNull(); + }); +}); + +describe('computeSemverBump', () => { + it('detects major jump', () => { + expect(computeSemverBump('1.2.3', '2.0.0')).toBe('major'); + }); + it('detects minor jump', () => { + expect(computeSemverBump('1.2.3', '1.3.0')).toBe('minor'); + }); + it('detects patch jump', () => { + expect(computeSemverBump('1.2.3', '1.2.4')).toBe('patch'); + }); + it('returns patch when tags are identical (digest rebuild)', () => { + expect(computeSemverBump('latest', 'latest')).toBe('patch'); + }); + it('returns none when no next tag', () => { + expect(computeSemverBump('1.2.3', null)).toBe('none'); + }); + it('returns unknown for non-semver pairs', () => { + expect(computeSemverBump('main', 'stable')).toBe('unknown'); + }); +}); + +function makeDeps(overrides: Partial = {}): ComputePreviewDeps { + return { + getCredentials: vi.fn().mockResolvedValue(null), + getLocalDigest: vi.fn().mockResolvedValue(null), + getRemoteDigest: vi.fn().mockResolvedValue(null), + listRegistryTags: vi.fn().mockResolvedValue([]), + ...overrides, + }; +} + +describe('computeImagePreview', () => { + it('reports no update when digests match and no higher tag exists', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'), + listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(result.has_update).toBe(false); + expect(result.semver_bump).toBe('none'); + expect(result.next_tag).toBeNull(); + }); + + it('reports digest rebuild as patch when tag is unchanged but digest differs', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getRemoteDigest: vi.fn().mockResolvedValue('sha256:bbb'), + listRegistryTags: vi.fn().mockResolvedValue([]), + }); + const result = await computeImagePreview('web', 'nginx:latest', deps); + expect(result.has_update).toBe(true); + expect(result.current_tag).toBe('latest'); + expect(result.next_tag).toBe('latest'); + expect(result.semver_bump).toBe('patch'); + }); + + it('reports higher semver tag when available', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'), + listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']), + }); + const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps); + expect(result.has_update).toBe(true); + expect(result.next_tag).toBe('27.2.0'); + expect(result.semver_bump).toBe('minor'); + }); + + it('flags major semver jumps', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'), + listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']), + }); + const result = await computeImagePreview('db', 'postgres:1.2.3', deps); + expect(result.next_tag).toBe('2.0.0'); + expect(result.semver_bump).toBe('major'); + }); +}); + +describe('buildSummary', () => { + const baseImage = (partial: Partial[1][number]>) => ({ + service: 'svc', + image: 'nginx:1.0.0', + current_tag: '1.0.0', + next_tag: null, + has_update: false, + semver_bump: 'none' as const, + ...partial, + }); + + it('flags blocked when any image has a major bump', () => { + const images = [ + baseImage({ service: 'web', has_update: true, semver_bump: 'major', next_tag: '2.0.0' }), + baseImage({ service: 'cache', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', image: 'redis:1.0.0' }), + ]; + const preview = buildSummary('stacky', images); + expect(preview.summary.blocked).toBe(true); + expect(preview.summary.blocked_reason).toMatch(/major/i); + expect(preview.summary.semver_bump).toBe('major'); + }); + + it('picks first updated image as primary', () => { + const images = [ + baseImage({ service: 'clean', has_update: false }), + baseImage({ service: 'web', has_update: true, semver_bump: 'minor', next_tag: '1.1.0', image: 'nginx:1.0.0' }), + ]; + const preview = buildSummary('stacky', images); + expect(preview.summary.primary_image).toBe('nginx:1.0.0'); + expect(preview.summary.next_tag).toBe('1.1.0'); + expect(preview.summary.blocked).toBe(false); + }); + + it('returns has_update=false when no images update', () => { + const images = [baseImage({ service: 'clean', has_update: false })]; + const preview = buildSummary('stacky', images); + expect(preview.summary.has_update).toBe(false); + expect(preview.summary.semver_bump).toBe('none'); + }); + + it('handles empty image list', () => { + const preview = buildSummary('empty', []); + expect(preview.summary.has_update).toBe(false); + expect(preview.summary.primary_image).toBeNull(); + expect(preview.rollback_target).toBeNull(); + }); + + it('computes rollback target from current tag of primary', () => { + const images = [ + baseImage({ service: 'web', image: 'nginx:1.0.0', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }), + ]; + const preview = buildSummary('stacky', images); + expect(preview.rollback_target).toBe('nginx:1.0.0'); + }); + + it('computes rollback target for Docker Hub library image', () => { + const images = [ + baseImage({ service: 'db', image: 'library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }), + ]; + expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16'); + }); + + it('computes rollback target for registry with port', () => { + const images = [ + baseImage({ + service: 'app', + image: 'registry.example.com:5000/team/image:1.2.3', + has_update: true, + semver_bump: 'patch', + next_tag: '1.2.4', + current_tag: '1.2.3', + }), + ]; + expect(buildSummary('stacky', images).rollback_target).toBe('registry.example.com:5000/team/image:1.2.3'); + }); + + it('leaves blocked false for patch/minor only updates', () => { + const images = [ + baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1' }), + baseImage({ service: 'cache', has_update: true, semver_bump: 'minor', next_tag: '1.1.0', image: 'redis:1.0.0' }), + ]; + const preview = buildSummary('stacky', images); + expect(preview.summary.blocked).toBe(false); + expect(preview.summary.blocked_reason).toBeNull(); + expect(preview.summary.semver_bump).toBe('minor'); + }); + + it('does not let unknown bumps mask a real major bump', () => { + const images = [ + baseImage({ service: 'odd', has_update: true, semver_bump: 'unknown', next_tag: 'main', image: 'ghcr.io/org/odd:main' }), + baseImage({ service: 'db', has_update: true, semver_bump: 'major', next_tag: '2.0.0', image: 'postgres:1.0.0' }), + ]; + const preview = buildSummary('stacky', images); + expect(preview.summary.semver_bump).toBe('major'); + expect(preview.summary.blocked).toBe(true); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 3b0169d0..9658893c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ import { MonitorService } from './services/MonitorService'; import { AutoHealService } from './services/AutoHealService'; import { DockerEventManager } from './services/DockerEventManager'; import { ImageUpdateService } from './services/ImageUpdateService'; +import { UpdatePreviewService } from './services/UpdatePreviewService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; @@ -5198,6 +5199,21 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => } }); +// Update preview: semver diff, risk tagging, rollback target for the readiness board +app.get('/api/stacks/:stackName/update-preview', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } + try { + const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName); + res.json(preview); + } catch (error) { + console.error(`[Stacks] Update preview failed: ${stackName}`, error); + res.status(500).json({ error: 'Failed to compute update preview' }); + } +}); + // Update stack: pull images and recreate containers app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; @@ -6428,7 +6444,18 @@ app.get('/api/scheduled-tasks', (req: Request, res: Response): void => { } else if (excludeAction) { tasks = tasks.filter(t => t.action !== excludeAction); } - res.json(tasks); + + // Timeline view needs every firing inside a rolling window, not just the next run. + const scheduler = SchedulerService.getInstance(); + const windowHours = Math.min(Math.max(Number(req.query.window_hours) || 24, 1), 168); + const from = Date.now(); + const to = from + windowHours * 60 * 60 * 1000; + const enriched = tasks.map(t => ({ + ...t, + next_runs: t.enabled === 1 ? scheduler.calculateRunsWithin(t.cron_expression, from, to) : [], + })); + + res.json(enriched); } catch (error) { console.error('[ScheduledTasks] List error:', error); res.status(500).json({ error: 'Failed to fetch scheduled tasks' }); diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 79f1cc6e..0c07fa54 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -1,5 +1,3 @@ -import https from 'https'; -import http from 'http'; import path from 'path'; import YAML from 'yaml'; import DockerController from './DockerController'; @@ -8,87 +6,20 @@ import { FileSystemService } from './FileSystemService'; import { RegistryService } from './RegistryService'; import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; +import { parseImageRef, getRemoteDigest } from './registry-api'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; const BACKFILL_KEY = 'image_update_notifications_backfilled'; -// ─── Image ref parsing ──────────────────────────────────────────────────────── - -interface ParsedRef { - registry: string; // e.g. "registry-1.docker.io", "lscr.io", "ghcr.io" - repo: string; // e.g. "library/nginx", "linuxserver/sonarr" - tag: string; // e.g. "latest", "1.25" -} - -function parseImageRef(imageRef: string): ParsedRef | null { - if (imageRef.startsWith('sha256:')) return null; - - // Strip digest pin (e.g. "nginx@sha256:abc" → "nginx") - const atIdx = imageRef.indexOf('@'); - if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx); - - let registry = 'registry-1.docker.io'; - let rest = imageRef; - - const slashIdx = imageRef.indexOf('/'); - if (slashIdx !== -1) { - const firstPart = imageRef.slice(0, slashIdx); - if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { - registry = firstPart; - rest = imageRef.slice(slashIdx + 1); - } - } - - // Extract tag - let tag = 'latest'; - const colonIdx = rest.lastIndexOf(':'); - if (colonIdx > 0) { - tag = rest.slice(colonIdx + 1); - rest = rest.slice(0, colonIdx); - } - - // Docker Hub official images (no slash) → prepend "library/" - if (registry === 'registry-1.docker.io' && !rest.includes('/')) { - rest = `library/${rest}`; - } - - return { registry, repo: rest, tag }; -} - export interface ImageCheckResult { hasUpdate: boolean; error?: string; } -// ─── Minimal HTTP helper ────────────────────────────────────────────────────── - -interface HttpResult { - statusCode: number; - headers: Record; - body: string; -} - -function httpGet(url: string, headers: Record = {}, timeoutMs = 10000): Promise { - return new Promise((resolve, reject) => { - const lib = url.startsWith('https:') ? https : http; - const req = lib.get(url, { headers }, (res) => { - let body = ''; - res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); - res.on('end', () => resolve({ - statusCode: res.statusCode ?? 0, - headers: res.headers as Record, - body, - })); - }); - req.on('error', reject); - req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out'))); - }); -} - // ─── Compose file helpers ──────────────────────────────────────────────────── -function loadDotEnv(content: string): Record { +export function loadDotEnv(content: string): Record { const vars: Record = {}; for (const line of content.split('\n')) { const trimmed = line.trim(); @@ -106,10 +37,15 @@ function loadDotEnv(content: string): Record { return vars; } -function extractImagesFromCompose( +export interface ComposeServiceImage { + service: string; + image: string; +} + +export function extractServiceImagesFromCompose( yamlContent: string, - envVars: Record -): string[] { + envVars: Record, +): ComposeServiceImage[] { let parsed: Record; try { parsed = YAML.parse(yamlContent) as Record; @@ -118,8 +54,8 @@ function extractImagesFromCompose( } if (!parsed?.services || typeof parsed.services !== 'object') return []; - const images: string[] = []; - for (const svc of Object.values(parsed.services as Record)) { + const out: ComposeServiceImage[] = []; + for (const [service, svc] of Object.entries(parsed.services as Record)) { if (!svc || typeof svc !== 'object') continue; const raw = (svc as Record).image; if (!raw || typeof raw !== 'string') continue; @@ -137,84 +73,16 @@ function extractImagesFromCompose( ref = ref.trim(); if (!ref || ref.includes('${') || ref.startsWith('sha256:')) continue; - images.push(ref); + out.push({ service, image: ref }); } - return images; + return out; } -// ─── Registry auth ──────────────────────────────────────────────────────────── - -async function getAuthToken( - registry: string, - repo: string, - credentials?: { username: string; password: string } | null -): Promise { - try { - const basicHeaders: Record = {}; - if (credentials) { - basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - } - - let tokenUrl: string; - - if (registry === 'registry-1.docker.io') { - tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; - } else { - // Ping /v2/ to get the WWW-Authenticate challenge - const ping = await httpGet(`https://${registry}/v2/`, basicHeaders); - const wwwAuth = ping.headers['www-authenticate'] as string | undefined; - if (!wwwAuth) return null; - - const realmMatch = wwwAuth.match(/realm="([^"]+)"/); - const serviceMatch = wwwAuth.match(/service="([^"]+)"/); - const scopeMatch = wwwAuth.match(/scope="([^"]+)"/); - if (!realmMatch) return null; - - const params = new URLSearchParams(); - if (serviceMatch) params.set('service', serviceMatch[1]); - params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`); - tokenUrl = `${realmMatch[1]}?${params.toString()}`; - } - - const tokenRes = await httpGet(tokenUrl, basicHeaders); - if (tokenRes.statusCode !== 200) return null; - - const parsed = JSON.parse(tokenRes.body); - return parsed.token ?? parsed.access_token ?? null; - } catch { - return null; - } -} - -// ─── Remote digest lookup ───────────────────────────────────────────────────── - -// Include manifest list types so we get the fat-manifest digest for multi-arch -// images - this matches what Docker stores in local RepoDigests. -const MANIFEST_ACCEPT = [ - 'application/vnd.docker.distribution.manifest.list.v2+json', - 'application/vnd.docker.distribution.manifest.v2+json', - 'application/vnd.oci.image.index.v1+json', - 'application/vnd.oci.image.manifest.v1+json', -].join(', '); - -async function getRemoteDigest( - registry: string, - repo: string, - tag: string, - credentials?: { username: string; password: string } | null -): Promise { - try { - const token = await getAuthToken(registry, repo, credentials); - const headers: Record = { Accept: MANIFEST_ACCEPT }; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers); - if (res.statusCode !== 200) return null; - - return (res.headers['docker-content-digest'] as string) ?? null; - } catch { - return null; - } +export function extractImagesFromCompose( + yamlContent: string, + envVars: Record, +): string[] { + return extractServiceImagesFromCompose(yamlContent, envVars).map(e => e.image); } // ─── Service ────────────────────────────────────────────────────────────────── diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index c980566e..d5010a7e 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -140,6 +140,21 @@ export class SchedulerService { return expr.next().toDate().getTime(); } + public calculateRunsWithin(cronExpression: string, fromMs: number, toMs: number, limit = 16): number[] { + try { + const expr = CronExpressionParser.parse(cronExpression, { currentDate: new Date(fromMs) }); + const runs: number[] = []; + while (runs.length < limit) { + const next = expr.next().toDate().getTime(); + if (next > toMs) break; + runs.push(next); + } + return runs; + } catch { + return []; + } + } + /** * Fire a notification without awaiting completion, catching any promise * rejection so the scheduler never crashes on a failed dispatch. diff --git a/backend/src/services/UpdatePreviewService.ts b/backend/src/services/UpdatePreviewService.ts new file mode 100644 index 00000000..11b5dad4 --- /dev/null +++ b/backend/src/services/UpdatePreviewService.ts @@ -0,0 +1,270 @@ +import DockerController from './DockerController'; +import { FileSystemService } from './FileSystemService'; +import { RegistryService } from './RegistryService'; +import { + extractServiceImagesFromCompose, + loadDotEnv, + type ComposeServiceImage, +} from './ImageUpdateService'; +import { + parseImageRef, + getRemoteDigest, + listRegistryTags, + type RegistryCredentials, +} from './registry-api'; + +export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown'; + +export interface UpdatePreviewImage { + service: string; + image: string; + current_tag: string; + next_tag: string | null; + has_update: boolean; + semver_bump: SemverBump; +} + +export interface UpdatePreviewSummary { + has_update: boolean; + primary_image: string | null; + current_tag: string | null; + next_tag: string | null; + semver_bump: SemverBump; + blocked: boolean; + blocked_reason: string | null; +} + +export interface UpdatePreview { + stack_name: string; + images: UpdatePreviewImage[]; + summary: UpdatePreviewSummary; + rollback_target: string | null; + changelog: string | null; +} + +interface SemverParts { + prefix: string; + major: number; + minor: number; + patch: number; + suffix: string; + raw: string; +} + +const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/; + +export function parseSemverTag(tag: string): SemverParts | null { + const m = tag.match(SEMVER_RE); + if (!m) return null; + return { + prefix: m[1] ?? '', + major: Number(m[2]), + minor: Number(m[3]), + patch: Number(m[4]), + suffix: m[5] ?? '', + raw: tag, + }; +} + +function compareSemver(a: SemverParts, b: SemverParts): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +export function findNextTag(currentTag: string, availableTags: string[]): string | null { + const current = parseSemverTag(currentTag); + if (!current) return null; + let best: SemverParts | null = null; + for (const tag of availableTags) { + const parsed = parseSemverTag(tag); + if (!parsed) continue; + if (parsed.prefix !== current.prefix) continue; + if (parsed.suffix !== current.suffix) continue; + if (compareSemver(parsed, current) <= 0) continue; + if (!best || compareSemver(parsed, best) > 0) best = parsed; + } + return best ? best.raw : null; +} + +export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump { + if (!nextTag) return 'none'; + if (nextTag === currentTag) return 'patch'; + const current = parseSemverTag(currentTag); + const next = parseSemverTag(nextTag); + if (!current || !next) return 'unknown'; + if (next.major > current.major) return 'major'; + if (next.minor > current.minor) return 'minor'; + if (next.patch > current.patch) return 'patch'; + return 'none'; +} + +function maxBump(a: SemverBump, b: SemverBump): SemverBump { + // Ranking: none < unknown < patch < minor < major. + // unknown ranks below real semver bumps so a single unparseable tag never masks + // a genuine major bump elsewhere in the stack. + const order: SemverBump[] = ['none', 'unknown', 'patch', 'minor', 'major']; + const rank = (x: SemverBump) => order.indexOf(x); + return rank(a) >= rank(b) ? a : b; +} + +async function loadStackImages( + nodeId: number, + stackName: string, +): Promise { + const fs = FileSystemService.getInstance(nodeId); + const composeContent = await fs.getStackContent(stackName); + let envVars: Record = {}; + try { + const envContent = await fs.getEnvContent(stackName); + envVars = loadDotEnv(envContent); + } catch { + // No env file - fall back to process.env only + } + const merged: Record = { ...envVars }; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) merged[k] = v; + } + return extractServiceImagesFromCompose(composeContent, merged); +} + +export interface ComputePreviewDeps { + getLocalDigest: (imageRef: string) => Promise; + getRemoteDigest: typeof getRemoteDigest; + listRegistryTags: typeof listRegistryTags; + getCredentials: (registry: string) => Promise; +} + +export async function computeImagePreview( + service: string, + imageRef: string, + deps: ComputePreviewDeps, +): Promise { + const parsed = parseImageRef(imageRef); + if (!parsed) { + return { + service, + image: imageRef, + current_tag: 'unknown', + next_tag: null, + has_update: false, + semver_bump: 'none', + }; + } + + const credentials = await deps.getCredentials(parsed.registry); + + // Digest-based: is a new build of the SAME tag available? + const [localDigest, remoteDigest] = await Promise.all([ + deps.getLocalDigest(imageRef), + deps.getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials), + ]); + const digestUpdate = Boolean(localDigest && remoteDigest && localDigest !== remoteDigest); + + // Tag-based: is a higher semver tag available? + const tags = await deps.listRegistryTags(parsed.registry, parsed.repo, credentials); + const nextTag = findNextTag(parsed.tag, tags); + + const hasUpdate = digestUpdate || nextTag !== null; + let semverBump: SemverBump = 'none'; + let resolvedNext: string | null = null; + if (nextTag) { + resolvedNext = nextTag; + semverBump = computeSemverBump(parsed.tag, nextTag); + } else if (digestUpdate) { + resolvedNext = parsed.tag; + semverBump = 'patch'; + } + + return { + service, + image: imageRef, + current_tag: parsed.tag, + next_tag: resolvedNext, + has_update: hasUpdate, + semver_bump: semverBump, + }; +} + +function buildRollbackTarget(image: string, currentTag: string): string | null { + const parsed = parseImageRef(image); + if (!parsed) return null; + // Reconstruct without the library/ prefix Docker Hub uses internally, + // so "library/nginx" renders as "nginx:1.0.0" not "registry-1.docker.io/library/nginx:1.0.0". + const isDockerHub = parsed.registry === 'registry-1.docker.io'; + const repo = isDockerHub && parsed.repo.startsWith('library/') + ? parsed.repo.slice('library/'.length) + : parsed.repo; + const base = isDockerHub ? repo : `${parsed.registry}/${repo}`; + return `${base}:${currentTag}`; +} + +export function buildSummary(stackName: string, images: UpdatePreviewImage[]): UpdatePreview { + const updated = images.filter(i => i.has_update); + const hasUpdate = updated.length > 0; + const primary = updated[0] ?? images[0] ?? null; + const overallBump = updated.reduce( + (acc, img) => maxBump(acc, img.semver_bump), + 'none', + ); + const blocked = overallBump === 'major'; + return { + stack_name: stackName, + images, + summary: { + has_update: hasUpdate, + primary_image: primary ? primary.image : null, + current_tag: primary ? primary.current_tag : null, + next_tag: primary ? primary.next_tag : null, + semver_bump: overallBump, + blocked, + blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null, + }, + rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null, + changelog: null, + }; +} + +export class UpdatePreviewService { + private static instance: UpdatePreviewService; + + public static getInstance(): UpdatePreviewService { + if (!UpdatePreviewService.instance) { + UpdatePreviewService.instance = new UpdatePreviewService(); + } + return UpdatePreviewService.instance; + } + + public async getPreview(nodeId: number, stackName: string): Promise { + const stackImages = await loadStackImages(nodeId, stackName); + if (stackImages.length === 0) { + return buildSummary(stackName, []); + } + + const docker = DockerController.getInstance(nodeId); + const deps: ComputePreviewDeps = { + getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry), + getRemoteDigest, + listRegistryTags, + getLocalDigest: async (imageRef: string) => { + try { + const inspect = await docker.getDocker().getImage(imageRef).inspect(); + const repoDigests: string[] = inspect.RepoDigests ?? []; + for (const rd of repoDigests) { + if (!rd.includes('@sha256:')) continue; + const [, digest] = rd.split('@'); + return digest; + } + return null; + } catch { + return null; + } + }, + }; + + const results = await Promise.all( + stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)), + ); + return buildSummary(stackName, results); + } +} diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts new file mode 100644 index 00000000..5776436e --- /dev/null +++ b/backend/src/services/registry-api.ts @@ -0,0 +1,169 @@ +import https from 'https'; +import http from 'http'; + +export interface ParsedRef { + registry: string; + repo: string; + tag: string; +} + +export interface HttpResult { + statusCode: number; + headers: Record; + body: string; +} + +export interface RegistryCredentials { + username: string; + password: string; +} + +export function parseImageRef(imageRef: string): ParsedRef | null { + if (imageRef.startsWith('sha256:')) return null; + + const atIdx = imageRef.indexOf('@'); + if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx); + + let registry = 'registry-1.docker.io'; + let rest = imageRef; + + const slashIdx = imageRef.indexOf('/'); + if (slashIdx !== -1) { + const firstPart = imageRef.slice(0, slashIdx); + if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { + registry = firstPart; + rest = imageRef.slice(slashIdx + 1); + } + } + + let tag = 'latest'; + const colonIdx = rest.lastIndexOf(':'); + if (colonIdx > 0) { + tag = rest.slice(colonIdx + 1); + rest = rest.slice(0, colonIdx); + } + + if (registry === 'registry-1.docker.io' && !rest.includes('/')) { + rest = `library/${rest}`; + } + + return { registry, repo: rest, tag }; +} + +export function httpGet( + url: string, + headers: Record = {}, + timeoutMs = 10000, +): Promise { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https:') ? https : http; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + const req = lib.get(url, { headers }, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => finish(() => resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + body, + }))); + res.on('error', (err) => finish(() => reject(err))); + }); + req.on('error', (err) => finish(() => reject(err))); + req.setTimeout(timeoutMs, () => { + const err = new Error('Request timed out'); + req.destroy(err); + finish(() => reject(err)); + }); + }); +} + +export async function getAuthToken( + registry: string, + repo: string, + credentials?: RegistryCredentials | null, +): Promise { + try { + const basicHeaders: Record = {}; + if (credentials) { + basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; + } + + let tokenUrl: string; + if (registry === 'registry-1.docker.io') { + tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; + } else { + const ping = await httpGet(`https://${registry}/v2/`, basicHeaders); + const wwwAuth = ping.headers['www-authenticate'] as string | undefined; + if (!wwwAuth) return null; + + const realmMatch = wwwAuth.match(/realm="([^"]+)"/); + const serviceMatch = wwwAuth.match(/service="([^"]+)"/); + const scopeMatch = wwwAuth.match(/scope="([^"]+)"/); + if (!realmMatch) return null; + + const params = new URLSearchParams(); + if (serviceMatch) params.set('service', serviceMatch[1]); + params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`); + tokenUrl = `${realmMatch[1]}?${params.toString()}`; + } + + const tokenRes = await httpGet(tokenUrl, basicHeaders); + if (tokenRes.statusCode !== 200) return null; + + const parsed = JSON.parse(tokenRes.body); + return parsed.token ?? parsed.access_token ?? null; + } catch { + return null; + } +} + +const MANIFEST_ACCEPT = [ + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.oci.image.manifest.v1+json', +].join(', '); + +export async function getRemoteDigest( + registry: string, + repo: string, + tag: string, + credentials?: RegistryCredentials | null, +): Promise { + try { + const token = await getAuthToken(registry, repo, credentials); + const headers: Record = { Accept: MANIFEST_ACCEPT }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers); + if (res.statusCode !== 200) return null; + return (res.headers['docker-content-digest'] as string) ?? null; + } catch { + return null; + } +} + +export async function listRegistryTags( + registry: string, + repo: string, + credentials?: RegistryCredentials | null, +): Promise { + try { + const token = await getAuthToken(registry, repo, credentials); + const headers: Record = { Accept: 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await httpGet(`https://${registry}/v2/${repo}/tags/list`, headers); + if (res.statusCode !== 200) return []; + + const parsed = JSON.parse(res.body) as { tags?: string[] }; + return Array.isArray(parsed.tags) ? parsed.tags : []; + } catch { + return []; + } +} diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index e9a61e11..349b049e 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -1,194 +1,90 @@ --- -title: "Auto-Update Policies" -description: "Automatically check for and apply container image updates on a schedule." +title: "Auto-Update Readiness" +description: "Review pending container updates across your fleet, with risk tags, changelogs, and rollback targets, before applying." --- - Auto-Update Policies require a **Skipper** or **Admiral** license. + Auto-Update Readiness requires a **Skipper** or **Admiral** license. ## Overview -Auto-Update Policies let you define schedules for Sencho to automatically check your container images for updates and apply them when new versions are available. Think of it as a built-in Watchtower, integrated directly into your Sencho dashboard with full visibility into what was updated and when. +Auto-Update Readiness is the launchpad for every pending update across your stacks. Instead of a list of CRUD policies, the view surfaces one card per stack with an available update and tells you, at a glance, whether it is safe to apply. -Each policy targets a specific stack (or all stacks on a node) and runs on a cron schedule. When triggered, Sencho: +Each card shows: -1. Inspects every container in the target stack -2. Compares local image digests against the remote registry -3. If any image has a newer version, pulls the update and recreates the stack with `docker compose up -d` -4. Records the result in run history for auditability +- The current and next tag, with the new version highlighted in cyan. +- A **risk tag** derived from the version delta: `patch` (safe), `minor`, `major`, or `digest rebuild`. +- A one-line changelog preview when the registry publishes one. +- The **rollback target** (the tag Sencho will fall back to if an update is reverted). +- The next scheduled run for the matching auto-update task, if one exists. - Auto-Update Policies view showing the policies list + Readiness board with hero count, per-stack cards, and risk tags -## Creating a policy +The hero at the top counts pending updates fleet-wide and tells you how many of them are ready to apply without human review. Major version jumps and stacks with blocked registries are counted separately so they can be reviewed before the scheduler runs. -Navigate to **Auto-Update** in the sidebar and click **New Policy**. +## Workflow - - Create auto-update policy dialog - +1. Open the **Auto-Update** view from the sidebar. +2. Skim the card grid. Patch and minor bumps render with a green or amber tag; major bumps render in red and are marked as blocked. +3. For a safe update, click **Apply now** on the card to pull and recreate the stack immediately. +4. For a major bump, review the changelog preview and the rollback target before deciding. If you still want to apply it, switch to the **Schedules** view and create or edit an auto-update task for that stack. +5. Use **Recheck all** in the hero to force an immediate registry poll if you want to bypass the cached update status. -Fill in the following fields: +## Risk tags -| Field | Description | -|-------|-------------| -| **Name** | A descriptive name for the policy (e.g., "Nightly media stack update") | -| **Node** | The node where the target stack runs | -| **Stack** | The stack to monitor and update, or **All Stacks** to update every stack on the selected node | -| **Check Frequency** | A cron preset or custom cron expression defining how often to check | -| **Enabled** | Toggle to enable or disable the policy on creation | +| Tag | Meaning | Source | +|-----|---------|--------| +| **patch** | Safe automated update (e.g. `1.2.3` → `1.2.4`) | Semver comparison of tags | +| **minor** | Backwards-compatible update (e.g. `1.2.3` → `1.3.0`) | Semver comparison of tags | +| **major** | Potentially breaking change (e.g. `1.2.3` → `2.0.0`). Marked **blocked** by default | Semver comparison of tags | +| **digest rebuild** | Same tag, new image digest (e.g. `latest` pushed again) | Local vs remote digest diff | +| **unknown** | Non-semver tag (e.g. `main`, `stable`) | Fallback when tags cannot be compared | -The Stack selector becomes available after choosing a node. Selecting **All Stacks** will check and update every stack on that node during each run. +Blocked updates still schedule check runs, but the apply button is disabled until you review them manually. -### Schedule presets +## Scheduling auto-updates -For convenience, Sencho offers common schedule presets: +Auto-update is a first-class action in the Schedules view. To create a recurring check for a stack: -| Preset | Cron Expression | Description | -|--------|----------------|-------------| -| Every 6 hours | `0 */6 * * *` | Check four times per day | -| Every 12 hours | `0 */12 * * *` | Check twice per day | -| Daily at 3 AM | `0 3 * * *` | Low-traffic window for most users | -| Daily at midnight | `0 0 * * *` | Start of each day | -| Weekly (Sunday 3 AM) | `0 3 * * 0` | Minimal disruption for stable stacks | -| Custom | User-defined | Any valid cron expression | +1. Open **Schedules** in the top nav. +2. Click **New Schedule**. +3. Set **Action** to **Auto-update Stack**, choose the target node and stack, pick a cron expression, and save. -When using a preset, a human-readable description of the schedule is shown below the selector. Custom cron expressions are validated and described in real time. - -## Filtering by node - -In a multi-node environment, you can filter the policy list to show only policies targeting a specific node. Click the **calendar icon** on any node row in **Settings > Nodes** to jump directly to a filtered view. A filter badge at the top shows the active node, with a **Clear filter** button to return to the full list. - -## Managing policies - -The policy list is displayed as a table with the following columns: - -| Column | Description | -|--------|-------------| -| **Name** | The policy name | -| **Stack** | Target stack name, or "All Stacks" for wildcard policies | -| **Schedule** | Human-readable description with the raw cron expression below | -| **Status** | Last run result: **Success** (green), **Failed** (red), or "Never run" | -| **Last Run** | Timestamp of the most recent execution | -| **Next Run** | When the policy will next execute | -| **Enabled** | Toggle switch to enable or disable the policy | -| **Actions** | Action buttons (see below) | - -### Available actions - -Each policy row has four action buttons: - -- **Run Now** (play icon) - Trigger an immediate check-and-update cycle without waiting for the next scheduled run -- **Execution History** (clock icon) - Open the run history panel for this policy -- **Edit** (pencil icon) - Modify the policy name, target, or schedule -- **Delete** (trash icon) - Permanently remove the policy and all its execution history after confirmation - -## Run history - -Click the clock icon on any policy to open the run history panel. - - - Run history panel showing execution results - - -The history is displayed as a table with the following columns: - -| Column | Description | -|--------|-------------| -| **Time** | When the run started | -| **Source** | Whether the run was triggered by the **Scheduler** or **Manual** (via Run Now) | -| **Status** | Success, Failed, or Running | -| **Duration** | How long the run took (in seconds) | -| **Details** | Output summary or error message | - -Run history is paginated at 20 entries per page. You can export the full history as CSV using the download button in the panel header. +The task lives alongside restart, prune, snapshot, and scan tasks in the same timeline and table. Run history, notifications, and the Run Now button behave the same as for every other scheduled action. See [Scheduled Operations](/features/scheduled-operations) for details. ## Multi-node support -Auto-Update Policies work seamlessly across both local and remote nodes. When a policy targets a remote node, Sencho automatically proxies the update execution to the remote Sencho instance via the Distributed API. The remote node performs all image checks and compose updates locally on its own machine, then reports the results back. +The readiness board scopes to the active node selected in the sidebar, matching the scope of the auto-update schedule attached to each card. When a remote node is selected, Sencho proxies the registry checks and the apply call to the remote Sencho instance via the Distributed API. No additional configuration is needed. -No additional configuration is required. As long as your remote node is connected and reachable, auto-update policies will execute on it just like they do on the local node. +## How readiness is computed -## How it works +For each stack with a pending image update, Sencho computes a preview by: -Under the hood, Auto-Update Policies are built on the same scheduling engine as [Scheduled Operations](/features/scheduled-operations). The key difference is that auto-update policies: +1. Parsing the compose file to enumerate every pullable image reference. +2. Calling the registry with your configured credentials to fetch the current tag list and remote digest. +3. Picking the highest semver tag greater than the current tag (keeping the same prefix and suffix) or, if tags match but digests differ, treating it as a digest rebuild. +4. Scoring the overall stack by the most severe image bump. Any major bump marks the stack as blocked. +5. Deriving the rollback target from the running tag and normalizing Docker Hub library paths. -- Are available to **Skipper** tier (Scheduled Operations requires Admiral) -- Can target a single **stack** or **all stacks** on a node -- Perform a **check-then-update** flow rather than a blind restart - -### The check-then-update flow - -1. **Enumerate images** - Sencho lists all unique pullable images used by containers in the target stack -2. **Check digests** - For each image, Sencho compares the local digest against the remote registry manifest -3. **Conditional update** - Only if at least one image has a newer version does Sencho run `docker compose up -d` to pull and recreate -4. **Clear indicators** - After a successful update, the blue update indicator dot is automatically cleared -5. **Notify** - A notification is dispatched informing you which stack was updated and which images changed - -If no updates are found, the run completes with an "all images up to date" message and no containers are restarted. - -## Relationship to image update detection - -Sencho has two complementary features for keeping your images current: - -| Feature | Purpose | Tier | -|---------|---------|------| -| **Image Update Detection** | Passive: shows a blue dot on stacks with available updates | All tiers | -| **Auto-Update Policies** | Active: automatically applies updates on a schedule | Skipper+ | - -Image Update Detection runs in the background every 6 hours and highlights stacks that have newer images available. Auto-Update Policies take this a step further by automatically applying those updates based on your defined schedule. - -## Best practices - -- **Start with longer intervals** - Use "Daily at 3 AM" or "Weekly" for production stacks. Reserve shorter intervals for dev/staging environments. -- **Pin critical images** - If a stack uses `image: postgres:16.2` (pinned tag), auto-update will only detect updates to that exact tag. Use floating tags like `postgres:16` if you want minor version updates. -- **Monitor run history** - Check run history periodically to ensure updates are applying cleanly. Failed runs may indicate registry authentication issues or compose file problems. -- **Use "All Stacks" carefully** - Wildcard policies update every stack on the node. This is convenient for dev environments but may be too aggressive for production. -- **Combine with notifications** - Sencho sends alert notifications when auto-updates are applied, so you stay informed even when updates happen automatically. +The preview is recomputed each time the readiness board loads, so it reflects the live state of your registries and local images. ## Troubleshooting -### Policy reports "all images up to date" but I see an update elsewhere +### Card shows "No changelog available" -This usually means the registry check couldn't determine the remote digest. Common causes: +Sencho reads changelog metadata from the registry's manifest and OCI annotations. Registries that do not publish this metadata (most private registries and many self-hosted ones) will simply render the card without a changelog. The risk tag is still accurate because it is computed from the tag itself. -- **Private registry without credentials** - If your images are in a private registry, make sure you've added authentication credentials in **Settings > Registries**. Without valid credentials, Sencho can't query the remote manifest. -- **Network connectivity** - The Sencho instance needs outbound HTTPS access to the registry (e.g., `registry-1.docker.io`, `ghcr.io`). Firewall rules or proxy configurations may block these requests. -- **Digest-pinned images** - Images referenced by digest (`image: nginx@sha256:abc...`) are immutable by design. Sencho strips the pin for tag-based checking, but if your compose file only uses digest refs, consider switching to tag-based refs for auto-update support. +### Apply button is disabled with a "blocked" tooltip -Starting with this version, run history now distinguishes between "all images up to date" (clean check) and "image checks failed" (registry unreachable), so you can tell whether the check actually succeeded. +The stack has a major version bump. Open the cron schedule for that stack and apply manually after reviewing the upstream release notes. The block is a policy decision: major updates never auto-apply without human review. -### Policy keeps failing with "Target node offline" +### Card stays stuck on "Checking" -This means the remote Sencho instance was unreachable when the policy triggered. Verify: +The registry call is either still pending or failed. Click **Recheck all** in the hero to retry. If the stack has private-registry credentials, confirm they are still valid in **Settings > Registries**. -- The remote node's Sencho instance is running -- The API URL and token in **Settings > Nodes** are correct -- The remote node has not changed IP address or port +### "Nothing to update" but I see an update on another view -### Policy shows "no containers found" - -This warning appears when a policy targets a specific stack that has no running containers. The stack may have been: - -- Stopped or removed since the policy was created -- Renamed (stack names are directory-based) - -Check that the target stack exists and has at least one running container. For wildcard ("All Stacks") policies, empty stacks are silently skipped without a warning. - -### "Run Now" finishes instantly - -This is expected behavior. The Run Now button triggers the update check in the background and returns immediately. The actual check runs asynchronously; check the run history panel to see the result once it completes. - -### Policy was automatically disabled - -If a policy's cron expression becomes invalid after creation, the scheduler disables the policy and records the reason in the status column. To fix this: - -1. Open the policy in edit mode. -2. Re-enter a valid cron expression (or choose a preset). -3. Re-enable the policy using the toggle switch. - -### Run shows "Server restarted during execution" - -This means Sencho was restarted while this policy's update check was mid-execution. The run was marked as failed automatically on startup. The policy itself is still enabled and will run at its next scheduled time. Use **Run Now** if you want to trigger it immediately. +Image update detection runs every six hours. The readiness board uses the same cached status. Trigger **Recheck all** to force a fresh check, or see [Image update detection](/features/image-update-detection) for details on the refresh cycle. diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 2a126bb4..620b07e8 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -4,26 +4,41 @@ description: Automate recurring Docker operations like stack restarts, fleet sna --- - Scheduled Operations requires a Sencho **Admiral** license. - Skipper and Community Edition do not include this feature. + Scheduled Operations requires a Sencho **Admiral** license. Skipper users see only the **Auto-update Stack** action; Admiral users see every action. ## Overview Scheduled Operations lets you automate recurring maintenance tasks across your infrastructure. Define a cron schedule, choose an action, and Sencho handles the rest, including a full execution history log so you always know what ran and when. +The view opens on a **Timeline** that plots the next 24 hours of scheduled work across four lanes (Restart, Update, Scan, Prune) so you can see, at a glance, what is about to fire and when. Toggle to **All tasks** for the full CRUD table. + - Scheduled operations list view showing tasks with status, schedule, and actions + Schedules timeline showing the next 24 hours across four lanes with a cyan now rail +## Timeline view + +The timeline is the default view. It shows: + +- A hero with the current 24-hour window as a date range, and the **next firing** on the right (time, task name, and relative countdown). +- Four color-coded lanes: **Restart** (cyan), **Update** (green), **Scan** (purple), and **Prune** (amber). Snapshot tasks share the Prune lane. +- One pill per firing within the window, positioned proportionally to the task's next run time. Click any pill to open that task's execution history. +- A vertical cyan **now rail** at the left edge, and six mono time ticks along the bottom axis. + +Tasks that fire more than once in the window (e.g. an hourly cron) render a pill for each firing. Disabled tasks do not appear on the timeline. + +Toggle to **All tasks** from the header to see every schedule in a table, regardless of whether it fires in the next 24 hours. + ## Supported Actions | Action | Target | Description | |--------|--------|-------------| | **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack | +| **Auto-update Stack** | A specific stack on a specific node | Checks each image for updates and recreates the stack if any image has a newer version. See [Auto-Update Readiness](/features/auto-update-policies) for the companion board. Available on Skipper and Admiral. | | **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files | | **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label | -| **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed — see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. | +| **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed, see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. | ## Creating a Scheduled Task @@ -31,9 +46,9 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i 2. Click **New Schedule**. 3. Fill in the form: - **Name**: A descriptive label (e.g. "Nightly staging restart"). - - **Action**: Choose Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection. - - **Node**: (Restart Stack and Vulnerability Scan) Select the node to run against. For Restart Stack it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned. - - **Stack**: (Restart Stack only) Select the stack to restart. Becomes available after choosing a node. + - **Action**: Choose Restart Stack, Auto-update Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection. + - **Node**: (Restart Stack, Auto-update Stack, and Vulnerability Scan) Select the node to run against. For stack actions it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned. + - **Stack**: (Restart Stack and Auto-update Stack) Select the stack to target. Becomes available after choosing a node. - **Services**: (Restart Stack only) Optionally select specific services within the stack to restart. Leave empty to restart all services. - **Prune Targets**: (System Prune only) Select which resources to prune: containers, images, networks, volumes. All are selected by default. - **Label Filter**: (System Prune only) Optionally filter prune operations to resources matching a specific Docker label (e.g. `com.docker.compose.project=mystack`). @@ -182,7 +197,7 @@ If the server restarts while a task is mid-execution, the orphaned run record is ### Task was automatically disabled -If a task's cron expression becomes invalid after creation (for example, due to a corrupt database edit or an expression that was valid in a previous version), the scheduler disables the task and records the reason in the last error column. To fix this: +If a task's cron expression becomes invalid after creation (for example, due to a corrupt database edit), the scheduler disables the task and records the reason in the last error column. To fix this: 1. Open the task in edit mode. 2. Re-enter a valid cron expression. diff --git a/docs/images/auto-update/readiness-board.png b/docs/images/auto-update/readiness-board.png new file mode 100644 index 00000000..786fe4c0 Binary files /dev/null and b/docs/images/auto-update/readiness-board.png differ diff --git a/docs/images/scheduled-operations/timeline.png b/docs/images/scheduled-operations/timeline.png new file mode 100644 index 00000000..95243f77 Binary files /dev/null and b/docs/images/scheduled-operations/timeline.png differ diff --git a/frontend/src/components/AutoUpdatePoliciesView.tsx b/frontend/src/components/AutoUpdatePoliciesView.tsx deleted file mode 100644 index 9ded6989..00000000 --- a/frontend/src/components/AutoUpdatePoliciesView.tsx +++ /dev/null @@ -1,558 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Combobox } from '@/components/ui/combobox'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react'; -import { toast } from '@/components/ui/toast-store'; -import { apiFetch, fetchForNode } from '@/lib/api'; -import { PaidGate } from '@/components/PaidGate'; -import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; -import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; - -const CRON_PRESETS = [ - { label: 'Every 6 hours', value: '0 */6 * * *' }, - { label: 'Every 12 hours', value: '0 */12 * * *' }, - { label: 'Daily at 3 AM', value: '0 3 * * *' }, - { label: 'Daily at midnight', value: '0 0 * * *' }, - { label: 'Weekly (Sunday 3 AM)', value: '0 3 * * 0' }, - { label: 'Custom', value: 'custom' }, -]; - -interface AutoUpdatePoliciesProps { - filterNodeId?: number | null; - onClearFilter?: () => void; -} - -function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) { - const [policies, setPolicies] = useState([]); - const [loading, setLoading] = useState(true); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingPolicy, setEditingPolicy] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [runsTask, setRunsTask] = useState(null); - const [runs, setRuns] = useState([]); - const [runsLoading, setRunsLoading] = useState(false); - - // Form state - const [formName, setFormName] = useState(''); - const [formTargetId, setFormTargetId] = useState(''); - const [formNodeId, setFormNodeId] = useState(''); - const [formCron, setFormCron] = useState('0 3 * * *'); - const [formCronPreset, setFormCronPreset] = useState('0 3 * * *'); - const [formEnabled, setFormEnabled] = useState(true); - const [saving, setSaving] = useState(false); - const [runningPolicies, setRunningPolicies] = useState>(new Set()); - const [runsPage, setRunsPage] = useState(1); - const [runsTotal, setRunsTotal] = useState(0); - const runsLimit = 20; - - // Available stacks and nodes - const [stacks, setStacks] = useState([]); - const [nodes, setNodes] = useState([]); - - const filteredPolicies = filterNodeId != null - ? policies.filter(p => p.node_id === filterNodeId) - : policies; - const filterNodeName = filterNodeId != null - ? nodes.find(n => n.id === filterNodeId)?.name - : null; - - const fetchPolicies = useCallback(async () => { - setLoading(true); - try { - const res = await apiFetch('/scheduled-tasks?action=update', { localOnly: true }); - if (res.ok) { - setPolicies(await res.json()); - } - } catch { - // Non-critical - } finally { - setLoading(false); - } - }, []); - - const fetchStacks = useCallback(async (nodeId?: string, signal?: AbortSignal) => { - try { - const res = nodeId - ? await fetchForNode('/stacks', parseInt(nodeId, 10), { signal }) - : await apiFetch('/stacks', { signal }); - if (res.ok) setStacks(await res.json()); - else setStacks([]); - } catch { - if (!signal?.aborted) setStacks([]); - } - }, []); - - const fetchNodes = useCallback(async () => { - try { - const res = await apiFetch('/nodes', { localOnly: true }); - if (res.ok) { - const data = await res.json(); - setNodes(data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name }))); - } - } catch { /* Non-critical */ } - }, []); - - useEffect(() => { - fetchPolicies(); - fetchStacks(); - fetchNodes(); - }, [fetchPolicies, fetchStacks, fetchNodes]); - - // Re-fetch stacks when selected node changes in the dialog - useEffect(() => { - if (!dialogOpen || !formNodeId) return; - const controller = new AbortController(); - fetchStacks(formNodeId, controller.signal); - setFormTargetId(''); - return () => controller.abort(); - }, [formNodeId, dialogOpen, fetchStacks]); - - const openCreate = () => { - setEditingPolicy(null); - setFormName(''); - setFormTargetId(''); - setFormNodeId(filterNodeId != null ? String(filterNodeId) : ''); - setFormCron('0 3 * * *'); - setFormCronPreset('0 3 * * *'); - setFormEnabled(true); - setDialogOpen(true); - if (filterNodeId != null) { - fetchStacks(String(filterNodeId)); - } - }; - - const openEdit = (policy: ScheduledTask) => { - setEditingPolicy(policy); - setFormName(policy.name); - setFormTargetId(policy.target_id || ''); - setFormNodeId(policy.node_id != null ? String(policy.node_id) : ''); - setFormCron(policy.cron_expression); - const matchingPreset = CRON_PRESETS.find(p => p.value === policy.cron_expression); - setFormCronPreset(matchingPreset ? matchingPreset.value : 'custom'); - setFormEnabled(policy.enabled === 1); - setDialogOpen(true); - }; - - const handleSave = async () => { - const body: Record = { - name: formName.trim(), - target_type: 'stack', - action: 'update', - target_id: formTargetId, - node_id: formNodeId ? parseInt(formNodeId, 10) : null, - cron_expression: formCron, - enabled: formEnabled, - }; - - setSaving(true); - try { - const res = editingPolicy - ? await apiFetch(`/scheduled-tasks/${editingPolicy.id}`, { method: 'PUT', body: JSON.stringify(body), localOnly: true }) - : await apiFetch('/scheduled-tasks', { method: 'POST', body: JSON.stringify(body), localOnly: true }); - - if (res.ok) { - toast.success(editingPolicy ? 'Policy updated' : 'Policy created'); - setDialogOpen(false); - fetchPolicies(); - } else { - const data = await res.json().catch(() => ({})); - toast.error(data?.error || 'Failed to save policy'); - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Something went wrong.'; - toast.error(msg); - } finally { - setSaving(false); - } - }; - - const handleToggle = async (policy: ScheduledTask) => { - try { - const res = await apiFetch(`/scheduled-tasks/${policy.id}/toggle`, { method: 'PATCH', localOnly: true }); - if (res.ok) { - fetchPolicies(); - } else { - const data = await res.json().catch(() => ({})); - toast.error(data?.error || 'Failed to toggle policy'); - } - } catch { - toast.error('Something went wrong.'); - } - }; - - const handleDelete = async () => { - if (!deleteTarget) return; - try { - const res = await apiFetch(`/scheduled-tasks/${deleteTarget.id}`, { method: 'DELETE', localOnly: true }); - if (res.ok) { - toast.success('Policy deleted'); - setDeleteTarget(null); - fetchPolicies(); - } else { - const data = await res.json().catch(() => ({})); - toast.error(data?.error || 'Failed to delete policy'); - } - } catch { - toast.error('Something went wrong.'); - } - }; - - const openRuns = async (task: ScheduledTask, page = 1) => { - setRunsTask(task); - setRunsPage(page); - setRunsLoading(true); - const offset = (page - 1) * runsLimit; - try { - const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=${runsLimit}&offset=${offset}`, { localOnly: true }); - if (res.ok) { - const data = await res.json(); - setRuns(data.runs); - setRunsTotal(data.total); - } - } catch { /* Non-critical */ } - finally { setRunsLoading(false); } - }; - - const handleRunNow = async (policy: ScheduledTask) => { - setRunningPolicies(prev => new Set(prev).add(policy.id)); - try { - const res = await apiFetch(`/scheduled-tasks/${policy.id}/run`, { method: 'POST', localOnly: true }); - if (res.ok) { - toast.success(`Checking for updates on "${policy.target_id}"...`); - fetchPolicies(); - } else { - const data = await res.json().catch(() => ({})); - toast.error(data?.error || 'Failed to run policy'); - } - } catch { - toast.error('Something went wrong.'); - } finally { - setRunningPolicies(prev => { - const next = new Set(prev); - next.delete(policy.id); - return next; - }); - } - }; - - const cronDescription = getCronDescription(formCron); - - return ( -
- - -
-
- - Auto-Update Policies -
-
- - -
-
-

- Automatically check for new images and update your stacks on a schedule. -

-
- - {filterNodeId != null && filterNodeName && ( -
- - Filtered to node: {filterNodeName} - - -
- )} - {loading && filteredPolicies.length === 0 ? ( -
Loading...
- ) : filteredPolicies.length === 0 ? ( -
- {filterNodeId != null - ? 'No auto-update policies for this node. Create one to keep your stacks up to date automatically.' - : 'No auto-update policies yet. Create one to keep your stacks up to date automatically.'} -
- ) : ( - - - - Name - Stack - Schedule - Status - Last Run - Next Run - Enabled - Actions - - - - {filteredPolicies.map((policy) => ( - - {policy.name} - {policy.target_id === '*' ? 'All Stacks' : policy.target_id} - -
{getCronDescription(policy.cron_expression)}
-
{policy.cron_expression}
-
- - {policy.last_status === 'success' ? ( - Success - ) : policy.last_status === 'failure' ? ( - Failed - ) : ( - Never run - )} - - - {formatTimestamp(policy.last_run_at)} - - - {formatTimestamp(policy.next_run_at)} - - - handleToggle(policy)} - /> - - -
- - - - -
-
-
- ))} -
-
- )} -
-
- - {/* Create/Edit Dialog */} - - - - {editingPolicy ? 'Edit Auto-Update Policy' : 'New Auto-Update Policy'} - Configure an auto-update policy for a stack. - -
-
- - setFormName(e.target.value)} /> -
- -
- - ({ value: String(n.id), label: n.name }))} - value={formNodeId} - onValueChange={setFormNodeId} - placeholder="Select node..." - searchPlaceholder="Search nodes..." - emptyText="No nodes found." - /> -
- -
- - ({ value: s, label: s })), - ]} - value={formTargetId} - onValueChange={setFormTargetId} - placeholder={formNodeId ? "Select stack..." : "Select a node first"} - searchPlaceholder="Search stacks..." - emptyText="No stacks found." - disabled={!formNodeId} - /> -
- -
- - ({ value: p.value, label: p.label }))} - value={formCronPreset} - onValueChange={(val) => { - setFormCronPreset(val); - if (val !== 'custom') setFormCron(val); - }} - placeholder="Select frequency..." - searchPlaceholder="Search frequencies..." - emptyText="No matching frequency." - /> - {formCronPreset === 'custom' && ( - setFormCron(e.target.value)} - className="font-mono" - /> - )} -

{cronDescription}

-
- -
- - -
-
- - - - -
-
- - {/* Delete Confirmation */} - { if (!open) setDeleteTarget(null); }}> - - - Delete Auto-Update Policy - - Are you sure you want to delete “{deleteTarget?.name}”? This will also remove all execution history. This action cannot be undone. - - - - Cancel - - Delete - - - - - - {/* Run History Sheet */} - { if (!open) setRunsTask(null); }}> - - -
- Update History - {runsTask?.name} - {runsTask && runs.length > 0 && ( - - )} -
-
- -
- {runsLoading ? ( -
Loading...
- ) : runs.length === 0 ? ( -
No executions yet.
- ) : ( - <> - - - - Time - Source - Status - Duration - Details - - - - {runs.map((run) => { - const duration = run.completed_at && run.started_at - ? `${((run.completed_at - run.started_at) / 1000).toFixed(1)}s` - : '-'; - return ( - - - {new Date(run.started_at).toLocaleString()} - - - - {run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'} - - - - {run.status === 'success' ? ( - Success - ) : run.status === 'failure' ? ( - Failed - ) : ( - Running - )} - - {duration} - - {run.error || run.output || '-'} - - - ); - })} - -
- {Math.ceil(runsTotal / runsLimit) > 1 && runsTask && ( -
-

- Page {runsPage} of {Math.ceil(runsTotal / runsLimit)} -

-
- - -
-
- )} - - )} -
-
-
-
-
- ); -} - -export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) { - return ( - - - - ); -} diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx new file mode 100644 index 00000000..c2fa4b51 --- /dev/null +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -0,0 +1,468 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { RefreshCw, Shield, AlertTriangle, ShieldAlert, Clock, Play, CalendarClock } from 'lucide-react'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; +import { PaidGate } from '@/components/PaidGate'; +import { useNodes } from '@/context/NodeContext'; +import type { ScheduledTask } from '@/types/scheduling'; + +type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown'; + +interface UpdatePreviewImage { + service: string; + image: string; + current_tag: string; + next_tag: string | null; + has_update: boolean; + semver_bump: SemverBump; +} + +interface UpdatePreview { + stack_name: string; + images: UpdatePreviewImage[]; + summary: { + has_update: boolean; + primary_image: string | null; + current_tag: string | null; + next_tag: string | null; + semver_bump: SemverBump; + blocked: boolean; + blocked_reason: string | null; + }; + rollback_target: string | null; + changelog: string | null; +} + +interface StackCard { + stack: string; + preview: UpdatePreview | null; + previewLoaded: boolean; + scheduledTask: ScheduledTask | null; + applying: boolean; +} + +function formatRelative(ts: number | null): string { + if (ts == null) return ''; + const delta = ts - Date.now(); + if (delta <= 0) return 'due now'; + const mins = Math.round(delta / 60_000); + if (mins < 60) return `in ${mins}m`; + const hours = Math.floor(mins / 60); + const remMins = mins % 60; + if (hours < 24) return remMins > 0 ? `in ${hours}h ${remMins}m` : `in ${hours}h`; + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `in ${days}d ${remHours}h` : `in ${days}d`; +} + +function formatClock(ts: number | null): string { + if (ts == null) return ''; + return new Date(ts).toLocaleString(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }); +} + +function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) { + if (blocked || bump === 'major') { + return ( + + + Blocked · major + + ); + } + if (bump === 'minor') { + return ( + + + Review · minor + + ); + } + if (bump === 'patch') { + return ( + + + Safe · patch + + ); + } + if (bump === 'unknown') { + return ( + + Digest rebuild + + ); + } + return ( + + None + + ); +} + +function VersionDiff({ current, next }: { current: string | null; next: string | null }) { + if (!current) return null; + const changed = next && next !== current; + return ( +
+ {current} + + + {next ?? current} + +
+ ); +} + +function StackReadinessCard({ + card, + onApply, +}: { + card: StackCard; + onApply: (stack: string) => void; +}) { + const { stack, preview, previewLoaded, scheduledTask, applying } = card; + const loading = !previewLoaded; + const failed = previewLoaded && preview === null; + const blocked = preview?.summary.blocked ?? false; + const bump = preview?.summary.semver_bump ?? 'none'; + const updatingImageCount = preview?.images.filter(i => i.has_update).length ?? 0; + const nextRun = scheduledTask?.next_run_at ?? null; + + return ( + +
+
+ + Stack + + + {stack} + +
+ {previewLoaded && preview && } +
+ + {loading ? ( +
Checking registry...
+ ) : failed ? ( +
+ Preview failed. Registry may be unreachable. +
+ ) : ( + (() => { + const p = preview!; + const blockedReason = p.summary.blocked_reason; + return ( + <> + + +
+ {p.summary.primary_image ?? '-'} + {updatingImageCount > 1 && ( + + · {updatingImageCount} services + + )} +
+ +
+ {p.changelog ?? 'No changelog available from the registry yet.'} +
+ + {blocked && blockedReason && ( +
+ {blockedReason} +
+ )} + +
+
+ {nextRun ? ( + <> +
+ +
+ + ); + })() + )} +
+ ); +} + +function ReadinessHero({ + total, + ready, + refreshing, + onRefresh, +}: { + total: number; + ready: number; + refreshing: boolean; + onRefresh: () => void; +}) { + const headline = total === 0 + ? 'Everything is up to date' + : total === 1 + ? '1 update pending' + : `${total} updates pending`; + + return ( +
+
+
+
+
+ + Fleet readiness + + + {headline} + + {total > 0 && ( + + {ready} of {total} ready to apply automatically + {total - ready > 0 ? ` · ${total - ready} blocked by major bump` : ''} + + )} +
+
+ {total > 0 && ( +
+
+ {ready} / {total} +
+
+ Ready +
+
+ )} + +
+
+
+ ); +} + +function AutoUpdateReadinessContent() { + const { activeNode } = useNodes(); + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const refreshTimerRef = useRef | null>(null); + // Monotonic token guards against stale setCards from older node-scoped fetches. + const loadTokenRef = useRef(0); + + const loadReadiness = useCallback(async () => { + const token = ++loadTokenRef.current; + const currentNodeId = activeNode?.id ?? null; + setLoading(true); + try { + const [statusRes, tasksRes] = await Promise.all([ + apiFetch('/image-updates'), + apiFetch('/scheduled-tasks?action=update', { localOnly: true }), + ]); + if (token !== loadTokenRef.current) return; + + if (!statusRes.ok) { + throw new Error('Failed to load image update status'); + } + const statuses = await statusRes.json() as Record; + const stacksWithUpdates = Object.entries(statuses) + .filter(([, hasUpdate]) => hasUpdate) + .map(([stack]) => stack) + .sort(); + + const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : []; + const taskByStack = new Map(); + for (const t of tasks) { + // Match tasks targeting this stack on this node. Tasks with node_id=null + // are local-node-scoped and only apply when viewing the local node. + const matchesNode = currentNodeId != null + ? (t.node_id === currentNodeId || (t.node_id == null && activeNode?.type === 'local')) + : t.node_id == null; + if (t.target_type === 'stack' && t.target_id && matchesNode) { + const existing = taskByStack.get(t.target_id); + if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) { + taskByStack.set(t.target_id, t); + } + } + } + + const initial: StackCard[] = stacksWithUpdates.map(stack => ({ + stack, + preview: null, + previewLoaded: false, + scheduledTask: taskByStack.get(stack) ?? null, + applying: false, + })); + if (token !== loadTokenRef.current) return; + setCards(initial); + + const previews = await Promise.all( + stacksWithUpdates.map(async (stack) => { + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(stack)}/update-preview`); + if (!res.ok) return null; + return await res.json() as UpdatePreview; + } catch { + return null; + } + }), + ); + if (token !== loadTokenRef.current) return; + + setCards(stacksWithUpdates.map((stack, idx) => ({ + stack, + preview: previews[idx], + previewLoaded: true, + scheduledTask: taskByStack.get(stack) ?? null, + applying: false, + }))); + } catch (err) { + if (token !== loadTokenRef.current) return; + toast.error((err as Error)?.message || 'Failed to load readiness'); + } finally { + if (token === loadTokenRef.current) setLoading(false); + } + }, [activeNode?.id, activeNode?.type]); + + useEffect(() => { + loadReadiness(); + return () => { + // Invalidate any in-flight fetch and cancel pending refresh timers on unmount/node-change. + loadTokenRef.current++; + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [loadReadiness]); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); + if (res.status === 429) { + const data = await res.json().catch(() => ({ error: 'Rate limited' })); + toast.warning(data.error ?? 'Please wait before rechecking'); + return; + } + if (!res.ok) { + toast.error('Failed to trigger refresh'); + return; + } + toast.success('Checking registries for updates...'); + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null; + loadReadiness(); + }, 2500); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to trigger refresh'); + } finally { + setRefreshing(false); + } + }, [loadReadiness]); + + const handleApply = useCallback(async (stack: string) => { + setCards(prev => prev.map(c => c.stack === stack ? { ...c, applying: true } : c)); + const loadingId = toast.loading(`Applying update to ${stack}...`); + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(stack)}/update`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Update failed' })); + throw new Error(data.error ?? 'Update failed'); + } + toast.success(`${stack} updated successfully`); + setCards(prev => prev.filter(c => c.stack !== stack)); + } catch (err) { + toast.error((err as Error)?.message || 'Update failed'); + setCards(prev => prev.map(c => c.stack === stack ? { ...c, applying: false } : c)); + } finally { + toast.dismiss(loadingId); + } + }, []); + + const { total, ready } = useMemo(() => { + const t = cards.length; + const r = cards.filter(c => c.previewLoaded && c.preview !== null && !c.preview.summary.blocked).length; + return { total: t, ready: r }; + }, [cards]); + + return ( +
+ + + {loading && cards.length === 0 ? ( +
+ Loading readiness... +
+ ) : cards.length === 0 ? ( +
+
+ ) : ( +
+ {cards.map(card => ( + + ))} +
+ )} +
+ ); +} + +export default function AutoUpdateReadinessView() { + return ( + + + + ); +} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 95c77350..5da9965c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -54,7 +54,7 @@ import { GlobalObservabilityView } from './GlobalObservabilityView'; import { FleetView } from './FleetView'; import { AuditLogView } from './AuditLogView'; import ScheduledOperationsView from './ScheduledOperationsView'; -import AutoUpdatePoliciesView from './AutoUpdatePoliciesView'; +import AutoUpdateReadinessView from './AutoUpdateReadinessView'; import { SecurityHistoryView } from './SecurityHistoryView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; @@ -2779,8 +2779,8 @@ export default function EditorLayout() { ) : activeView === 'auto-updates' ? ( - - setFilterNodeId(null)} /> + + ) : activeView === 'scheduled-ops' ? ( diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 1e5d443b..5823208d 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -11,7 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; -import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react'; +import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; @@ -20,11 +20,37 @@ import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; const ACTION_OPTIONS = [ { value: 'restart', label: 'Restart Stack', targetType: 'stack' as const }, + { value: 'update', label: 'Auto-update Stack', targetType: 'stack' as const }, { value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const }, { value: 'prune', label: 'System Prune', targetType: 'system' as const }, { value: 'scan', label: 'Vulnerability Scan', targetType: 'system' as const }, ]; +const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [ + { key: 'restart', label: 'Restart', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)', actions: ['restart'] }, + { key: 'update', label: 'Update', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)', actions: ['update'] }, + { key: 'scan', label: 'Scan', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)', actions: ['scan'] }, + { key: 'prune', label: 'Prune', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)', actions: ['prune', 'snapshot'] }, +]; + +const TIMELINE_WINDOW_HOURS = 24; +const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000; + +function formatHourTick(ts: number): string { + const d = new Date(ts); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function formatRelative(ts: number, now: number): string { + const diff = ts - now; + if (diff <= 0) return 'now'; + const mins = Math.round(diff / 60000); + if (mins < 60) return `in ${mins}m`; + const hours = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins === 0 ? `in ${hours}h` : `in ${hours}h ${remMins}m`; +} + interface ScheduledOperationsViewProps { filterNodeId?: number | null; onClearFilter?: () => void; @@ -33,6 +59,8 @@ interface ScheduledOperationsViewProps { export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ScheduledOperationsViewProps) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); + const [view, setView] = useState<'timeline' | 'table'>('timeline'); + const [now, setNow] = useState(() => Date.now()); const [dialogOpen, setDialogOpen] = useState(false); const [editingTask, setEditingTask] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -71,7 +99,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: const fetchTasks = useCallback(async () => { setLoading(true); try { - const res = await apiFetch('/scheduled-tasks?exclude_action=update', { localOnly: true }); + const res = await apiFetch('/scheduled-tasks', { localOnly: true }); if (res.ok) { setTasks(await res.json()); } @@ -113,6 +141,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: fetchNodes(); }, [fetchTasks, fetchStacks, fetchNodes]); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + useEffect(() => { if (formAction !== 'restart' || !formTargetId) { setAvailableServices([]); @@ -301,6 +334,18 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: const targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType; const cronDescription = getCronDescription(formCron); + const windowEnd = now + TIMELINE_WINDOW_MS; + const timelinePills = filteredTasks + .filter(t => t.enabled === 1 && t.next_runs && t.next_runs.length > 0) + .flatMap(task => (task.next_runs ?? []).map(runAt => ({ task, runAt }))) + .filter(p => p.runAt >= now && p.runAt <= windowEnd) + .sort((a, b) => a.runAt - b.runAt); + + const nextPill = timelinePills[0] ?? null; + const hourTicks = Array.from({ length: 6 }, (_, i) => now + (i / 5) * TIMELINE_WINDOW_MS); + const windowStartLabel = new Date(now).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + const windowEndLabel = new Date(windowEnd).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + return (
@@ -311,6 +356,26 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: Scheduled Operations
+
+ + +
)} - {loading && filteredTasks.length === 0 ? ( + {view === 'timeline' ? ( +
+
+
+
+ Next 24 hours +
+
+ Next 24 hours +
+
+ {windowStartLabel} {formatHourTick(now)} → {windowEndLabel} {formatHourTick(windowEnd)} +
+
+ {nextPill ? ( +
+
+ Next +
+
+ {formatHourTick(nextPill.runAt)} +
+
+ {nextPill.task.name} · {formatRelative(nextPill.runAt, now)} +
+
+ ) : ( +
+
+ Next +
+
+ --:-- +
+
+ Nothing scheduled +
+
+ )} +
+ + {loading && filteredTasks.length === 0 ? ( +
Loading...
+ ) : ( +
+
+ {TIMELINE_LANES.map(lane => { + const lanePills = timelinePills.filter(p => lane.actions.includes(p.task.action)); + return ( +
+
+
+
+ {lanePills.map((pill, idx) => { + const leftPct = ((pill.runAt - now) / TIMELINE_WINDOW_MS) * 100; + const clamped = Math.max(0, Math.min(100, leftPct)); + const targetLabel = pill.task.target_type === 'stack' + ? pill.task.target_id ?? pill.task.name + : pill.task.name; + return ( + + ); + })} +
+
+ ); + })} +
+ {/* Now rail */} + + ) : loading && filteredTasks.length === 0 ? (
Loading...
) : filteredTasks.length === 0 ? (
diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index 214d3942..65703dd5 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -17,6 +17,7 @@ export interface ScheduledTask { prune_targets: string | null; target_services: string | null; prune_label_filter: string | null; + next_runs?: number[]; } export interface TaskRun {