mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Generated
+22
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 |
@@ -98,6 +98,7 @@
|
||||
"features/fleet-backups",
|
||||
"features/audit-log",
|
||||
"features/api-tokens",
|
||||
"features/scheduled-operations",
|
||||
"features/sso",
|
||||
"features/licensing"
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 (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.
|
||||
+27
-27
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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 “{deleteTarget?.name}”? 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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user