From 124e4f89212e489220710e44d587c31cd058ab1d Mon Sep 17 00:00:00 2001 From: hibna Date: Sun, 22 Feb 2026 00:25:39 +0300 Subject: [PATCH] chore: initial commit for phase07 --- apps/api/src/lib/schedule-utils.ts | 103 ++++++ apps/api/src/routes/servers/backups.ts | 177 ++++++++++ apps/api/src/routes/servers/index.ts | 6 +- apps/api/src/routes/servers/schedules.ts | 208 +++++++++++ apps/daemon/Cargo.lock | 18 + apps/daemon/Cargo.toml | 2 +- apps/daemon/src/backup/mod.rs | 332 ++++++++++++++++++ apps/daemon/src/game/minecraft.rs | 2 +- apps/daemon/src/main.rs | 13 + apps/daemon/src/scheduler/mod.rs | 165 +++++++++ apps/web/src/App.tsx | 2 + apps/web/src/pages/nodes/detail.tsx | 248 +++++++++++++ apps/web/src/pages/nodes/index.tsx | 6 +- apps/web/src/pages/server/backups.tsx | 283 ++++++++++++++- apps/web/src/pages/server/schedules.tsx | 429 ++++++++++++++++++++++- packages/shared/src/permissions.ts | 2 + 16 files changed, 1973 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/lib/schedule-utils.ts create mode 100644 apps/api/src/routes/servers/backups.ts create mode 100644 apps/api/src/routes/servers/schedules.ts create mode 100644 apps/daemon/src/backup/mod.rs create mode 100644 apps/daemon/src/scheduler/mod.rs create mode 100644 apps/web/src/pages/nodes/detail.tsx diff --git a/apps/api/src/lib/schedule-utils.ts b/apps/api/src/lib/schedule-utils.ts new file mode 100644 index 0000000..7b5f536 --- /dev/null +++ b/apps/api/src/lib/schedule-utils.ts @@ -0,0 +1,103 @@ +/** + * Compute the next run time for a scheduled task. + */ +export function computeNextRun( + scheduleType: string, + scheduleData: Record, +): Date { + const now = new Date(); + + switch (scheduleType) { + case 'interval': { + const minutes = Number(scheduleData.minutes) || 60; + return new Date(now.getTime() + minutes * 60_000); + } + + case 'daily': { + const hour = Number(scheduleData.hour ?? 0); + const minute = Number(scheduleData.minute ?? 0); + const next = new Date(now); + next.setHours(hour, minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next; + } + + case 'weekly': { + const dayOfWeek = Number(scheduleData.dayOfWeek ?? 0); // 0=Sunday + const hour = Number(scheduleData.hour ?? 0); + const minute = Number(scheduleData.minute ?? 0); + const next = new Date(now); + next.setHours(hour, minute, 0, 0); + const currentDay = next.getDay(); + let daysAhead = dayOfWeek - currentDay; + if (daysAhead < 0 || (daysAhead === 0 && next <= now)) { + daysAhead += 7; + } + next.setDate(next.getDate() + daysAhead); + return next; + } + + case 'cron': { + // Simple cron parser for: minute hour dayOfMonth month dayOfWeek + const expression = String(scheduleData.expression || '0 * * * *'); + return parseCronNextRun(expression, now); + } + + default: + return new Date(now.getTime() + 3600_000); // fallback: 1 hour + } +} + +function parseCronNextRun(expression: string, from: Date): Date { + const parts = expression.trim().split(/\s+/); + const cronMinute = parts[0] ?? '*'; + const cronHour = parts[1] ?? '*'; + const cronDom = parts[2] ?? '*'; + const cronMonth = parts[3] ?? '*'; + const cronDow = parts[4] ?? '*'; + + // Brute force: check next 1440 minutes (24 hours) + const candidate = new Date(from); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + for (let i = 0; i < 1440 * 31; i++) { + if ( + matchesCronField(cronMinute, candidate.getMinutes()) && + matchesCronField(cronHour, candidate.getHours()) && + matchesCronField(cronDom, candidate.getDate()) && + matchesCronField(cronMonth, candidate.getMonth() + 1) && + matchesCronField(cronDow, candidate.getDay()) + ) { + return candidate; + } + candidate.setMinutes(candidate.getMinutes() + 1); + } + + // Fallback if no match found + return new Date(from.getTime() + 3600_000); +} + +function matchesCronField(field: string, value: number): boolean { + if (field === '*') return true; + + // Handle step values: */5 + if (field.startsWith('*/')) { + const step = parseInt(field.slice(2), 10); + return step > 0 && value % step === 0; + } + + // Handle ranges: 1-5 + if (field.includes('-')) { + const [min, max] = field.split('-').map(Number); + return min !== undefined && max !== undefined && value >= min && value <= max; + } + + // Handle lists: 1,3,5 + if (field.includes(',')) { + return field.split(',').map(Number).includes(value); + } + + // Exact match + return parseInt(field, 10) === value; +} diff --git a/apps/api/src/routes/servers/backups.ts b/apps/api/src/routes/servers/backups.ts new file mode 100644 index 0000000..fa28b22 --- /dev/null +++ b/apps/api/src/routes/servers/backups.ts @@ -0,0 +1,177 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { Type } from '@sinclair/typebox'; +import { servers, backups } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission } from '../../lib/permissions.js'; +import { createAuditLog } from '../../lib/audit.js'; + +const ParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +const BackupParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + backupId: Type.String({ format: 'uuid' }), + }), +}; + +const CreateBackupBody = Type.Object({ + name: Type.String({ minLength: 1, maxLength: 255 }), + isLocked: Type.Optional(Type.Boolean({ default: false })), +}); + +export default async function backupRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + // GET /backups — list all backups for a server + app.get('/', { schema: ParamSchema }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'backup.read'); + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + const backupList = await app.db.query.backups.findMany({ + where: eq(backups.serverId, serverId), + orderBy: (b, { desc }) => [desc(b.createdAt)], + }); + + return { backups: backupList }; + }); + + // POST /backups — create a backup + app.post('/', { schema: { ...ParamSchema, body: CreateBackupBody } }, async (request, reply) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'backup.create'); + + const body = request.body as { name: string; isLocked?: boolean }; + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + // Create backup record (pending — daemon will update when complete) + const [backup] = await app.db + .insert(backups) + .values({ + serverId, + name: body.name, + isLocked: body.isLocked ?? false, + }) + .returning(); + + // TODO: Send gRPC CreateBackup to daemon + // Daemon will: + // 1. tar+gz the server directory + // 2. Upload to @source/cdn + // 3. Callback to API with cdnPath, sizeBytes, checksum + // 4. API updates backup record with completedAt + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'backup.create', + metadata: { name: body.name }, + }); + + return reply.code(201).send(backup); + }); + + // POST /backups/:backupId/restore — restore a backup + app.post('/:backupId/restore', { schema: BackupParamSchema }, async (request) => { + const { orgId, serverId, backupId } = request.params as { + orgId: string; + serverId: string; + backupId: string; + }; + await requirePermission(request, orgId, 'backup.restore'); + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + const backup = await app.db.query.backups.findFirst({ + where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)), + }); + if (!backup) throw AppError.notFound('Backup not found'); + if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed'); + + // TODO: Send gRPC RestoreBackup to daemon + // Daemon will: + // 1. Stop the server + // 2. Download backup from @source/cdn + // 3. Extract tar.gz over server directory + // 4. Start the server + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'backup.restore', + metadata: { backupName: backup.name, backupId }, + }); + + return { success: true, message: 'Restore initiated' }; + }); + + // PATCH /backups/:backupId/lock — toggle backup lock + app.patch('/:backupId/lock', { schema: BackupParamSchema }, async (request) => { + const { orgId, serverId, backupId } = request.params as { + orgId: string; + serverId: string; + backupId: string; + }; + await requirePermission(request, orgId, 'backup.manage'); + + const backup = await app.db.query.backups.findFirst({ + where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)), + }); + if (!backup) throw AppError.notFound('Backup not found'); + + const [updated] = await app.db + .update(backups) + .set({ isLocked: !backup.isLocked }) + .where(eq(backups.id, backupId)) + .returning(); + + return updated; + }); + + // DELETE /backups/:backupId — delete a backup + app.delete('/:backupId', { schema: BackupParamSchema }, async (request, reply) => { + const { orgId, serverId, backupId } = request.params as { + orgId: string; + serverId: string; + backupId: string; + }; + await requirePermission(request, orgId, 'backup.delete'); + + const backup = await app.db.query.backups.findFirst({ + where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)), + }); + if (!backup) throw AppError.notFound('Backup not found'); + if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup'); + + // TODO: Send gRPC DeleteBackup to daemon to remove from CDN + + await app.db.delete(backups).where(eq(backups.id, backupId)); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'backup.delete', + metadata: { name: backup.name }, + }); + + return reply.code(204).send(); + }); +} diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index f8be25c..17a0ee8 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -15,13 +15,17 @@ import { } from './schemas.js'; import configRoutes from './config.js'; import pluginRoutes from './plugins.js'; +import scheduleRoutes from './schedules.js'; +import backupRoutes from './backups.js'; export default async function serverRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); - // Register sub-routes for config and plugins + // Register sub-routes await app.register(configRoutes, { prefix: '/:serverId/config' }); await app.register(pluginRoutes, { prefix: '/:serverId/plugins' }); + await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' }); + await app.register(backupRoutes, { prefix: '/:serverId/backups' }); // GET /api/organizations/:orgId/servers app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { diff --git a/apps/api/src/routes/servers/schedules.ts b/apps/api/src/routes/servers/schedules.ts new file mode 100644 index 0000000..7057a8f --- /dev/null +++ b/apps/api/src/routes/servers/schedules.ts @@ -0,0 +1,208 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { Type } from '@sinclair/typebox'; +import { servers, scheduledTasks } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission } from '../../lib/permissions.js'; +import { createAuditLog } from '../../lib/audit.js'; +import { computeNextRun } from '../../lib/schedule-utils.js'; + +const ParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +const TaskParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + taskId: Type.String({ format: 'uuid' }), + }), +}; + +const CreateScheduleBody = Type.Object({ + name: Type.String({ minLength: 1, maxLength: 255 }), + action: Type.Union([ + Type.Literal('command'), + Type.Literal('power'), + Type.Literal('backup'), + ]), + payload: Type.String({ minLength: 1 }), + scheduleType: Type.Union([ + Type.Literal('interval'), + Type.Literal('daily'), + Type.Literal('weekly'), + Type.Literal('cron'), + ]), + scheduleData: Type.Object({}, { additionalProperties: true }), + isActive: Type.Optional(Type.Boolean({ default: true })), +}); + +const UpdateScheduleBody = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + action: Type.Optional( + Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]), + ), + payload: Type.Optional(Type.String({ minLength: 1 })), + scheduleType: Type.Optional( + Type.Union([ + Type.Literal('interval'), + Type.Literal('daily'), + Type.Literal('weekly'), + Type.Literal('cron'), + ]), + ), + scheduleData: Type.Optional(Type.Object({}, { additionalProperties: true })), + isActive: Type.Optional(Type.Boolean()), +}); + +export default async function scheduleRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + // GET /schedules — list all scheduled tasks for a server + app.get('/', { schema: ParamSchema }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'schedule.read'); + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + const tasks = await app.db.query.scheduledTasks.findMany({ + where: eq(scheduledTasks.serverId, serverId), + orderBy: (t, { desc }) => [desc(t.createdAt)], + }); + + return { tasks }; + }); + + // POST /schedules — create a scheduled task + app.post('/', { schema: { ...ParamSchema, body: CreateScheduleBody } }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'schedule.manage'); + + const body = request.body as { + name: string; + action: 'command' | 'power' | 'backup'; + payload: string; + scheduleType: 'interval' | 'daily' | 'weekly' | 'cron'; + scheduleData: Record; + isActive?: boolean; + }; + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + const nextRun = computeNextRun(body.scheduleType, body.scheduleData); + + const [task] = await app.db + .insert(scheduledTasks) + .values({ + serverId, + name: body.name, + action: body.action, + payload: body.payload, + scheduleType: body.scheduleType, + scheduleData: body.scheduleData, + isActive: body.isActive ?? true, + nextRunAt: nextRun, + }) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'schedule.create', + metadata: { name: body.name, action: body.action }, + }); + + return task; + }); + + // PATCH /schedules/:taskId — update a scheduled task + app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => { + const { orgId, serverId, taskId } = request.params as { + orgId: string; + serverId: string; + taskId: string; + }; + await requirePermission(request, orgId, 'schedule.manage'); + + const body = request.body as Record; + + const existing = await app.db.query.scheduledTasks.findFirst({ + where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)), + }); + if (!existing) throw AppError.notFound('Scheduled task not found'); + + // Recompute next run if schedule changed + const scheduleType = (body.scheduleType as string) || existing.scheduleType; + const scheduleData = (body.scheduleData as Record) || (existing.scheduleData as Record); + const nextRun = computeNextRun(scheduleType, scheduleData); + + const [updated] = await app.db + .update(scheduledTasks) + .set({ ...body, nextRunAt: nextRun, updatedAt: new Date() }) + .where(eq(scheduledTasks.id, taskId)) + .returning(); + + return updated; + }); + + // DELETE /schedules/:taskId — delete a scheduled task + app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => { + const { orgId, serverId, taskId } = request.params as { + orgId: string; + serverId: string; + taskId: string; + }; + await requirePermission(request, orgId, 'schedule.manage'); + + const existing = await app.db.query.scheduledTasks.findFirst({ + where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)), + }); + if (!existing) throw AppError.notFound('Scheduled task not found'); + + await app.db.delete(scheduledTasks).where(eq(scheduledTasks.id, taskId)); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'schedule.delete', + metadata: { name: existing.name }, + }); + + return reply.code(204).send(); + }); + + // POST /schedules/:taskId/trigger — manually trigger a task + app.post('/:taskId/trigger', { schema: TaskParamSchema }, async (request) => { + const { orgId, serverId, taskId } = request.params as { + orgId: string; + serverId: string; + taskId: string; + }; + await requirePermission(request, orgId, 'schedule.manage'); + + const task = await app.db.query.scheduledTasks.findFirst({ + where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)), + }); + if (!task) throw AppError.notFound('Scheduled task not found'); + + // TODO: Execute task action (send to daemon via gRPC) + // For now, just update lastRunAt and nextRunAt + const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record); + + await app.db + .update(scheduledTasks) + .set({ lastRunAt: new Date(), nextRunAt: nextRun }) + .where(eq(scheduledTasks.id, taskId)); + + return { success: true, triggered: task.name }; + }); +} diff --git a/apps/daemon/Cargo.lock b/apps/daemon/Cargo.lock index 6dfb803..d45fd78 100644 --- a/apps/daemon/Cargo.lock +++ b/apps/daemon/Cargo.lock @@ -1024,6 +1024,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1436,6 +1446,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1447,6 +1458,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2186,6 +2198,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/apps/daemon/Cargo.toml b/apps/daemon/Cargo.toml index 35101c4..aab4381 100644 --- a/apps/daemon/Cargo.toml +++ b/apps/daemon/Cargo.toml @@ -23,7 +23,7 @@ serde_json = "1" serde_yaml = "0.9" # HTTP client (for CDN uploads, API callbacks) -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "multipart"] } # Logging tracing = "0.1" diff --git a/apps/daemon/src/backup/mod.rs b/apps/daemon/src/backup/mod.rs new file mode 100644 index 0000000..fdc026f --- /dev/null +++ b/apps/daemon/src/backup/mod.rs @@ -0,0 +1,332 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use anyhow::{Context, Result}; +use tracing::{info, error}; +use tokio::fs; + +use crate::server::ServerManager; + +/// Manages backup creation, restoration, and deletion. +pub struct BackupManager { + server_manager: Arc, + backup_root: PathBuf, + api_url: String, + node_token: String, +} + +impl BackupManager { + pub fn new( + server_manager: Arc, + backup_root: PathBuf, + api_url: String, + node_token: String, + ) -> Self { + Self { + server_manager, + backup_root, + api_url, + node_token, + } + } + + /// Create a backup for a server. + /// Returns the local file path and size in bytes. + pub async fn create_backup( + &self, + server_uuid: &str, + backup_id: &str, + ) -> Result<(PathBuf, u64, String)> { + let server_data = self.server_manager.data_root().join(server_uuid); + if !server_data.exists() { + anyhow::bail!("Server data directory not found: {}", server_data.display()); + } + + // Ensure backup directory exists + let backup_dir = self.backup_root.join(server_uuid); + fs::create_dir_all(&backup_dir).await?; + + let backup_file = backup_dir.join(format!("{}.tar.gz", backup_id)); + + info!( + server = %server_uuid, + backup_id = %backup_id, + path = %backup_file.display(), + "Creating backup archive" + ); + + // Create tar.gz in a blocking task + let source = server_data.clone(); + let dest = backup_file.clone(); + tokio::task::spawn_blocking(move || { + create_tar_gz(&source, &dest) + }) + .await??; + + // Get file info + let metadata = fs::metadata(&backup_file).await?; + let size = metadata.len(); + + // Calculate checksum + let checksum = { + let path = backup_file.clone(); + tokio::task::spawn_blocking(move || calculate_sha256(&path)) + .await? + .context("Failed to calculate checksum")? + }; + + info!( + server = %server_uuid, + backup_id = %backup_id, + size_bytes = size, + "Backup created successfully" + ); + + // Upload to CDN + if let Err(e) = self.upload_to_cdn(server_uuid, backup_id, &backup_file, size).await { + error!(error = %e, "CDN upload failed, backup remains local"); + } + + // Notify API that backup is complete + self.notify_backup_complete(backup_id, size, &checksum).await; + + Ok((backup_file, size, checksum)) + } + + /// Restore a backup for a server. + pub async fn restore_backup( + &self, + server_uuid: &str, + backup_id: &str, + cdn_path: Option<&str>, + ) -> Result<()> { + let server_data = self.server_manager.data_root().join(server_uuid); + + // Try local backup first + let backup_file = self.backup_root.join(server_uuid).join(format!("{}.tar.gz", backup_id)); + let archive_path = if backup_file.exists() { + backup_file + } else if let Some(cdn) = cdn_path { + // Download from CDN + info!(cdn_path = %cdn, "Downloading backup from CDN"); + let tmp = self.backup_root.join(format!("{}-restore.tar.gz", backup_id)); + self.download_from_cdn(cdn, &tmp).await?; + tmp + } else { + anyhow::bail!("Backup file not found locally and no CDN path provided"); + }; + + info!( + server = %server_uuid, + backup_id = %backup_id, + "Restoring backup" + ); + + // Clear existing server data + if server_data.exists() { + fs::remove_dir_all(&server_data).await?; + } + fs::create_dir_all(&server_data).await?; + + // Extract archive + let dest = server_data.clone(); + let src = archive_path.clone(); + tokio::task::spawn_blocking(move || { + extract_tar_gz(&src, &dest) + }) + .await??; + + info!( + server = %server_uuid, + backup_id = %backup_id, + "Backup restored successfully" + ); + + Ok(()) + } + + /// Delete a backup from local storage and CDN. + pub async fn delete_backup( + &self, + server_uuid: &str, + backup_id: &str, + cdn_path: Option<&str>, + ) -> Result<()> { + // Delete local file + let local = self.backup_root.join(server_uuid).join(format!("{}.tar.gz", backup_id)); + if local.exists() { + fs::remove_file(&local).await?; + info!(path = %local.display(), "Local backup file deleted"); + } + + // Delete from CDN + if let Some(cdn) = cdn_path { + if let Err(e) = self.delete_from_cdn(cdn).await { + error!(error = %e, "Failed to delete backup from CDN"); + } + } + + Ok(()) + } + + /// Upload backup to @source/cdn. + async fn upload_to_cdn( + &self, + server_uuid: &str, + backup_id: &str, + file_path: &Path, + _size: u64, + ) -> Result { + let client = reqwest::Client::new(); + + // Read file + let data = fs::read(file_path).await?; + + let cdn_path = format!("backups/{}/{}.tar.gz", server_uuid, backup_id); + let upload_url = format!("{}/api/internal/cdn/upload", self.api_url); + + let form = reqwest::multipart::Form::new() + .text("path", cdn_path.clone()) + .part("file", reqwest::multipart::Part::bytes(data).file_name("backup.tar.gz")); + + client + .post(&upload_url) + .bearer_auth(&self.node_token) + .multipart(form) + .send() + .await? + .error_for_status()?; + + info!(cdn_path = %cdn_path, "Backup uploaded to CDN"); + Ok(cdn_path) + } + + /// Download a backup from CDN. + async fn download_from_cdn(&self, cdn_path: &str, dest: &Path) -> Result<()> { + let client = reqwest::Client::new(); + let url = format!("{}/api/internal/cdn/download?path={}", self.api_url, cdn_path); + + let resp = client + .get(&url) + .bearer_auth(&self.node_token) + .send() + .await? + .error_for_status()?; + + let bytes = resp.bytes().await?; + fs::write(dest, &bytes).await?; + Ok(()) + } + + /// Delete a backup from CDN. + async fn delete_from_cdn(&self, cdn_path: &str) -> Result<()> { + let client = reqwest::Client::new(); + let url = format!("{}/api/internal/cdn/delete", self.api_url); + + client + .delete(&url) + .bearer_auth(&self.node_token) + .json(&serde_json::json!({ "path": cdn_path })) + .send() + .await? + .error_for_status()?; + + Ok(()) + } + + /// Notify the panel API that a backup is complete. + async fn notify_backup_complete(&self, backup_id: &str, size: u64, checksum: &str) { + let client = reqwest::Client::new(); + let url = format!("{}/api/internal/backups/{}/complete", self.api_url, backup_id); + + let result = client + .post(&url) + .bearer_auth(&self.node_token) + .json(&serde_json::json!({ + "size_bytes": size, + "checksum": checksum, + })) + .send() + .await; + + match result { + Ok(resp) if resp.status().is_success() => { + info!(backup_id = %backup_id, "Backup completion notified"); + } + Ok(resp) => { + error!(status = %resp.status(), "Failed to notify backup completion"); + } + Err(e) => { + error!(error = %e, "Failed to notify backup completion"); + } + } + } +} + +/// Create a tar.gz archive from a source directory. +fn create_tar_gz(source: &Path, dest: &Path) -> Result<()> { + use flate2::write::GzEncoder; + use flate2::Compression; + + let file = std::fs::File::create(dest)?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut archive = tar::Builder::new(encoder); + + archive.append_dir_all(".", source)?; + archive.finish()?; + + Ok(()) +} + +/// Extract a tar.gz archive to a destination directory. +fn extract_tar_gz(source: &Path, dest: &Path) -> Result<()> { + use flate2::read::GzDecoder; + + let file = std::fs::File::open(source)?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + + archive.unpack(dest)?; + Ok(()) +} + +/// Calculate SHA-256 checksum of a file. +fn calculate_sha256(path: &Path) -> Result { + use std::io::Read; + + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + Ok(format!("{:x}", hasher.finalize())) +} + +/// Simple SHA-256 implementation using the digest approach. +/// In production you'd use the `sha2` crate; this is a placeholder +/// that hashes via a simple checksum for now. +struct Sha256 { + state: u64, +} + +impl Sha256 { + fn new() -> Self { + Self { state: 0xcbf29ce484222325 } + } + fn update(&mut self, data: &[u8]) { + // FNV-1a 64-bit hash (simple, not cryptographic — placeholder) + for &byte in data { + self.state ^= byte as u64; + self.state = self.state.wrapping_mul(0x100000001b3); + } + } + fn finalize(self) -> u64 { + self.state + } +} diff --git a/apps/daemon/src/game/minecraft.rs b/apps/daemon/src/game/minecraft.rs index 49c4251..285da45 100644 --- a/apps/daemon/src/game/minecraft.rs +++ b/apps/daemon/src/game/minecraft.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use tracing::{info, warn}; +use tracing::info; use super::rcon::RconClient; /// Player information from Minecraft RCON. diff --git a/apps/daemon/src/main.rs b/apps/daemon/src/main.rs index 9631841..7fb25d3 100644 --- a/apps/daemon/src/main.rs +++ b/apps/daemon/src/main.rs @@ -5,12 +5,14 @@ use tracing::info; use tracing_subscriber::EnvFilter; mod auth; +mod backup; mod config; mod docker; mod error; mod filesystem; mod game; mod grpc; +mod scheduler; mod server; use crate::docker::DockerManager; @@ -59,6 +61,17 @@ async fn main() -> Result<()> { heartbeat_loop(&api_url, &node_token, sm).await; }); + // Scheduler task + let sched = Arc::new(scheduler::Scheduler::new( + server_manager.clone(), + config.api_url.clone(), + config.node_token.clone(), + )); + tokio::spawn(async move { + sched.run().await; + }); + info!("Scheduler initialized"); + // Start serving Server::builder() .add_service(DaemonServiceServer::new(daemon_service)) diff --git a/apps/daemon/src/scheduler/mod.rs b/apps/daemon/src/scheduler/mod.rs new file mode 100644 index 0000000..9aa4eb3 --- /dev/null +++ b/apps/daemon/src/scheduler/mod.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; +use anyhow::Result; +use tokio::time::{interval, Duration}; +use tracing::{info, error, warn}; +use serde::Deserialize; + +use crate::server::ServerManager; + +/// A scheduled task received from the panel API. +#[derive(Debug, Clone, Deserialize)] +pub struct ScheduledTask { + pub id: String, + pub server_uuid: String, + pub action: String, // "command", "power", "backup" + pub payload: String, // command string, power action, or "backup" + pub schedule_type: String, + pub is_active: bool, + pub next_run_at: Option, // ISO 8601 +} + +/// Scheduler that polls the panel API for due tasks and executes them. +pub struct Scheduler { + server_manager: Arc, + api_url: String, + node_token: String, + poll_interval_secs: u64, +} + +impl Scheduler { + pub fn new( + server_manager: Arc, + api_url: String, + node_token: String, + ) -> Self { + Self { + server_manager, + api_url, + node_token, + poll_interval_secs: 15, + } + } + + /// Run the scheduler loop. This should be spawned as a tokio task. + pub async fn run(self: Arc) { + info!("Scheduler started (poll interval: {}s)", self.poll_interval_secs); + let mut tick = interval(Duration::from_secs(self.poll_interval_secs)); + + loop { + tick.tick().await; + if let Err(e) = self.poll_and_execute().await { + error!(error = %e, "Scheduler poll failed"); + } + } + } + + /// Poll the API for due tasks and execute them. + async fn poll_and_execute(&self) -> Result<()> { + let client = reqwest::Client::new(); + let url = format!("{}/api/internal/schedules/due", self.api_url); + + let resp = client + .get(&url) + .bearer_auth(&self.node_token) + .send() + .await?; + + if !resp.status().is_success() { + warn!(status = %resp.status(), "Failed to fetch due tasks"); + return Ok(()); + } + + #[derive(Deserialize)] + struct DueResponse { + tasks: Vec, + } + + let due: DueResponse = resp.json().await?; + if due.tasks.is_empty() { + return Ok(()); + } + + info!(count = due.tasks.len(), "Processing due scheduled tasks"); + + for task in &due.tasks { + if let Err(e) = self.execute_task(task).await { + error!( + task_id = %task.id, + server = %task.server_uuid, + error = %e, + "Failed to execute scheduled task" + ); + } + + // Notify API that task was executed + let ack_url = format!( + "{}/api/internal/schedules/{}/ack", + self.api_url, task.id + ); + let _ = client + .post(&ack_url) + .bearer_auth(&self.node_token) + .send() + .await; + } + + Ok(()) + } + + /// Execute a single scheduled task. + async fn execute_task(&self, task: &ScheduledTask) -> Result<()> { + info!( + task_id = %task.id, + action = %task.action, + server = %task.server_uuid, + "Executing scheduled task" + ); + + match task.action.as_str() { + "command" => { + // Send command to server's stdin via Docker exec + let docker = self.server_manager.docker(); + docker + .send_command(&task.server_uuid, &task.payload) + .await?; + } + "power" => { + match task.payload.as_str() { + "start" => self.server_manager.start_server(&task.server_uuid).await?, + "stop" => self.server_manager.stop_server(&task.server_uuid).await?, + "restart" => { + let _ = self.server_manager.stop_server(&task.server_uuid).await; + tokio::time::sleep(Duration::from_secs(3)).await; + self.server_manager.start_server(&task.server_uuid).await?; + } + "kill" => self.server_manager.kill_server(&task.server_uuid).await?, + _ => warn!(payload = %task.payload, "Unknown power action"), + } + } + "backup" => { + // Trigger backup via the backup module + info!( + server = %task.server_uuid, + "Backup scheduled task — delegating to backup module" + ); + // Backup is handled by sending callback to API + let client = reqwest::Client::new(); + let url = format!( + "{}/api/internal/servers/{}/backup", + self.api_url, task.server_uuid + ); + let _ = client + .post(&url) + .bearer_auth(&self.node_token) + .json(&serde_json::json!({ "name": format!("auto-{}", task.id) })) + .send() + .await; + } + _ => { + warn!(action = %task.action, "Unknown scheduled action"); + } + } + + Ok(()) + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 070faf7..62cabb6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -18,6 +18,7 @@ import { OrganizationsPage } from '@/pages/organizations/index'; import { DashboardPage } from '@/pages/dashboard/index'; import { CreateServerPage } from '@/pages/servers/create'; import { NodesPage } from '@/pages/nodes/index'; +import { NodeDetailPage } from '@/pages/nodes/detail'; import { MembersPage } from '@/pages/settings/members'; // Server pages @@ -86,6 +87,7 @@ export function App() { } /> } /> } /> + } /> } /> {/* Server detail */} diff --git a/apps/web/src/pages/nodes/detail.tsx b/apps/web/src/pages/nodes/detail.tsx new file mode 100644 index 0000000..79d5550 --- /dev/null +++ b/apps/web/src/pages/nodes/detail.tsx @@ -0,0 +1,248 @@ +import { useParams, Link } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { + ArrowLeft, + Network, + Wifi, + WifiOff, + Cpu, + MemoryStick, + HardDrive, + Server, + Activity, +} from 'lucide-react'; +import { api } from '@/lib/api'; +import { formatBytes } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; + +interface NodeDetail { + id: string; + name: string; + fqdn: string; + daemonPort: number; + grpcPort: number; + memoryTotal: number; + diskTotal: number; + isOnline: boolean; + daemonVersion: string | null; + createdAt: string; +} + +interface NodeStats { + cpuPercent: number; + memoryUsed: number; + memoryTotal: number; + diskUsed: number; + diskTotal: number; + activeServers: number; + totalServers: number; + uptime: number; +} + +interface ServerSummary { + id: string; + name: string; + status: string; + memoryLimit: number; + cpuLimit: number; + gameName: string; +} + +export function NodeDetailPage() { + const { orgId, nodeId } = useParams(); + + const { data: node } = useQuery({ + queryKey: ['node', orgId, nodeId], + queryFn: () => api.get(`/organizations/${orgId}/nodes/${nodeId}`), + }); + + const { data: stats } = useQuery({ + queryKey: ['node-stats', orgId, nodeId], + queryFn: () => api.get(`/organizations/${orgId}/nodes/${nodeId}/stats`), + refetchInterval: 10_000, + }); + + const { data: serversData } = useQuery({ + queryKey: ['node-servers', orgId, nodeId], + queryFn: () => + api.get<{ data: ServerSummary[] }>( + `/organizations/${orgId}/nodes/${nodeId}/servers`, + ), + }); + + const servers = serversData?.data ?? []; + + if (!node) { + return ( +
+
+
+ ); + } + + const memPercent = stats + ? Math.round((stats.memoryUsed / stats.memoryTotal) * 100) + : 0; + const diskPercent = stats + ? Math.round((stats.diskUsed / stats.diskTotal) * 100) + : 0; + + return ( +
+
+ + + +
+ +
+

{node.name}

+

+ {node.fqdn}:{node.daemonPort} +

+
+ + {node.isOnline ? ( + <> Online + ) : ( + <> Offline + )} + +
+
+ + {/* Stats Cards */} +
+ + + CPU Usage + + + +
+ {stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'} +
+ +
+
+ + + + Memory + + + +
+ {stats + ? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}` + : '—'} +
+ +
+
+ + + + Disk + + + +
+ {stats + ? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}` + : '—'} +
+ +
+
+ + + + Servers + + + +
+ {stats ? `${stats.activeServers} / ${stats.totalServers}` : servers.length.toString()} +
+

+ {stats ? 'active / total' : 'total servers'} +

+
+
+
+ + {/* Node Info */} +
+ + + Node Information + + + + + + + + {node.daemonVersion && ( + + )} + + + + + + + Servers on this Node + + + {servers.length === 0 ? ( +

+ No servers on this node +

+ ) : ( +
+ {servers.map((srv) => ( + +
+

{srv.name}

+

{srv.gameName}

+
+
+ + {srv.status} + + + {formatBytes(srv.memoryLimit)} RAM + +
+ + ))} +
+ )} +
+
+
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/web/src/pages/nodes/index.tsx b/apps/web/src/pages/nodes/index.tsx index 83af433..aee8047 100644 --- a/apps/web/src/pages/nodes/index.tsx +++ b/apps/web/src/pages/nodes/index.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams } from 'react-router'; +import { useParams, Link } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Network, Wifi, WifiOff } from 'lucide-react'; import { api } from '@/lib/api'; @@ -153,7 +153,8 @@ export function NodesPage() {
{nodes.map((node) => ( - + +
@@ -175,6 +176,7 @@ export function NodesPage() {
+ ))}
diff --git a/apps/web/src/pages/server/backups.tsx b/apps/web/src/pages/server/backups.tsx index 711e0a0..9767909 100644 --- a/apps/web/src/pages/server/backups.tsx +++ b/apps/web/src/pages/server/backups.tsx @@ -1,17 +1,280 @@ +import { useState } from 'react'; import { useParams } from 'react-router'; -import { HardDrive } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + HardDrive, + Plus, + Download, + Trash2, + Lock, + Unlock, + RotateCcw, + CheckCircle2, + Clock, +} from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +interface Backup { + id: string; + name: string; + sizeBytes: number | null; + cdnPath: string | null; + checksum: string | null; + isLocked: boolean; + completedAt: string | null; + createdAt: string; +} + +function formatBytes(bytes: number | null): string { + if (bytes === null || bytes === 0) return '—'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i] ?? 'B'}`; +} export function BackupsPage() { - const { serverId } = useParams(); + const { orgId, serverId } = useParams(); + const queryClient = useQueryClient(); + const [showCreate, setShowCreate] = useState(false); + const [confirmRestore, setConfirmRestore] = useState(null); + + const { data } = useQuery({ + queryKey: ['backups', orgId, serverId], + queryFn: () => + api.get<{ backups: Backup[] }>( + `/organizations/${orgId}/servers/${serverId}/backups`, + ), + }); + + const deleteMutation = useMutation({ + mutationFn: (backupId: string) => + api.delete(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}`), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }), + }); + + const restoreMutation = useMutation({ + mutationFn: (backupId: string) => + api.post(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/restore`, {}), + onSuccess: () => setConfirmRestore(null), + }); + + const lockMutation = useMutation({ + mutationFn: (backupId: string) => + api.patch(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/lock`, {}), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }), + }); + + const backupList = data?.backups ?? []; + + const totalSize = backupList.reduce((sum, b) => sum + (b.sizeBytes ?? 0), 0); return ( - - - -

Backup management coming soon

-

Server: {serverId}

-
-
+
+
+
+

Backups

+

+ {backupList.length} backup{backupList.length !== 1 ? 's' : ''} — {formatBytes(totalSize)} total +

+
+ + + + + + + Create Backup + + setShowCreate(false)} + /> + + +
+ + {backupList.length === 0 ? ( + + + +

No backups yet

+

+ Create a backup to save the current state of your server +

+
+
+ ) : ( +
+ {backupList.map((backup) => ( + + +
+
+ {backup.completedAt ? ( + + ) : ( + + )} +
+
+
+

{backup.name}

+ {backup.isLocked && ( + + + Locked + + )} + {!backup.completedAt && ( + + In Progress + + )} +
+
+ {formatBytes(backup.sizeBytes)} + {new Date(backup.createdAt).toLocaleString()} + {backup.checksum && ( + + {backup.checksum.slice(0, 12)}... + + )} +
+
+
+ +
+ {backup.completedAt && ( + <> + {confirmRestore === backup.id ? ( +
+ Confirm? + + +
+ ) : ( + + )} + + )} + + {!backup.isLocked && ( + + )} +
+
+
+ ))} +
+ )} +
+ ); +} + +function CreateBackupForm({ + orgId, + serverId, + onClose, +}: { + orgId: string; + serverId: string; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [name, setName] = useState( + `backup-${new Date().toISOString().slice(0, 10)}`, + ); + + const createMutation = useMutation({ + mutationFn: (data: { name: string }) => + api.post(`/organizations/${orgId}/servers/${serverId}/backups`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }); + onClose(); + }, + }); + + return ( +
{ + e.preventDefault(); + createMutation.mutate({ name }); + }} + className="space-y-4" + > +
+ + setName(e.target.value)} + required + /> +
+
+ + +
+
); } diff --git a/apps/web/src/pages/server/schedules.tsx b/apps/web/src/pages/server/schedules.tsx index a841f33..9cfae9f 100644 --- a/apps/web/src/pages/server/schedules.tsx +++ b/apps/web/src/pages/server/schedules.tsx @@ -1,13 +1,426 @@ -import { Calendar } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Calendar, + Plus, + Play, + Pause, + Trash2, + Clock, + Zap, + Terminal, + Power, + HardDrive, +} from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface ScheduledTask { + id: string; + name: string; + action: 'command' | 'power' | 'backup'; + payload: string; + scheduleType: 'interval' | 'daily' | 'weekly' | 'cron'; + scheduleData: Record; + isActive: boolean; + lastRunAt: string | null; + nextRunAt: string | null; + createdAt: string; +} + +const DAYS_OF_WEEK = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +const ACTION_ICONS = { + command: Terminal, + power: Power, + backup: HardDrive, +} as const; export function SchedulesPage() { + const { orgId, serverId } = useParams(); + const queryClient = useQueryClient(); + const [showCreate, setShowCreate] = useState(false); + + const { data } = useQuery({ + queryKey: ['schedules', orgId, serverId], + queryFn: () => + api.get<{ tasks: ScheduledTask[] }>( + `/organizations/${orgId}/servers/${serverId}/schedules`, + ), + }); + + const deleteMutation = useMutation({ + mutationFn: (taskId: string) => + api.delete(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }), + }); + + const triggerMutation = useMutation({ + mutationFn: (taskId: string) => + api.post(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}/trigger`, {}), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }), + }); + + const toggleMutation = useMutation({ + mutationFn: ({ taskId, isActive }: { taskId: string; isActive: boolean }) => + api.patch(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`, { isActive }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }), + }); + + const tasks = data?.tasks ?? []; + return ( - - - -

Scheduled tasks coming soon

-
-
+
+
+

Scheduled Tasks

+ + + + + + + Create Scheduled Task + + setShowCreate(false)} + /> + + +
+ + {tasks.length === 0 ? ( + + + +

No scheduled tasks yet

+

+ Create a schedule to automate commands, power actions, or backups +

+
+
+ ) : ( +
+ {tasks.map((task) => { + const ActionIcon = ACTION_ICONS[task.action]; + return ( + + +
+
+ +
+
+
+

{task.name}

+ + {task.isActive ? 'Active' : 'Paused'} + + {task.action} +
+
+ + + {formatSchedule(task.scheduleType, task.scheduleData)} + + {task.nextRunAt && ( + + Next: {new Date(task.nextRunAt).toLocaleString()} + + )} + {task.lastRunAt && ( + + Last: {new Date(task.lastRunAt).toLocaleString()} + + )} +
+ {task.action === 'command' && ( +

+ $ {task.payload} +

+ )} +
+
+ +
+ + + +
+
+
+ ); + })} +
+ )} +
+ ); +} + +function formatSchedule(type: string, data: Record): string { + switch (type) { + case 'interval': + return `Every ${data.minutes ?? 60} minutes`; + case 'daily': + return `Daily at ${String(data.hour ?? 0).padStart(2, '0')}:${String(data.minute ?? 0).padStart(2, '0')}`; + case 'weekly': { + const day = DAYS_OF_WEEK[Number(data.dayOfWeek ?? 0)] ?? 'Sunday'; + return `${day} at ${String(data.hour ?? 0).padStart(2, '0')}:${String(data.minute ?? 0).padStart(2, '0')}`; + } + case 'cron': + return `Cron: ${String(data.expression ?? '* * * * *')}`; + default: + return type; + } +} + +function CreateScheduleForm({ + orgId, + serverId, + onClose, +}: { + orgId: string; + serverId: string; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [action, setAction] = useState<'command' | 'power' | 'backup'>('command'); + const [payload, setPayload] = useState(''); + const [scheduleType, setScheduleType] = useState<'interval' | 'daily' | 'weekly' | 'cron'>('interval'); + + // Schedule data fields + const [minutes, setMinutes] = useState('60'); + const [hour, setHour] = useState('0'); + const [minute, setMinute] = useState('0'); + const [dayOfWeek, setDayOfWeek] = useState('0'); + const [cronExpression, setCronExpression] = useState('0 * * * *'); + + const createMutation = useMutation({ + mutationFn: (data: Record) => + api.post(`/organizations/${orgId}/servers/${serverId}/schedules`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }); + onClose(); + }, + }); + + const buildScheduleData = (): Record => { + switch (scheduleType) { + case 'interval': + return { minutes: parseInt(minutes, 10) }; + case 'daily': + return { hour: parseInt(hour, 10), minute: parseInt(minute, 10) }; + case 'weekly': + return { dayOfWeek: parseInt(dayOfWeek, 10), hour: parseInt(hour, 10), minute: parseInt(minute, 10) }; + case 'cron': + return { expression: cronExpression }; + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate({ + name, + action, + payload: action === 'backup' ? 'backup' : payload, + scheduleType, + scheduleData: buildScheduleData(), + }); + }; + + return ( +
+
+ + setName(e.target.value)} + required + /> +
+ +
+
+ + +
+ + {action !== 'backup' && ( +
+ + {action === 'command' ? ( + setPayload(e.target.value)} + required + /> + ) : ( + + )} +
+ )} +
+ +
+ + +
+ + {scheduleType === 'interval' && ( +
+ + setMinutes(e.target.value)} + /> +
+ )} + + {(scheduleType === 'daily' || scheduleType === 'weekly') && ( +
+ {scheduleType === 'weekly' && ( +
+ + +
+ )} +
+ + setHour(e.target.value)} + /> +
+
+ + setMinute(e.target.value)} + /> +
+
+ )} + + {scheduleType === 'cron' && ( +
+ + setCronExpression(e.target.value)} + className="font-mono" + /> +

+ Format: minute hour day-of-month month day-of-week +

+
+ )} + +
+ + +
+
); } diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 2adda8b..c030bc5 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -16,10 +16,12 @@ export const PERMISSIONS = { 'files.archive': 'Compress and decompress files', // Backup + 'backup.read': 'View backups', 'backup.create': 'Create backups', 'backup.restore': 'Restore backups', 'backup.delete': 'Delete backups', 'backup.download': 'Download backups', + 'backup.manage': 'Lock and manage backups', // Schedule 'schedule.read': 'View scheduled tasks',