diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 0ade7f55..de8f545b 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -27,6 +27,7 @@ import { configSets, hostMetrics, autoUpdateSettings, + containerStartSchedules, notificationSettings, environmentNotifications, authSettings, @@ -55,6 +56,7 @@ import { type ConfigSet, type HostMetric, type AutoUpdateSetting, + type ContainerStartSchedule, type NotificationSetting, type EnvironmentNotification, type AuthSetting, @@ -88,6 +90,7 @@ export type { ConfigSet, HostMetric, AutoUpdateSetting as AutoUpdateSettingType, + ContainerStartSchedule as ContainerStartScheduleType, User, Session, Role, @@ -624,6 +627,18 @@ export interface AutoUpdateSettingData { updatedAt: string; } +export interface ContainerStartScheduleData { + id: number; + environmentId: number | null; + containerName: string; + enabled: boolean; + scheduleType: 'daily' | 'weekly' | 'custom'; + cronExpression: string | null; + lastStarted: string | null; + createdAt: string; + updatedAt: string; +} + export async function getAutoUpdateSettings(environmentId?: number): Promise { if (environmentId) { return db.select().from(autoUpdateSettings) @@ -754,6 +769,119 @@ export async function renameAutoUpdateSchedule( return true; } +export async function getContainerStartSchedules(environmentId?: number): Promise { + if (environmentId) { + return db.select().from(containerStartSchedules) + .where(eq(containerStartSchedules.environmentId, environmentId)) as Promise; + } + return db.select().from(containerStartSchedules) as Promise; +} + +export async function getContainerStartSchedule(containerName: string, environmentId?: number): Promise { + const results = await db.select().from(containerStartSchedules) + .where(and( + eq(containerStartSchedules.containerName, containerName), + environmentId ? eq(containerStartSchedules.environmentId, environmentId) : isNull(containerStartSchedules.environmentId) + )); + return results[0] as ContainerStartScheduleData | undefined; +} + +export async function getContainerStartScheduleById(id: number): Promise { + const results = await db.select().from(containerStartSchedules) + .where(eq(containerStartSchedules.id, id)); + return results[0] as ContainerStartScheduleData | undefined; +} + +export async function updateContainerStartScheduleById(id: number, data: Partial): Promise { + await db.update(containerStartSchedules) + .set({ + ...data, + updatedAt: new Date().toISOString() + }) + .where(eq(containerStartSchedules.id, id)); +} + +export async function getEnabledContainerStartSchedules(): Promise { + return db.select().from(containerStartSchedules) + .where(eq(containerStartSchedules.enabled, true)) as Promise; +} + +export async function getAllContainerStartSchedules(): Promise { + return db.select().from(containerStartSchedules) + .orderBy(desc(containerStartSchedules.containerName)) as Promise; +} + +export async function upsertContainerStartSchedule( + containerName: string, + settingsData: { + enabled: boolean; + scheduleType: 'daily' | 'weekly' | 'custom'; + cronExpression?: string | null; + }, + environmentId?: number +): Promise { + const existing = await getContainerStartSchedule(containerName, environmentId); + + if (existing) { + await db.update(containerStartSchedules) + .set({ + enabled: settingsData.enabled, + scheduleType: settingsData.scheduleType, + cronExpression: settingsData.cronExpression || null, + updatedAt: new Date().toISOString() + }) + .where(eq(containerStartSchedules.id, existing.id)); + return getContainerStartSchedule(containerName, environmentId) as Promise; + } + + await db.insert(containerStartSchedules).values({ + environmentId: environmentId || null, + containerName, + enabled: settingsData.enabled, + scheduleType: settingsData.scheduleType, + cronExpression: settingsData.cronExpression || null + }); + + return getContainerStartSchedule(containerName, environmentId) as Promise; +} + +export async function updateContainerStartLastStarted(containerName: string, environmentId?: number): Promise { + await db.update(containerStartSchedules) + .set({ + lastStarted: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(and( + eq(containerStartSchedules.containerName, containerName), + environmentId ? eq(containerStartSchedules.environmentId, environmentId) : isNull(containerStartSchedules.environmentId) + )); +} + +export async function deleteContainerStartSchedule(containerName: string, environmentId?: number): Promise { + await db.delete(containerStartSchedules) + .where(and( + eq(containerStartSchedules.containerName, containerName), + environmentId ? eq(containerStartSchedules.environmentId, environmentId) : isNull(containerStartSchedules.environmentId) + )); + return true; +} + +export async function renameContainerStartSchedule( + oldName: string, + newName: string, + environmentId?: number +): Promise { + await db.update(containerStartSchedules) + .set({ containerName: newName }) + .where(and( + eq(containerStartSchedules.containerName, oldName), + environmentId + ? eq(containerStartSchedules.environmentId, environmentId) + : isNull(containerStartSchedules.environmentId) + )); + return true; +} + // ============================================================================= // NOTIFICATION SETTINGS // ============================================================================= @@ -3827,7 +3955,7 @@ export async function saveDashboardPreferences(data: { // SCHEDULE EXECUTION OPERATIONS // ============================================================================= -export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check'; +export type ScheduleType = 'container_update' | 'container_start' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup'; export type ScheduleStatus = 'queued' | 'running' | 'success' | 'failed' | 'skipped'; diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 7e2cbfba..5ce81d00 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -879,6 +879,7 @@ export const stackEvents = schemaProxy.stackEvents; export const hostMetrics = schemaProxy.hostMetrics; export const configSets = schemaProxy.configSets; export const autoUpdateSettings = schemaProxy.autoUpdateSettings; +export const containerStartSchedules = schemaProxy.containerStartSchedules; export const notificationSettings = schemaProxy.notificationSettings; export const environmentNotifications = schemaProxy.environmentNotifications; export const authSettings = schemaProxy.authSettings; @@ -951,6 +952,8 @@ export type { NewStackEvent, AutoUpdateSetting, NewAutoUpdateSetting, + ContainerStartSchedule, + NewContainerStartSchedule, UserPreference, NewUserPreference, ScheduleExecution, diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index e48d1fe2..ef995d5e 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -136,6 +136,20 @@ export const autoUpdateSettings = sqliteTable('auto_update_settings', { envContainerUnique: unique().on(table.environmentId, table.containerName) })); +export const containerStartSchedules = sqliteTable('container_start_schedules', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').references(() => environments.id), + containerName: text('container_name').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).default(false), + scheduleType: text('schedule_type').default('daily'), + cronExpression: text('cron_expression'), + lastStarted: text('last_started'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envContainerUnique: unique().on(table.environmentId, table.containerName) +})); + export const notificationSettings = sqliteTable('notification_settings', { id: integer('id').primaryKey({ autoIncrement: true }), type: text('type').notNull(), @@ -580,6 +594,9 @@ export type NewStackEvent = typeof stackEvents.$inferInsert; export type AutoUpdateSetting = typeof autoUpdateSettings.$inferSelect; export type NewAutoUpdateSetting = typeof autoUpdateSettings.$inferInsert; +export type ContainerStartSchedule = typeof containerStartSchedules.$inferSelect; +export type NewContainerStartSchedule = typeof containerStartSchedules.$inferInsert; + export type UserPreference = typeof userPreferences.$inferSelect; export type NewUserPreference = typeof userPreferences.$inferInsert; diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 93292376..8218fe5f 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -139,6 +139,20 @@ export const autoUpdateSettings = pgTable('auto_update_settings', { envContainerUnique: unique().on(table.environmentId, table.containerName) })); +export const containerStartSchedules = pgTable('container_start_schedules', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').references(() => environments.id), + containerName: text('container_name').notNull(), + enabled: boolean('enabled').default(false), + scheduleType: text('schedule_type').default('daily'), + cronExpression: text('cron_expression'), + lastStarted: timestamp('last_started', { mode: 'string' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + envContainerUnique: unique().on(table.environmentId, table.containerName) +})); + export const notificationSettings = pgTable('notification_settings', { id: serial('id').primaryKey(), type: text('type').notNull(), diff --git a/src/lib/server/scheduler/index.ts b/src/lib/server/scheduler/index.ts index f323515c..5e42d1ef 100644 --- a/src/lib/server/scheduler/index.ts +++ b/src/lib/server/scheduler/index.ts @@ -12,8 +12,10 @@ import { Cron } from 'croner'; import { getEnabledAutoUpdateSettings, + getEnabledContainerStartSchedules, getEnabledAutoUpdateGitStacks, getAutoUpdateSettingById, + getContainerStartScheduleById, getGitStack, getScheduleCleanupCron, getEventCleanupCron, @@ -40,6 +42,7 @@ import { // Import task execution functions import { runContainerUpdate } from './tasks/container-update'; +import { runContainerStart } from './tasks/container-start'; import { runGitStackSync } from './tasks/git-stack-sync'; import { runEnvUpdateCheckJob } from './tasks/env-update-check'; import { runImagePrune } from './tasks/image-prune'; @@ -231,6 +234,7 @@ export async function refreshAllSchedules(): Promise { activeJobs.clear(); let containerCount = 0; + let containerStartCount = 0; let gitStackCount = 0; // Register container auto-update schedules @@ -251,6 +255,24 @@ export async function refreshAllSchedules(): Promise { console.error('[Scheduler] Error loading container schedules:', errorMsg); } + // Register container start schedules + try { + const containerStartSettings = await getEnabledContainerStartSchedules(); + for (const setting of containerStartSettings) { + if (setting.cronExpression) { + const registered = await registerSchedule( + setting.id, + 'container_start', + setting.environmentId + ); + if (registered) containerStartCount++; + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error loading container start schedules:', errorMsg); + } + // Register git stack auto-sync schedules try { const gitStacks = await getEnabledAutoUpdateGitStacks(); @@ -307,7 +329,7 @@ export async function refreshAllSchedules(): Promise { console.error('[Scheduler] Error loading image prune schedules:', errorMsg); } - console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules, ${imagePruneCount} image prune schedules`); + console.log(`[Scheduler] Registered ${containerCount} container update schedules, ${containerStartCount} container start schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules, ${imagePruneCount} image prune schedules`); } /** @@ -316,7 +338,7 @@ export async function refreshAllSchedules(): Promise { */ export async function registerSchedule( scheduleId: number, - type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune', + type: 'container_update' | 'container_start' | 'git_stack_sync' | 'env_update_check' | 'image_prune', environmentId: number | null ): Promise { const key = `${type}-${scheduleId}`; @@ -336,6 +358,12 @@ export async function registerSchedule( cronExpression = setting.cronExpression; entityName = setting.containerName; enabled = setting.enabled; + } else if (type === 'container_start') { + const setting = await getContainerStartScheduleById(scheduleId); + if (!setting) return false; + cronExpression = setting.cronExpression; + entityName = setting.containerName; + enabled = setting.enabled; } else if (type === 'git_stack_sync') { const stack = await getGitStack(scheduleId); if (!stack) return false; @@ -375,6 +403,10 @@ export async function registerSchedule( const setting = await getAutoUpdateSettingById(scheduleId); if (!setting || !setting.enabled) return; await runContainerUpdate(scheduleId, setting.containerName, environmentId, 'cron'); + } else if (type === 'container_start') { + const setting = await getContainerStartScheduleById(scheduleId); + if (!setting || !setting.enabled) return; + await runContainerStart(scheduleId, setting.containerName, environmentId, 'cron'); } else if (type === 'git_stack_sync') { const stack = await getGitStack(scheduleId); if (!stack || !stack.autoUpdate) return; @@ -406,7 +438,7 @@ export async function registerSchedule( */ export function unregisterSchedule( scheduleId: number, - type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune' + type: 'container_update' | 'container_start' | 'git_stack_sync' | 'env_update_check' | 'image_prune' ): void { const key = `${type}-${scheduleId}`; const job = activeJobs.get(key); @@ -445,6 +477,24 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro console.error('[Scheduler] Error refreshing container schedules:', errorMsg); } + // Re-register container start schedules for this environment + try { + const containerStartSettings = await getEnabledContainerStartSchedules(); + for (const setting of containerStartSettings) { + if (setting.environmentId === environmentId && setting.cronExpression) { + const registered = await registerSchedule( + setting.id, + 'container_start', + setting.environmentId + ); + if (registered) refreshedCount++; + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error refreshing container start schedules:', errorMsg); + } + // Re-register git stack auto-sync schedules for this environment try { const gitStacks = await getEnabledAutoUpdateGitStacks(); @@ -584,6 +634,24 @@ export async function triggerContainerUpdate(settingId: number): Promise<{ succe } } +/** + * Manually trigger a container start schedule. + */ +export async function triggerContainerStart(settingId: number): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + const setting = await getContainerStartScheduleById(settingId); + if (!setting) { + return { success: false, error: 'Container start schedule not found' }; + } + + runContainerStart(settingId, setting.containerName, setting.environmentId, 'manual'); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + /** * Manually trigger a git stack sync. */ diff --git a/src/lib/server/scheduler/tasks/container-start.ts b/src/lib/server/scheduler/tasks/container-start.ts new file mode 100644 index 00000000..a327ec10 --- /dev/null +++ b/src/lib/server/scheduler/tasks/container-start.ts @@ -0,0 +1,108 @@ +/** + * Container Start Task + * + * Starts an existing container on a cron schedule. The container is expected + * to stop on its own when its work is complete. + */ + +import type { ScheduleTrigger } from '../../db'; +import { + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog, + updateContainerStartLastStarted +} from '../../db'; +import { listContainers, startContainer } from '../../docker'; + +interface ContainerStartDetails { + mode: 'scheduled_start'; + containerId?: string; + previousState?: string; + reason?: string; +} + +export async function runContainerStart( + settingId: number, + containerName: string, + environmentId: number | null | undefined, + triggeredBy: ScheduleTrigger +): Promise { + const envId = environmentId ?? undefined; + const startTime = Date.now(); + + const execution = await createScheduleExecution({ + scheduleType: 'container_start', + scheduleId: settingId, + environmentId: environmentId ?? null, + entityName: containerName, + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[Container Start] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + await log(`Looking up container: ${containerName}`); + + const containers = await listContainers(true, envId); + const container = containers.find((item) => item.name === containerName); + + if (!container) { + await log(`Container not found: ${containerName}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: 'Container not found' + }); + return; + } + + if (container.state === 'running') { + await log(`Container already running: ${containerName}`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + mode: 'scheduled_start', + containerId: container.id, + previousState: container.state, + reason: 'Container already running' + } satisfies ContainerStartDetails + }); + return; + } + + await log(`Starting container ${containerName} (${container.id})`); + await startContainer(container.id, envId); + await updateContainerStartLastStarted(containerName, envId); + await log(`Container started successfully`); + + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + mode: 'scheduled_start', + containerId: container.id, + previousState: container.state + } satisfies ContainerStartDetails + }); + } catch (error: any) { + await log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + } +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index a7418cd1..7a19cdd9 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -25,6 +25,8 @@ import { removePendingContainerUpdate, deleteAutoUpdateSchedule, getAutoUpdateSetting, + deleteContainerStartSchedule, + getContainerStartSchedule, getStackSourceByComposePath } from './db'; import { unregisterSchedule } from './scheduler'; @@ -872,7 +874,7 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str * @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing) */ async function executeLocalCompose( - operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'create', stackName: string, composeContent: string, dockerHost?: string, @@ -1134,6 +1136,9 @@ async function executeLocalCompose( args.push(serviceName); } break; + case 'create': + args.push('--profile', '*', 'create'); + break; } const commandStr = args.join(' '); @@ -1272,7 +1277,7 @@ async function executeLocalCompose( * @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env) */ async function executeComposeViaHawser( - operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'create', stackName: string, composeContent: string, envId: number, @@ -1287,6 +1292,13 @@ async function executeComposeViaHawser( noBuildCache?: boolean, pullPolicy?: string ): Promise { + if (operation === 'create') { + // No-op for now + return { + success: true + }; + } + const logPrefix = `[Stack:${stackName}]`; // Import dockerFetch dynamically to avoid circular dependency const { dockerFetch } = await import('./docker.js'); @@ -1425,7 +1437,7 @@ async function executeComposeViaHawser( * @param secretVars - Secret environment variables (from DB, injected via shell env) */ async function executeComposeCommand( - operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'create', options: ComposeCommandOptions, composeContent: string, envVars?: Record, @@ -1433,6 +1445,16 @@ async function executeComposeCommand( ): Promise { const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options; + // Always run 'create' before 'up' to ensure containers, networks, and volumes + // are created before starting. This handles cases where 'up' is called directly + // (e.g., deployStack, startStack when no containers exist, restartStack recreate mode). + if (operation === 'up') { + const createResult = await executeComposeCommand('create', options, composeContent, envVars, secretVars); + if (!createResult.success) { + return createResult; + } + } + // Get environment configuration const env = envId ? await getEnvironment(envId) : null; @@ -2053,6 +2075,9 @@ export async function downStack( return withContainerFallback(stackName, envId, 'stop'); } + // Capture current stack containers before compose down removes them. + const stackContainers = await getStackContainers(stackName, envId); + const composeResult = await executeComposeCommand( 'down', { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, @@ -2064,6 +2089,36 @@ export async function downStack( // Remove any dynamically-spawned child containers not in the compose file await cleanupOrphanStackContainers(stackName, envId, 'remove'); + if (composeResult.success) { + const envIdNum = typeof envId === 'number' ? envId : undefined; + for (const container of stackContainers) { + const containerName = container.names?.[0]?.replace(/^\//, '') || container.name; + const containerId = container.id; + + try { + const setting = await getAutoUpdateSetting(containerName, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_update'); + await deleteAutoUpdateSchedule(containerName, envIdNum); + } + } catch { } + + try { + const setting = await getContainerStartSchedule(containerName, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_start'); + await deleteContainerStartSchedule(containerName, envIdNum); + } + } catch { } + + try { + if (envIdNum) { + await removePendingContainerUpdate(envIdNum, containerId); + } + } catch { } + } + } + return composeResult; } @@ -2151,6 +2206,17 @@ export async function removeStack( // Ignore cleanup errors } + // Clean up container start schedule + try { + const setting = await getContainerStartSchedule(containerName, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_start'); + await deleteContainerStartSchedule(containerName, envIdNum); + } + } catch { + // Ignore cleanup errors + } + // Clean up pending container update try { if (envIdNum) { diff --git a/src/routes/api/batch/+server.ts b/src/routes/api/batch/+server.ts index 0c7757ca..650ea33e 100644 --- a/src/routes/api/batch/+server.ts +++ b/src/routes/api/batch/+server.ts @@ -19,7 +19,7 @@ import { downStack, removeStack } from '$lib/server/stacks'; -import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting, deleteContainerStartSchedule, getContainerStartSchedule, removePendingContainerUpdate } from '$lib/server/db'; import { unregisterSchedule } from '$lib/server/scheduler'; import { prefersJSON } from '$lib/server/sse'; import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs'; @@ -324,6 +324,16 @@ async function executeContainerOperation( // Ignore cleanup errors } + try { + const setting = await getContainerStartSchedule(name, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_start'); + await deleteContainerStartSchedule(name, envIdNum); + } + } catch { + // Ignore cleanup errors + } + // Clean up pending container update if exists try { if (envIdNum) { diff --git a/src/routes/api/container-start/+server.ts b/src/routes/api/container-start/+server.ts new file mode 100644 index 00000000..1b196968 --- /dev/null +++ b/src/routes/api/container-start/+server.ts @@ -0,0 +1,32 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getContainerStartSchedules } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const settings = await getContainerStartSchedules(envId); + const settingsMap: Record = {}; + + for (const setting of settings) { + if (setting.enabled) { + settingsMap[setting.containerName] = { + enabled: setting.enabled, + scheduleType: setting.scheduleType, + cronExpression: setting.cronExpression + }; + } + } + + return json(settingsMap); + } catch (error) { + console.error('Failed to get container start schedules:', error); + return json({ error: 'Failed to get container start schedules' }, { status: 500 }); + } +}; diff --git a/src/routes/api/container-start/[containerName]/+server.ts b/src/routes/api/container-start/[containerName]/+server.ts new file mode 100644 index 00000000..856a6ad2 --- /dev/null +++ b/src/routes/api/container-start/[containerName]/+server.ts @@ -0,0 +1,116 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getContainerStartSchedule, + upsertContainerStartSchedule, + deleteContainerStartSchedule +} from '$lib/server/db'; +import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; + +export const GET: RequestHandler = async ({ params, url }) => { + try { + const containerName = decodeURIComponent(params.containerName); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const setting = await getContainerStartSchedule(containerName, envId); + + if (!setting) { + return json({ + enabled: false, + scheduleType: 'daily', + cronExpression: '0 3 * * *' + }); + } + + return json({ + ...setting, + scheduleType: setting.scheduleType, + cronExpression: setting.cronExpression + }); + } catch (error) { + console.error('Failed to get container start schedule:', error); + return json({ error: 'Failed to get container start schedule' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ params, url, request }) => { + try { + const containerName = decodeURIComponent(params.containerName); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const body = await request.json(); + const enabled = body.enabled; + const cronExpression = body.cronExpression ?? body.cron_expression; + + if (enabled === false) { + const existing = await getContainerStartSchedule(containerName, envId); + await deleteContainerStartSchedule(containerName, envId); + if (existing) { + unregisterSchedule(existing.id, 'container_start'); + } + return json({ success: true, deleted: true }); + } + + let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom'; + if (cronExpression) { + const parts = cronExpression.split(' '); + if (parts.length >= 5) { + const [, , day, month, dow] = parts; + if (dow !== '*' && day === '*' && month === '*') { + scheduleType = 'weekly'; + } else if (day === '*' && month === '*' && dow === '*') { + scheduleType = 'daily'; + } + } + } + + const setting = await upsertContainerStartSchedule( + containerName, + { + enabled: Boolean(enabled), + scheduleType, + cronExpression: cronExpression || null + }, + envId + ); + + if (setting.enabled && setting.cronExpression) { + await registerSchedule(setting.id, 'container_start', setting.environmentId); + } else { + unregisterSchedule(setting.id, 'container_start'); + } + + return json({ + ...setting, + scheduleType: setting.scheduleType, + cronExpression: setting.cronExpression + }); + } catch (error) { + console.error('Failed to save container start schedule:', error); + return json({ error: 'Failed to save container start schedule' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, url }) => { + try { + const containerName = decodeURIComponent(params.containerName); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const setting = await getContainerStartSchedule(containerName, envId); + const settingId = setting?.id; + + const deleted = await deleteContainerStartSchedule(containerName, envId); + + if (deleted && settingId) { + unregisterSchedule(settingId, 'container_start'); + } + + return json({ success: deleted }); + } catch (error) { + console.error('Failed to delete container start schedule:', error); + return json({ error: 'Failed to delete container start schedule' }, { status: 500 }); + } +}; diff --git a/src/routes/api/containers/[id]/+server.ts b/src/routes/api/containers/[id]/+server.ts index 3fc64358..cfc9de01 100644 --- a/src/routes/api/containers/[id]/+server.ts +++ b/src/routes/api/containers/[id]/+server.ts @@ -4,8 +4,8 @@ import { removeContainer, getContainerLogs } from '$lib/server/docker'; -import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db'; import { getStackComposeFile } from '$lib/server/stacks'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting, deleteContainerStartSchedule, getContainerStartSchedule, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { unregisterSchedule } from '$lib/server/scheduler'; @@ -116,6 +116,17 @@ export const DELETE: RequestHandler = async (event) => { // Don't fail the deletion if schedule cleanup fails } + // Clean up container start schedule if exists + try { + const setting = await getContainerStartSchedule(containerName, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_start'); + await deleteContainerStartSchedule(containerName, envIdNum); + } + } catch (error) { + console.error('Failed to cleanup container start schedule:', error); + } + // Clean up pending container update if exists try { if (envIdNum) { diff --git a/src/routes/api/containers/[id]/rename/+server.ts b/src/routes/api/containers/[id]/rename/+server.ts index afb036aa..b9dfd277 100644 --- a/src/routes/api/containers/[id]/rename/+server.ts +++ b/src/routes/api/containers/[id]/rename/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import { renameContainer, inspectContainer } from '$lib/server/docker'; -import { renameAutoUpdateSchedule } from '$lib/server/db'; +import { renameAutoUpdateSchedule, renameContainerStartSchedule } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; @@ -44,6 +44,7 @@ export const POST: RequestHandler = async (event) => { // Update schedule if exists try { await renameAutoUpdateSchedule(oldName, name, envIdNum); + await renameContainerStartSchedule(oldName, name, envIdNum); } catch (error) { console.error('Failed to update schedule name:', error); // Don't fail the rename if schedule update fails diff --git a/src/routes/api/schedules/+server.ts b/src/routes/api/schedules/+server.ts index b7329202..b4c10c14 100644 --- a/src/routes/api/schedules/+server.ts +++ b/src/routes/api/schedules/+server.ts @@ -7,9 +7,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { - getEnabledAutoUpdateSettings, - getEnabledAutoUpdateGitStacks, getAllAutoUpdateSettings, + getAllContainerStartSchedules, getAllAutoUpdateGitStacks, getAllEnvUpdateCheckSettings, getAllImagePruneSettings, @@ -25,7 +24,7 @@ import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/s export interface ScheduleInfo { id: number; - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; + type: 'container_update' | 'container_start' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; name: string; entityName: string; description?: string; @@ -87,6 +86,39 @@ export const GET: RequestHandler = async () => { ); schedules.push(...containerSchedules); + // Get container start schedules + const containerStartSettings = await getAllContainerStartSchedules(); + const containerStartSchedules = await Promise.all( + containerStartSettings.map(async (setting) => { + const [env, lastExecution, recentExecutions, timezone] = await Promise.all([ + setting.environmentId ? getEnvironment(setting.environmentId) : null, + getLastExecutionForSchedule('container_start', setting.id), + getRecentExecutionsForSchedule('container_start', setting.id, 5), + setting.environmentId ? getEnvironmentTimezone(setting.environmentId) : 'UTC' + ]); + const isEnabled = setting.enabled ?? false; + const nextRun = isEnabled && setting.cronExpression ? getNextRun(setting.cronExpression, timezone) : null; + + return { + id: setting.id, + type: 'container_start' as const, + name: `Start container: ${setting.containerName}`, + entityName: setting.containerName, + description: 'Start container on a schedule', + environmentId: setting.environmentId ?? null, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: setting.scheduleType ?? 'daily', + cronExpression: setting.cronExpression ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false + }; + }) + ); + schedules.push(...containerStartSchedules); + // Get git stack auto-sync schedules const gitStacks = await getAllAutoUpdateGitStacks(); const gitSchedules = await Promise.all( diff --git a/src/routes/api/schedules/[type]/[id]/+server.ts b/src/routes/api/schedules/[type]/[id]/+server.ts index 055c4436..3b551037 100644 --- a/src/routes/api/schedules/[type]/[id]/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/+server.ts @@ -8,6 +8,8 @@ import type { RequestHandler } from './$types'; import { getAutoUpdateSettingById, deleteAutoUpdateSchedule, + getContainerStartScheduleById, + deleteContainerStartSchedule, updateGitStack, deleteEnvUpdateCheckSettings, deleteImagePruneSettings @@ -39,12 +41,18 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { } return json({ success: true }); + } else if (type === 'container_start') { + const schedule = await getContainerStartScheduleById(scheduleId); + if (schedule) { + await deleteContainerStartSchedule(schedule.containerName, schedule.environmentId ?? undefined); + unregisterSchedule(scheduleId, 'container_start'); + } + return json({ success: true }); + } else if (type === 'git_stack_sync') { // Disable auto-update for git stack (don't delete the stack itself) await updateGitStack(scheduleId, { - autoUpdate: false, - autoUpdateSchedule: null, - autoUpdateCron: null + autoUpdate: false }); // Unregister from croner unregisterSchedule(scheduleId, 'git_stack_sync'); diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts index 85aafb92..2d9e9a7c 100644 --- a/src/routes/api/schedules/[type]/[id]/run/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts @@ -4,13 +4,13 @@ * POST /api/schedules/[type]/[id]/run - Trigger a manual execution * * Path params: - * - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' + * - type: 'container_update' | 'container_start' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' * - id: schedule ID */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler'; +import { triggerContainerUpdate, triggerContainerStart, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; export const POST: RequestHandler = async ({ params, cookies }) => { @@ -33,6 +33,9 @@ export const POST: RequestHandler = async ({ params, cookies }) => { case 'container_update': result = await triggerContainerUpdate(scheduleId); break; + case 'container_start': + result = await triggerContainerStart(scheduleId); + break; case 'git_stack_sync': result = await triggerGitStackSync(scheduleId); break; diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts index b226c341..b7395630 100644 --- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -5,7 +5,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db'; +import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getContainerStartScheduleById, updateContainerStartScheduleById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; @@ -41,6 +41,24 @@ export const POST: RequestHandler = async ({ params, cookies }) => { unregisterSchedule(scheduleId, 'container_update'); } + return json({ success: true, enabled: newEnabled }); + } else if (type === 'container_start') { + const setting = await getContainerStartScheduleById(scheduleId); + if (!setting) { + return json({ error: 'Schedule not found' }, { status: 404 }); + } + + const newEnabled = !setting.enabled; + await updateContainerStartScheduleById(scheduleId, { + enabled: newEnabled + }); + + if (newEnabled && setting.cronExpression) { + await registerSchedule(scheduleId, 'container_start', setting.environmentId); + } else { + unregisterSchedule(scheduleId, 'container_start'); + } + return json({ success: true, enabled: newEnabled }); } else if (type === 'git_stack_sync') { const stack = await getGitStack(scheduleId); diff --git a/src/routes/api/schedules/stream/+server.ts b/src/routes/api/schedules/stream/+server.ts index 597c36b4..9001eb52 100644 --- a/src/routes/api/schedules/stream/+server.ts +++ b/src/routes/api/schedules/stream/+server.ts @@ -7,6 +7,7 @@ import type { RequestHandler } from './$types'; import { getAllAutoUpdateSettings, + getAllContainerStartSchedules, getAllAutoUpdateGitStacks, getAllEnvUpdateCheckSettings, getAllImagePruneSettings, @@ -63,6 +64,39 @@ async function getSchedulesData(): Promise { ); schedules.push(...containerSchedules); + // Get container start schedules + const containerStartSettings = await getAllContainerStartSchedules(); + const containerStartSchedules = await Promise.all( + containerStartSettings.map(async (setting) => { + const [env, lastExecution, recentExecutions, timezone] = await Promise.all([ + setting.environmentId ? getEnvironment(setting.environmentId) : null, + getLastExecutionForSchedule('container_start', setting.id), + getRecentExecutionsForSchedule('container_start', setting.id, 5), + setting.environmentId ? getEnvironmentTimezone(setting.environmentId) : 'UTC' + ]); + const isEnabled = setting.enabled ?? false; + const nextRun = isEnabled && setting.cronExpression ? getNextRun(setting.cronExpression, timezone) : null; + + return { + id: setting.id, + type: 'container_start' as const, + name: `Start container: ${setting.containerName}`, + entityName: setting.containerName, + description: 'Start container on a schedule', + environmentId: setting.environmentId ?? null, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: setting.scheduleType ?? 'daily', + cronExpression: setting.cronExpression ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false + }; + }) + ); + schedules.push(...containerStartSchedules); + // Get git stack auto-sync schedules const gitStacks = await getAllAutoUpdateGitStacks(); const gitSchedules = await Promise.all( diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte index be81ba6e..77a06d5b 100644 --- a/src/routes/containers/ContainerSettingsTab.svelte +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -8,12 +8,13 @@ import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; - import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte'; + import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp, PlayCircle } from 'lucide-svelte'; import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse'; import * as Tooltip from '$lib/components/ui/tooltip'; import { currentEnvironment } from '$lib/stores/environment'; import { Badge } from '$lib/components/ui/badge'; import AutoUpdateSettings from './AutoUpdateSettings.svelte'; + import ScheduledStartSettings from './ScheduledStartSettings.svelte'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; import type { SystemContainerType } from '$lib/types'; @@ -145,6 +146,9 @@ autoUpdateEnabled: boolean; autoUpdateCronExpression: string; vulnerabilityCriteria: VulnerabilityCriteria; + // Scheduled start + scheduledStartEnabled: boolean; + scheduledStartCronExpression: string; // Config sets configSets: ConfigSet[]; selectedConfigSetId: string; @@ -210,6 +214,8 @@ autoUpdateEnabled = $bindable(), autoUpdateCronExpression = $bindable(), vulnerabilityCriteria = $bindable(), + scheduledStartEnabled = $bindable(), + scheduledStartCronExpression = $bindable(), configSets, selectedConfigSetId = $bindable(), errors = $bindable(), @@ -1899,6 +1905,17 @@ +
+
+ +

Scheduled run

+
+ +
+
diff --git a/src/routes/containers/CreateContainerModal.svelte b/src/routes/containers/CreateContainerModal.svelte index 3b4b6b0a..b791e7d2 100644 --- a/src/routes/containers/CreateContainerModal.svelte +++ b/src/routes/containers/CreateContainerModal.svelte @@ -74,7 +74,7 @@ let hasAutoPulled = $state(false); // Tab state - start on settings if skipping pull tab - let activeTab = $state<'pull' | 'scan' | 'container'>(skipPullTab ? 'container' : 'pull'); + let activeTab = $state<'pull' | 'scan' | 'container'>('pull'); // Config sets let configSets = $state([]); @@ -114,6 +114,8 @@ let autoUpdateEnabled = $state(false); let autoUpdateCronExpression = $state('0 3 * * *'); let vulnerabilityCriteria = $state('never'); + let scheduledStartEnabled = $state(false); + let scheduledStartCronExpression = $state('0 2 * * *'); // User/Group @@ -169,8 +171,8 @@ let errors = $state<{ name?: string; image?: string }>({}); // Component refs - let pullTabRef: PullTab | undefined; - let scanTabRef: ScanTab | undefined; + let pullTabRef = $state(undefined); + let scanTabRef = $state(undefined); // Pull & Scan status (tracked via component callbacks) let pullStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle'); @@ -502,6 +504,22 @@ } } + if (scheduledStartEnabled) { + try { + const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : ''; + await fetch(`/api/container-start/${encodeURIComponent(name.trim())}${envParam}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: scheduledStartEnabled, + cronExpression: scheduledStartCronExpression + }) + }); + } catch (err) { + console.error('Failed to save container start schedule:', err); + } + } + if (result.imagePulled) { toast.success(`Container created (image ${image.trim()} was pulled automatically)`); } else { @@ -537,6 +555,8 @@ autoUpdateEnabled = false; autoUpdateCronExpression = '0 3 * * *'; vulnerabilityCriteria = 'never'; + scheduledStartEnabled = false; + scheduledStartCronExpression = '0 2 * * *'; errors = {}; selectedConfigSetId = ''; containerUser = ''; @@ -760,6 +780,8 @@ bind:autoUpdateEnabled bind:autoUpdateCronExpression bind:vulnerabilityCriteria + bind:scheduledStartEnabled + bind:scheduledStartCronExpression {configSets} bind:selectedConfigSetId bind:errors diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index e8db8c72..c7ab2176 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -165,6 +165,8 @@ let autoUpdateEnabled = $state(false); let autoUpdateCronExpression = $state('0 3 * * *'); let vulnerabilityCriteria = $state('never'); + let scheduledStartEnabled = $state(false); + let scheduledStartCronExpression = $state('0 3 * * *'); let currentEnvId = $state(null); currentEnvironment.subscribe(env => currentEnvId = env?.id || null); @@ -224,6 +226,10 @@ cronExpression: string; vulnerabilityCriteria: string; } | null>(null); + let originalScheduledStart = $state<{ + enabled: boolean; + cronExpression: string; + } | null>(null); let loading = $state(false); let loadingData = $state(true); @@ -241,7 +247,7 @@ let renamingTitle = $state(false); // Inline title rename functions - let titleInputRef: HTMLInputElement | null = null; + let titleInputRef = $state(null); function startEditingTitle() { editTitleName = name; @@ -284,8 +290,9 @@ async function fetchAutoUpdateSettings(containerName: string) { try { const envParam = currentEnvId ? `?env=${currentEnvId}` : ''; - const [autoUpdateResponse] = await Promise.all([ + const [autoUpdateResponse, scheduledStartResponse] = await Promise.all([ fetch(`/api/auto-update/${encodeURIComponent(containerName)}${envParam}`), + fetch(`/api/container-start/${encodeURIComponent(containerName)}${envParam}`), checkScannerSettings() ]); if (autoUpdateResponse.ok) { @@ -299,6 +306,16 @@ vulnerabilityCriteria: vulnerabilityCriteria }; } + + if (scheduledStartResponse.ok) { + const data = await scheduledStartResponse.json(); + scheduledStartEnabled = data.enabled || false; + scheduledStartCronExpression = data.cronExpression || '0 2 * * *'; + originalScheduledStart = { + enabled: scheduledStartEnabled, + cronExpression: scheduledStartCronExpression + }; + } } catch (err) { console.error('Failed to fetch auto-update settings:', err); } @@ -321,6 +338,22 @@ } } + async function saveScheduledStartSettings(containerName: string) { + try { + const envParam = currentEnvId ? `?env=${currentEnvId}` : ''; + await fetch(`/api/container-start/${encodeURIComponent(containerName)}${envParam}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: scheduledStartEnabled, + cronExpression: scheduledStartCronExpression + }) + }); + } catch (err) { + console.error('Failed to save scheduled start settings:', err); + } + } + async function loadContainerData() { loadingData = true; try { @@ -695,6 +728,14 @@ ); } + function hasScheduledStartChanged(): boolean { + if (!originalScheduledStart) return true; + return ( + scheduledStartEnabled !== originalScheduledStart.enabled || + scheduledStartCronExpression !== originalScheduledStart.cronExpression + ); + } + function serializeConfigWithoutName() { return JSON.stringify({ image: image.trim(), @@ -791,8 +832,9 @@ const containerConfigChanged = hasContainerConfigChanged(); const autoUpdateChanged = hasAutoUpdateChanged(); + const scheduledStartChanged = hasScheduledStartChanged(); - if (!containerConfigChanged && !autoUpdateChanged) { + if (!containerConfigChanged && !autoUpdateChanged && !scheduledStartChanged) { onClose(); loading = false; return; @@ -827,6 +869,10 @@ await saveAutoUpdateSettings(name.trim()); } + if (scheduledStartChanged) { + await saveScheduledStartSettings(name.trim()); + } + await new Promise(resolve => setTimeout(resolve, 500)); onSuccess(); onClose(); @@ -999,6 +1045,16 @@ } } + if (scheduledStartChanged) { + if (!containerConfigChanged && !autoUpdateChanged) { + statusMessage = 'Saving scheduled run settings...'; + } + await saveScheduledStartSettings(name.trim()); + if (!containerConfigChanged && !autoUpdateChanged) { + statusMessage = 'Scheduled run settings saved!'; + } + } + await new Promise(resolve => setTimeout(resolve, 500)); onSuccess(); @@ -1157,6 +1213,8 @@ bind:autoUpdateEnabled bind:autoUpdateCronExpression bind:vulnerabilityCriteria + bind:scheduledStartEnabled + bind:scheduledStartCronExpression {configSets} bind:selectedConfigSetId bind:errors diff --git a/src/routes/containers/ScheduledStartSettings.svelte b/src/routes/containers/ScheduledStartSettings.svelte new file mode 100644 index 00000000..bb14f692 --- /dev/null +++ b/src/routes/containers/ScheduledStartSettings.svelte @@ -0,0 +1,40 @@ + + +
+
+ + onenablechange?.(value)} /> +
+ + {#if enabled} + { + cronExpression = cron; + oncronchange?.(cron); + }} + /> + +

+ Start this container on a schedule. The container should exit on its own after completing its work. +

+ {/if} +
diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index 183281e5..aaac26e6 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -146,7 +146,7 @@ interface ScheduleExecution { id: number; - scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; + scheduleType: 'container_update' | 'container_start' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; scheduleId: number; environmentId: number | null; entityName: string; @@ -165,7 +165,7 @@ interface Schedule { key: string; // Unique key: type-id id: number; - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; + type: 'container_update' | 'container_start' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; name: string; entityName: string; description?: string; @@ -369,12 +369,12 @@ if (!latestCurrent || latestNew.id !== latestCurrent.id || latestNew.status !== latestCurrent.status) { // Merge new executions with existing ones - const existingIds = new Set(currentExecutions.map(e => e.id)); - const toAdd = newExecutions.filter(e => !existingIds.has(e.id)); + const existingIds = new Set(currentExecutions.map((e: ScheduleExecution) => e.id)); + const toAdd = newExecutions.filter((e: ScheduleExecution) => !existingIds.has(e.id)); // Update existing executions and prepend new ones - const updated = currentExecutions.map(e => { - const newer = newExecutions.find(n => n.id === e.id); + const updated = currentExecutions.map((e: ScheduleExecution) => { + const newer = newExecutions.find((n: ScheduleExecution) => n.id === e.id); return newer || e; }); @@ -916,7 +916,7 @@
- +
@@ -938,6 +938,8 @@ {:else if filterTypes.length === 1} {#if filterTypes[0] === 'container_update'} Container updates + {:else if filterTypes[0] === 'container_start'} + Container starts {:else if filterTypes[0] === 'git_stack_sync'} Git stack syncs {:else if filterTypes[0] === 'env_update_check'} @@ -966,6 +968,10 @@ Container updates + + + Container starts + Git stack syncs @@ -1149,6 +1155,8 @@
{#if schedule.type === 'container_update'} + {:else if schedule.type === 'container_start'} + {:else if schedule.type === 'git_stack_sync'} {:else if schedule.type === 'env_update_check'} @@ -1182,6 +1190,8 @@ {:else} Check & auto-update {/if} + {:else if schedule.type === 'container_start'} + Start container on schedule {:else if schedule.type === 'git_stack_sync'} Git sync {:else if schedule.type === 'env_update_check'} @@ -1498,6 +1508,8 @@ {#if selectedExecution?.scheduleType === 'container_update'} + {:else if selectedExecution?.scheduleType === 'container_start'} + {:else if selectedExecution?.scheduleType === 'git_stack_sync'} {:else if selectedExecution?.scheduleType === 'env_update_check'} @@ -1512,7 +1524,7 @@ Execution details {#if selectedExecution} - ({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else}System job{/if}) + ({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'container_start'}Container start{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else if selectedExecution.scheduleType === 'image_prune'}Image prune{:else}System job{/if}) {/if}