feat(scheduled-ops): add scheduled operations for Team Pro users (#231)

Adds the ability to schedule recurring Docker operations (stack restarts,
fleet snapshots, system prunes) via cron expressions with full execution
history logging. Includes Run Now for on-demand execution.
This commit is contained in:
Anso
2026-03-28 23:56:37 -04:00
committed by GitHub
parent 89c4a9cb77
commit 31e1795af0
31 changed files with 1552 additions and 160 deletions
+70
View File
@@ -0,0 +1,70 @@
name: Start Sencho App
description: >
Shared setup for integration jobs: installs deps, builds backend,
starts backend + frontend dev servers, waits for readiness,
and installs Playwright browsers.
inputs:
jwt-secret:
required: false
default: 'ci-test-secret-key-not-for-production'
compose-dir:
required: false
default: '/tmp/compose'
port:
required: false
default: '3000'
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install root dependencies (Playwright)
shell: bash
run: npm ci
- name: Install backend dependencies
shell: bash
run: npm ci
working-directory: ./backend
- name: Build backend
shell: bash
run: npm run build
working-directory: ./backend
- name: Install frontend dependencies
shell: bash
run: npm ci
working-directory: ./frontend
- name: Create compose directory
shell: bash
run: mkdir -p ${{ inputs.compose-dir }}
- name: Start backend
shell: bash
run: node dist/index.js &
working-directory: ./backend
env:
JWT_SECRET: ${{ inputs.jwt-secret }}
COMPOSE_DIR: ${{ inputs.compose-dir }}
PORT: ${{ inputs.port }}
NODE_ENV: test
- name: Start frontend dev server
shell: bash
run: npm run dev &
working-directory: ./frontend
- name: Wait for services to be ready
shell: bash
run: npx wait-on http://localhost:${{ inputs.port }}/api/health http://localhost:5173 --timeout 30000
- name: Install Playwright browsers
shell: bash
run: npx playwright install --with-deps chromium
+28 -85
View File
@@ -5,12 +5,21 @@ on:
branches: [main]
push:
branches: [main]
# Screenshot auto-merge commits only touch docs/images/.
# Ignoring this path breaks the cascade:
# merge PR → screenshots PR auto-merges → would re-trigger CI → now skipped.
# The next real merge will sync everything (rsync --delete does a full mirror).
paths-ignore:
- 'docs/images/**'
# Cancel previous runs on the same branch/PR
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# ---------------------------------------------------------------------------
# Build & Validation (runs on PRs and push to main)
# ---------------------------------------------------------------------------
jobs:
backend:
name: Backend (Build, Test, Lint)
@@ -100,11 +109,16 @@ jobs:
uses: aquasecurity/trivy-action@master
with:
image-ref: sencho:pr-test
exit-code: '0'
exit-code: '1'
severity: 'CRITICAL,HIGH'
format: 'table'
# Findings surface as a visible warning on the job without blocking the PR.
# Base-image CVEs are often outside our control - review manually before ignoring.
continue-on-error: true
# ---------------------------------------------------------------------------
# E2E Tests (runs on PRs and push to main)
# ---------------------------------------------------------------------------
e2e:
name: E2E Tests (Playwright)
runs-on: ubuntu-latest
@@ -113,47 +127,8 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install root dependencies (Playwright)
run: npm ci
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Build backend
working-directory: ./backend
run: npm run build
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Create compose directory
run: mkdir -p /tmp/compose
- name: Start backend
working-directory: ./backend
run: node dist/index.js &
env:
JWT_SECRET: ci-test-secret-key-not-for-production
COMPOSE_DIR: /tmp/compose
PORT: 3000
NODE_ENV: test
- name: Start frontend dev server
working-directory: ./frontend
run: npm run dev &
- name: Wait for services to be ready
run: npx wait-on http://localhost:3000/api/health http://localhost:5173 --timeout 30000
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start app & install Playwright
uses: ./.github/actions/start-app
- name: Run E2E tests
run: npx playwright test
@@ -171,11 +146,18 @@ jobs:
test-results/
retention-days: 7
# ---------------------------------------------------------------------------
# Post-release only: Screenshot refresh
# Only runs when release-please merges a "chore(main): release" commit,
# so screenshots are refreshed once per release - not on every merge.
# ---------------------------------------------------------------------------
update-screenshots:
name: Refresh Doc Screenshots
runs-on: ubuntu-latest
# Only on main pushes (merged PRs); skip bot commits to avoid infinite loop
if: "github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, 'docs: refresh screenshots')"
if: >-
github.event_name == 'push'
&& github.ref == 'refs/heads/main'
&& startsWith(github.event.head_commit.message, 'chore(main): release')
needs: [backend, frontend]
permissions:
contents: write
@@ -186,47 +168,8 @@ jobs:
with:
token: ${{ secrets.DOCS_REPO_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install root dependencies (Playwright)
run: npm ci
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Build backend
working-directory: ./backend
run: npm run build
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Create compose directory
run: mkdir -p /tmp/compose
- name: Start backend
working-directory: ./backend
run: node dist/index.js &
env:
JWT_SECRET: ci-test-secret-key-not-for-production
COMPOSE_DIR: /tmp/compose
PORT: 3000
NODE_ENV: test
- name: Start frontend dev server
working-directory: ./frontend
run: npm run dev &
- name: Wait for services to be ready
run: npx wait-on http://localhost:3000/api/health http://localhost:5173 --timeout 30000
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start app & install Playwright
uses: ./.github/actions/start-app
- name: Capture screenshots
run: npx playwright test e2e/screenshots.spec.ts --project=chromium
+14 -8
View File
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
* **scheduled-operations:** Team Pro feature for scheduling recurring Docker operations (stack restarts, fleet snapshots, system prunes) via cron expressions with full execution history logging. Includes new SchedulerService, CRUD API endpoints, and a dedicated UI section.
## [0.14.2](https://github.com/AnsoCode/Sencho/compare/v0.14.1...v0.14.2) (2026-03-29)
@@ -22,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
* **api-tokens:** harden scope enforcement fix deploy-only allowlist to match actual routes, add WebSocket scope gating, block API tokens from all sensitive management endpoints (user management, SSO configuration, node management, license management, console access, password change, node-token generation, and token self-management), add configurable expiration support (30/60/90/365 days)
* **api-tokens:** harden scope enforcement - fix deploy-only allowlist to match actual routes, add WebSocket scope gating, block API tokens from all sensitive management endpoints (user management, SSO configuration, node management, license management, console access, password change, node-token generation, and token self-management), add configurable expiration support (30/60/90/365 days)
## [0.14.0](https://github.com/AnsoCode/Sencho/compare/v0.13.2...v0.14.0) (2026-03-28)
@@ -77,20 +83,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* **sso:** Team Pro SSO/LDAP integration authenticate via LDAP/Active Directory, Google, GitHub, or Okta alongside existing password login. Auto-provisions new users on first SSO login with configurable role mapping from identity provider groups/claims. All provider credentials encrypted at rest. Configurable via environment variables or Settings UI. OIDC flows use PKCE and state-based CSRF protection. Added `trust proxy` for correct behavior behind reverse proxies.
* **audit-log:** Team Pro audit logging records all mutating API actions (deploy, stop, delete, settings changes, user CRUD) with user attribution, timestamp, HTTP method, status code, and node context. Searchable timeline UI with filtering by username and method. 90-day retention with automatic cleanup.
* **security:** encryption at rest for sensitive database values node API tokens are now encrypted with AES-256-GCM using a per-instance key stored outside the database. Existing plaintext tokens are automatically migrated on startup.
* **sso:** Team Pro SSO/LDAP integration - authenticate via LDAP/Active Directory, Google, GitHub, or Okta alongside existing password login. Auto-provisions new users on first SSO login with configurable role mapping from identity provider groups/claims. All provider credentials encrypted at rest. Configurable via environment variables or Settings UI. OIDC flows use PKCE and state-based CSRF protection. Added `trust proxy` for correct behavior behind reverse proxies.
* **audit-log:** Team Pro audit logging - records all mutating API actions (deploy, stop, delete, settings changes, user CRUD) with user attribution, timestamp, HTTP method, status code, and node context. Searchable timeline UI with filtering by username and method. 90-day retention with automatic cleanup.
* **security:** encryption at rest for sensitive database values - node API tokens are now encrypted with AES-256-GCM using a per-instance key stored outside the database. Existing plaintext tokens are automatically migrated on startup.
### Removed
* **database:** dropped 9 legacy SSH/TLS columns from the nodes table (`host`, `port`, `ssh_port`, `ssh_user`, `ssh_password`, `ssh_key`, `tls_ca`, `tls_cert`, `tls_key`) these were superseded by the Distributed API model in v0.7.0 and have been inert since
* **frontend:** removed orphaned `MaintenanceModal.tsx` component (dead code, never imported prune functionality lives in `ResourcesView.tsx`)
* **database:** dropped 9 legacy SSH/TLS columns from the nodes table (`host`, `port`, `ssh_port`, `ssh_user`, `ssh_password`, `ssh_key`, `tls_ca`, `tls_cert`, `tls_key`) - these were superseded by the Distributed API model in v0.7.0 and have been inert since
* **frontend:** removed orphaned `MaintenanceModal.tsx` component (dead code, never imported - prune functionality lives in `ResourcesView.tsx`)
### Changed
* **settings/license:** replaced static "View Pricing" button with dynamic upgrade cards Community users see Personal Pro and Team Pro options with feature highlights; Personal Pro users see Team Pro upgrade only; each card links directly to Lemon Squeezy checkout
* **settings/license:** replaced static "View Pricing" button with dynamic upgrade cards - Community users see Personal Pro and Team Pro options with feature highlights; Personal Pro users see Team Pro upgrade only; each card links directly to Lemon Squeezy checkout
* **docs:** comprehensive documentation audit updated 11 pages with accurate content and 14 fresh screenshots
* **docs:** comprehensive documentation audit - updated 11 pages with accurate content and 14 fresh screenshots
* **docs/configuration:** removed JWT_SECRET from required env vars (it's auto-generated), added compose directory reorganization explanation
* **docs/settings:** added missing License, Users, Webhooks, Support, and About sections; removed obsolete Appearance section
* **docs/editor:** fixed container actions table to match actual UI (Open App, View Live Logs, Open Bash Terminal)
+22
View File
@@ -20,6 +20,7 @@
"composerize": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"cron-parser": "^5.5.0",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"express-rate-limit": "^8.3.1",
@@ -1930,6 +1931,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
"license": "MIT",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3642,6 +3655,15 @@
"node": ">=10"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -44,6 +44,7 @@
"composerize": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"cron-parser": "^5.5.0",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"express-rate-limit": "^8.3.1",
+1 -1
View File
@@ -242,7 +242,7 @@ describe('SSO Config CRUD (DB layer)', () => {
});
});
describe('Database migration SSO columns', () => {
describe('Database migration - SSO columns', () => {
it('users table has auth_provider, provider_id, email columns', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
+258 -3
View File
@@ -18,7 +18,7 @@ import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'path';
import { HostTerminalService } from './services/HostTerminalService';
import { DatabaseService, Node, AuthProvider } from './services/DatabaseService';
import { DatabaseService, Node, AuthProvider, ScheduledTask } from './services/DatabaseService';
import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
import { ImageUpdateService } from './services/ImageUpdateService';
@@ -28,6 +28,8 @@ import { NodeRegistry } from './services/NodeRegistry';
import { LicenseService } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
import YAML from 'yaml';
import fs, { promises as fsPromises } from 'fs';
@@ -652,7 +654,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
authMiddleware(req, res, next);
});
// Audit logging middleware records all mutating API actions for Team Pro accountability.
// Audit logging middleware - records all mutating API actions for Team Pro accountability.
// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code.
const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'POST /stacks': 'Created stack',
@@ -684,6 +686,10 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'DELETE /sso/config': 'Deleted SSO configuration',
'POST /api-tokens': 'Created API token',
'DELETE /api-tokens': 'Revoked API token',
'POST /scheduled-tasks': 'Created scheduled task',
'PUT /scheduled-tasks': 'Updated scheduled task',
'DELETE /scheduled-tasks': 'Deleted scheduled task',
'PATCH /scheduled-tasks': 'Toggled scheduled task',
};
function getAuditSummary(method: string, apiPath: string): string {
@@ -765,7 +771,7 @@ const requireAdmin = (req: Request, res: Response): boolean => {
return true;
};
// Scope enforcement for API tokens restricts which endpoints a token can reach.
// Scope enforcement for API tokens - restricts which endpoints a token can reach.
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
/^\/api\/stacks\/[^/]+\/deploy$/,
/^\/api\/stacks\/[^/]+\/down$/,
@@ -3364,6 +3370,251 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp
}
});
// --- Scheduled Operations Routes (Team Pro, admin-only, local-only) ---
app.get('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const tasks = DatabaseService.getInstance().getScheduledTasks();
res.json(tasks);
} catch (error) {
console.error('[ScheduledTasks] List error:', error);
res.status(500).json({ error: 'Failed to fetch scheduled tasks' });
}
});
app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const { name, target_type, target_id, node_id, action, cron_expression, enabled } = req.body;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Name is required' }); return;
}
if (!['stack', 'fleet', 'system'].includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
}
if (!['restart', 'snapshot', 'prune'].includes(action)) {
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, or prune.' }); return;
}
// Validate action-target combos
if (action === 'restart' && target_type !== 'stack') {
res.status(400).json({ error: 'Restart action requires target_type "stack".' }); return;
}
if (action === 'snapshot' && target_type !== 'fleet') {
res.status(400).json({ error: 'Snapshot action requires target_type "fleet".' }); return;
}
if (action === 'prune' && target_type !== 'system') {
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
}
if (target_type === 'stack' && (!target_id || !node_id)) {
res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return;
}
// Validate cron expression
try { CronExpressionParser.parse(cron_expression); } catch {
res.status(400).json({ error: 'Invalid cron expression.' }); return;
}
const scheduler = SchedulerService.getInstance();
const now = Date.now();
const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null;
const id = DatabaseService.getInstance().createScheduledTask({
name,
target_type,
target_id: target_id || null,
node_id: node_id != null ? Number(node_id) : null,
action,
cron_expression,
enabled: enabled !== false ? 1 : 0,
created_by: req.user?.username || 'admin',
created_at: now,
updated_at: now,
last_run_at: null,
next_run_at: nextRun,
last_status: null,
last_error: null,
});
const task = DatabaseService.getInstance().getScheduledTask(id);
res.status(201).json(task);
} catch (error) {
console.error('[ScheduledTasks] Create error:', error);
res.status(500).json({ error: 'Failed to create scheduled task' });
}
});
app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = DatabaseService.getInstance().getScheduledTask(id);
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Get error:', error);
res.status(500).json({ error: 'Failed to fetch scheduled task' });
}
});
app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const { name, target_type, target_id, node_id, action, cron_expression, enabled } = req.body;
if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type' }); return;
}
if (action && !['restart', 'snapshot', 'prune'].includes(action)) {
res.status(400).json({ error: 'Invalid action' }); return;
}
const finalAction = action || existing.action;
const finalTargetType = target_type || existing.target_type;
if (finalAction === 'restart' && finalTargetType !== 'stack') {
res.status(400).json({ error: 'Restart action requires target_type "stack".' }); return;
}
if (finalAction === 'snapshot' && finalTargetType !== 'fleet') {
res.status(400).json({ error: 'Snapshot action requires target_type "fleet".' }); return;
}
if (finalAction === 'prune' && finalTargetType !== 'system') {
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
}
if (cron_expression) {
try { CronExpressionParser.parse(cron_expression); } catch {
res.status(400).json({ error: 'Invalid cron expression.' }); return;
}
}
const updates: Record<string, unknown> = { updated_at: Date.now() };
if (name !== undefined) updates.name = name;
if (target_type !== undefined) updates.target_type = target_type;
if (target_id !== undefined) updates.target_id = target_id || null;
if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null;
if (action !== undefined) updates.action = action;
if (cron_expression !== undefined) updates.cron_expression = cron_expression;
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
// Recalculate next_run if cron changed or if enabling
const finalCron = cron_expression || existing.cron_expression;
const finalEnabled = enabled !== undefined ? enabled : existing.enabled;
if (finalEnabled) {
updates.next_run_at = SchedulerService.getInstance().calculateNextRun(finalCron);
} else {
updates.next_run_at = null;
}
db.updateScheduledTask(id, updates as Partial<Omit<ScheduledTask, 'id'>>);
const task = db.getScheduledTask(id);
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Update error:', error);
res.status(500).json({ error: 'Failed to update scheduled task' });
}
});
app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
db.deleteScheduledTask(id);
res.json({ success: true });
} catch (error) {
console.error('[ScheduledTasks] Delete error:', error);
res.status(500).json({ error: 'Failed to delete scheduled task' });
}
});
app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const newEnabled = existing.enabled ? 0 : 1;
const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null;
db.updateScheduledTask(id, {
enabled: newEnabled,
next_run_at: nextRun,
updated_at: Date.now(),
});
const task = db.getScheduledTask(id);
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Toggle error:', error);
res.status(500).json({ error: 'Failed to toggle scheduled task' });
}
});
app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
await SchedulerService.getInstance().triggerTask(id);
const task = db.getScheduledTask(id);
res.json(task);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Failed to run task';
console.error('[ScheduledTasks] Run error:', msg);
res.status(500).json({ error: msg });
}
});
app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const runs = db.getScheduledTaskRuns(id, limit);
res.json(runs);
} catch (error) {
console.error('[ScheduledTasks] Runs error:', error);
res.status(500).json({ error: 'Failed to fetch task runs' });
}
});
// --- System Maintenance Routes (The System Janitor) ---
app.get('/api/system/orphans', async (req: Request, res: Response) => {
@@ -3817,6 +4068,9 @@ async function startServer() {
// Start Background Image Update Checker
ImageUpdateService.getInstance().start();
// Start Scheduled Operations Service
SchedulerService.getInstance().start();
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
@@ -3841,6 +4095,7 @@ const gracefulShutdown = (signal: string) => {
try { LicenseService.getInstance().destroy(); } catch { /* already stopped */ }
try { MonitorService.getInstance().stop(); } catch { /* already stopped */ }
try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ }
try { SchedulerService.getInstance().stop(); } catch { /* already stopped */ }
try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ }
console.log('[Shutdown] Done - exiting');
process.exit(0);
+148
View File
@@ -137,6 +137,34 @@ export interface ApiToken {
revoked_at: number | null;
}
export interface ScheduledTask {
id: number;
name: string;
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune';
cron_expression: string;
enabled: number;
created_by: string;
created_at: number;
updated_at: number;
last_run_at: number | null;
next_run_at: number | null;
last_status: string | null;
last_error: string | null;
}
export interface ScheduledTaskRun {
id: number;
task_id: number;
started_at: number;
completed_at: number | null;
status: 'running' | 'success' | 'failure';
output: string | null;
error: string | null;
}
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
@@ -324,6 +352,38 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
node_id INTEGER,
action TEXT NOT NULL,
cron_expression TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER,
last_status TEXT,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
output TEXT,
error TEXT,
FOREIGN KEY(task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task ON scheduled_task_runs(task_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at);
`);
// Apply migrations safely (ignore if columns already exist)
@@ -1016,4 +1076,92 @@ export class DatabaseService {
public updateApiTokenLastUsed(id: number): void {
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
}
// --- Scheduled Tasks ---
public getScheduledTasks(): ScheduledTask[] {
return this.db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[];
}
public getScheduledTask(id: number): ScheduledTask | undefined {
return this.db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined;
}
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
task.name, task.target_type, task.target_id, task.node_id,
task.action, task.cron_expression, task.enabled, task.created_by,
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
task.last_status, task.last_error
);
return result.lastInsertRowid as number;
}
public updateScheduledTask(id: number, updates: Partial<Omit<ScheduledTask, 'id'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
const map: Record<string, unknown> = {
name: updates.name, target_type: updates.target_type, target_id: updates.target_id,
node_id: updates.node_id, action: updates.action, cron_expression: updates.cron_expression,
enabled: updates.enabled, created_by: updates.created_by, updated_at: updates.updated_at,
last_run_at: updates.last_run_at, next_run_at: updates.next_run_at,
last_status: updates.last_status, last_error: updates.last_error,
};
for (const [col, val] of Object.entries(map)) {
if (val !== undefined) {
fields.push(`${col} = ?`);
values.push(val);
}
}
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteScheduledTask(id: number): void {
this.db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
}
public getDueScheduledTasks(now: number): ScheduledTask[] {
return this.db.prepare(
'SELECT * FROM scheduled_tasks WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?'
).all(now) as ScheduledTask[];
}
public getScheduledTaskRuns(taskId: number, limit = 50): ScheduledTaskRun[] {
return this.db.prepare(
'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ?'
).all(taskId, limit) as ScheduledTaskRun[];
}
public createScheduledTaskRun(run: Omit<ScheduledTaskRun, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error) VALUES (?, ?, ?, ?, ?, ?)'
).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error);
return result.lastInsertRowid as number;
}
public updateScheduledTaskRun(id: number, updates: Partial<Omit<ScheduledTaskRun, 'id' | 'task_id'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.completed_at !== undefined) { fields.push('completed_at = ?'); values.push(updates.completed_at); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.output !== undefined) { fields.push('output = ?'); values.push(updates.output); }
if (updates.error !== undefined) { fields.push('error = ?'); values.push(updates.error); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE scheduled_task_runs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public cleanupOldTaskRuns(retentionDays = 30): void {
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ export class LicenseService {
/**
* Get the license variant (personal or team) from stored metadata.
* Trial licenses default to "personal" Team Pro features require a Team Pro license.
* Trial licenses default to "personal" - Team Pro features require a Team Pro license.
*/
public getVariant(): LicenseVariant {
const db = DatabaseService.getInstance();
+1 -1
View File
@@ -432,7 +432,7 @@ export class SSOService {
let issuer: InstanceType<typeof Issuer>;
if (provider === 'oidc_github') {
// GitHub is not a standard OIDC provider manually configure
// GitHub is not a standard OIDC provider - manually configure
issuer = new Issuer({
issuer: 'https://github.com',
authorization_endpoint: 'https://github.com/login/oauth/authorize',
+315
View File
@@ -0,0 +1,315 @@
import { CronExpressionParser } from 'cron-parser';
import { DatabaseService } from './DatabaseService';
import type { ScheduledTask } from './DatabaseService';
import { LicenseService } from './LicenseService';
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
export class SchedulerService {
private static instance: SchedulerService;
private intervalId: ReturnType<typeof setInterval> | null = null;
private isProcessing = false;
private runningTasks = new Set<number>();
private constructor() {}
public static getInstance(): SchedulerService {
if (!SchedulerService.instance) {
SchedulerService.instance = new SchedulerService();
}
return SchedulerService.instance;
}
public start(): void {
if (this.intervalId) return;
this.intervalId = setInterval(() => this.tick(), 60_000);
setTimeout(() => this.tick(), 10_000);
console.log('[SchedulerService] Started');
}
public stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
console.log('[SchedulerService] Stopped');
}
public calculateNextRun(cronExpression: string): number {
const expr = CronExpressionParser.parse(cronExpression);
return expr.next().toDate().getTime();
}
private async tick(): Promise<void> {
if (this.isProcessing) return;
this.isProcessing = true;
try {
const ls = LicenseService.getInstance();
if (ls.getTier() !== 'pro' || ls.getVariant() !== 'team') return;
const db = DatabaseService.getInstance();
const now = Date.now();
const dueTasks = db.getDueScheduledTasks(now);
// Clean up old runs periodically (piggyback on tick)
db.cleanupOldTaskRuns(30);
for (const task of dueTasks) {
if (this.runningTasks.has(task.id)) continue;
this.runningTasks.add(task.id);
this.executeTask(task).finally(() => this.runningTasks.delete(task.id));
}
} catch (error) {
console.error('[SchedulerService] Tick error:', error);
} finally {
this.isProcessing = false;
}
}
public async triggerTask(taskId: number): Promise<void> {
const db = DatabaseService.getInstance();
const task = db.getScheduledTask(taskId);
if (!task) throw new Error('Task not found');
if (this.runningTasks.has(task.id)) throw new Error('Task is already running');
this.runningTasks.add(task.id);
try {
await this.executeTask(task);
} finally {
this.runningTasks.delete(task.id);
}
}
private async executeTask(task: ScheduledTask): Promise<void> {
const db = DatabaseService.getInstance();
const runId = db.createScheduledTaskRun({
task_id: task.id,
started_at: Date.now(),
completed_at: null,
status: 'running',
output: null,
error: null,
});
try {
let output = '';
switch (task.action) {
case 'restart':
output = await this.executeRestart(task);
break;
case 'snapshot':
output = await this.executeSnapshot(task);
break;
case 'prune':
output = await this.executePrune(task);
break;
}
const nextRun = this.calculateNextRun(task.cron_expression);
db.updateScheduledTask(task.id, {
last_run_at: Date.now(),
next_run_at: nextRun,
last_status: 'success',
last_error: null,
updated_at: Date.now(),
});
db.updateScheduledTaskRun(runId, {
completed_at: Date.now(),
status: 'success',
output,
});
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
let nextRun: number | null = null;
try {
nextRun = this.calculateNextRun(task.cron_expression);
} catch {
// If cron expression is somehow invalid, disable the task
}
db.updateScheduledTask(task.id, {
last_run_at: Date.now(),
next_run_at: nextRun,
last_status: 'failure',
last_error: errMsg,
updated_at: Date.now(),
});
db.updateScheduledTaskRun(runId, {
completed_at: Date.now(),
status: 'failure',
error: errMsg,
});
console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg);
}
}
private async executeRestart(task: ScheduledTask): Promise<string> {
if (!task.target_id || task.node_id == null) {
throw new Error('Stack restart requires target_id and node_id');
}
const docker = DockerController.getInstance(task.node_id);
const containers = await docker.getContainersByStack(task.target_id);
if (!containers || containers.length === 0) {
throw new Error(`No containers found for stack "${task.target_id}"`);
}
await Promise.all(containers.map(c => docker.restartContainer(c.Id)));
return `Restarted ${containers.length} container(s) in stack "${task.target_id}"`;
}
private async executeSnapshot(task: ScheduledTask): Promise<string> {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
return this.captureRemoteNodeFiles(node);
}
return this.captureLocalNodeFiles(node);
})
);
const capturedNodes: Array<{ nodeId: number; nodeName: string; stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> }> = [];
const skippedNodes: Array<{ nodeId: number; nodeName: string; reason: string }> = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
capturedNodes.push(result.value);
} else {
skippedNodes.push({
nodeId: nodes[i].id,
nodeName: nodes[i].name,
reason: result.reason instanceof Error ? result.reason.message : 'Unknown error',
});
}
});
let totalStacks = 0;
const allFiles: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }> = [];
for (const nodeData of capturedNodes) {
totalStacks += nodeData.stacks.length;
for (const stack of nodeData.stacks) {
for (const file of stack.files) {
allFiles.push({
nodeId: nodeData.nodeId,
nodeName: nodeData.nodeName,
stackName: stack.stackName,
filename: file.filename,
content: file.content,
});
}
}
}
const description = `Scheduled snapshot: ${task.name}`;
const snapshotId = db.createSnapshot(
description,
task.created_by,
capturedNodes.length,
totalStacks,
JSON.stringify(skippedNodes),
);
if (allFiles.length > 0) {
db.insertSnapshotFiles(snapshotId, allFiles);
}
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''})`;
}
private async captureLocalNodeFiles(node: { id: number; name: string }) {
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch {
continue;
}
try {
const envContent = await fsService.getEnvContent(stackName);
files.push({ filename: '.env', content: envContent });
} catch {
// No .env file
}
stacks.push({ stackName, files });
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
private async captureRemoteNodeFiles(node: { id: number; name: string; api_url?: string; api_token?: string }) {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
const stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (composeRes.ok) {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch {
continue;
}
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (envRes.ok) {
const content = await envRes.text();
files.push({ filename: '.env', content });
}
} catch {
// No .env
}
if (files.length > 0) {
stacks.push({ stackName, files });
}
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
private async executePrune(task: ScheduledTask): Promise<string> {
const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId();
const docker = DockerController.getInstance(nodeId);
const targets = ['containers', 'images', 'networks', 'volumes'] as const;
const results: string[] = [];
for (const target of targets) {
try {
const result = await docker.pruneSystem(target);
results.push(`${target}: ${result.reclaimedBytes ?? 0} bytes reclaimed`);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
results.push(`${target}: failed (${msg})`);
}
}
return `System prune completed: ${results.join('; ')}`;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

+1
View File
@@ -98,6 +98,7 @@
"features/fleet-backups",
"features/audit-log",
"features/api-tokens",
"features/scheduled-operations",
"features/sso",
"features/licensing"
]
+9 -9
View File
@@ -7,7 +7,7 @@ description: Generate scoped API tokens for CI/CD pipelines, scripts, and automa
API Tokens require a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature.
</Note>
API tokens let you authenticate external tools CI/CD pipelines, deployment scripts, monitoring integrations without sharing user credentials. Each token is scoped to a specific permission level so you can follow the principle of least privilege.
API tokens let you authenticate external tools - CI/CD pipelines, deployment scripts, monitoring integrations - without sharing user credentials. Each token is scoped to a specific permission level so you can follow the principle of least privilege.
## Permission scopes
@@ -15,9 +15,9 @@ Every token is created with one of three permission levels:
| Scope | Allowed actions |
|-------|----------------|
| **Read Only** | `GET` requests only view stacks, containers, metrics, and settings |
| **Read Only** | `GET` requests only - view stacks, containers, metrics, and settings |
| **Deploy Only** | Everything in Read Only, plus stack operations: deploy, down, restart, stop, start, update |
| **Full Admin** | Full stack and container management all read and write operations on stacks, containers, images, and system metrics |
| **Full Admin** | Full stack and container management - all read and write operations on stacks, containers, images, and system metrics |
Choose the narrowest scope that fits your use case. A CI pipeline that only deploys stacks should use **Deploy Only**, not Full Admin.
@@ -42,7 +42,7 @@ These restrictions ensure that API tokens cannot escalate privileges or modify t
1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Team Pro admins only).
2. Click **Create Token**.
3. Enter a descriptive name (e.g., "GitHub Actions deploy"), select a permission scope, and optionally choose an expiration period (30, 60, 90 days, or 1 year). Tokens without an expiration must be revoked manually.
4. Click **Create**. The raw token is displayed **once** copy it immediately.
4. Click **Create**. The raw token is displayed **once** - copy it immediately.
<Frame>
<img src="/images/api-tokens/api-tokens-overview.png" alt="API Tokens management view in Settings Hub" />
@@ -84,11 +84,11 @@ If a token attempts an action outside its scope, Sencho returns a `403` response
## Revoking a token
Click the trash icon next to any token in the API Tokens settings tab. Revocation is immediate any in-flight or future requests using the revoked token will receive a `401` response.
Click the trash icon next to any token in the API Tokens settings tab. Revocation is immediate - any in-flight or future requests using the revoked token will receive a `401` response.
## Security model
- **Hashed storage** Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted.
- **Audit trail** All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username.
- **Optional expiry** Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed.
- **Scope enforcement** Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints.
- **Hashed storage** - Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted.
- **Audit trail** - All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username.
- **Optional expiry** - Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed.
- **Scope enforcement** - Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints.
+2 -2
View File
@@ -44,8 +44,8 @@ Navigate to the **Audit** tab in the sidebar (visible to Team Pro admins only).
### Filtering
- **Username filter** Search for actions by a specific user
- **Method filter** Filter by HTTP method (POST, PUT, DELETE, PATCH)
- **Username filter** - Search for actions by a specific user
- **Method filter** - Filter by HTTP method (POST, PUT, DELETE, PATCH)
Pagination is built in for navigating large audit histories.
+1 -1
View File
@@ -27,7 +27,7 @@ You can upgrade directly from **Settings > License** in your Sencho dashboard. T
- **Community users** see both **Personal Pro** and **Team Pro** options with feature highlights and direct checkout links.
- **Personal Pro users** see a **Team Pro** upgrade card for when you need unlimited accounts.
- **Team Pro users** are on the highest tier no upgrade cards are shown.
- **Team Pro users** are on the highest tier - no upgrade cards are shown.
Clicking an upgrade button opens the Lemon Squeezy checkout in a new tab. After completing the purchase, you'll receive a license key by email.
+1 -1
View File
@@ -69,7 +69,7 @@ Create point-in-time snapshots of every compose file and environment file across
## Audit log
Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings with timestamps, user attribution, and node context. Team Pro only. [Learn more →](/features/audit-log)
Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings - with timestamps, user attribution, and node context. Team Pro only. [Learn more →](/features/audit-log)
## Licensing & billing
+1 -1
View File
@@ -53,7 +53,7 @@ Admins can manage accounts in **Settings → Users**. From there you can:
With a Team Pro license, users can also be created automatically when they log in via SSO (LDAP, Google, GitHub, or Okta). SSO users appear in the Users list alongside local accounts. They are assigned a role based on identity provider group membership or claim mapping.
SSO users cannot log in with a password they must always authenticate through their identity provider.
SSO users cannot log in with a password - they must always authenticate through their identity provider.
To set up identity provider authentication, see [SSO Authentication →](/features/sso).
+85
View File
@@ -0,0 +1,85 @@
---
title: Scheduled Operations
description: Automate recurring Docker operations like stack restarts, fleet snapshots, and system prunes on a cron schedule.
---
<Note>
Scheduled Operations requires a Sencho **Team Pro** license.
Personal Pro and Community Edition do not include this feature.
</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.
## Supported Actions
| Action | Target | Description |
|--------|--------|-------------|
| **Restart Stack** | A specific stack on a specific node | Restarts all containers in the stack via the Docker Engine API |
| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files |
| **System Prune** | A specific node (or the default node) | Prunes unused containers, images, networks, and volumes |
## Creating a Scheduled Task
1. Navigate to the **Schedules** tab in the top navigation bar (visible to Team Pro admins).
2. Click **New Schedule**.
3. Fill in the form:
- **Name** - a descriptive label (e.g. "Nightly staging restart").
- **Action** - choose Restart Stack, Fleet Snapshot, or System Prune.
- **Stack / Node** - if you chose Restart Stack, select the target stack and the node it runs on.
- **Cron Expression** - standard 5-field cron format. A human-readable preview appears below the input.
- **Enabled** - toggle the task on or off.
4. Click **Create**.
## Cron Expression Reference
Sencho uses standard 5-field cron expressions:
```
┌───────────── minute (059)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (131)
│ │ │ ┌─────── month (112)
│ │ │ │ ┌───── day of week (07, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *
```
### Common Examples
| Expression | Description |
|-----------|-------------|
| `0 3 * * *` | Every day at 3:00 AM |
| `0 */6 * * *` | Every 6 hours |
| `0 3 * * 0` | Every Sunday at 3:00 AM |
| `30 2 1 * *` | 1st of every month at 2:30 AM |
| `0 0 * * 1-5` | Midnight on weekdays |
## Managing Tasks
- **Enable/Disable** - Use the toggle switch in the task list to pause or resume a schedule without deleting it.
- **Edit** - Click the pencil icon to update the task name, schedule, or target.
- **Delete** - Click the trash icon to permanently remove the task and all its execution history.
## Execution History
Click the history icon on any task to view its execution log. Each entry shows:
- **Timestamp** - when the task ran.
- **Status** - success or failure.
- **Duration** - how long the execution took.
- **Details** - output message or error description.
Execution history is retained for 30 days.
## How It Works
The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed:
1. The scheduler verifies your Team Pro license is active.
2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune).
3. Results are logged to the execution history.
4. The next run time is recalculated from the cron expression.
If a task is still running from a previous execution, the scheduler skips it to prevent overlap.
+27 -27
View File
@@ -1,13 +1,13 @@
---
title: SSO & LDAP Authentication
description: Authenticate with your existing identity provider LDAP, Google, GitHub, or Okta.
description: Authenticate with your existing identity provider - LDAP, Google, GitHub, or Okta.
---
<Note>
SSO requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature.
</Note>
Sencho Team Pro lets your team sign in using existing identity providers instead of managing separate credentials. SSO works **alongside** password authentication it does not replace it.
Sencho Team Pro lets your team sign in using existing identity providers instead of managing separate credentials. SSO works **alongside** password authentication - it does not replace it.
## Supported providers
@@ -25,7 +25,7 @@ Sencho Team Pro lets your team sign in using existing identity providers instead
1. User enters their directory username and password on the Sencho login page
2. Sencho binds to LDAP with a service account, searches for the user, then verifies their password
3. If this is their first login, a Sencho account is automatically created
4. Sencho issues a JWT and the user is logged in identical to a password login
4. Sencho issues a JWT and the user is logged in - identical to a password login
### OIDC / OAuth flow (Google, GitHub, Okta)
@@ -43,8 +43,8 @@ All OIDC flows use **PKCE** (Proof Key for Code Exchange) and a **state paramete
When a user logs in via SSO for the first time, Sencho automatically creates a local account:
- **Username** is derived from their identity provider profile (display name, email prefix, or login handle)
- **Role** is assigned based on [role mapping](#role-mapping) defaults to Viewer if no mapping matches
- **Password** is set to an unusable placeholder SSO users cannot log in with a password
- **Role** is assigned based on [role mapping](#role-mapping) - defaults to Viewer if no mapping matches
- **Password** is set to an unusable placeholder - SSO users cannot log in with a password
- **Seat limits** from your license apply. If admin seats are full, the user is downgraded to Viewer. If all seats are full, login is denied with a clear error message.
On subsequent logins, the existing account is reused. The user's email is updated if it changed at the provider.
@@ -76,8 +76,8 @@ If the user's ID token contains a `groups` claim with the value `sencho-admins`,
SSO can be configured two ways:
1. **Settings UI** Go to **Settings → SSO** in the Sencho dashboard. Enable providers, enter credentials, and test connections from the UI. Changes take effect immediately without restarting.
2. **Environment variables** Set `SSO_*` variables in your Docker Compose file. These seed the database on first boot. After that, the database configuration is authoritative.
1. **Settings UI** - Go to **Settings → SSO** in the Sencho dashboard. Enable providers, enter credentials, and test connections from the UI. Changes take effect immediately without restarting.
2. **Environment variables** - Set `SSO_*` variables in your Docker Compose file. These seed the database on first boot. After that, the database configuration is authoritative.
### Via Settings UI
@@ -114,12 +114,12 @@ Environment variables are useful for initial deployment or infrastructure-as-cod
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_LDAP_ENABLED` | `false` | Enable LDAP authentication |
| `SSO_LDAP_URL` | | LDAP server URL (e.g., `ldap://ldap.example.com:389` or `ldaps://ldap.example.com:636`) |
| `SSO_LDAP_BIND_DN` | | Service account DN for searching users |
| `SSO_LDAP_BIND_PASSWORD` | | Service account password (encrypted at rest in the database) |
| `SSO_LDAP_SEARCH_BASE` | | Base DN for user searches (e.g., `ou=users,dc=example,dc=com`) |
| `SSO_LDAP_URL` | - | LDAP server URL (e.g., `ldap://ldap.example.com:389` or `ldaps://ldap.example.com:636`) |
| `SSO_LDAP_BIND_DN` | - | Service account DN for searching users |
| `SSO_LDAP_BIND_PASSWORD` | - | Service account password (encrypted at rest in the database) |
| `SSO_LDAP_SEARCH_BASE` | - | Base DN for user searches (e.g., `ou=users,dc=example,dc=com`) |
| `SSO_LDAP_SEARCH_FILTER` | `(uid={{username}})` | LDAP filter template. Use `(sAMAccountName={{username}})` for Active Directory |
| `SSO_LDAP_ADMIN_GROUP_DN` | | DN of the group whose members receive the Admin role |
| `SSO_LDAP_ADMIN_GROUP_DN` | - | DN of the group whose members receive the Admin role |
| `SSO_LDAP_DEFAULT_ROLE` | `viewer` | Role assigned to LDAP users not in the admin group |
| `SSO_LDAP_TLS_REJECT_UNAUTHORIZED` | `true` | Whether to verify the LDAP server's TLS certificate |
@@ -128,25 +128,25 @@ Environment variables are useful for initial deployment or infrastructure-as-cod
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_GOOGLE_ENABLED` | `false` | Enable Google SSO |
| `SSO_OIDC_GOOGLE_CLIENT_ID` | | OAuth client ID from Google Cloud Console |
| `SSO_OIDC_GOOGLE_CLIENT_SECRET` | | OAuth client secret (encrypted at rest) |
| `SSO_OIDC_GOOGLE_CLIENT_ID` | - | OAuth client ID from Google Cloud Console |
| `SSO_OIDC_GOOGLE_CLIENT_SECRET` | - | OAuth client secret (encrypted at rest) |
### GitHub OAuth
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_GITHUB_ENABLED` | `false` | Enable GitHub SSO |
| `SSO_OIDC_GITHUB_CLIENT_ID` | | OAuth app client ID from GitHub Developer Settings |
| `SSO_OIDC_GITHUB_CLIENT_SECRET` | | OAuth app client secret (encrypted at rest) |
| `SSO_OIDC_GITHUB_CLIENT_ID` | - | OAuth app client ID from GitHub Developer Settings |
| `SSO_OIDC_GITHUB_CLIENT_SECRET` | - | OAuth app client secret (encrypted at rest) |
### Okta OIDC
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_OKTA_ENABLED` | `false` | Enable Okta SSO |
| `SSO_OIDC_OKTA_ISSUER_URL` | | Okta issuer URL (e.g., `https://dev-123456.okta.com`) |
| `SSO_OIDC_OKTA_CLIENT_ID` | | Okta application client ID |
| `SSO_OIDC_OKTA_CLIENT_SECRET` | | Okta client secret (encrypted at rest) |
| `SSO_OIDC_OKTA_ISSUER_URL` | - | Okta issuer URL (e.g., `https://dev-123456.okta.com`) |
| `SSO_OIDC_OKTA_CLIENT_ID` | - | Okta application client ID |
| `SSO_OIDC_OKTA_CLIENT_SECRET` | - | Okta client secret (encrypted at rest) |
### General
@@ -163,22 +163,22 @@ Environment variables are useful for initial deployment or infrastructure-as-cod
If Sencho is behind a reverse proxy (nginx, Traefik, Caddy), you **must** set `SSO_CALLBACK_URL` to your external URL. Otherwise, OAuth callbacks will fail.
</Warning>
Set `SSO_CALLBACK_URL` to the URL users access Sencho from for example, `https://sencho.example.com`. Sencho uses this to construct the OAuth redirect URI that your identity provider calls back to.
Set `SSO_CALLBACK_URL` to the URL users access Sencho from - for example, `https://sencho.example.com`. Sencho uses this to construct the OAuth redirect URI that your identity provider calls back to.
If not set, Sencho auto-detects the URL from the request's `Host` header and protocol, which works for direct access but fails behind proxies that rewrite the host.
## Security
- **PKCE** All OIDC flows use `code_challenge_method=S256` to prevent authorization code interception
- **State parameter** A cryptographic random value protects against CSRF attacks on the OAuth callback
- **Encrypted secrets** LDAP bind passwords and OIDC client secrets are encrypted at rest with AES-256-GCM
- **No local password** SSO users are created with an unusable password hash. They cannot bypass SSO by using the password login form
- **Admin-only configuration** Only Team Pro administrators can enable or configure SSO providers
- **PKCE** - All OIDC flows use `code_challenge_method=S256` to prevent authorization code interception
- **State parameter** - A cryptographic random value protects against CSRF attacks on the OAuth callback
- **Encrypted secrets** - LDAP bind passwords and OIDC client secrets are encrypted at rest with AES-256-GCM
- **No local password** - SSO users are created with an unusable password hash. They cannot bypass SSO by using the password login form
- **Admin-only configuration** - Only Team Pro administrators can enable or configure SSO providers
## Troubleshooting
### LDAP connection refused
Verify the LDAP server is reachable from the Sencho container. If LDAP is on the host machine, use `host.docker.internal` (Docker Desktop) or the host's LAN IP address not `localhost`.
Verify the LDAP server is reachable from the Sencho container. If LDAP is on the host machine, use `host.docker.internal` (Docker Desktop) or the host's LAN IP address - not `localhost`.
### TLS certificate errors
If your LDAP server uses a self-signed certificate, set `SSO_LDAP_TLS_REJECT_UNAUTHORIZED=false`. For production, install a trusted certificate instead.
@@ -187,7 +187,7 @@ If your LDAP server uses a self-signed certificate, set `SSO_LDAP_TLS_REJECT_UNA
The redirect URI registered in your identity provider must exactly match what Sencho sends. Check:
1. `SSO_CALLBACK_URL` is set to your external URL (e.g., `https://sencho.example.com`)
2. The callback URL in your provider's settings is `https://sencho.example.com/api/auth/sso/oidc/<provider>/callback`
3. Protocol matches don't mix `http` and `https`
3. Protocol matches - don't mix `http` and `https`
### SSO buttons not appearing on login page
SSO providers only appear on the login page when they are both **configured** and **enabled**. Check Settings → SSO to verify the provider is active.
+7 -7
View File
@@ -63,13 +63,13 @@ Right-click or use the **⋮** button on any stack in the sidebar to access:
<img src="/images/stack-management/stack-context-menu.png" alt="Stack context menu" />
</Frame>
- **Alerts** configure metric-based alerting rules for this stack
- **Check for updates** manually trigger an image update check
- **Deploy** run `docker compose up -d`
- **Stop** stop all containers in the stack
- **Restart** restart all containers in the stack
- **Update** pull latest images and recreate containers
- **Delete** stop and remove the stack (admin only)
- **Alerts** - configure metric-based alerting rules for this stack
- **Check for updates** - manually trigger an image update check
- **Deploy** - run `docker compose up -d`
- **Stop** - stop all containers in the stack
- **Restart** - restart all containers in the stack
- **Update** - pull latest images and recreate containers
- **Delete** - stop and remove the stack (admin only)
## Converting a `docker run` command
+10
View File
@@ -33,6 +33,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cronstrue": "^3.14.0",
"geist": "^1.7.0",
"lucide-react": "^1.6.0",
"monaco-editor": "^0.55.1",
@@ -3660,6 +3661,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cronstrue": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
"integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==",
"license": "MIT",
"bin": {
"cronstrue": "bin/cli.js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+1
View File
@@ -35,6 +35,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cronstrue": "^3.14.0",
"geist": "^1.7.0",
"lucide-react": "^1.6.0",
"monaco-editor": "^0.55.1",
+4 -4
View File
@@ -155,9 +155,9 @@ export function ApiTokensSection() {
<Select value={formScope} onValueChange={setFormScope}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="read-only">Read Only GET requests only</SelectItem>
<SelectItem value="deploy-only">Deploy Only read + deploy actions</SelectItem>
<SelectItem value="full-admin">Full Admin unrestricted access</SelectItem>
<SelectItem value="read-only">Read Only - GET requests only</SelectItem>
<SelectItem value="deploy-only">Deploy Only - read + deploy actions</SelectItem>
<SelectItem value="full-admin">Full Admin - unrestricted access</SelectItem>
</SelectContent>
</Select>
</div>
@@ -187,7 +187,7 @@ export function ApiTokensSection() {
{newToken && (
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle className="w-4 h-4" /> Token created copy it now
<CheckCircle className="w-4 h-4" /> Token created - copy it now
</div>
<p className="text-xs text-muted-foreground">This token will not be shown again. Store it securely.</p>
<div className="flex items-center gap-2">
+2 -3
View File
@@ -43,7 +43,7 @@ export function AuditLogView() {
setTotal(data.total);
}
} catch {
// Silently fail non-critical view
// Silently fail - non-critical view
} finally {
setLoading(false);
}
@@ -79,7 +79,6 @@ export function AuditLogView() {
<div className="flex items-center gap-2">
<ScrollText className="w-5 h-5" />
<CardTitle>Audit Log</CardTitle>
<Badge variant="outline" className="text-xs">Team Pro</Badge>
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
@@ -163,7 +162,7 @@ export function AuditLogView() {
{entry.status_code}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.node_id ?? ''}
{entry.node_id ?? '-'}
</TableCell>
</TableRow>
))
+18 -2
View File
@@ -16,7 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
import { apiFetch, fetchForNode } from '@/lib/api';
import { toast } from 'sonner';
@@ -37,6 +37,7 @@ import { LogViewer } from './LogViewer';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { FleetView } from './FleetView';
import { AuditLogView } from './AuditLogView';
import ScheduledOperationsView from './ScheduledOperationsView';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
@@ -119,7 +120,7 @@ export default function EditorLayout() {
window.matchMedia('(prefers-color-scheme: dark)').matches
);
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log'>('dashboard');
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -1359,6 +1360,19 @@ export default function EditorLayout() {
Audit
</Button>
)}
{/* Scheduled Operations Toggle (Team Pro + Admin only) */}
{isPro && license?.variant === 'team' && isAdmin && (
<Button
variant={activeView === 'scheduled-ops' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'scheduled-ops' ? (selectedFile ? 'editor' : 'dashboard') : 'scheduled-ops')}
title="Scheduled Operations"
>
<Clock className="w-4 h-4 mr-2" />
Schedules
</Button>
)}
{/* Notifications Popover */}
<Popover>
@@ -1764,6 +1778,8 @@ export default function EditorLayout() {
}} />
) : activeView === 'audit-log' ? (
<AuditLogView />
) : activeView === 'scheduled-ops' ? (
<ScheduledOperationsView />
) : (
<HomeDashboard />
)}
+1 -1
View File
@@ -338,7 +338,7 @@ export function SSOSection() {
</h3>
<p className="text-sm text-muted-foreground mt-1">
Connect your identity provider so team members can sign in with their existing credentials.
SSO works alongside password authentication it does not replace it.
SSO works alongside password authentication - it does not replace it.
</p>
</div>
@@ -0,0 +1,520 @@
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, 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 { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play } from 'lucide-react';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import cronstrue from 'cronstrue';
interface ScheduledTask {
id: number;
name: string;
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune';
cron_expression: string;
enabled: number;
created_by: string;
created_at: number;
updated_at: number;
last_run_at: number | null;
next_run_at: number | null;
last_status: string | null;
last_error: string | null;
}
interface TaskRun {
id: number;
task_id: number;
started_at: number;
completed_at: number | null;
status: 'running' | 'success' | 'failure';
output: string | null;
error: string | null;
}
interface NodeOption {
id: number;
name: string;
}
const ACTION_OPTIONS = [
{ value: 'restart', label: 'Restart Stack', targetType: 'stack' as const },
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const },
{ value: 'prune', label: 'System Prune', targetType: 'system' as const },
];
function getCronDescription(expression: string): string {
try {
return cronstrue.toString(expression);
} catch {
return 'Invalid expression';
}
}
function formatTimestamp(ts: number | null): string {
if (!ts) return '-';
return new Date(ts).toLocaleString();
}
export default function ScheduledOperationsView() {
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTask, setEditingTask] = 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 [formAction, setFormAction] = useState('restart');
const [formTargetId, setFormTargetId] = useState('');
const [formNodeId, setFormNodeId] = useState('');
const [formCron, setFormCron] = useState('0 3 * * *');
const [formEnabled, setFormEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const [runningTaskId, setRunningTaskId] = useState<number | null>(null);
// Available stacks and nodes for selection
const [stacks, setStacks] = useState<string[]>([]);
const [nodes, setNodes] = useState<NodeOption[]>([]);
const fetchTasks = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch('/scheduled-tasks', { localOnly: true });
if (res.ok) {
setTasks(await res.json());
}
} catch {
// Non-critical
} finally {
setLoading(false);
}
}, []);
const fetchStacks = useCallback(async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) {
setStacks(await res.json());
}
} catch {
// Non-critical
}
}, []);
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(() => {
fetchTasks();
fetchStacks();
fetchNodes();
}, [fetchTasks, fetchStacks, fetchNodes]);
const openCreate = () => {
setEditingTask(null);
setFormName('');
setFormAction('restart');
setFormTargetId('');
setFormNodeId('');
setFormCron('0 3 * * *');
setFormEnabled(true);
setDialogOpen(true);
};
const openEdit = (task: ScheduledTask) => {
setEditingTask(task);
setFormName(task.name);
setFormAction(task.action);
setFormTargetId(task.target_id || '');
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
setFormCron(task.cron_expression);
setFormEnabled(task.enabled === 1);
setDialogOpen(true);
};
const handleSave = async () => {
const actionOption = ACTION_OPTIONS.find(a => a.value === formAction);
if (!actionOption) return;
const body: Record<string, unknown> = {
name: formName,
target_type: actionOption.targetType,
action: formAction,
cron_expression: formCron,
enabled: formEnabled,
};
if (actionOption.targetType === 'stack') {
body.target_id = formTargetId;
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
}
setSaving(true);
try {
const res = editingTask
? await apiFetch(`/scheduled-tasks/${editingTask.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(editingTask ? 'Task updated' : 'Task created');
setDialogOpen(false);
fetchTasks();
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || 'Failed to save task');
}
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleToggle = async (task: ScheduledTask) => {
try {
const res = await apiFetch(`/scheduled-tasks/${task.id}/toggle`, { method: 'PATCH', localOnly: true });
if (res.ok) {
fetchTasks();
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || 'Failed to toggle task');
}
} 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('Task deleted');
setDeleteTarget(null);
fetchTasks();
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || 'Failed to delete task');
}
} catch {
toast.error('Something went wrong.');
}
};
const openRuns = async (task: ScheduledTask) => {
setRunsTask(task);
setRunsLoading(true);
try {
const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=50`, { localOnly: true });
if (res.ok) {
setRuns(await res.json());
}
} catch {
// Non-critical
} finally {
setRunsLoading(false);
}
};
const handleRunNow = async (task: ScheduledTask) => {
setRunningTaskId(task.id);
try {
const res = await apiFetch(`/scheduled-tasks/${task.id}/run`, { method: 'POST', localOnly: true });
if (res.ok) {
toast.success(`Task "${task.name}" executed successfully`);
fetchTasks();
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || 'Failed to run task');
}
} catch {
toast.error('Something went wrong.');
} finally {
setRunningTaskId(null);
}
};
const targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType;
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">
<Clock className="w-5 h-5" />
<CardTitle>Scheduled Operations</CardTitle>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchTasks} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
New Schedule
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loading && tasks.length === 0 ? (
<div className="text-center text-muted-foreground py-12">Loading...</div>
) : tasks.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
No scheduled tasks yet. Create one to automate recurring operations.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Status</TableHead>
<TableHead>Next Run</TableHead>
<TableHead>Enabled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">{task.name}</TableCell>
<TableCell>
<Badge variant="outline">
{ACTION_OPTIONS.find(a => a.value === task.action)?.label || task.action}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{task.target_type === 'stack' ? task.target_id : task.target_type}
</TableCell>
<TableCell>
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
<div className="text-xs text-muted-foreground font-mono">{task.cron_expression}</div>
</TableCell>
<TableCell>
{task.last_status === 'success' ? (
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">Success</Badge>
) : task.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(task.next_run_at)}
</TableCell>
<TableCell>
<Switch
checked={task.enabled === 1}
onCheckedChange={() => handleToggle(task)}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleRunNow(task)} title="Run now" disabled={runningTaskId === task.id}>
<Play className={`w-4 h-4 ${runningTaskId === task.id ? 'animate-pulse' : ''}`} />
</Button>
<Button variant="ghost" size="sm" onClick={() => openRuns(task)} title="Execution history">
<History className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(task)} title="Edit">
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(task)} title="Delete" className="text-destructive hover:text-destructive">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{editingTask ? 'Edit Scheduled Task' : 'New Scheduled Task'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Nightly stack restart" value={formName} onChange={e => setFormName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Action</Label>
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{targetType === 'stack' && (
<>
<div className="space-y-2">
<Label>Stack</Label>
<Select value={formTargetId} onValueChange={setFormTargetId}>
<SelectTrigger>
<SelectValue placeholder="Select stack..." />
</SelectTrigger>
<SelectContent>
{stacks.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Node</Label>
<Select value={formNodeId} onValueChange={setFormNodeId}>
<SelectTrigger>
<SelectValue placeholder="Select node..." />
</SelectTrigger>
<SelectContent>
{nodes.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="space-y-2">
<Label>Cron Expression</Label>
<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="task-enabled" />
<Label htmlFor="task-enabled">Enabled</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId))}>
{saving ? 'Saving...' : editingTask ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Scheduled Task</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>
<SheetTitle>Execution History - {runsTask?.name}</SheetTitle>
</SheetHeader>
<div className="mt-4">
{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>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>
{run.status === 'success' ? (
<Badge className="bg-green-500/10 text-green-500 border-green-500/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>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
}
+3 -3
View File
@@ -1192,12 +1192,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
)}
{/* Upgrade Cards Community: show both, Personal Pro: show Team only, Team Pro: none */}
{/* Upgrade Cards - Community: show both, Personal Pro: show Team only, Team Pro: none */}
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Personal Pro Card only for Community users */}
{/* Personal Pro Card - only for Community users */}
{license?.tier !== 'pro' && (
<div className="relative border border-border rounded-xl p-4 space-y-3 bg-muted/5">
<div className="flex items-center gap-2">
@@ -1262,7 +1262,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
)}
{/* License key activation show when not active */}
{/* License key activation - show when not active */}
{license?.status !== 'active' && (
<div className="border-t border-border pt-4 space-y-2">
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB