diff --git a/.github/actions/start-app/action.yml b/.github/actions/start-app/action.yml new file mode 100644 index 00000000..ea4c0a10 --- /dev/null +++ b/.github/actions/start-app/action.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d7be7dd..6361802c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4d5da2..aac8f5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/package-lock.json b/backend/package-lock.json index 343f09e0..2ddaa86e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 70c00635..b23dd205 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index b48fb60d..1c31b8c2 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -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(); diff --git a/backend/src/index.ts b/backend/src/index.ts index a5c75bb8..c719a299 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 = { 'POST /stacks': 'Created stack', @@ -684,6 +686,10 @@ const AUDIT_ROUTE_SUMMARIES: Record = { '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 = { 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>); + 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 => { + 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); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a377caf5..6f361767 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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>): void { + const fields: string[] = []; + const values: unknown[] = []; + + const map: Record = { + 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): 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>): 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); + } } diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 8b25119a..c0aa50f3 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -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(); diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts index ff6370e6..d22dcf48 100644 --- a/backend/src/services/SSOService.ts +++ b/backend/src/services/SSOService.ts @@ -432,7 +432,7 @@ export class SSOService { let issuer: InstanceType; 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', diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts new file mode 100644 index 00000000..1c8e71fe --- /dev/null +++ b/backend/src/services/SchedulerService.ts @@ -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 | null = null; + private isProcessing = false; + private runningTasks = new Set(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('; ')}`; + } +} diff --git a/docs-sencho-current.png b/docs-sencho-current.png new file mode 100644 index 00000000..64f1df87 Binary files /dev/null and b/docs-sencho-current.png differ diff --git a/docs/docs.json b/docs/docs.json index 843bf4e7..6dc99053 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,7 @@ "features/fleet-backups", "features/audit-log", "features/api-tokens", + "features/scheduled-operations", "features/sso", "features/licensing" ] diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index 02c40321..b56acc02 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -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. -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. 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. diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 8f5928e3..7377f671 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -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. diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 1588a175..052cff4f 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -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. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index be3d47b5..73cbbaf8 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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 diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index a582499a..44339fdc 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -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). diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx new file mode 100644 index 00000000..133e0b6d --- /dev/null +++ b/docs/features/scheduled-operations.mdx @@ -0,0 +1,85 @@ +--- +title: Scheduled Operations +description: Automate recurring Docker operations like stack restarts, fleet snapshots, and system prunes on a cron schedule. +--- + + + Scheduled Operations requires a Sencho **Team Pro** license. + Personal Pro and Community Edition do not include this feature. + + +## 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 (0–59) +│ ┌─────────── hour (0–23) +│ │ ┌───────── day of month (1–31) +│ │ │ ┌─────── month (1–12) +│ │ │ │ ┌───── day of week (0–7, 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. diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index 0abf5180..57bafe7a 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -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. --- SSO requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. -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. -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//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. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 1ce5fca2..168d4c19 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -63,13 +63,13 @@ Right-click or use the **⋮** button on any stack in the sidebar to access: Stack context menu -- **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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10690a2c..53e9146c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 7ef5114e..ee855b26 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index d7336fa5..b558b303 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -155,9 +155,9 @@ export function ApiTokensSection() { @@ -187,7 +187,7 @@ export function ApiTokensSection() { {newToken && (
- Token created — copy it now + Token created - copy it now

This token will not be shown again. Store it securely.

diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index ab86d02b..62eda9ab 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -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() {
Audit Log - Team Pro
)} + {/* Scheduled Operations Toggle (Team Pro + Admin only) */} + {isPro && license?.variant === 'team' && isAdmin && ( + + )} {/* Notifications Popover */} @@ -1764,6 +1778,8 @@ export default function EditorLayout() { }} /> ) : activeView === 'audit-log' ? ( + ) : activeView === 'scheduled-ops' ? ( + ) : ( )} diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 40f160c0..69d275d9 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -338,7 +338,7 @@ export function SSOSection() {

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.

diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx new file mode 100644 index 00000000..d7a98229 --- /dev/null +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingTask, setEditingTask] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [runsTask, setRunsTask] = useState(null); + const [runs, setRuns] = useState([]); + const [runsLoading, setRunsLoading] = useState(false); + + // Form state + const [formName, setFormName] = useState(''); + const [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(null); + + // Available stacks and nodes for selection + const [stacks, setStacks] = useState([]); + const [nodes, setNodes] = useState([]); + + 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 = { + 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 ( +
+ + +
+
+ + Scheduled Operations +
+
+ + +
+
+
+ + {loading && tasks.length === 0 ? ( +
Loading...
+ ) : tasks.length === 0 ? ( +
+ No scheduled tasks yet. Create one to automate recurring operations. +
+ ) : ( + + + + Name + Action + Target + Schedule + Status + Next Run + Enabled + Actions + + + + {tasks.map((task) => ( + + {task.name} + + + {ACTION_OPTIONS.find(a => a.value === task.action)?.label || task.action} + + + + {task.target_type === 'stack' ? task.target_id : task.target_type} + + +
{getCronDescription(task.cron_expression)}
+
{task.cron_expression}
+
+ + {task.last_status === 'success' ? ( + Success + ) : task.last_status === 'failure' ? ( + Failed + ) : ( + Never run + )} + + + {formatTimestamp(task.next_run_at)} + + + handleToggle(task)} + /> + + +
+ + + + +
+
+
+ ))} +
+
+ )} +
+
+ + {/* Create/Edit Dialog */} + + + + {editingTask ? 'Edit Scheduled Task' : 'New Scheduled Task'} + +
+
+ + setFormName(e.target.value)} /> +
+ +
+ + +
+ + {targetType === 'stack' && ( + <> +
+ + +
+
+ + +
+ + )} + +
+ + setFormCron(e.target.value)} + className="font-mono" + /> +

{cronDescription}

+
+ +
+ + +
+
+ + + + +
+
+ + {/* Delete Confirmation */} + { if (!open) setDeleteTarget(null); }}> + + + Delete Scheduled Task + + Are you sure you want to delete “{deleteTarget?.name}”? This will also remove all execution history. This action cannot be undone. + + + + Cancel + + Delete + + + + + + {/* Run History Sheet */} + { if (!open) setRunsTask(null); }}> + + + Execution History - {runsTask?.name} + +
+ {runsLoading ? ( +
Loading...
+ ) : runs.length === 0 ? ( +
No executions yet.
+ ) : ( + + + + Time + Status + Duration + Details + + + + {runs.map((run) => { + const duration = run.completed_at && run.started_at + ? `${((run.completed_at - run.started_at) / 1000).toFixed(1)}s` + : '-'; + return ( + + + {new Date(run.started_at).toLocaleString()} + + + {run.status === 'success' ? ( + Success + ) : run.status === 'failure' ? ( + Failed + ) : ( + Running + )} + + {duration} + + {run.error || run.output || '-'} + + + ); + })} + +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 99bdf1f9..9f3aa6c7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1192,12 +1192,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)} - {/* 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')) && (
- {/* Personal Pro Card — only for Community users */} + {/* Personal Pro Card - only for Community users */} {license?.tier !== 'pro' && (
@@ -1262,7 +1262,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)} - {/* License key activation — show when not active */} + {/* License key activation - show when not active */} {license?.status !== 'active' && (
diff --git a/sencho-app-theme.png b/sencho-app-theme.png new file mode 100644 index 00000000..deb36001 Binary files /dev/null and b/sencho-app-theme.png differ diff --git a/sencho-io-branding.png b/sencho-io-branding.png new file mode 100644 index 00000000..f04aac1d Binary files /dev/null and b/sencho-io-branding.png differ