Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions frontend/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,21 @@ export async function getDashboardStats(): Promise<DashboardStats> {
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<TimezoneOption[]> {
const response = await apiClient.get('/api/server/timezones')
return response.data.data
}
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<TimezoneOption[]>([])
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 {
Expand Down Expand Up @@ -802,6 +824,7 @@ onMounted(() => {
fetchVoteGrants()
fetchVipPerksSettings()
fetchRestartSettings()
fetchTimezones()
}
})
</script>
Expand Down Expand Up @@ -1711,7 +1734,17 @@ onMounted(() => {

<div class="form-group">
<label class="form-label">{{ t('settings.serverRestartTimezone') }}</label>
<InputText v-model="restartSettings.scheduledTimezone" class="form-input" placeholder="America/Los_Angeles" />
<Select
v-model="restartSettings.scheduledTimezone"
:options="availableTimezones"
optionLabel="displayName"
optionValue="id"
:loading="loadingTimezones"
:filter="true"
:placeholder="t('settings.serverRestartTimezonePlaceholder')"
class="form-input"
:editable="availableTimezones.length === 0"
/>
<small class="settings-hint">{{ t('settings.serverRestartTimezoneHint') }}</small>
</div>
</div>
Expand Down
120 changes: 120 additions & 0 deletions src/KitsuneCommand.Tests/Services/GracefulRestartFeatureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.IO;
using Moq;
using Newtonsoft.Json;
using NUnit.Framework;
using KitsuneCommand.Configuration;
using KitsuneCommand.Core;
using KitsuneCommand.Data.Repositories;
using KitsuneCommand.Features;

namespace KitsuneCommand.Tests.Services
{
/// <summary>
/// Unit tests for <see cref="GracefulRestartFeature"/> — focused on the
/// "heal-on-read" path that recovers from a persisted timezone the host
/// runtime can't resolve. The bug being guarded against: .NET Framework
/// 4.8 / Windows can't resolve IANA IDs like "America/Los_Angeles", so
/// the scheduled tick used to throw and log "bad schedule config" every
/// 30s without ever firing the restart.
/// </summary>
[TestFixture]
public class GracefulRestartFeatureTests
{
private Mock<ISettingsRepository> _mockSettingsRepo;
private ModEventBus _bus;
private ConfigManager _config;

[SetUp]
public void SetUp()
{
_mockSettingsRepo = new Mock<ISettingsRepository>();
_bus = new ModEventBus();
_config = new ConfigManager(new AppSettings());
}

/// <summary>
/// When persisted settings carry a timezone string the host can't
/// resolve (the literal "UTC-5" observed on a live install), Start()
/// should heal it to TimeZoneInfo.Local.Id and persist the corrected
/// value back to the DB so the next boot is clean.
/// </summary>
[Test]
public void Start_WithUnresolvableTimezone_HealsToLocalAndPersists()
{
var stored = new GracefulRestartSettings
{
Enabled = false,
ScheduledTime = "04:00",
ScheduledTimezone = "UTC-5", // not a valid IANA id, not a Windows id
};
_mockSettingsRepo
.Setup(r => r.Get("GracefulRestart"))
.Returns(JsonConvert.SerializeObject(stored));

string persistedJson = null;
_mockSettingsRepo
.Setup(r => r.Set("GracefulRestart", It.IsAny<string>()))
.Callback<string, string>((_, v) => persistedJson = v);

var feature = new GracefulRestartFeature(_bus, _config, _mockSettingsRepo.Object);
try
{
feature.Start();

// Healed to whatever the runtime considers Local — verify by
// round-tripping through FindSystemTimeZoneById, which is the
// exact gate the schedule tick uses.
Assert.That(feature.Settings.ScheduledTimezone, Is.Not.EqualTo("UTC-5"));
Assert.DoesNotThrow(() =>
System.TimeZoneInfo.FindSystemTimeZoneById(feature.Settings.ScheduledTimezone));

// The healed value should have been persisted exactly once so
// operators don't see the warning again on the next boot.
_mockSettingsRepo.Verify(r => r.Set("GracefulRestart", It.IsAny<string>()), Times.Once);
Assert.That(persistedJson, Is.Not.Null);
var roundTripped = JsonConvert.DeserializeObject<GracefulRestartSettings>(persistedJson);
Assert.That(roundTripped.ScheduledTimezone, Is.EqualTo(feature.Settings.ScheduledTimezone));
}
finally
{
feature.Stop();
}
}

/// <summary>
/// When the persisted timezone already resolves, Start() must NOT
/// rewrite the DB. We don't want every boot to churn the settings row.
/// </summary>
[Test]
public void Start_WithResolvableTimezone_DoesNotPersist()
{
// Pick whatever Local is — guaranteed to round-trip on this host.
var localId = System.TimeZoneInfo.Local?.Id ?? System.TimeZoneInfo.Utc.Id;

var stored = new GracefulRestartSettings
{
Enabled = false,
ScheduledTime = "04:00",
ScheduledTimezone = localId,
};
_mockSettingsRepo
.Setup(r => r.Get("GracefulRestart"))
.Returns(JsonConvert.SerializeObject(stored));

var feature = new GracefulRestartFeature(_bus, _config, _mockSettingsRepo.Object);
try
{
feature.Start();

Assert.That(feature.Settings.ScheduledTimezone, Is.EqualTo(localId));
_mockSettingsRepo.Verify(
r => r.Set(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
finally
{
feature.Stop();
}
}
}
}
Loading
Loading