feat(schedules): next-24h timeline + merge auto-update into schedules (#681)

* feat(backend): add stack update-preview endpoint for readiness board

Adds GET /api/stacks/:stackName/update-preview that returns per-image
semver diff, bump classification, and a stack-level summary powering
the Auto-Update readiness board.

- New UpdatePreviewService parses compose images, inspects local
  digests, fetches remote digests and tag lists, and finds the
  highest compatible semver tag.
- Major bumps are flagged blocked until human review; unknown bumps
  rank below real semver so they cannot mask a major.
- Rollback target is reconstructed through parseImageRef to preserve
  registry ports and drop the Docker Hub library/ prefix.
- Registry helpers (httpGet, auth token, digest, tag list, ref parse)
  are extracted into registry-api.ts and shared with ImageUpdateService.
- 28 Vitest cases cover parse, selection, bump math, digest rebuilds,
  blocked policy, and rollback target construction.

* feat(schedules): next-24h timeline, merge auto-update crud, add readiness board

Replace the flat task table with a Timeline view as the default, showing the
next 24 hours of scheduled work across four lanes (Restart, Update, Scan,
Prune) with a live now rail and per-firing pills. The All tasks tab preserves
the existing CRUD surface.

Merge Auto-update Stack into Schedules as a first-class action and replace the
standalone Auto-Update Policies view with a per-stack Readiness board that
surfaces version diffs, risk tags, changelog previews, and rollback targets
sourced from the stack update-preview endpoint.
This commit is contained in:
Anso
2026-04-18 17:48:02 -04:00
committed by GitHub
parent 0bf061a745
commit 95278843cf
16 changed files with 1515 additions and 878 deletions
@@ -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', () => {
@@ -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> = {}): 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<Parameters<typeof buildSummary>[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);
});
});
+28 -1
View File
@@ -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' });
+19 -151
View File
@@ -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<string, string | string[] | undefined>;
body: string;
}
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
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<string, string | string[] | undefined>,
body,
}));
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
});
}
// ─── Compose file helpers ────────────────────────────────────────────────────
function loadDotEnv(content: string): Record<string, string> {
export function loadDotEnv(content: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
@@ -106,10 +37,15 @@ function loadDotEnv(content: string): Record<string, string> {
return vars;
}
function extractImagesFromCompose(
export interface ComposeServiceImage {
service: string;
image: string;
}
export function extractServiceImagesFromCompose(
yamlContent: string,
envVars: Record<string, string>
): string[] {
envVars: Record<string, string>,
): ComposeServiceImage[] {
let parsed: Record<string, unknown>;
try {
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
@@ -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<string, unknown>)) {
const out: ComposeServiceImage[] = [];
for (const [service, svc] of Object.entries(parsed.services as Record<string, unknown>)) {
if (!svc || typeof svc !== 'object') continue;
const raw = (svc as Record<string, unknown>).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<string | null> {
try {
const basicHeaders: Record<string, string> = {};
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<string | null> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { 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, string>,
): string[] {
return extractServiceImagesFromCompose(yamlContent, envVars).map(e => e.image);
}
// ─── Service ──────────────────────────────────────────────────────────────────
+15
View File
@@ -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.
@@ -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<ComposeServiceImage[]> {
const fs = FileSystemService.getInstance(nodeId);
const composeContent = await fs.getStackContent(stackName);
let envVars: Record<string, string> = {};
try {
const envContent = await fs.getEnvContent(stackName);
envVars = loadDotEnv(envContent);
} catch {
// No env file - fall back to process.env only
}
const merged: Record<string, string> = { ...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<string | null>;
getRemoteDigest: typeof getRemoteDigest;
listRegistryTags: typeof listRegistryTags;
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
}
export async function computeImagePreview(
service: string,
imageRef: string,
deps: ComputePreviewDeps,
): Promise<UpdatePreviewImage> {
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<SemverBump>(
(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<UpdatePreview> {
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);
}
}
+169
View File
@@ -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<string, string | string[] | undefined>;
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<string, string> = {},
timeoutMs = 10000,
): Promise<HttpResult> {
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<string, string | string[] | undefined>,
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<string | null> {
try {
const basicHeaders: Record<string, string> = {};
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<string | null> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { 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<string[]> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { 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 [];
}
}
+50 -154
View File
@@ -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."
---
<Note>
Auto-Update Policies require a **Skipper** or **Admiral** license.
Auto-Update Readiness requires a **Skipper** or **Admiral** license.
</Note>
## 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.
<Frame>
<img src="/images/auto-update-policies/overview.png" alt="Auto-Update Policies view showing the policies list" />
<img src="/images/auto-update/readiness-board.png" alt="Readiness board with hero count, per-stack cards, and risk tags" />
</Frame>
## 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
<Frame>
<img src="/images/auto-update-policies/create-dialog.png" alt="Create auto-update policy dialog" />
</Frame>
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.
<Frame>
<img src="/images/auto-update-policies/run-history.png" alt="Run history panel showing execution results" />
</Frame>
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.
+23 -8
View File
@@ -4,26 +4,41 @@ description: Automate recurring Docker operations like stack restarts, fleet sna
---
<Note>
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.
</Note>
## 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.
<Frame>
<img src="/images/scheduled-operations/overview.png" alt="Scheduled operations list view showing tasks with status, schedule, and actions" />
<img src="/images/scheduled-operations/timeline.png" alt="Schedules timeline showing the next 24 hours across four lanes with a cyan now rail" />
</Frame>
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

@@ -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<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<ScheduledTask | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ScheduledTask | null>(null);
const [runsTask, setRunsTask] = useState<ScheduledTask | null>(null);
const [runs, setRuns] = useState<TaskRun[]>([]);
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<Set<number>>(new Set());
const [runsPage, setRunsPage] = useState(1);
const [runsTotal, setRunsTotal] = useState(0);
const runsLimit = 20;
// Available stacks and nodes
const [stacks, setStacks] = useState<string[]>([]);
const [nodes, setNodes] = useState<NodeOption[]>([]);
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<string, unknown> = {
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 (
<div className="p-6 space-y-6 max-w-6xl mx-auto">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<RefreshCw className="w-5 h-5" strokeWidth={1.5} />
<CardTitle>Auto-Update Policies</CardTitle>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchPolicies} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
New Policy
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground mt-1">
Automatically check for new images and update your stacks on a schedule.
</p>
</CardHeader>
<CardContent>
{filterNodeId != null && filterNodeName && (
<div className="flex items-center gap-2 mb-4 px-1">
<Badge variant="outline" className="gap-1.5 text-xs">
Filtered to node: <span className="font-medium">{filterNodeName}</span>
</Badge>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={onClearFilter}>
Clear filter
</Button>
</div>
)}
{loading && filteredPolicies.length === 0 ? (
<div className="text-center text-muted-foreground py-12">Loading...</div>
) : filteredPolicies.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
{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.'}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Stack</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Next Run</TableHead>
<TableHead>Enabled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPolicies.map((policy) => (
<TableRow key={policy.id}>
<TableCell className="font-medium">{policy.name}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">{policy.target_id === '*' ? 'All Stacks' : policy.target_id}</TableCell>
<TableCell>
<div className="text-sm">{getCronDescription(policy.cron_expression)}</div>
<div className="text-xs text-muted-foreground font-mono">{policy.cron_expression}</div>
</TableCell>
<TableCell>
{policy.last_status === 'success' ? (
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
) : policy.last_status === 'failure' ? (
<Badge variant="destructive">Failed</Badge>
) : (
<span className="text-xs text-muted-foreground">Never run</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTimestamp(policy.last_run_at)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTimestamp(policy.next_run_at)}
</TableCell>
<TableCell>
<Switch
checked={policy.enabled === 1}
onCheckedChange={() => handleToggle(policy)}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleRunNow(policy)} title="Run now" disabled={runningPolicies.has(policy.id)}>
<Play className={`w-4 h-4 ${runningPolicies.has(policy.id) ? 'animate-pulse' : ''}`} strokeWidth={1.5} />
</Button>
<Button variant="ghost" size="sm" onClick={() => openRuns(policy)} title="Execution history">
<History className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(policy)} title="Edit">
<Pencil className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(policy)} title="Delete" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{editingPolicy ? 'Edit Auto-Update Policy' : 'New Auto-Update Policy'}</DialogTitle>
<DialogDescription className="sr-only">Configure an auto-update policy for a stack.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Update media stack nightly" value={formName} onChange={e => setFormName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Node</Label>
<Combobox
options={nodes.map(n => ({ value: String(n.id), label: n.name }))}
value={formNodeId}
onValueChange={setFormNodeId}
placeholder="Select node..."
searchPlaceholder="Search nodes..."
emptyText="No nodes found."
/>
</div>
<div className="space-y-2">
<Label>Stack</Label>
<Combobox
options={[
{ value: '*', label: 'All Stacks' },
...stacks.map(s => ({ 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}
/>
</div>
<div className="space-y-2">
<Label>Check Frequency</Label>
<Combobox
options={CRON_PRESETS.map(p => ({ 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' && (
<Input
placeholder="0 3 * * *"
value={formCron}
onChange={e => setFormCron(e.target.value)}
className="font-mono"
/>
)}
<p className="text-xs text-muted-foreground">{cronDescription}</p>
</div>
<div className="flex items-center gap-2">
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} id="policy-enabled" />
<Label htmlFor="policy-enabled">Enabled</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !formName.trim() || !formCron || !formTargetId || !formNodeId}>
{saving ? 'Saving...' : editingPolicy ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Auto-Update Policy</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &ldquo;{deleteTarget?.name}&rdquo;? This will also remove all execution history. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Run History Sheet */}
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
<SheetContent className="sm:max-w-xl">
<SheetHeader>
<div className="flex items-center justify-between">
<SheetTitle>Update History - {runsTask?.name}</SheetTitle>
{runsTask && runs.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank')}
title="Export as CSV"
>
<Download className="w-4 h-4" strokeWidth={1.5} />
</Button>
)}
</div>
</SheetHeader>
<ScrollArea className="mt-4 flex-1" style={{ maxHeight: 'calc(100vh - 10rem)' }}>
<div>
{runsLoading ? (
<div className="text-center text-muted-foreground py-8">Loading...</div>
) : runs.length === 0 ? (
<div className="text-center text-muted-foreground py-8">No executions yet.</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Source</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => {
const duration = run.completed_at && run.started_at
? `${((run.completed_at - run.started_at) / 1000).toFixed(1)}s`
: '-';
return (
<TableRow key={run.id}>
<TableCell className="text-xs font-mono text-muted-foreground">
{new Date(run.started_at).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'}
</Badge>
</TableCell>
<TableCell>
{run.status === 'success' ? (
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
) : run.status === 'failure' ? (
<Badge variant="destructive">Failed</Badge>
) : (
<Badge variant="outline">Running</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{duration}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate" title={run.output || run.error || ''}>
{run.error || run.output || '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{Math.ceil(runsTotal / runsLimit) > 1 && runsTask && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {runsPage} of {Math.ceil(runsTotal / runsLimit)}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openRuns(runsTask, runsPage - 1)} disabled={runsPage <= 1}>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={() => openRuns(runsTask, runsPage + 1)} disabled={runsPage >= Math.ceil(runsTotal / runsLimit)}>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
)}
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}
export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
return (
<PaidGate featureName="Auto-Update Policies">
<AutoUpdatePoliciesContent filterNodeId={filterNodeId} onClearFilter={onClearFilter} />
</PaidGate>
);
}
@@ -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 (
<span className="inline-flex items-center gap-1.5 rounded-full border border-destructive/40 bg-destructive/10 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-destructive">
<ShieldAlert className="h-3 w-3" strokeWidth={1.5} />
Blocked · major
</span>
);
}
if (bump === 'minor') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-warning">
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
Review · minor
</span>
);
}
if (bump === 'patch') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-success/40 bg-success/10 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-success">
<Shield className="h-3 w-3" strokeWidth={1.5} />
Safe · patch
</span>
);
}
if (bump === 'unknown') {
return (
<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">
Digest rebuild
</span>
);
}
return (
<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">
None
</span>
);
}
function VersionDiff({ current, next }: { current: string | null; next: string | null }) {
if (!current) return null;
const changed = next && next !== current;
return (
<div className="flex items-baseline gap-2 font-mono text-sm">
<span className="text-stat-subtitle">{current}</span>
<span className="text-stat-subtitle/60"></span>
<span className={changed ? 'text-brand font-medium' : 'text-stat-subtitle'}>
{next ?? current}
</span>
</div>
);
}
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 (
<Card className="flex flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1 min-w-0">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
Stack
</span>
<span className="font-display italic text-2xl leading-tight tracking-tight text-stat-value truncate">
{stack}
</span>
</div>
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
</div>
{loading ? (
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
) : failed ? (
<div className="font-mono text-xs text-destructive/80">
Preview failed. Registry may be unreachable.
</div>
) : (
(() => {
const p = preview!;
const blockedReason = p.summary.blocked_reason;
return (
<>
<VersionDiff
current={p.summary.current_tag}
next={p.summary.next_tag}
/>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle/80">
<span>{p.summary.primary_image ?? '-'}</span>
{updatingImageCount > 1 && (
<span className="text-stat-subtitle/60">
· {updatingImageCount} services
</span>
)}
</div>
<div className="border-t border-dashed border-card-border pt-3 text-xs text-stat-subtitle/90 leading-relaxed">
{p.changelog ?? 'No changelog available from the registry yet.'}
</div>
{blocked && blockedReason && (
<div className="rounded border border-destructive/25 bg-destructive/5 px-3 py-2 text-[11px] text-destructive/90">
{blockedReason}
</div>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
{nextRun ? (
<>
<CalendarClock className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
<span>Scheduled · <span className="text-stat-value">{formatClock(nextRun)}</span></span>
<span className="text-stat-subtitle/70">· {formatRelative(nextRun)}</span>
</>
) : (
<>
<Clock className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
<span>No schedule</span>
</>
)}
</div>
<Button
size="sm"
onClick={() => onApply(stack)}
disabled={blocked || applying}
title={blocked ? (blockedReason ?? undefined) : undefined}
className="gap-1.5"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
{applying ? 'Applying...' : 'Apply now'}
</Button>
</div>
</>
);
})()
)}
</Card>
);
}
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 (
<div className="relative overflow-hidden rounded-lg border border-brand/25 border-t-brand/35 bg-card shadow-card-bevel">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.10] via-brand/[0.02] to-transparent" />
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
<div className="flex flex-col gap-1 min-w-0">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-brand">
Fleet readiness
</span>
<span className="font-display italic text-3xl leading-tight tracking-tight text-stat-value">
{headline}
</span>
{total > 0 && (
<span className="font-mono text-[11px] text-stat-subtitle/90">
{ready} of {total} ready to apply automatically
{total - ready > 0 ? ` · ${total - ready} blocked by major bump` : ''}
</span>
)}
</div>
<div className="flex items-center gap-3">
{total > 0 && (
<div className="text-right">
<div className="font-mono tabular-nums text-2xl text-stat-value">
{ready}<span className="text-stat-subtitle/60"> / {total}</span>
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Ready
</div>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={refreshing}
aria-label="Recheck registries"
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
strokeWidth={1.5}
aria-hidden="true"
/>
Recheck
</Button>
</div>
</div>
</div>
);
}
function AutoUpdateReadinessContent() {
const { activeNode } = useNodes();
const [cards, setCards] = useState<StackCard[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, boolean>;
const stacksWithUpdates = Object.entries(statuses)
.filter(([, hasUpdate]) => hasUpdate)
.map(([stack]) => stack)
.sort();
const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : [];
const taskByStack = new Map<string, ScheduledTask>();
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 (
<div className="flex flex-col gap-6 p-6 max-w-[1600px] mx-auto w-full">
<ReadinessHero total={total} ready={ready} refreshing={refreshing} onRefresh={handleRefresh} />
{loading && cards.length === 0 ? (
<div className="flex items-center justify-center py-16 font-mono text-xs text-stat-subtitle">
Loading readiness...
</div>
) : cards.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16">
<Shield className="h-8 w-8 text-success/70" strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">All stacks on current builds</div>
<div className="font-mono text-[11px] text-stat-subtitle">
Sencho will recheck registries on the scheduler interval.
</div>
</div>
) : (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3">
{cards.map(card => (
<StackReadinessCard key={card.stack} card={card} onApply={handleApply} />
))}
</div>
)}
</div>
);
}
export default function AutoUpdateReadinessView() {
return (
<PaidGate featureName="Auto-Update Readiness">
<AutoUpdateReadinessContent />
</PaidGate>
);
}
+3 -3
View File
@@ -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() {
<AuditLogView />
</CapabilityGate>
) : activeView === 'auto-updates' ? (
<CapabilityGate capability="auto-updates" featureName="Auto-Update Policies">
<AutoUpdatePoliciesView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
<AutoUpdateReadinessView />
</CapabilityGate>
) : activeView === 'scheduled-ops' ? (
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
@@ -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<ScheduledTask[]>([]);
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<ScheduledTask | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ScheduledTask | null>(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 (
<div className="p-6 space-y-6 max-w-6xl mx-auto">
<Card>
@@ -311,6 +356,26 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
<CardTitle>Scheduled Operations</CardTitle>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center rounded-md border border-card-border bg-card p-0.5 shadow-btn-glow">
<Button
variant={view === 'timeline' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2.5 gap-1.5"
onClick={() => setView('timeline')}
>
<CalendarClock className="w-3.5 h-3.5" strokeWidth={1.5} />
<span className="text-xs">Timeline</span>
</Button>
<Button
variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2.5 gap-1.5"
onClick={() => setView('table')}
>
<Table2 className="w-3.5 h-3.5" strokeWidth={1.5} />
<span className="text-xs">All tasks</span>
</Button>
</div>
<Button variant="outline" size="sm" onClick={fetchTasks} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh
@@ -333,7 +398,142 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
</Button>
</div>
)}
{loading && filteredTasks.length === 0 ? (
{view === 'timeline' ? (
<div className="space-y-5">
<div className="flex items-end justify-between gap-6 border-b border-card-border pb-4">
<div>
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
Next 24 hours
</div>
<div className="font-display italic text-3xl text-foreground leading-tight">
Next <em className="not-italic text-brand">24 hours</em>
</div>
<div className="text-xs font-mono text-stat-subtitle mt-1 tabular-nums">
{windowStartLabel} {formatHourTick(now)} {windowEndLabel} {formatHourTick(windowEnd)}
</div>
</div>
{nextPill ? (
<div className="text-right">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
Next
</div>
<div className="font-mono tabular-nums text-2xl text-brand leading-tight">
{formatHourTick(nextPill.runAt)}
</div>
<div className="text-xs font-mono text-stat-subtitle mt-1 truncate max-w-[220px]">
{nextPill.task.name} · {formatRelative(nextPill.runAt, now)}
</div>
</div>
) : (
<div className="text-right">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
Next
</div>
<div className="font-mono tabular-nums text-2xl text-stat-subtitle leading-tight">
--:--
</div>
<div className="text-xs font-mono text-stat-subtitle mt-1">
Nothing scheduled
</div>
</div>
)}
</div>
{loading && filteredTasks.length === 0 ? (
<div className="text-center text-muted-foreground py-12">Loading...</div>
) : (
<div className="relative">
<div className="space-y-1.5">
{TIMELINE_LANES.map(lane => {
const lanePills = timelinePills.filter(p => lane.actions.includes(p.task.action));
return (
<div key={lane.key} className="grid grid-cols-[80px_1fr] items-center gap-3">
<div className="flex items-center gap-2">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: lane.color }}
aria-hidden="true"
/>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle">
{lane.label}
</span>
</div>
<div
className="relative h-8 rounded-md border border-card-border bg-background/40 shadow-[inset_0_1px_2px_0_oklch(0_0_0/0.15)]"
>
{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 (
<button
key={`${pill.task.id}-${idx}-${pill.runAt}`}
type="button"
onClick={() => openRuns(pill.task)}
className="absolute top-1/2 -translate-y-1/2 h-6 px-2 rounded-sm text-[10px] font-mono tabular-nums flex items-center gap-1.5 border transition-transform hover:scale-105 hover:z-10 focus:outline-none focus-visible:ring-1 focus-visible:ring-brand"
style={{
left: `${clamped}%`,
backgroundColor: lane.bg,
borderColor: lane.color,
color: lane.color,
transform: clamped > 90
? 'translate(-100%, -50%)'
: 'translate(0, -50%)',
}}
title={`${pill.task.name} · ${formatHourTick(pill.runAt)} · ${targetLabel}`}
>
<span>{formatHourTick(pill.runAt)}</span>
<span className="opacity-70 max-w-[100px] truncate">{targetLabel}</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
{/* Now rail */}
<div
className="absolute top-0 bottom-6 w-px pointer-events-none"
style={{
left: 'calc(80px + 0.75rem)',
backgroundColor: 'var(--brand)',
boxShadow: '0 0 6px 0 var(--brand), 0 0 2px 0 var(--brand)',
}}
aria-hidden="true"
/>
{/* Axis ticks */}
<div className="grid grid-cols-[80px_1fr] gap-3 mt-3">
<div />
<div className="relative h-4">
{hourTicks.map((ts, i) => {
const leftPct = (i / 5) * 100;
return (
<div
key={ts}
className="absolute top-0 text-[10px] font-mono tabular-nums text-stat-subtitle"
style={{
left: `${leftPct}%`,
transform: i === 0 ? 'translateX(0)' : i === 5 ? 'translateX(-100%)' : 'translateX(-50%)',
}}
>
{formatHourTick(ts)}
</div>
);
})}
</div>
</div>
{timelinePills.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-6 mt-2">
Nothing scheduled in the next 24 hours. Toggle to All tasks to see every schedule, or create a new one.
</div>
)}
</div>
)}
</div>
) : loading && filteredTasks.length === 0 ? (
<div className="text-center text-muted-foreground py-12">Loading...</div>
) : filteredTasks.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
+1
View File
@@ -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 {