diff --git a/config.go b/config.go index 32b3b659b..eb6fb3fe8 100644 --- a/config.go +++ b/config.go @@ -12,6 +12,7 @@ import ( "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/native" "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/internal/powersched" "github.com/jetkvm/kvm/internal/sync" "github.com/jetkvm/kvm/internal/usbgadget" @@ -88,41 +89,42 @@ func (m *KeyboardMacro) Validate() error { } type Config struct { - CloudURL string `json:"cloud_url"` - UpdateAPIURL string `json:"update_api_url"` - CloudAppURL string `json:"cloud_app_url"` - CloudToken string `json:"cloud_token"` - TailscaleControlURL string `json:"tailscale_control_url,omitempty"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - JigglerConfig *JigglerConfig `json:"jiggler_config"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - LocalLoopbackOnly bool `json:"local_loopback_only"` - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` - KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` - KeyboardLayout string `json:"keyboard_layout"` - EdidString string `json:"hdmi_edid_string"` - ActiveExtension string `json:"active_extension"` - DisplayRotation string `json:"display_rotation"` - DisplayMaxBrightness int `json:"display_max_brightness"` - DisplayDimAfterSec int `json:"display_dim_after_sec"` - DisplayOffAfterSec int `json:"display_off_after_sec"` - TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" - UsbConfig *usbgadget.Config `json:"usb_config"` - UsbDevices *usbgadget.Devices `json:"usb_devices"` - NetworkConfig *types.NetworkConfig `json:"network_config"` - DefaultLogLevel string `json:"default_log_level"` - VideoSleepAfterSec int `json:"video_sleep_after_sec"` - VideoQualityFactor float64 `json:"video_quality_factor"` - VideoCodecPreference string `json:"video_codec_preference"` - HideDisplayWhenIdle bool `json:"host_display_disable_when_idle"` - NativeMaxRestart uint `json:"native_max_restart_attempts"` - MqttConfig *MQTTConfig `json:"mqtt_config"` - AudioEnabled bool `json:"audio_enabled"` + CloudURL string `json:"cloud_url"` + UpdateAPIURL string `json:"update_api_url"` + CloudAppURL string `json:"cloud_app_url"` + CloudToken string `json:"cloud_token"` + TailscaleControlURL string `json:"tailscale_control_url,omitempty"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + JigglerConfig *JigglerConfig `json:"jiggler_config"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + LocalLoopbackOnly bool `json:"local_loopback_only"` + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + PowerSchedules []powersched.Schedule `json:"power_schedules"` + KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` + KeyboardLayout string `json:"keyboard_layout"` + EdidString string `json:"hdmi_edid_string"` + ActiveExtension string `json:"active_extension"` + DisplayRotation string `json:"display_rotation"` + DisplayMaxBrightness int `json:"display_max_brightness"` + DisplayDimAfterSec int `json:"display_dim_after_sec"` + DisplayOffAfterSec int `json:"display_off_after_sec"` + TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" + UsbConfig *usbgadget.Config `json:"usb_config"` + UsbDevices *usbgadget.Devices `json:"usb_devices"` + NetworkConfig *types.NetworkConfig `json:"network_config"` + DefaultLogLevel string `json:"default_log_level"` + VideoSleepAfterSec int `json:"video_sleep_after_sec"` + VideoQualityFactor float64 `json:"video_quality_factor"` + VideoCodecPreference string `json:"video_codec_preference"` + HideDisplayWhenIdle bool `json:"host_display_disable_when_idle"` + NativeMaxRestart uint `json:"native_max_restart_attempts"` + MqttConfig *MQTTConfig `json:"mqtt_config"` + AudioEnabled bool `json:"audio_enabled"` } // GetUpdateAPIURL returns the update API URL @@ -189,6 +191,7 @@ func getDefaultConfig() Config { AutoUpdateEnabled: true, // Set a default value ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, + PowerSchedules: []powersched.Schedule{}, DisplayRotation: "270", KeyboardLayout: "en-US", DisplayMaxBrightness: 64, @@ -303,6 +306,10 @@ func LoadConfig() { loadedConfig.MqttConfig = getDefaultConfig().MqttConfig } + if loadedConfig.PowerSchedules == nil { + loadedConfig.PowerSchedules = []powersched.Schedule{} + } + // fixup old keyboard layout value if loadedConfig.KeyboardLayout == "en_US" { loadedConfig.KeyboardLayout = "en-US" diff --git a/internal/powersched/schedule.go b/internal/powersched/schedule.go new file mode 100644 index 000000000..08efa59b9 --- /dev/null +++ b/internal/powersched/schedule.go @@ -0,0 +1,151 @@ +// Package powersched describes recurring power actions for the attached host. +// +// It holds only the schedule model and its translation to a crontab, with no +// dependency on the device runtime, so the rules can be unit tested on any +// platform. Executing a schedule lives in the main kvm package. +package powersched + +import ( + "fmt" + "net" + "sort" + "strings" + "time" + _ "time/tzdata" +) + +// Schedule methods. +const ( + MethodWOL = "wol" + MethodATX = "atx" +) + +// Schedule actions. +const ( + ActionOn = "on" + ActionOff = "off" + ActionOffForce = "off-force" +) + +// MaxSchedules limits how many schedules a device may store, mirroring the +// keyboard macro limits so a misbehaving client can't grow the config forever. +const MaxSchedules = 25 + +// Schedule describes a recurring power action on the attached host. +// +// The schedule is stored as a weekday set plus a wall-clock time in an IANA +// timezone rather than a raw crontab: the UI exposes a weekday/time picker, and +// keeping the structured form lets both ends render the schedule consistently. +type Schedule struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Method string `json:"method"` // "wol" | "atx" + Action string `json:"action"` // "on" | "off" | "off-force" + Weekdays []int `json:"weekdays"` // 0=Sunday .. 6=Saturday + Hour int `json:"hour"` // 0-23 + Minute int `json:"minute"` // 0-59 + Timezone string `json:"timezone"` // IANA name, e.g. "Europe/Berlin" + + // Wake-on-LAN only. The MAC is copied onto the schedule rather than + // referencing an entry in WakeOnLanDevices, so removing a saved device + // can't leave a schedule pointing at nothing. + MacAddress string `json:"macAddress,omitempty"` + BroadcastIP string `json:"broadcastIP,omitempty"` +} + +// AllowedActions returns the actions that are valid for a given method. +func AllowedActions(method string) []string { + switch method { + case MethodWOL: + // A magic packet can only ever turn a host on. + return []string{ActionOn} + case MethodATX: + return []string{ActionOn, ActionOff, ActionOffForce} + default: + return nil + } +} + +// Validate checks the schedule and normalises its weekday list. It returns an +// error describing the first problem found. +func (s *Schedule) Validate() error { + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("schedule name cannot be empty") + } + + actions := AllowedActions(s.Method) + if actions == nil { + return fmt.Errorf("invalid method: %s", s.Method) + } + + valid := false + for _, a := range actions { + if s.Action == a { + valid = true + break + } + } + if !valid { + return fmt.Errorf("action %q is not valid for method %q", s.Action, s.Method) + } + + if s.Method == MethodWOL { + if _, err := net.ParseMAC(s.MacAddress); err != nil { + return fmt.Errorf("invalid MAC address %q: %w", s.MacAddress, err) + } + if s.BroadcastIP != "" { + if ip := net.ParseIP(s.BroadcastIP); ip == nil || ip.To4() == nil { + return fmt.Errorf("invalid broadcast IP address: %s", s.BroadcastIP) + } + } + } + + if s.Hour < 0 || s.Hour > 23 { + return fmt.Errorf("hour must be between 0 and 23, got %d", s.Hour) + } + if s.Minute < 0 || s.Minute > 59 { + return fmt.Errorf("minute must be between 0 and 59, got %d", s.Minute) + } + + if len(s.Weekdays) == 0 { + return fmt.Errorf("at least one weekday must be selected") + } + seen := make(map[int]bool, len(s.Weekdays)) + days := make([]int, 0, len(s.Weekdays)) + for _, d := range s.Weekdays { + if d < 0 || d > 6 { + return fmt.Errorf("weekday must be between 0 and 6, got %d", d) + } + if seen[d] { + continue + } + seen[d] = true + days = append(days, d) + } + sort.Ints(days) + s.Weekdays = days + + if s.Timezone != "" { + if _, err := time.LoadLocation(s.Timezone); err != nil { + return fmt.Errorf("invalid timezone %q: %w", s.Timezone, err) + } + } + + return nil +} + +// CronTab renders the schedule as a 6-field crontab, matching the +// with-seconds format the jiggler already uses. +func (s *Schedule) CronTab() string { + days := make([]string, 0, len(s.Weekdays)) + for _, d := range s.Weekdays { + days = append(days, fmt.Sprintf("%d", d)) + } + + tab := fmt.Sprintf("0 %d %d * * %s", s.Minute, s.Hour, strings.Join(days, ",")) + if s.Timezone != "" && s.Timezone != "UTC" { + tab = fmt.Sprintf("TZ=%s %s", s.Timezone, tab) + } + return tab +} diff --git a/internal/powersched/schedule_test.go b/internal/powersched/schedule_test.go new file mode 100644 index 000000000..91a2d0110 --- /dev/null +++ b/internal/powersched/schedule_test.go @@ -0,0 +1,238 @@ +package powersched + +import ( + "testing" + "time" + + "github.com/go-co-op/gocron/v2" +) + +func validSchedule() Schedule { + return Schedule{ + ID: "abc1234", + Name: "Morning wake", + Enabled: true, + Method: MethodWOL, + Action: ActionOn, + Weekdays: []int{1, 2, 3, 4, 5}, + Hour: 8, + Minute: 30, + Timezone: "Europe/Berlin", + MacAddress: "00:b0:d0:63:c2:26", + } +} + +func TestCronTab(t *testing.T) { + tests := []struct { + name string + mut func(*Schedule) + want string + }{ + { + name: "weekdays with timezone", + mut: func(_ *Schedule) {}, + want: "TZ=Europe/Berlin 0 30 8 * * 1,2,3,4,5", + }, + { + name: "UTC omits the TZ prefix", + mut: func(s *Schedule) { s.Timezone = "UTC" }, + want: "0 30 8 * * 1,2,3,4,5", + }, + { + name: "empty timezone omits the TZ prefix", + mut: func(s *Schedule) { s.Timezone = "" }, + want: "0 30 8 * * 1,2,3,4,5", + }, + { + name: "sunday only", + mut: func(s *Schedule) { + s.Weekdays = []int{0} + s.Hour = 0 + s.Minute = 0 + s.Timezone = "UTC" + }, + want: "0 0 0 * * 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := validSchedule() + tt.mut(&s) + if got := s.CronTab(); got != tt.want { + t.Errorf("CronTab() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestCronTabIsAcceptedByGocron guards the format contract with the scheduler +// library: a crontab we generate but gocron rejects would silently disable a +// schedule at runtime. +func TestCronTabIsAcceptedByGocron(t *testing.T) { + s, err := gocron.NewScheduler() + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + defer func() { _ = s.Shutdown() }() + + type scheduled struct { + tab string + job gocron.Job + } + var jobs []scheduled + + for _, tz := range []string{"Europe/Berlin", "UTC", "America/New_York"} { + for _, weekdays := range [][]int{{0}, {6}, {1, 2, 3, 4, 5}, {0, 1, 2, 3, 4, 5, 6}} { + sched := validSchedule() + sched.Timezone = tz + sched.Weekdays = weekdays + + job, err := s.NewJob( + gocron.CronJob(sched.CronTab(), true), + gocron.NewTask(func() {}), + ) + if err != nil { + t.Fatalf("gocron rejected crontab %q: %v", sched.CronTab(), err) + } + jobs = append(jobs, scheduled{tab: sched.CronTab(), job: job}) + } + } + + // Next run times are only populated once the scheduler is running. + s.Start() + + for _, j := range jobs { + next, err := j.job.NextRun() + if err != nil { + t.Errorf("no next run for crontab %q: %v", j.tab, err) + continue + } + if next.IsZero() { + t.Errorf("next run for crontab %q is the zero time", j.tab) + } + } +} + +// TestCronTabNextRunMatchesTimezone verifies the TZ= prefix is actually honoured +// rather than silently ignored, which is what makes per-schedule timezones work. +func TestCronTabNextRunMatchesTimezone(t *testing.T) { + s, err := gocron.NewScheduler() + if err != nil { + t.Fatalf("failed to create scheduler: %v", err) + } + defer func() { _ = s.Shutdown() }() + + sched := validSchedule() + sched.Timezone = "America/New_York" + sched.Hour = 8 + sched.Minute = 30 + sched.Weekdays = []int{0, 1, 2, 3, 4, 5, 6} + + job, err := s.NewJob(gocron.CronJob(sched.CronTab(), true), gocron.NewTask(func() {})) + if err != nil { + t.Fatalf("failed to schedule: %v", err) + } + s.Start() + + next, err := job.NextRun() + if err != nil { + t.Fatalf("failed to get next run: %v", err) + } + + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("failed to load location: %v", err) + } + local := next.In(loc) + if local.Hour() != 8 || local.Minute() != 30 { + t.Errorf("next run is %s in New York, want 08:30", local.Format("15:04")) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + mut func(*Schedule) + wantErr bool + }{ + {"valid wol", func(_ *Schedule) {}, false}, + {"empty name", func(s *Schedule) { s.Name = " " }, true}, + {"unknown method", func(s *Schedule) { s.Method = "smoke-signal" }, true}, + {"wol cannot power off", func(s *Schedule) { s.Action = ActionOff }, true}, + {"invalid mac", func(s *Schedule) { s.MacAddress = "not-a-mac" }, true}, + {"invalid broadcast ip", func(s *Schedule) { s.BroadcastIP = "999.1.1.1" }, true}, + {"ipv6 broadcast rejected", func(s *Schedule) { s.BroadcastIP = "::1" }, true}, + {"valid broadcast ip", func(s *Schedule) { s.BroadcastIP = "192.168.1.255" }, false}, + {"hour too large", func(s *Schedule) { s.Hour = 24 }, true}, + {"hour negative", func(s *Schedule) { s.Hour = -1 }, true}, + {"minute too large", func(s *Schedule) { s.Minute = 60 }, true}, + {"no weekdays", func(s *Schedule) { s.Weekdays = []int{} }, true}, + {"weekday out of range", func(s *Schedule) { s.Weekdays = []int{7} }, true}, + {"invalid timezone", func(s *Schedule) { s.Timezone = "Mars/Olympus_Mons" }, true}, + { + name: "atx may power off", + mut: func(s *Schedule) { + s.Method = MethodATX + s.Action = ActionOff + s.MacAddress = "" + }, + wantErr: false, + }, + { + name: "atx may force power off", + mut: func(s *Schedule) { + s.Method = MethodATX + s.Action = ActionOffForce + s.MacAddress = "" + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := validSchedule() + tt.mut(&s) + err := s.Validate() + if tt.wantErr && err == nil { + t.Errorf("Validate() = nil, want an error") + } + if !tt.wantErr && err != nil { + t.Errorf("Validate() = %v, want nil", err) + } + }) + } +} + +// Validate doubles as a normaliser so the stored weekday list is stable. +func TestValidateNormalizesWeekdays(t *testing.T) { + s := validSchedule() + s.Weekdays = []int{5, 1, 5, 3, 1} + + if err := s.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + + want := []int{1, 3, 5} + if len(s.Weekdays) != len(want) { + t.Fatalf("weekdays = %v, want %v", s.Weekdays, want) + } + for i := range want { + if s.Weekdays[i] != want[i] { + t.Fatalf("weekdays = %v, want %v", s.Weekdays, want) + } + } +} + +func TestAllowedActions(t *testing.T) { + if got := AllowedActions(MethodWOL); len(got) != 1 || got[0] != ActionOn { + t.Errorf("AllowedActions(wol) = %v, want [on]", got) + } + if got := AllowedActions(MethodATX); len(got) != 3 { + t.Errorf("AllowedActions(atx) = %v, want 3 actions", got) + } + if got := AllowedActions("nope"); got != nil { + t.Errorf("AllowedActions(nope) = %v, want nil", got) + } +} diff --git a/jsonrpc.go b/jsonrpc.go index 5a662530d..d35320d80 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1351,6 +1351,8 @@ var rpcHandlers = map[string]RPCHandler{ "setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, "getJigglerConfig": {Func: rpcGetJigglerConfig}, "getTimezones": {Func: rpcGetTimezones}, + "getPowerSchedules": {Func: rpcGetPowerSchedules}, + "setPowerSchedules": {Func: rpcSetPowerSchedules, Params: []string{"params"}}, "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}, OptionalParams: []string{"broadcastIP"}}, "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, diff --git a/log.go b/log.go index da32d74ca..42257ec7d 100644 --- a/log.go +++ b/log.go @@ -10,28 +10,29 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { } var ( - logger = logging.GetSubsystemLogger("jetkvm") - failsafeLogger = logging.GetSubsystemLogger("failsafe") - networkLogger = logging.GetSubsystemLogger("network") - cloudLogger = logging.GetSubsystemLogger("cloud") - websocketLogger = logging.GetSubsystemLogger("websocket") - webrtcLogger = logging.GetSubsystemLogger("webrtc") - nativeLogger = logging.GetSubsystemLogger("native") - nbdLogger = logging.GetSubsystemLogger("nbd") - timesyncLogger = logging.GetSubsystemLogger("timesync") - jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc") - hidRPCLogger = logging.GetSubsystemLogger("hidrpc") - watchdogLogger = logging.GetSubsystemLogger("watchdog") - websecureLogger = logging.GetSubsystemLogger("websecure") - otaLogger = logging.GetSubsystemLogger("ota") - serialLogger = logging.GetSubsystemLogger("serial") - terminalLogger = logging.GetSubsystemLogger("terminal") - cdcACMLogger = logging.GetSubsystemLogger("cdcacm") - displayLogger = logging.GetSubsystemLogger("display") - audioLogger = logging.GetSubsystemLogger("audio") - wolLogger = logging.GetSubsystemLogger("wol") - usbLogger = logging.GetSubsystemLogger("usb") - tailscaleLogger = logging.GetSubsystemLogger("tailscale") + logger = logging.GetSubsystemLogger("jetkvm") + failsafeLogger = logging.GetSubsystemLogger("failsafe") + networkLogger = logging.GetSubsystemLogger("network") + cloudLogger = logging.GetSubsystemLogger("cloud") + websocketLogger = logging.GetSubsystemLogger("websocket") + webrtcLogger = logging.GetSubsystemLogger("webrtc") + nativeLogger = logging.GetSubsystemLogger("native") + nbdLogger = logging.GetSubsystemLogger("nbd") + timesyncLogger = logging.GetSubsystemLogger("timesync") + jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc") + hidRPCLogger = logging.GetSubsystemLogger("hidrpc") + watchdogLogger = logging.GetSubsystemLogger("watchdog") + websecureLogger = logging.GetSubsystemLogger("websecure") + otaLogger = logging.GetSubsystemLogger("ota") + serialLogger = logging.GetSubsystemLogger("serial") + terminalLogger = logging.GetSubsystemLogger("terminal") + cdcACMLogger = logging.GetSubsystemLogger("cdcacm") + displayLogger = logging.GetSubsystemLogger("display") + audioLogger = logging.GetSubsystemLogger("audio") + wolLogger = logging.GetSubsystemLogger("wol") + powerSchedLogger = logging.GetSubsystemLogger("powersched") + usbLogger = logging.GetSubsystemLogger("usb") + tailscaleLogger = logging.GetSubsystemLogger("tailscale") // external components ginLogger = logging.GetSubsystemLogger("gin") ) diff --git a/main.go b/main.go index 5ade107dc..edf516436 100644 --- a/main.go +++ b/main.go @@ -114,6 +114,7 @@ func Main() { logger.Warn().Err(err).Msg("failed to init images folder") } initJiggler() + initPowerScheduler() // Initialize MQTT initMQTT() diff --git a/power_schedule.go b/power_schedule.go new file mode 100644 index 000000000..ac890ebfa --- /dev/null +++ b/power_schedule.go @@ -0,0 +1,192 @@ +package kvm + +import ( + "fmt" + "time" + + "github.com/jetkvm/kvm/internal/powersched" + + "github.com/go-co-op/gocron/v2" + "github.com/rs/zerolog" +) + +// powerScheduler is deliberately a separate gocron instance from the jiggler's: +// rpcSetJigglerConfig removes *every* job on its scheduler, which would silently +// drop all power schedules if the two shared one. +var powerScheduler gocron.Scheduler + +func initPowerScheduler() { + ensureConfigLoaded() + if err := rebuildPowerScheduler(); err != nil { + powerSchedLogger.Error().Err(err).Msg("failed to initialize power scheduler") + } +} + +// rebuildPowerScheduler tears down the existing scheduler and re-registers a job +// for every enabled schedule. Disabled schedules stay in the config but get no +// job, which is how "temporarily disable" is implemented. +func rebuildPowerScheduler() error { + if powerScheduler != nil { + if err := powerScheduler.Shutdown(); err != nil { + powerSchedLogger.Warn().Err(err).Msg("failed to shut down previous power scheduler") + } + powerScheduler = nil + } + + s, err := gocron.NewScheduler() + if err != nil { + return fmt.Errorf("failed to create power scheduler: %w", err) + } + powerScheduler = s + + scheduled := 0 + for i := range config.PowerSchedules { + schedule := config.PowerSchedules[i] + if !schedule.Enabled { + continue + } + + tab := schedule.CronTab() + _, err := s.NewJob( + gocron.CronJob(tab, true), + gocron.NewTask(func() { runPowerSchedule(schedule) }), + ) + if err != nil { + // One bad schedule shouldn't prevent the others from running. + powerSchedLogger.Error().Err(err). + Str("id", schedule.ID). + Str("name", schedule.Name). + Str("crontab", tab). + Msg("failed to schedule power action") + continue + } + scheduled++ + powerSchedLogger.Info(). + Str("id", schedule.ID). + Str("name", schedule.Name). + Str("crontab", tab). + Msg("power schedule registered") + } + + s.Start() + powerSchedLogger.Info().Int("scheduled", scheduled).Int("total", len(config.PowerSchedules)).Msg("power scheduler started") + return nil +} + +// runPowerSchedule executes a single schedule's action against the host. +func runPowerSchedule(s powersched.Schedule) { + l := powerSchedLogger.With(). + Str("id", s.ID). + Str("name", s.Name). + Str("method", s.Method). + Str("action", s.Action). + Logger() + + // Cron jobs fire off the system clock; if NTP hasn't succeeded the device + // clock may be far from the user's intended wall time. + if timeSync != nil && !timeSync.IsSyncSuccess() { + l.Warn().Msg("running power schedule while system time is not synced; firing time may be inaccurate") + } + + var err error + switch s.Method { + case powersched.MethodWOL: + err = runPowerScheduleWOL(s) + case powersched.MethodATX: + err = runPowerScheduleATX(s, &l) + default: + err = fmt.Errorf("unknown method: %s", s.Method) + } + + if err != nil { + l.Error().Err(err).Msg("power schedule failed") + return + } + l.Info().Msg("power schedule executed") +} + +func runPowerScheduleWOL(s powersched.Schedule) error { + return rpcSendWOLMagicPacket(s.MacAddress, s.BroadcastIP) +} + +func runPowerScheduleATX(s powersched.Schedule, l *zerolog.Logger) error { + if config.ActiveExtension != "atx-power" { + return fmt.Errorf("ATX power extension is not active") + } + + // The power LED tells us the current state, so we can avoid pressing the + // button when the host is already in the requested state - a stray press + // would otherwise toggle a running machine off. + powered := ledPWRState.Load() + switch s.Action { + case powersched.ActionOn: + if powered { + l.Info().Msg("host is already powered on, skipping") + return nil + } + return pressATXPowerButton(200 * time.Millisecond) + case powersched.ActionOff: + if !powered { + l.Info().Msg("host is already powered off, skipping") + return nil + } + // Short press requests a graceful ACPI shutdown. + return pressATXPowerButton(200 * time.Millisecond) + case powersched.ActionOffForce: + if !powered { + l.Info().Msg("host is already powered off, skipping") + return nil + } + // Long press cuts power regardless of OS state. + return pressATXPowerButton(5 * time.Second) + default: + return fmt.Errorf("unknown action: %s", s.Action) + } +} + +func rpcGetPowerSchedules() ([]powersched.Schedule, error) { + if config.PowerSchedules == nil { + return []powersched.Schedule{}, nil + } + return config.PowerSchedules, nil +} + +type SetPowerSchedulesParams struct { + Schedules []powersched.Schedule `json:"schedules"` +} + +func rpcSetPowerSchedules(params SetPowerSchedulesParams) error { + schedules := params.Schedules + if schedules == nil { + schedules = []powersched.Schedule{} + } + + if len(schedules) > powersched.MaxSchedules { + return fmt.Errorf("too many schedules (max %d)", powersched.MaxSchedules) + } + + ids := make(map[string]bool, len(schedules)) + for i := range schedules { + if err := schedules[i].Validate(); err != nil { + return fmt.Errorf("invalid schedule %q: %w", schedules[i].Name, err) + } + if schedules[i].ID == "" { + return fmt.Errorf("schedule %q is missing an id", schedules[i].Name) + } + if ids[schedules[i].ID] { + return fmt.Errorf("duplicate schedule id: %s", schedules[i].ID) + } + ids[schedules[i].ID] = true + } + + config.PowerSchedules = schedules + + if err := rebuildPowerScheduler(); err != nil { + return fmt.Errorf("failed to apply power schedules: %w", err) + } + + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 63be6e8d9..d37111ff4 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -866,6 +866,56 @@ "rename_device_no_name": "Please specify a name", "retry": "Retry", "saving": "Saving…", + "scheduler_action_atx_description": "A graceful power off requests an OS shutdown; forcing cuts power immediately", + "scheduler_action_label": "Action", + "scheduler_action_off": "Power off (graceful)", + "scheduler_action_off_force": "Force power off", + "scheduler_action_on": "Power on", + "scheduler_action_wol_description": "A magic packet can only power a host on", + "scheduler_add": "Add Schedule", + "scheduler_aria_delete": "Delete schedule {name}", + "scheduler_aria_edit": "Edit schedule {name}", + "scheduler_aria_toggle": "Enable or disable schedule {name}", + "scheduler_badge_atx_inactive": "ATX extension inactive", + "scheduler_badge_disabled": "Disabled", + "scheduler_confirm_delete_description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.", + "scheduler_confirm_delete_title": "Delete Schedule", + "scheduler_created_success": "Schedule \"{name}\" created successfully", + "scheduler_days_label": "Days", + "scheduler_deleted_success": "Schedule \"{name}\" deleted successfully", + "scheduler_description": "Automatically turn the connected host on or off at set times", + "scheduler_device_custom": "Custom MAC address…", + "scheduler_device_description": "Pick a saved Wake-on-LAN device or enter a MAC address", + "scheduler_device_label": "Target Device", + "scheduler_disabled_success": "Schedule \"{name}\" disabled", + "scheduler_edit": "Edit", + "scheduler_empty_description": "Power the connected host on or off automatically on a weekly schedule", + "scheduler_empty_headline": "Create Your First Schedule", + "scheduler_enabled_success": "Schedule \"{name}\" enabled", + "scheduler_failed_save": "Failed to save schedules: {error}", + "scheduler_form_title": "Schedule Details", + "scheduler_loading": "Loading schedules…", + "scheduler_mac_label": "MAC Address", + "scheduler_max_reached": "Maximum Schedules Reached", + "scheduler_method_atx": "ATX Power Extension", + "scheduler_method_atx_unavailable": "Enable the ATX power extension to use this method", + "scheduler_method_label": "Method", + "scheduler_method_wol": "Wake-on-LAN", + "scheduler_name_label": "Name", + "scheduler_name_placeholder": "Morning wake", + "scheduler_next_run_today": "Next run today at {time}", + "scheduler_next_run_tomorrow": "Next run tomorrow at {time}", + "scheduler_next_run_weekday": "Next run {weekday} at {time}", + "scheduler_preset_daily": "Every day", + "scheduler_preset_weekdays": "Mon–Fri", + "scheduler_preset_weekend": "Sat, Sun", + "scheduler_save": "Save Schedule", + "scheduler_summary_at": "at", + "scheduler_time_label": "Time", + "scheduler_timezone_description": "The schedule runs at this local time", + "scheduler_timezone_label": "Timezone", + "scheduler_title": "Power Scheduler", + "scheduler_updated_success": "Schedule \"{name}\" updated successfully", "search_placeholder": "Search…", "serial_console": "Serial Console", "serial_console_add_button": "Add Button", @@ -922,6 +972,7 @@ "settings_mouse": "Mouse", "settings_mqtt": "MQTT", "settings_network": "Network", + "settings_scheduler": "Scheduler", "settings_video": "Video", "something_went_wrong": "Something went wrong. Please try again later or contact support", "step_counter_step": "Step {step}", diff --git a/ui/src/components/PowerScheduleForm.tsx b/ui/src/components/PowerScheduleForm.tsx new file mode 100644 index 000000000..e1d7442ae --- /dev/null +++ b/ui/src/components/PowerScheduleForm.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from "react"; + +import { cx } from "@/cva.config"; +import { Button } from "@components/Button"; +import { InputFieldWithLabel } from "@components/InputField"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import { m } from "@localizations/messages.js"; + +export type PowerScheduleMethod = "wol" | "atx"; +export type PowerScheduleAction = "on" | "off" | "off-force"; + +export interface PowerSchedule { + id: string; + name: string; + enabled: boolean; + method: PowerScheduleMethod; + action: PowerScheduleAction; + /** 0 = Sunday .. 6 = Saturday, matching the Go backend. */ + weekdays: number[]; + hour: number; + minute: number; + timezone: string; + macAddress?: string; + broadcastIP?: string; +} + +export interface WakeOnLanDevice { + name: string; + macAddress: string; + broadcastIP?: string; +} + +export const MAX_POWER_SCHEDULES = 25; + +const MAC_PATTERN = "^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"; +const MAC_REGEX = new RegExp(MAC_PATTERN); + +/** Weekday values in Monday-first display order. */ +export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0]; +export const WEEKDAY_PRESETS = { + weekdays: [1, 2, 3, 4, 5], + weekend: [0, 6], + daily: [0, 1, 2, 3, 4, 5, 6], +}; + +export function generateScheduleId() { + return Math.random().toString(36).substring(2, 9); +} + +export function browserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +export function actionsForMethod(method: PowerScheduleMethod): PowerScheduleAction[] { + // A magic packet can only ever turn a host on. + return method === "wol" ? ["on"] : ["on", "off", "off-force"]; +} + +export function actionLabel(action: PowerScheduleAction) { + switch (action) { + case "on": + return m.scheduler_action_on(); + case "off": + return m.scheduler_action_off(); + case "off-force": + return m.scheduler_action_off_force(); + } +} + +export function methodLabel(method: PowerScheduleMethod) { + return method === "wol" ? m.scheduler_method_wol() : m.scheduler_method_atx(); +} + +export function shortWeekdayName(weekday: number) { + // 2024-01-07 was a Sunday, so adding the weekday index lands on the right day. + const date = new Date(Date.UTC(2024, 0, 7 + weekday)); + return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(date); +} + +export function formatTime(hour: number, minute: number) { + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + +/** Renders the weekday set as "Every day", "Mon–Fri", "Sat, Sun" etc. */ +export function formatWeekdays(weekdays: number[]) { + const sorted = [...weekdays].sort((a, b) => a - b); + const key = sorted.join(","); + if (key === WEEKDAY_PRESETS.daily.join(",")) return m.scheduler_preset_daily(); + if (key === WEEKDAY_PRESETS.weekdays.join(",")) return m.scheduler_preset_weekdays(); + if (key === [...WEEKDAY_PRESETS.weekend].sort((a, b) => a - b).join(",")) + return m.scheduler_preset_weekend(); + return WEEKDAY_ORDER.filter(d => weekdays.includes(d)) + .map(shortWeekdayName) + .join(", "); +} + +/** + * Describes the next firing as a human label ("Today at 08:00", "Mon at 08:00"). + * + * We deliberately compare wall-clock fields inside the schedule's own timezone + * instead of converting to absolute time: it sidesteps DST edge cases and the + * label only ever needs a day-relative description anyway. + */ +export function describeNextRun(schedule: PowerSchedule): string | null { + if (schedule.weekdays.length === 0) return null; + + let parts: Intl.DateTimeFormatPart[]; + try { + parts = new Intl.DateTimeFormat("en-US", { + timeZone: schedule.timezone || "UTC", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(new Date()); + } catch { + return null; + } + + const lookup = (type: string) => parts.find(p => p.type === type)?.value ?? ""; + const weekdayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const nowWeekday = weekdayNames.indexOf(lookup("weekday")); + if (nowWeekday < 0) return null; + + // "24" shows up at midnight in some hour12:false implementations. + const nowHour = Number(lookup("hour")) % 24; + const nowMinute = Number(lookup("minute")); + + const nowMinutes = nowHour * 60 + nowMinute; + const targetMinutes = schedule.hour * 60 + schedule.minute; + const time = formatTime(schedule.hour, schedule.minute); + + for (let offset = 0; offset <= 7; offset++) { + const weekday = (nowWeekday + offset) % 7; + if (!schedule.weekdays.includes(weekday)) continue; + if (offset === 0 && targetMinutes <= nowMinutes) continue; + if (offset === 0) return m.scheduler_next_run_today({ time }); + if (offset === 1) return m.scheduler_next_run_tomorrow({ time }); + return m.scheduler_next_run_weekday({ weekday: shortWeekdayName(weekday), time }); + } + return null; +} + +export function emptySchedule(timezone: string): PowerSchedule { + return { + id: generateScheduleId(), + name: "", + enabled: true, + method: "wol", + action: "on", + weekdays: [...WEEKDAY_PRESETS.weekdays], + hour: 8, + minute: 0, + timezone, + macAddress: "", + }; +} + +interface PowerScheduleFormProps { + schedule: PowerSchedule; + timezones: string[]; + wolDevices: WakeOnLanDevice[]; + atxAvailable: boolean; + isSaving: boolean; + onSave: (schedule: PowerSchedule) => void; + onCancel: () => void; +} + +export default function PowerScheduleForm({ + schedule: initialSchedule, + timezones, + wolDevices, + atxAvailable, + isSaving, + onSave, + onCancel, +}: PowerScheduleFormProps) { + // A new schedule starts on the first saved Wake-on-LAN device when there is + // one, so the select and the schedule never disagree about what's chosen. + const [schedule, setSchedule] = useState(() => + !initialSchedule.macAddress && wolDevices.length > 0 + ? { + ...initialSchedule, + macAddress: wolDevices[0].macAddress, + broadcastIP: wolDevices[0].broadcastIP, + } + : initialSchedule, + ); + const [macMode, setMacMode] = useState(() => { + if (!initialSchedule.macAddress) { + return wolDevices.length > 0 ? wolDevices[0].macAddress : "custom"; + } + return wolDevices.some(d => d.macAddress === initialSchedule.macAddress) + ? initialSchedule.macAddress + : "custom"; + }); + + const update = (patch: Partial) => setSchedule(prev => ({ ...prev, ...patch })); + + // Keep the action legal whenever the method changes. + useEffect(() => { + const allowed = actionsForMethod(schedule.method); + if (!allowed.includes(schedule.action)) { + setSchedule(prev => ({ ...prev, action: allowed[0] })); + } + }, [schedule.method, schedule.action]); + + const timezoneOptions = useMemo( + () => timezones.map(tz => ({ value: tz, label: tz })), + [timezones], + ); + + const macOptions = useMemo( + () => [ + ...wolDevices.map(d => ({ value: d.macAddress, label: `${d.name} (${d.macAddress})` })), + { value: "custom", label: m.scheduler_device_custom() }, + ], + [wolDevices], + ); + + const toggleWeekday = (weekday: number) => { + setSchedule(prev => ({ + ...prev, + weekdays: prev.weekdays.includes(weekday) + ? prev.weekdays.filter(d => d !== weekday) + : [...prev.weekdays, weekday].sort((a, b) => a - b), + })); + }; + + const macIsValid = schedule.method !== "wol" || MAC_REGEX.test(schedule.macAddress ?? ""); + const canSave = + schedule.name.trim().length > 0 && schedule.weekdays.length > 0 && macIsValid && !isSaving; + + const handleMacModeChange = (value: string) => { + setMacMode(value); + if (value === "custom") { + update({ macAddress: "", broadcastIP: undefined }); + return; + } + const device = wolDevices.find(d => d.macAddress === value); + update({ macAddress: value, broadcastIP: device?.broadcastIP }); + }; + + return ( +
+

+ {m.scheduler_form_title()} +

+ +
+ e.stopPropagation()} + onChange={e => update({ name: e.target.value })} + /> + + update({ method: e.target.value as PowerScheduleMethod })} + options={[ + { value: "wol", label: m.scheduler_method_wol() }, + { value: "atx", label: m.scheduler_method_atx(), disabled: !atxAvailable }, + ]} + /> + + update({ action: e.target.value as PowerScheduleAction })} + options={actionsForMethod(schedule.method).map(a => ({ + value: a, + label: actionLabel(a), + }))} + /> + + {schedule.method === "wol" && ( + handleMacModeChange(e.target.value)} + options={macOptions} + /> + )} + + {schedule.method === "wol" && macMode === "custom" && ( + e.stopPropagation()} + onChange={e => update({ macAddress: e.target.value })} + /> + )} + + e.stopPropagation()} + onChange={e => { + const [hour, minute] = e.target.value.split(":").map(Number); + if (Number.isFinite(hour) && Number.isFinite(minute)) update({ hour, minute }); + }} + /> + + update({ timezone: e.target.value })} + options={timezoneOptions} + /> +
+ +
+
+ {m.scheduler_days_label()} +
+
+ {WEEKDAY_ORDER.map(weekday => { + const active = schedule.weekdays.includes(weekday); + return ( + + ); + })} +
+
+
+
+ +
+
+
+ ); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 5427412c1..51bc18ca3 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -57,6 +57,7 @@ const SecurityAccessLocalAuthRoute = lazy( () => import("@routes/devices.$id.settings.access.local-auth"), ); const SettingsMqttRoute = lazy(() => import("@routes/devices.$id.settings.mqtt")); +const SettingsSchedulerRoute = lazy(() => import("@routes/devices.$id.settings.scheduler")); const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros")); const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add")); const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit")); @@ -193,6 +194,10 @@ const getDeviceRoute = (r: Omit): RouteObject path: "appearance", element: , }, + { + path: "scheduler", + element: , + }, { path: "macros", children: [ diff --git a/ui/src/routes/devices.$id.settings.scheduler.tsx b/ui/src/routes/devices.$id.settings.scheduler.tsx new file mode 100644 index 000000000..aba6aad20 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.scheduler.tsx @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { LuCalendarClock, LuPenLine, LuTrash2 } from "react-icons/lu"; + +import { cx } from "@/cva.config"; +import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { Button } from "@components/Button"; +import Card from "@components/Card"; +import { Checkbox } from "@components/Checkbox"; +import { ConfirmDialog } from "@components/ConfirmDialog"; +import EmptyCard from "@components/EmptyCard"; +import LoadingSpinner from "@components/LoadingSpinner"; +import PowerScheduleForm, { + MAX_POWER_SCHEDULES, + PowerSchedule, + WakeOnLanDevice, + actionLabel, + browserTimezone, + describeNextRun, + emptySchedule, + formatTime, + formatWeekdays, + methodLabel, +} from "@components/PowerScheduleForm"; +import notifications from "@/notifications"; +import { m } from "@localizations/messages.js"; + +export default function SettingsSchedulerRoute() { + const { send } = useJsonRpc(); + + const [schedules, setSchedules] = useState([]); + const [timezones, setTimezones] = useState([]); + const [wolDevices, setWolDevices] = useState([]); + const [activeExtension, setActiveExtension] = useState(""); + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [editing, setEditing] = useState(null); + const [scheduleToDelete, setScheduleToDelete] = useState(null); + + const atxAvailable = activeExtension === "atx-power"; + const isMaxReached = schedules.length >= MAX_POWER_SCHEDULES; + + useEffect(() => { + let settled = 0; + const settle = () => { + settled++; + if (settled >= 4) setLoading(false); + }; + + send("getPowerSchedules", {}, (resp: JsonRpcResponse) => { + if ("result" in resp) setSchedules((resp.result as PowerSchedule[]) ?? []); + settle(); + }); + send("getTimezones", {}, (resp: JsonRpcResponse) => { + if ("result" in resp) setTimezones(resp.result as string[]); + settle(); + }); + send("getWakeOnLanDevices", {}, (resp: JsonRpcResponse) => { + if ("result" in resp) setWolDevices((resp.result as WakeOnLanDevice[]) ?? []); + settle(); + }); + send("getActiveExtension", {}, (resp: JsonRpcResponse) => { + if ("result" in resp) setActiveExtension(resp.result as string); + settle(); + }); + }, [send]); + + /** + * The backend owns the whole list, so every mutation sends the full array. + * We only update local state once the device has accepted it. + */ + const persist = useCallback( + (next: PowerSchedule[], successMessage: string) => { + setIsSaving(true); + send("setPowerSchedules", { params: { schedules: next } }, (resp: JsonRpcResponse) => { + setIsSaving(false); + if ("error" in resp) { + notifications.error( + m.scheduler_failed_save({ + error: resp.error.data || resp.error.message || m.unknown_error(), + }), + ); + return; + } + setSchedules(next); + setEditing(null); + setScheduleToDelete(null); + notifications.success(successMessage); + }); + }, + [send], + ); + + const handleSave = useCallback( + (schedule: PowerSchedule) => { + const exists = schedules.some(s => s.id === schedule.id); + const next = exists + ? schedules.map(s => (s.id === schedule.id ? schedule : s)) + : [...schedules, schedule]; + persist( + next, + exists + ? m.scheduler_updated_success({ name: schedule.name }) + : m.scheduler_created_success({ name: schedule.name }), + ); + }, + [schedules, persist], + ); + + const handleToggleEnabled = useCallback( + (schedule: PowerSchedule) => { + const next = schedules.map(s => (s.id === schedule.id ? { ...s, enabled: !s.enabled } : s)); + persist( + next, + schedule.enabled + ? m.scheduler_disabled_success({ name: schedule.name }) + : m.scheduler_enabled_success({ name: schedule.name }), + ); + }, + [schedules, persist], + ); + + const handleDelete = useCallback(() => { + if (!scheduleToDelete) return; + persist( + schedules.filter(s => s.id !== scheduleToDelete.id), + m.scheduler_deleted_success({ name: scheduleToDelete.name }), + ); + }, [scheduleToDelete, schedules, persist]); + + const defaultTimezone = useMemo(() => { + const tz = browserTimezone(); + return timezones.length === 0 || timezones.includes(tz) ? tz : "UTC"; + }, [timezones]); + + const scheduleList = ( +
+ {schedules.map(schedule => { + const nextRun = schedule.enabled ? describeNextRun(schedule) : null; + // An ATX schedule is inert while the extension is switched off. + const stale = schedule.method === "atx" && !atxAvailable; + + return ( + +
+
+ handleToggleEnabled(schedule)} + aria-label={m.scheduler_aria_toggle({ name: schedule.name })} + /> +
+

+ {schedule.name} +

+

+ {formatWeekdays(schedule.weekdays)} {m.scheduler_summary_at()}{" "} + {formatTime(schedule.hour, schedule.minute)} · {schedule.timezone} +

+

+ {methodLabel(schedule.method)} → {actionLabel(schedule.action)} + {schedule.method === "wol" && schedule.macAddress + ? ` · ${schedule.macAddress}` + : ""} +

+ {nextRun && ( +

{nextRun}

+ )} + {!schedule.enabled && ( + + {m.scheduler_badge_disabled()} + + )} + {stale && schedule.enabled && ( + + {m.scheduler_badge_atx_inactive()} + + )} +
+
+ +
+
+
+
+ ); + })} +
+ ); + + return ( +
+
+ + {schedules.length > 0 && !editing && ( +
+
+ )} +
+ + {loading ? ( + + +
+ } + /> + ) : ( +
+ {schedules.length > 0 && scheduleList} + + {editing ? ( + setEditing(null)} + /> + ) : ( + schedules.length === 0 && ( + setEditing(emptySchedule(defaultTimezone))} + /> + } + /> + ) + )} +
+ )} + + setScheduleToDelete(null)} + title={m.scheduler_confirm_delete_title()} + description={m.scheduler_confirm_delete_description({ + name: scheduleToDelete?.name || "", + })} + variant="danger" + confirmText={m.delete()} + onConfirm={handleDelete} + isConfirming={isSaving} + /> + + ); +} diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index c8b05f0bf..2fe7f106d 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -15,6 +15,7 @@ import { LuCommand, LuNetwork, LuRadio, + LuCalendarClock, } from "react-icons/lu"; import { cx } from "@/cva.config"; @@ -226,6 +227,14 @@ export default function SettingsRoute() { +
+ (isActive ? "active" : "")}> +
+ +

{m.settings_scheduler()}

+
+
+
(isActive ? "active" : "")}>