From e325de9908edf51a3a8f3ad6079998ac8bcae2cc Mon Sep 17 00:00:00 2001 From: Eduardo Miranda Date: Mon, 4 May 2026 18:02:15 +0100 Subject: [PATCH 1/2] Add password-protected shutdown/restart --- .../providers/global-system-state/index.tsx | 31 +++- .../providers/global-system-state/restart.tsx | 19 ++- .../global-system-state/shutdown.tsx | 19 ++- packages/ui/src/routes/login.tsx | 132 +++++++++++++++++- .../umbreld/source/modules/system/routes.ts | 44 ++++++ 5 files changed, 235 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/providers/global-system-state/index.tsx b/packages/ui/src/providers/global-system-state/index.tsx index a7b8ec511..22b27c96f 100644 --- a/packages/ui/src/providers/global-system-state/index.tsx +++ b/packages/ui/src/providers/global-system-state/index.tsx @@ -10,9 +10,9 @@ import {toast} from '@/components/ui/toast' import {usePrefixedLocalStorage} from '@/hooks/use-prefixed-local-storage' import {useJwt} from '@/modules/auth/use-auth' import {MigratingCover, useMigrate} from '@/providers/global-system-state/migrate' -import {RestartingCover, useRestart} from '@/providers/global-system-state/restart' -import {ShuttingDownCover, useShutdown} from '@/providers/global-system-state/shutdown' -import {RouterError, RouterOutput, trpcReact} from '@/trpc/trpc' +import {RestartingCover, useRestart, useRestartWithPassword} from '@/providers/global-system-state/restart' +import {ShuttingDownCover, useShutdown, useShutdownWithPassword} from '@/providers/global-system-state/shutdown' +import {RouterError, RouterInput, RouterOutput, trpcReact} from '@/trpc/trpc' import {MS_PER_SECOND} from '@/utils/date-time' import {assertUnreachable, IS_DEV} from '@/utils/misc' @@ -24,7 +24,9 @@ type SystemStatus = RouterOutput['system']['status'] const GlobalSystemStateContext = createContext<{ shutdown: () => void + shutdownWithPassword: (input: RouterInput['system']['shutdownWithPassword']) => Promise restart: () => void + restartWithPassword: (input: RouterInput['system']['restartWithPassword']) => Promise update: () => void migrate: () => void reset: (password: string) => void @@ -73,6 +75,14 @@ export function GlobalSystemStateProvider({children}: {children: ReactNode}) { // Prevent logout/redirect when error occurs setShouldLogoutOnRunning(false) } + + const onPowerError = async () => { + setTriggered(false) + setShouldLogoutOnRunning(false) + setStartShutdownTimer(false) + setShutdownComplete(false) + setRouterError(null) + } const getError = () => routerError const clearError = () => setRouterError(null) // Allow external code to suppress errors (e.g., RAID setup doing its own restart flow) @@ -97,7 +107,9 @@ export function GlobalSystemStateProvider({children}: {children: ReactNode}) { // TODO: handle `onError` for other actions than reset? const restart = useRestart({onMutate, onSuccess}) + const restartWithPassword = useRestartWithPassword({onMutate, onSuccess, onError: onPowerError}) const shutdown = useShutdown({onMutate, onSuccess}) + const shutdownWithPassword = useShutdownWithPassword({onMutate, onSuccess, onError: onPowerError}) const update = useUpdate({onMutate, onSuccess}) const migrate = useMigrate({onMutate, onSuccess}) const reset = useReset({onMutate, onError}) @@ -248,7 +260,18 @@ export function GlobalSystemStateProvider({children}: {children: ReactNode}) { case 'running': { return ( {children} {debugInfo} diff --git a/packages/ui/src/providers/global-system-state/restart.tsx b/packages/ui/src/providers/global-system-state/restart.tsx index e95e5c805..4d73b8ed2 100644 --- a/packages/ui/src/providers/global-system-state/restart.tsx +++ b/packages/ui/src/providers/global-system-state/restart.tsx @@ -2,7 +2,7 @@ import {useTranslation} from 'react-i18next' import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message' import {Loading} from '@/components/ui/loading' -import {trpcReact} from '@/trpc/trpc' +import {type RouterError, trpcReact} from '@/trpc/trpc' export function useRestart({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) { const restartMut = trpcReact.system.restart.useMutation({ @@ -14,6 +14,23 @@ export function useRestart({onMutate, onSuccess}: {onMutate?: () => void; onSucc return restart } +export function useRestartWithPassword({ + onMutate, + onSuccess, + onError, +}: { + onMutate?: () => void + onSuccess?: (didWork: boolean) => void + onError?: (error: RouterError) => void +}) { + const restartMut = trpcReact.system.restartWithPassword.useMutation({ + onMutate, + onSuccess, + onError, + }) + return restartMut.mutateAsync +} + export function RestartingCover() { const {t} = useTranslation() return ( diff --git a/packages/ui/src/providers/global-system-state/shutdown.tsx b/packages/ui/src/providers/global-system-state/shutdown.tsx index 801a3470b..c6fd779bb 100644 --- a/packages/ui/src/providers/global-system-state/shutdown.tsx +++ b/packages/ui/src/providers/global-system-state/shutdown.tsx @@ -2,7 +2,7 @@ import {useTranslation} from 'react-i18next' import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message' import {Loading} from '@/components/ui/loading' -import {trpcReact} from '@/trpc/trpc' +import {type RouterError, trpcReact} from '@/trpc/trpc' export function useShutdown({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) { const shutdownMut = trpcReact.system.shutdown.useMutation({ @@ -14,6 +14,23 @@ export function useShutdown({onMutate, onSuccess}: {onMutate?: () => void; onSuc return shutdown } +export function useShutdownWithPassword({ + onMutate, + onSuccess, + onError, +}: { + onMutate?: () => void + onSuccess?: (didWork: boolean) => void + onError?: (error: RouterError) => void +}) { + const shutdownMut = trpcReact.system.shutdownWithPassword.useMutation({ + onMutate, + onSuccess, + onError, + }) + return shutdownMut.mutateAsync +} + export function ShuttingDownCover() { const {t} = useTranslation() return ( diff --git a/packages/ui/src/routes/login.tsx b/packages/ui/src/routes/login.tsx index 7cbca4cc2..081713750 100644 --- a/packages/ui/src/routes/login.tsx +++ b/packages/ui/src/routes/login.tsx @@ -1,25 +1,31 @@ -import {useState} from 'react' +import {useEffect, useState} from 'react' import {useTranslation} from 'react-i18next' import {TbCircleCheckFilled} from 'react-icons/tb' +import {RiRestartLine, RiShutDownLine} from 'react-icons/ri' import {useLocation} from 'react-router-dom' import { AlertDialog, AlertDialogAction, + AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger, } from '@/components/ui/alert-dialog' import {PasswordInput} from '@/components/ui/input' import {PinInput} from '@/components/ui/pin-input' -import {formGroupClass, Layout, primaryButtonProps} from '@/layouts/bare/shared' +import {formGroupClass, Layout, primaryButtonProps, secondaryButtonClasss} from '@/layouts/bare/shared' import {cn} from '@/lib/utils' import {useAuth} from '@/modules/auth/use-auth' +import {useGlobalSystemState} from '@/providers/global-system-state/index' import {trpcReact} from '@/trpc/trpc' type Step = 'password' | '2fa' +type PowerStep = 'password' | '2fa' +type PowerAction = 'shutdown' | 'restart' export default function Login() { const {t} = useTranslation() @@ -68,11 +74,18 @@ export default function Login() { ) + const powerFooter = ( + <> + + + + ) + switch (step) { case 'password': { return ( <> - +
- + @@ -106,3 +119,114 @@ export default function Login() { } } } + +function PowerActionDialog({action}: {action: PowerAction}) { + const {t} = useTranslation() + const {shutdownWithPassword, restartWithPassword} = useGlobalSystemState() + const powerAction = action === 'shutdown' ? shutdownWithPassword : restartWithPassword + const [open, setOpen] = useState(false) + const [step, setStep] = useState('password') + const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!open) { + setStep('password') + setPassword('') + setPasswordError('') + setIsPending(false) + } + }, [open]) + + const titleKey = action === 'shutdown' ? 'shut-down.confirm.title' : 'restart.confirm.title' + const submitKey = action === 'shutdown' ? 'shut-down.confirm.submit' : 'restart.confirm.submit' + const triggerKey = action === 'shutdown' ? 'shut-down' : 'restart' + const ActionIcon = action === 'shutdown' ? RiShutDownLine : RiRestartLine + + const handlePasswordSubmit = async (event: React.FormEvent) => { + event.preventDefault() + if (!password) return + setPasswordError('') + setIsPending(true) + try { + await powerAction({password}) + setOpen(false) + } catch (error) { + const message = (error as {message?: string})?.message ?? '' + if (message === 'Missing 2FA code') { + setPasswordError('') + setStep('2fa') + return + } + setPasswordError(message || t('something-went-wrong')) + } finally { + setIsPending(false) + } + } + + const handleSubmit2fa = async (totpToken: string) => { + try { + await powerAction({password, totpToken}) + setOpen(false) + return true + } catch (error) { + const message = (error as {message?: string})?.message ?? '' + if (message === 'Incorrect password') { + setPasswordError(message) + setStep('password') + } + return false + } + } + + return ( + + + + + + {step === 'password' ? ( +
+ + {t(titleKey)} + +
+ { + setPasswordError('') + setPassword(value) + }} + error={passwordError} + /> +
+ + + {t(submitKey)} + + {t('cancel')} + +
+ ) : ( +
+ + {t(titleKey)} + {t('login-2fa.subtitle')} + +
+ +
+ + {t('cancel')} + +
+ )} +
+
+ ) +} diff --git a/packages/umbreld/source/modules/system/routes.ts b/packages/umbreld/source/modules/system/routes.ts index ec7e9613e..f5024de43 100644 --- a/packages/umbreld/source/modules/system/routes.ts +++ b/packages/umbreld/source/modules/system/routes.ts @@ -30,10 +30,32 @@ import { } from './system.js' import {privateProcedure, publicProcedure, publicProcedureWhenNoUserExists, router} from '../server/trpc/trpc.js' +import type {Context} from '../server/trpc/context.js' type SystemStatus = 'running' | 'updating' | 'shutting-down' | 'restarting' | 'migrating' | 'resetting' | 'restoring' let systemStatus: SystemStatus = 'running' +const powerActionInput = z.object({ + password: z.string(), + totpToken: z.string().optional(), +}) + +async function validatePowerActionCredentials(ctx: Context, input: z.infer) { + const userExists = await ctx.user.exists() + if (!userExists) return + if (!(await ctx.user.validatePassword(input.password))) { + throw new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect password'}) + } + if (await ctx.user.is2faEnabled()) { + if (!input.totpToken) { + throw new TRPCError({code: 'UNAUTHORIZED', message: 'Missing 2FA code'}) + } + if (!(await ctx.user.validate2faToken(input.totpToken))) { + throw new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect 2FA code'}) + } + } +} + // Quick hack so we can set system status from migration module until we refactor this export function setSystemStatus(status: SystemStatus) { systemStatus = status @@ -155,6 +177,28 @@ export default router({ }), ) .mutation(async ({ctx, input}) => clearStaticIp(ctx.umbreld, input)), + // Public on login screen, but requires password (and 2FA when enabled) + shutdownWithPassword: publicProcedure + .input(powerActionInput) + .mutation(async ({ctx, input}) => { + await validatePowerActionCredentials(ctx, input) + systemStatus = 'shutting-down' + await ctx.umbreld.stop() + await shutdown() + + return true + }), + // Public on login screen, but requires password (and 2FA when enabled) + restartWithPassword: publicProcedure + .input(powerActionInput) + .mutation(async ({ctx, input}) => { + await validatePowerActionCredentials(ctx, input) + systemStatus = 'restarting' + await ctx.umbreld.stop() + await reboot() + + return true + }), // Public during onboarding and recovery mode so users can shut down during RAID setup or mount failure shutdown: publicProcedureWhenNoUserExists.mutation(async ({ctx}) => { systemStatus = 'shutting-down' From ecd5850df4e357eb6824016446c2f0426e97c561 Mon Sep 17 00:00:00 2001 From: Claudia Mesquita Date: Mon, 4 May 2026 18:42:14 +0100 Subject: [PATCH 2/2] Added the ability to add a server shutdown and restart schedule to the dashboard --- package-lock.json | 8 +- packages/ui/public/locales/en.json | 9 + .../_components/settings-content-mobile.tsx | 7 + .../settings/_components/settings-content.tsx | 6 + packages/ui/src/routes/settings/index.tsx | 2 + .../ui/src/routes/settings/power-schedule.tsx | 172 ++++++++++++++++++ packages/umbreld/source/index.ts | 15 ++ .../source/modules/system/power-schedule.ts | 118 ++++++++++++ .../umbreld/source/modules/system/routes.ts | 26 +++ 9 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/routes/settings/power-schedule.tsx create mode 100644 packages/umbreld/source/modules/system/power-schedule.ts diff --git a/package-lock.json b/package-lock.json index 319ececd5..9e8193b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { - "name": "umbrel", - "lockfileVersion": 3, - "requires": true, - "packages": {} + "name": "umbrel_fork", + "lockfileVersion": 3, + "requires": true, + "packages": {} } diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index ea1bd50d4..2aaceae8d 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -984,6 +984,15 @@ "shortcut.open": "Open", "shortcut.remove": "Remove shortcut", "show-details": "Show details", + "power-schedule.title": "Power schedule", + "power-schedule.description": "Schedule daily shutdown and restart times", + "power-schedule.configure": "Configure", + "power-schedule.subtitle": "Set daily times for automatic shutdown and restart.", + "power-schedule.note": "Schedules run only while Umbrel is online. This does not power on a shut down device.", + "power-schedule.shutdown.title": "Daily shutdown", + "power-schedule.shutdown.description": "Shut down Umbrel at the selected time", + "power-schedule.restart.title": "Daily restart", + "power-schedule.restart.description": "Restart Umbrel at the selected time", "shut-down": "Shut down", "shut-down.complete": "Shutdown complete", "shut-down.complete-text": "You can now unplug your device from the power.", diff --git a/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx b/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx index 1ca68e36d..9dc2edb80 100644 --- a/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx +++ b/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx @@ -3,6 +3,7 @@ import { Tb2Fa, TbArrowBigRightLines, TbCircleArrowUp, + TbClock, TbColumns3, TbHistory, TbLanguage, @@ -186,6 +187,12 @@ export function SettingsContentMobile() { description={t('settings.file-sharing.description')} onClick={() => navigate('file-sharing')} /> + navigate('power-schedule')} + /> {isUmbrelPro && ( + + navigate('power-schedule')}> + {t('power-schedule.configure')} + + {/* Backups */}
diff --git a/packages/ui/src/routes/settings/index.tsx b/packages/ui/src/routes/settings/index.tsx index 98298bf6a..313f0a222 100644 --- a/packages/ui/src/routes/settings/index.tsx +++ b/packages/ui/src/routes/settings/index.tsx @@ -40,6 +40,7 @@ const ChangeNameDialog = React.lazy(() => import('@/routes/settings/change-name' const ChangePasswordDialog = React.lazy(() => import('@/routes/settings/change-password')) const RestartDialog = React.lazy(() => import('@/routes/settings/restart')) const ShutdownDialog = React.lazy(() => import('@/routes/settings/shutdown')) +const PowerScheduleDialog = React.lazy(() => import('@/routes/settings/power-schedule')) const TroubleshootDialog = React.lazy(() => import('@/routes/settings/troubleshoot/index')) const TerminalDialog = React.lazy(() => import('@/routes/settings/terminal/index')) const DeviceInfoDialog = React.lazy(() => import('@/routes/settings/device-info')) @@ -157,6 +158,7 @@ export function Settings() { + diff --git a/packages/ui/src/routes/settings/power-schedule.tsx b/packages/ui/src/routes/settings/power-schedule.tsx new file mode 100644 index 000000000..73518352b --- /dev/null +++ b/packages/ui/src/routes/settings/power-schedule.tsx @@ -0,0 +1,172 @@ +import {useEffect, useMemo, useState} from 'react' +import {useTranslation} from 'react-i18next' + +import {Button} from '@/components/ui/button' +import {Dialog, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog' +import {Drawer, DrawerContent, DrawerHeader, DrawerScroller, DrawerTitle} from '@/components/ui/drawer' +import {Input} from '@/components/ui/input' +import {Switch} from '@/components/ui/switch' +import {toast} from '@/components/ui/toast' +import {useIsMobile} from '@/hooks/use-is-mobile' +import {useSettingsDialogProps} from '@/routes/settings/_components/shared' +import {RouterInput, trpcReact} from '@/trpc/trpc' +import {cn} from '@/lib/utils' + +const DEFAULT_TIME = '00:00' + +type PowerScheduleInput = RouterInput['system']['setPowerSchedule'] + +export default function PowerScheduleDialog() { + const {t} = useTranslation() + const dialogProps = useSettingsDialogProps() + const isMobile = useIsMobile() + + const scheduleQ = trpcReact.system.getPowerSchedule.useQuery() + const updateScheduleMut = trpcReact.system.setPowerSchedule.useMutation({ + onSuccess: () => scheduleQ.refetch(), + onError: (error) => toast.error(error.message), + }) + + const [shutdownEnabled, setShutdownEnabled] = useState(false) + const [shutdownTime, setShutdownTime] = useState(DEFAULT_TIME) + const [restartEnabled, setRestartEnabled] = useState(false) + const [restartTime, setRestartTime] = useState(DEFAULT_TIME) + + useEffect(() => { + if (!scheduleQ.data) return + setShutdownEnabled(scheduleQ.data.shutdown.enabled) + setShutdownTime(scheduleQ.data.shutdown.time || DEFAULT_TIME) + setRestartEnabled(scheduleQ.data.restart.enabled) + setRestartTime(scheduleQ.data.restart.time || DEFAULT_TIME) + }, [scheduleQ.data]) + + const scheduleInput = useMemo( + () => ({ + shutdown: {enabled: shutdownEnabled, time: shutdownTime}, + restart: {enabled: restartEnabled, time: restartTime}, + }), + [shutdownEnabled, shutdownTime, restartEnabled, restartTime], + ) + + const isDirty = useMemo(() => { + if (!scheduleQ.data) return false + return ( + scheduleQ.data.shutdown.enabled !== shutdownEnabled || + scheduleQ.data.shutdown.time !== shutdownTime || + scheduleQ.data.restart.enabled !== restartEnabled || + scheduleQ.data.restart.time !== restartTime + ) + }, [restartEnabled, restartTime, scheduleQ.data, shutdownEnabled, shutdownTime]) + + const isLoading = scheduleQ.isLoading || updateScheduleMut.isPending + + const handleSave = () => { + updateScheduleMut.mutate(scheduleInput) + } + + const content = ( +
+
+

{t('power-schedule.subtitle')}

+

{t('power-schedule.note')}

+
+
+ + +
+
+ + +
+
+ ) + + if (isMobile) { + return ( + + + + {t('power-schedule.title')} + + +
{content}
+
+
+
+ ) + } + + return ( + + +
+ + {t('power-schedule.title')} + + {content} +
+
+
+ ) +} + +function ScheduleRow({ + title, + description, + enabled, + onEnabledChange, + time, + onTimeChange, + disabled, +}: { + title: string + description: string + enabled: boolean + onEnabledChange: (value: boolean) => void + time: string + onTimeChange: (value: string) => void + disabled: boolean +}) { + return ( +
+
+
+

{title}

+

{description}

+
+ +
+
+ onTimeChange(value)} + sizeVariant='short' + disabled={!enabled || disabled} + className={cn('w-[140px] text-white', !enabled && 'opacity-50')} + /> + {enabled ? time : '--:--'} +
+
+ ) +} diff --git a/packages/umbreld/source/index.ts b/packages/umbreld/source/index.ts index 45b47b103..8f23dd1ed 100644 --- a/packages/umbreld/source/index.ts +++ b/packages/umbreld/source/index.ts @@ -29,6 +29,7 @@ import { waitForSystemTime, reboot, } from './modules/system/system.js' +import {initializePowerSchedule, stopPowerSchedule} from './modules/system/power-schedule.js' import {cleanupFactoryResetBackups} from './modules/system/factory-reset.js' type StoreSchema = { @@ -52,6 +53,16 @@ type StoreSchema = { } settings: { releaseChannel: 'stable' | 'beta' + powerSchedule?: { + shutdown: { + enabled: boolean + time: string + } + restart: { + enabled: boolean + time: string + } + } wifi?: { ssid: string password?: string @@ -232,6 +243,9 @@ export default class Umbreld { // Start backups last because it depends on files this.backups.start() + + // Initialize power schedules after time sync and module startup + initializePowerSchedule(this).catch((error) => this.logger.error('Failed to initialize power schedule', error)) } private async setBackupRestoreFirstStartFlag() { @@ -249,6 +263,7 @@ export default class Umbreld { async stop() { try { + stopPowerSchedule() // Stop backups first because it depends on files await this.backups.stop() diff --git a/packages/umbreld/source/modules/system/power-schedule.ts b/packages/umbreld/source/modules/system/power-schedule.ts new file mode 100644 index 000000000..5bafb74e9 --- /dev/null +++ b/packages/umbreld/source/modules/system/power-schedule.ts @@ -0,0 +1,118 @@ +import type Umbreld from '../../index.js' +import {reboot, shutdown} from './system.js' + +type PowerAction = 'shutdown' | 'restart' + +export type PowerSchedule = { + shutdown: { + enabled: boolean + time: string + } + restart: { + enabled: boolean + time: string + } +} + +const DEFAULT_TIME = '00:00' +const TIME_REGEX = /^\d{2}:\d{2}$/ + +const defaultSchedule: PowerSchedule = { + shutdown: {enabled: false, time: DEFAULT_TIME}, + restart: {enabled: false, time: DEFAULT_TIME}, +} + +let shutdownTimer: NodeJS.Timeout | null = null +let restartTimer: NodeJS.Timeout | null = null + +function isValidTime(value: string) { + if (!TIME_REGEX.test(value)) return false + const [hours, minutes] = value.split(':').map((part) => Number(part)) + if (Number.isNaN(hours) || Number.isNaN(minutes)) return false + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 +} + +function normalizeTime(value: string | undefined) { + if (value && isValidTime(value)) return value + return DEFAULT_TIME +} + +export function normalizePowerSchedule(input?: Partial | null): PowerSchedule { + return { + shutdown: { + enabled: input?.shutdown?.enabled ?? defaultSchedule.shutdown.enabled, + time: normalizeTime(input?.shutdown?.time), + }, + restart: { + enabled: input?.restart?.enabled ?? defaultSchedule.restart.enabled, + time: normalizeTime(input?.restart?.time), + }, + } +} + +function getNextOccurrence(time: string, now = new Date()) { + const [hours, minutes] = time.split(':').map((part) => Number(part)) + const next = new Date(now) + next.setSeconds(0, 0) + next.setHours(hours, minutes, 0, 0) + if (next.getTime() <= now.getTime()) { + next.setDate(next.getDate() + 1) + } + return next +} + +function clearTimer(timer: NodeJS.Timeout | null) { + if (timer) clearTimeout(timer) +} + +function scheduleAction(umbreld: Umbreld, action: PowerAction, config: PowerSchedule[PowerAction]) { + const logger = umbreld.logger.createChildLogger('power-schedule') + const nextRun = getNextOccurrence(config.time) + const delayMs = Math.max(0, nextRun.getTime() - Date.now()) + + logger.log(`${action} scheduled for ${nextRun.toISOString()}`) + + return setTimeout(async () => { + try { + await umbreld.stop() + if (action === 'shutdown') { + await shutdown() + } else { + await reboot() + } + } catch (error) { + logger.error(`Failed scheduled ${action}`, error) + } + }, delayMs) +} + +export function schedulePowerSchedule(umbreld: Umbreld, schedule: PowerSchedule) { + clearTimer(shutdownTimer) + clearTimer(restartTimer) + + shutdownTimer = schedule.shutdown.enabled ? scheduleAction(umbreld, 'shutdown', schedule.shutdown) : null + restartTimer = schedule.restart.enabled ? scheduleAction(umbreld, 'restart', schedule.restart) : null +} + +export async function getPowerSchedule(umbreld: Umbreld) { + const stored = await umbreld.store.get('settings.powerSchedule') + return normalizePowerSchedule(stored) +} + +export async function setPowerSchedule(umbreld: Umbreld, schedule: PowerSchedule) { + await umbreld.store.set('settings.powerSchedule', schedule) + schedulePowerSchedule(umbreld, schedule) + return true +} + +export async function initializePowerSchedule(umbreld: Umbreld) { + const schedule = await getPowerSchedule(umbreld) + schedulePowerSchedule(umbreld, schedule) +} + +export function stopPowerSchedule() { + clearTimer(shutdownTimer) + clearTimer(restartTimer) + shutdownTimer = null + restartTimer = null +} diff --git a/packages/umbreld/source/modules/system/routes.ts b/packages/umbreld/source/modules/system/routes.ts index f5024de43..0c00c421d 100644 --- a/packages/umbreld/source/modules/system/routes.ts +++ b/packages/umbreld/source/modules/system/routes.ts @@ -28,6 +28,7 @@ import { clearStaticIp, syncDns, } from './system.js' +import {getPowerSchedule, setPowerSchedule} from './power-schedule.js' import {privateProcedure, publicProcedure, publicProcedureWhenNoUserExists, router} from '../server/trpc/trpc.js' import type {Context} from '../server/trpc/context.js' @@ -40,6 +41,25 @@ const powerActionInput = z.object({ totpToken: z.string().optional(), }) +const timeString = z + .string() + .regex(/^\d{2}:\d{2}$/, 'Invalid time format') + .refine((value: string) => { + const [hours, minutes] = value.split(':').map((part: string) => Number(part)) + return Number.isInteger(hours) && Number.isInteger(minutes) && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 + }, 'Invalid time value') + +const powerScheduleInput = z.object({ + shutdown: z.object({ + enabled: z.boolean(), + time: timeString, + }), + restart: z.object({ + enabled: z.boolean(), + time: timeString, + }), +}) + async function validatePowerActionCredentials(ctx: Context, input: z.infer) { const userExists = await ctx.user.exists() if (!userExists) return @@ -177,6 +197,12 @@ export default router({ }), ) .mutation(async ({ctx, input}) => clearStaticIp(ctx.umbreld, input)), + getPowerSchedule: privateProcedure.query(async ({ctx}) => { + return getPowerSchedule(ctx.umbreld) + }), + setPowerSchedule: privateProcedure.input(powerScheduleInput).mutation(async ({ctx, input}) => { + return setPowerSchedule(ctx.umbreld, input) + }), // Public on login screen, but requires password (and 2FA when enabled) shutdownWithPassword: publicProcedure .input(powerActionInput)