From 43b5046e62351ba0873aef8c77ac9d345ba9dc9b Mon Sep 17 00:00:00 2001 From: AdaInTheLab Date: Thu, 28 May 2026 22:36:31 -0400 Subject: [PATCH] feat(restart): host-resolvable timezone dropdown + heal-on-read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graceful-restart feature called TimeZoneInfo.FindSystemTimeZoneById with whatever string was in the persisted config. On .NET Framework 4.8 / Windows, IANA IDs like "America/Los_Angeles" don't resolve, so the 30-second tick threw, the catch logged "bad schedule config" forever, and the daily restart never fired. Observed in the wild with "ScheduledTimezone":"UTC-5" — not even an IANA ID — where nobody realized the feature was a silent no-op. The free-text UI labeled "TIMEZONE (IANA)" was actively setting users up to type in strings the host couldn't parse. Two-part fix: 1. Heal-on-read in LoadPersistedSettings. After deserializing, probe the configured timezone via FindSystemTimeZoneById. If it doesn't resolve (or is null/empty), fall back to TimeZoneInfo.Local.Id (TimeZoneInfo.Utc.Id if Local has no Id), log one INFO line about the heal, and persist the corrected JSON back to the settings row. The next boot is clean — no spam, no recurring warning, no second heal. The tick path itself is unchanged: it still gates on FindSystemTimeZoneById, so a hand-edited bad value in the DB between boots is still caught and warned about (just now without the silent-feature-failure mode). 2. New GET /api/server/timezones endpoint. Projects TimeZoneInfo.GetSystemTimeZones() to { id, displayName, baseUtcOffsetMinutes }, sorted by BaseUtcOffset then DisplayName. The IDs returned are whatever the runtime accepts back through FindSystemTimeZoneById on THIS host — Windows registry IDs on .NET Framework / Windows, IANA on .NET Core / Linux. That's the point: we hand the panel a list of strings we know will round- trip, and the panel saves the raw Id verbatim. Authorize-only, no role gate — same as /api/server/info, since the list is host metadata, not anything sensitive. 3. SettingsView.vue swaps the InputText for a PrimeVue Select bound to that endpoint with optionLabel=displayName, optionValue=id, filter enabled (the list can be 100+ entries). Editable mode is only enabled if the fetch fails so admins can still hand-type a value as an escape hatch. i18n strings updated across all eight locales (en/de/fr/es translated; ja/ko/zh-CN/zh-TW carry English placeholders pending translation — consistent with the existing pattern in this file). Tests in src/KitsuneCommand.Tests/Services/GracefulRestartFeatureTests.cs cover both paths: unresolvable timezone heals to Local and persists exactly once; resolvable timezone passes through without touching the DB so we don't churn the settings row on every boot. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 +++ frontend/src/api/server.ts | 18 +++ frontend/src/i18n/locales/de.ts | 5 +- frontend/src/i18n/locales/en.ts | 5 +- frontend/src/i18n/locales/es.ts | 5 +- frontend/src/i18n/locales/fr.ts | 5 +- frontend/src/i18n/locales/ja.ts | 5 +- frontend/src/i18n/locales/ko.ts | 5 +- frontend/src/i18n/locales/zh-CN.ts | 5 +- frontend/src/i18n/locales/zh-TW.ts | 5 +- frontend/src/views/SettingsView.vue | 35 ++++- .../Services/GracefulRestartFeatureTests.cs | 120 ++++++++++++++++++ .../Features/GracefulRestartFeature.cs | 53 ++++++++ .../Web/Controllers/ServerController.cs | 30 +++++ 14 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 src/KitsuneCommand.Tests/Services/GracefulRestartFeatureTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1581864..205b4d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,26 @@ pulls notes from — it's the minimum, the GitHub release page is the maximum. command `krestart` already uses the OS-agnostic `GracefulRestartFeature.TriggerNow` (no shell-out) so it didn't need to change. +- **Graceful restart timezone is now host-resolvable.** The + GracefulRestart feature called + `TimeZoneInfo.FindSystemTimeZoneById` with whatever string was in + the persisted config. On .NET Framework 4.8 / Windows, IANA IDs + like `America/Los_Angeles` aren't recognized, so the tick threw, + the catch block logged "bad schedule config" every 30 seconds, and + the daily restart never fired. Observed in the wild with a value + of `"UTC-5"` (not even an IANA ID) where nobody realized the + feature was a no-op. Two-part fix: (1) `LoadPersistedSettings` + now probes the configured timezone, and if it doesn't resolve, + falls back to `TimeZoneInfo.Local` (or `Utc` if Local is unset), + logs one INFO line, and persists the corrected value back to the + DB so the next boot is clean. (2) New `GET /api/server/timezones` + endpoint returns the runtime-resolvable timezone list sorted by + UTC offset; the Settings → Server Restart panel now renders a + PrimeVue `Select` populated from that endpoint instead of a + free-text input that admins would fill with strings the host + couldn't parse. Saves `id` (whatever the runtime accepts: + Windows registry IDs on .NET Framework, IANA on .NET Core), + shows `displayName` to the user. ## [2.8.1] - 2026-05-29 diff --git a/frontend/src/api/server.ts b/frontend/src/api/server.ts index 406e69c..9ddf8c7 100644 --- a/frontend/src/api/server.ts +++ b/frontend/src/api/server.ts @@ -51,3 +51,21 @@ export async function getDashboardStats(): Promise { const response = await apiClient.get('/api/dashboard/stats') return response.data.data } + +/** + * One entry from GET /api/server/timezones. The `id` is whatever string the + * runtime will accept back via TimeZoneInfo.FindSystemTimeZoneById — Windows + * registry IDs on .NET Framework / Windows ("Pacific Standard Time"), IANA + * IDs on .NET Core / Linux ("America/Los_Angeles"). Don't try to interpret + * it; just round-trip it. + */ +export interface TimezoneOption { + id: string + displayName: string + baseUtcOffsetMinutes: number +} + +export async function getTimezones(): Promise { + const response = await apiClient.get('/api/server/timezones') + return response.data.data +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index dccb17f..0833f55 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -877,8 +877,9 @@ const de = { serverRestartEnableHint: 'Wenn aus, pausieren geplante Neustarts. „Jetzt neu starten“ funktioniert weiterhin.', serverRestartTime: 'Tageszeit (HH:mm)', serverRestartTimeHint: 'Wanduhrzeit, zu der das finale Herunterfahren erfolgt. Warnungen beginnen früher gemäß der unten stehenden Stufenliste.', - serverRestartTimezone: 'Zeitzone (IANA)', - serverRestartTimezoneHint: 'z. B. America/Los_Angeles, Europe/Berlin, UTC. Sommerzeit wird automatisch berücksichtigt.', + serverRestartTimezone: 'Zeitzone', + serverRestartTimezonePlaceholder: 'Zeitzone auswählen', + serverRestartTimezoneHint: 'Die Liste stammt vom Server-Host. Sommerzeit wird automatisch berücksichtigt.', serverRestartLadder: 'Warnungs-Stufen', serverRestartLadderSubtitle: 'Jede Stufe sendet eine {minutes}-bewusste Nachricht N Minuten vor dem tatsächlichen Herunterfahren. Die 0-Minuten-Stufe ist die finale „Jetzt herunterfahren“-Zeile.', serverRestartColMinutes: 'Min. davor', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d2d4e67..e8d8504 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -898,8 +898,9 @@ const en = { serverRestartEnableHint: 'When off, scheduled restarts pause. Manual "Restart Now" still works.', serverRestartTime: 'Time of day (HH:mm)', serverRestartTimeHint: 'Wall-clock time the final shutdown fires. Warnings start earlier per the ladder below.', - serverRestartTimezone: 'Timezone (IANA)', - serverRestartTimezoneHint: 'e.g. America/Los_Angeles, Europe/Berlin, UTC. DST shifts handled automatically.', + serverRestartTimezone: 'Timezone', + serverRestartTimezonePlaceholder: 'Select a timezone', + serverRestartTimezoneHint: 'List comes from the server host. DST shifts are handled automatically.', serverRestartLadder: 'Warning ladder', serverRestartLadderSubtitle: 'Each step broadcasts a {minutes}-aware message N minutes before the actual shutdown. The 0-minute step is the final "going down now" line.', serverRestartColMinutes: 'Min before', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 490af83..9303146 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -877,8 +877,9 @@ const es = { serverRestartEnableHint: 'Cuando está apagado, los reinicios programados se pausan. «Reiniciar ahora» sigue funcionando.', serverRestartTime: 'Hora del día (HH:mm)', serverRestartTimeHint: 'Hora de reloj a la que se ejecuta el apagado final. Los avisos comienzan antes según la lista de abajo.', - serverRestartTimezone: 'Zona horaria (IANA)', - serverRestartTimezoneHint: 'p. ej. America/Los_Angeles, Europe/Madrid, UTC. Los cambios de horario de verano se gestionan automáticamente.', + serverRestartTimezone: 'Zona horaria', + serverRestartTimezonePlaceholder: 'Seleccione una zona horaria', + serverRestartTimezoneHint: 'La lista proviene del host del servidor. Los cambios de horario de verano se gestionan automáticamente.', serverRestartLadder: 'Lista de avisos', serverRestartLadderSubtitle: 'Cada paso difunde un mensaje consciente de {minutes}, N minutos antes del apagado real. El paso de 0 minutos es la línea final «Apagando ahora».', serverRestartColMinutes: 'Min. antes', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 3a10c56..90eae86 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -877,8 +877,9 @@ const fr = { serverRestartEnableHint: 'Désactivé, les redémarrages planifiés sont en pause. « Redémarrer maintenant » fonctionne toujours.', serverRestartTime: 'Heure de la journée (HH:mm)', serverRestartTimeHint: 'Heure de l\'horloge à laquelle l\'arrêt final se déclenche. Les avertissements commencent plus tôt selon la liste ci-dessous.', - serverRestartTimezone: 'Fuseau horaire (IANA)', - serverRestartTimezoneHint: 'p. ex. America/Los_Angeles, Europe/Paris, UTC. Les changements d\'heure sont gérés automatiquement.', + serverRestartTimezone: 'Fuseau horaire', + serverRestartTimezonePlaceholder: 'Sélectionner un fuseau horaire', + serverRestartTimezoneHint: 'La liste provient de l\'hôte du serveur. Les changements d\'heure sont gérés automatiquement.', serverRestartLadder: 'Liste d\'avertissements', serverRestartLadderSubtitle: 'Chaque étape diffuse un message conscient de {minutes}, N minutes avant l\'arrêt réel. L\'étape 0 minute est la dernière ligne « Arrêt en cours ».', serverRestartColMinutes: 'Min. avant', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 7f5911d..0d485d2 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -901,8 +901,9 @@ const ja: Messages = { serverRestartEnableHint: 'When off, scheduled restarts pause. Manual "Restart Now" still works.', serverRestartTime: 'Time of day (HH:mm)', serverRestartTimeHint: 'Wall-clock time the final shutdown fires. Warnings start earlier per the ladder below.', - serverRestartTimezone: 'Timezone (IANA)', - serverRestartTimezoneHint: 'e.g. America/Los_Angeles, Europe/Berlin, UTC. DST shifts handled automatically.', + serverRestartTimezone: 'Timezone', + serverRestartTimezonePlaceholder: 'Select a timezone', + serverRestartTimezoneHint: 'List comes from the server host. DST shifts are handled automatically.', serverRestartLadder: 'Warning ladder', serverRestartLadderSubtitle: 'Each step broadcasts a {minutes}-aware message N minutes before the actual shutdown. The 0-minute step is the final "going down now" line.', serverRestartColMinutes: 'Min before', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index b597715..a8cee31 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -901,8 +901,9 @@ const ko: Messages = { serverRestartEnableHint: 'When off, scheduled restarts pause. Manual "Restart Now" still works.', serverRestartTime: 'Time of day (HH:mm)', serverRestartTimeHint: 'Wall-clock time the final shutdown fires. Warnings start earlier per the ladder below.', - serverRestartTimezone: 'Timezone (IANA)', - serverRestartTimezoneHint: 'e.g. America/Los_Angeles, Europe/Berlin, UTC. DST shifts handled automatically.', + serverRestartTimezone: 'Timezone', + serverRestartTimezonePlaceholder: 'Select a timezone', + serverRestartTimezoneHint: 'List comes from the server host. DST shifts are handled automatically.', serverRestartLadder: 'Warning ladder', serverRestartLadderSubtitle: 'Each step broadcasts a {minutes}-aware message N minutes before the actual shutdown. The 0-minute step is the final "going down now" line.', serverRestartColMinutes: 'Min before', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ccf476b..c7a2247 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -901,8 +901,9 @@ const zhCN: Messages = { serverRestartEnableHint: 'When off, scheduled restarts pause. Manual "Restart Now" still works.', serverRestartTime: 'Time of day (HH:mm)', serverRestartTimeHint: 'Wall-clock time the final shutdown fires. Warnings start earlier per the ladder below.', - serverRestartTimezone: 'Timezone (IANA)', - serverRestartTimezoneHint: 'e.g. America/Los_Angeles, Europe/Berlin, UTC. DST shifts handled automatically.', + serverRestartTimezone: 'Timezone', + serverRestartTimezonePlaceholder: 'Select a timezone', + serverRestartTimezoneHint: 'List comes from the server host. DST shifts are handled automatically.', serverRestartLadder: 'Warning ladder', serverRestartLadderSubtitle: 'Each step broadcasts a {minutes}-aware message N minutes before the actual shutdown. The 0-minute step is the final "going down now" line.', serverRestartColMinutes: 'Min before', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 06953f0..1201535 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -901,8 +901,9 @@ const zhTW: Messages = { serverRestartEnableHint: 'When off, scheduled restarts pause. Manual "Restart Now" still works.', serverRestartTime: 'Time of day (HH:mm)', serverRestartTimeHint: 'Wall-clock time the final shutdown fires. Warnings start earlier per the ladder below.', - serverRestartTimezone: 'Timezone (IANA)', - serverRestartTimezoneHint: 'e.g. America/Los_Angeles, Europe/Berlin, UTC. DST shifts handled automatically.', + serverRestartTimezone: 'Timezone', + serverRestartTimezonePlaceholder: 'Select a timezone', + serverRestartTimezoneHint: 'List comes from the server host. DST shifts are handled automatically.', serverRestartLadder: 'Warning ladder', serverRestartLadderSubtitle: 'Each step broadcasts a {minutes}-aware message N minutes before the actual shutdown. The 0-minute step is the final "going down now" line.', serverRestartColMinutes: 'Min before', diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index d1bc001..8d7d7bf 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -13,6 +13,7 @@ import { getVoteSettings, updateVoteSettings } from '@/api/bloodmoonvote' import { getVoteRewardsSettings, updateVoteRewardsSettings, getVoteGrants } from '@/api/voterewards' import { getVipPerksSettings, updateVipPerksSettings } from '@/api/vipperks' import { getRestartSettings, updateRestartSettings, triggerRestartNow } from '@/api/restart' +import { getTimezones, type TimezoneOption } from '@/api/server' import { getTicketSettings, updateTicketSettings } from '@/api/tickets' import { getDiscordSettings, updateDiscordSettings, getDiscordStatus, testDiscordConnection } from '@/api/discord' import { restartServer } from '@/api/serverControl' @@ -516,6 +517,27 @@ const savingRestart = ref(false) const triggeringRestart = ref(false) const restartLeadMinutes = ref(10) +// Timezones the backend host can actually resolve. We fetch from the server +// rather than shipping a static IANA list because on .NET Framework / Windows +// installs the runtime returns Windows registry IDs ("Pacific Standard Time"), +// not IANA. The dropdown shows the friendly DisplayName but saves the raw Id. +const availableTimezones = ref([]) +const loadingTimezones = ref(false) + +async function fetchTimezones() { + loadingTimezones.value = true + try { + availableTimezones.value = await getTimezones() + } catch { + // Non-fatal — the dropdown just stays empty and the existing value still + // shows as the selected label. Don't toast: the restart tab itself toasts + // for the main settings load failure and we don't want to double-noise. + availableTimezones.value = [] + } finally { + loadingTimezones.value = false + } +} + async function fetchRestartSettings() { loadingRestart.value = true try { @@ -802,6 +824,7 @@ onMounted(() => { fetchVoteGrants() fetchVipPerksSettings() fetchRestartSettings() + fetchTimezones() } }) @@ -1711,7 +1734,17 @@ onMounted(() => {
- +