From 9d01eb531169be89f8158195f4f6c6538e14c54d Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Wed, 29 Jul 2026 20:06:52 +0200 Subject: [PATCH] feat(server): adds temporary disabling functionality --- src/Ngino.Server/AdminEndpoints.cs | 13 +- src/Ngino.Server/ManagementStore.cs | 64 ++++++--- src/Ngino.Server/ReverseProxyEndpoint.cs | 12 +- src/Ngino.Server/wwwroot/admin/app.css | 65 ++++++++++ src/Ngino.Server/wwwroot/admin/app.js | 158 +++++++++++++++++++++-- 5 files changed, 283 insertions(+), 29 deletions(-) diff --git a/src/Ngino.Server/AdminEndpoints.cs b/src/Ngino.Server/AdminEndpoints.cs index 172037b..0f91ba1 100644 --- a/src/Ngino.Server/AdminEndpoints.cs +++ b/src/Ngino.Server/AdminEndpoints.cs @@ -167,11 +167,14 @@ internal static class AdminEndpoints try { var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase); + TimeSpan? duration = manual ? null - : TimeSpan.FromMinutes(Math.Clamp(request.DurationMinutes ?? 60, 1, 24 * 60)); + : request.DurationMinutes is { } minutes + ? TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 24 * 60)) + : null; - store.DisableClient(clientId, duration, manual, request.Reason); + store.DisableClient(clientId, duration, manual, request.Reason, request.StartAtUtc, request.UntilUtc); return Results.Ok(new { clientId, disabled = true }); } catch (Exception exception) @@ -673,6 +676,7 @@ internal static class AdminEndpoints snapshot?.ActiveModels ?? [], snapshot?.ModelsUpdatedAt, access.IsDisabled, + access.DisabledFromUtc, access.DisabledUntilUtc, access.DisabledManually, access.DisabledReason, @@ -880,7 +884,9 @@ internal static class AdminEndpoints internal sealed record DisableClientRequest( string? Mode, int? DurationMinutes, - string? Reason); + string? Reason, + DateTimeOffset? StartAtUtc, + DateTimeOffset? UntilUtc); internal sealed record ModelActionRequest( string ClientId, @@ -929,6 +935,7 @@ internal sealed record ClientSummary( IReadOnlyList ActiveModels, DateTimeOffset? ModelsUpdatedAt, bool Disabled, + DateTimeOffset? DisabledFromUtc, DateTimeOffset? DisabledUntilUtc, bool DisabledManually, string? DisabledReason, diff --git a/src/Ngino.Server/ManagementStore.cs b/src/Ngino.Server/ManagementStore.cs index 54b6f02..07e251b 100644 --- a/src/Ngino.Server/ManagementStore.cs +++ b/src/Ngino.Server/ManagementStore.cs @@ -378,7 +378,7 @@ internal sealed class ManagementStore using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ - SELECT disabled_until_utc, disabled_manually, disabled_reason + SELECT disabled_until_utc, disabled_manually, disabled_reason, disabled_from_utc FROM client_controls WHERE client_id = $client_id """; @@ -393,18 +393,22 @@ internal sealed class ManagementStore var disabledUntil = ReadNullableDateTimeOffset(reader, 0); var disabledManually = reader.GetInt32(1) != 0; var reason = reader.IsDBNull(2) ? null : reader.GetString(2); + var disabledFrom = ReadNullableDateTimeOffset(reader, 3); - if (disabledManually) + var now = DateTimeOffset.UtcNow; + var isScheduled = disabledFrom > now; + + if (disabledManually && !isScheduled) { - return new ClientAccess(true, null, true, reason); + return new ClientAccess(true, disabledFrom, null, true, reason); } - if (disabledUntil is { } until && until > DateTimeOffset.UtcNow) + if (!isScheduled && disabledUntil is { } until && until > now) { - return new ClientAccess(true, until, false, reason); + return new ClientAccess(true, disabledFrom, until, false, reason); } - return ClientAccess.Enabled; + return new ClientAccess(false, disabledFrom, disabledUntil, false, reason); } } @@ -421,7 +425,7 @@ internal sealed class ManagementStore { using var connection = OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "SELECT client_id, disabled_until_utc, disabled_manually, disabled_reason FROM client_controls"; + command.CommandText = "SELECT client_id, disabled_until_utc, disabled_manually, disabled_reason, disabled_from_utc FROM client_controls"; using var reader = command.ExecuteReader(); while (reader.Read()) @@ -430,19 +434,23 @@ internal sealed class ManagementStore var disabledUntil = ReadNullableDateTimeOffset(reader, 1); var disabledManually = reader.GetInt32(2) != 0; var reason = reader.IsDBNull(3) ? null : reader.GetString(3); + var disabledFrom = ReadNullableDateTimeOffset(reader, 4); - result[clientId] = disabledManually - ? new ClientAccess(true, null, true, reason) - : disabledUntil is { } until && until > DateTimeOffset.UtcNow - ? new ClientAccess(true, until, false, reason) - : ClientAccess.Enabled; + var now = DateTimeOffset.UtcNow; + var isScheduled = disabledFrom > now; + + result[clientId] = disabledManually && !isScheduled + ? new ClientAccess(true, disabledFrom, null, true, reason) + : !isScheduled && disabledUntil is { } until && until > now + ? new ClientAccess(true, disabledFrom, until, false, reason) + : new ClientAccess(false, disabledFrom, disabledUntil, disabledManually, reason); } } return result; } - public void DisableClient(string clientId, TimeSpan? duration, bool manually, string? reason) + public void DisableClient(string clientId, TimeSpan? duration, bool manually, string? reason, DateTimeOffset? startAtUtc = null, DateTimeOffset? untilUtc = null) { EnsureAvailable(); @@ -452,7 +460,9 @@ internal sealed class ManagementStore } var now = DateTimeOffset.UtcNow; - var disabledUntil = manually ? null : now.Add(duration ?? TimeSpan.FromHours(1)).ToString("O"); + var disabledFrom = startAtUtc?.ToString("O"); + var disabledUntil = untilUtc?.ToString("O") + ?? (manually ? null : now.Add(duration ?? TimeSpan.FromHours(1)).ToString("O")); lock (_lock) { @@ -462,23 +472,27 @@ internal sealed class ManagementStore INSERT INTO client_controls ( client_id, disabled_until_utc, + disabled_from_utc, disabled_manually, disabled_reason, updated_at_utc) VALUES ( $client_id, $disabled_until_utc, + $disabled_from_utc, $disabled_manually, $disabled_reason, $updated_at_utc) ON CONFLICT(client_id) DO UPDATE SET disabled_until_utc = excluded.disabled_until_utc, + disabled_from_utc = excluded.disabled_from_utc, disabled_manually = excluded.disabled_manually, disabled_reason = excluded.disabled_reason, updated_at_utc = excluded.updated_at_utc """; command.Parameters.AddWithValue("$client_id", clientId); command.Parameters.AddWithValue("$disabled_until_utc", (object?)disabledUntil ?? DBNull.Value); + command.Parameters.AddWithValue("$disabled_from_utc", (object?)disabledFrom ?? DBNull.Value); command.Parameters.AddWithValue("$disabled_manually", manually ? 1 : 0); command.Parameters.AddWithValue("$disabled_reason", string.IsNullOrWhiteSpace(reason) ? DBNull.Value : reason.Trim()); command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O")); @@ -503,17 +517,20 @@ internal sealed class ManagementStore INSERT INTO client_controls ( client_id, disabled_until_utc, + disabled_from_utc, disabled_manually, disabled_reason, updated_at_utc) VALUES ( $client_id, NULL, + NULL, 0, NULL, $updated_at_utc) ON CONFLICT(client_id) DO UPDATE SET disabled_until_utc = NULL, + disabled_from_utc = NULL, disabled_manually = 0, disabled_reason = NULL, updated_at_utc = excluded.updated_at_utc @@ -1997,6 +2014,7 @@ internal sealed class ManagementStore CREATE TABLE IF NOT EXISTS client_controls ( client_id TEXT NOT NULL PRIMARY KEY, disabled_until_utc TEXT NULL, + disabled_from_utc TEXT NULL, disabled_manually INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NULL, updated_at_utc TEXT NOT NULL @@ -2306,6 +2324,21 @@ internal sealed class ManagementStore } } + using (var migrate = connection.CreateCommand()) + { + migrate.CommandText = """ + SELECT COUNT(*) FROM pragma_table_info('client_controls') WHERE name = 'disabled_from_utc' + """; + var hasFromColumn = (long)migrate.ExecuteScalar()! > 0; + + if (!hasFromColumn) + { + using var addFromCol = connection.CreateCommand(); + addFromCol.CommandText = "ALTER TABLE client_controls ADD COLUMN disabled_from_utc TEXT NULL"; + addFromCol.ExecuteNonQuery(); + } + } + _logger.LogInformation( "Loaded {ClientKeyCount} client key(s) from {DatabasePath}.", _clientKeysByHash.Count, @@ -2406,11 +2439,12 @@ internal sealed class ManagementStore internal sealed record ClientAccess( bool IsDisabled, + DateTimeOffset? DisabledFromUtc, DateTimeOffset? DisabledUntilUtc, bool DisabledManually, string? DisabledReason) { - public static ClientAccess Enabled { get; } = new(false, null, false, null); + public static ClientAccess Enabled { get; } = new(false, null, null, false, null); } internal sealed record UserKeyInfo( diff --git a/src/Ngino.Server/ReverseProxyEndpoint.cs b/src/Ngino.Server/ReverseProxyEndpoint.cs index ff7d704..5d98f2d 100644 --- a/src/Ngino.Server/ReverseProxyEndpoint.cs +++ b/src/Ngino.Server/ReverseProxyEndpoint.cs @@ -378,8 +378,16 @@ internal static class ReverseProxyEndpoint return $"Tunnel client '{clientId}' is disabled until it is enabled manually."; } - return access.DisabledUntilUtc is { } disabledUntil - ? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}." + if (access.DisabledUntilUtc is { } disabledUntil) + { + var from = access.DisabledFromUtc is { } fromUtc + ? $" (scheduled from {fromUtc:O})" + : ""; + return $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}.{from}"; + } + + return access.DisabledFromUtc is { } fromUtc2 + ? $"Tunnel client '{clientId}' is disabled (scheduled from {fromUtc2:O})." : $"Tunnel client '{clientId}' is disabled."; } diff --git a/src/Ngino.Server/wwwroot/admin/app.css b/src/Ngino.Server/wwwroot/admin/app.css index 5d8b747..7dcd9a5 100644 --- a/src/Ngino.Server/wwwroot/admin/app.css +++ b/src/Ngino.Server/wwwroot/admin/app.css @@ -592,6 +592,71 @@ tr:last-child td { } } +.modal-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.35); + opacity: 0; + transition: opacity 0.15s; +} + +.modal-overlay.visible { + opacity: 1; +} + +.modal-dialog { + width: min(480px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + overflow-y: auto; + display: grid; + gap: 14px; + padding: 20px; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); +} + +.modal-dialog h3 { + margin: 0; + font-size: 16px; + overflow-wrap: anywhere; +} + +.field-label { + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.radio-row { + display: flex; + gap: 14px; + align-items: center; +} + +.radio-row label { + display: flex; + align-items: center; + gap: 5px; + font-size: 14px; + cursor: pointer; +} + +.field-row { + display: flex; + gap: 8px; + align-items: center; +} + +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + @media (max-width: 520px) { .nav { grid-template-columns: 1fr; diff --git a/src/Ngino.Server/wwwroot/admin/app.js b/src/Ngino.Server/wwwroot/admin/app.js index 49c8310..b7f0ecd 100644 --- a/src/Ngino.Server/wwwroot/admin/app.js +++ b/src/Ngino.Server/wwwroot/admin/app.js @@ -94,13 +94,21 @@ content.addEventListener("click", async (event) => { try { setBusy(button, true); - if (action === "disable-hour") { - await api(`/clients/${encodeURIComponent(clientId)}/disable`, { - method: "POST", - body: { mode: "duration", durationMinutes: 60 } - }); - setNotice(`Disabled ${clientId} for one hour.`); - await refresh(); + if (action === "disable-temporary") { + setBusy(button, false); + try { + const body = await showDisableModal(clientId); + setBusy(button, true); + await api(`/clients/${encodeURIComponent(clientId)}/disable`, { + method: "POST", + body + }); + setNotice(`Disabled ${clientId}.`); + await refresh(); + } catch { + // cancelled + } + return; } if (action === "disable-manual") { @@ -497,6 +505,7 @@ function clientsTable(clients) { ${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")} ${client.disabled ? `
${escapeHtml(disabledText(client))}
` : ""} + ${isScheduled(client) ? `
${escapeHtml(disabledText(client))}
` : ""} ${number(client.pendingRequests)} @@ -512,7 +521,7 @@ function clientsTable(clients) {
- +
@@ -1659,8 +1668,17 @@ function disabledText(client) { if (client.disabledManually) { return "Until enabled manually"; } + if (client.disabledUntilUtc) { + return `Until ${formatDate(client.disabledUntilUtc)}`; + } + if (isScheduled(client)) { + return `Scheduled from ${formatDate(client.disabledFromUtc)}`; + } + return "Disabled"; +} - return client.disabledUntilUtc ? `Until ${formatDate(client.disabledUntilUtc)}` : "Disabled"; +function isScheduled(client) { + return client.disabledFromUtc && new Date(client.disabledFromUtc) > new Date(); } function formatDate(value) { @@ -1722,4 +1740,126 @@ function escapeAttr(value) { return escapeHtml(value); } +function showDisableModal(clientId) { + return new Promise((resolve, reject) => { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add("visible")); + + const whenRadios = overlay.querySelectorAll('input[name="d-when"]'); + const forRadios = overlay.querySelectorAll('input[name="d-for"]'); + const fromInput = overlay.querySelector("#d-from"); + const timespanGroup = overlay.querySelector("#d-timespan-group"); + const untilInput = overlay.querySelector("#d-until"); + + function updateWhen() { + const val = overlay.querySelector('input[name="d-when"]:checked').value; + fromInput.disabled = val !== "later"; + if (val === "now") fromInput.value = ""; + } + + function updateFor() { + const val = overlay.querySelector('input[name="d-for"]:checked').value; + timespanGroup.style.display = val === "timespan" ? "flex" : "none"; + untilInput.style.display = val === "until" ? "" : "none"; + untilInput.disabled = val !== "until"; + if (val === "timespan") untilInput.value = ""; + } + + whenRadios.forEach(r => r.addEventListener("change", updateWhen)); + forRadios.forEach(r => r.addEventListener("change", updateFor)); + updateFor(); + + overlay.querySelector("#d-confirm").addEventListener("click", () => { + const when = overlay.querySelector('input[name="d-when"]:checked').value; + const forVal = overlay.querySelector('input[name="d-for"]:checked').value; + const reason = overlay.querySelector("#d-reason").value.trim() || null; + + if (when === "later" && !fromInput.value) { + setNotice("Please select a date and time for the disable.", true); + return; + } + if (forVal === "until" && !untilInput.value) { + setNotice("Please select a date and time for the end.", true); + return; + } + if (forVal === "timespan") { + const val = parseInt(overlay.querySelector("#d-duration").value); + if (!val || val < 1) { + setNotice("Please enter a valid duration.", true); + return; + } + } + + const body = { reason }; + + if (when === "later") { + body.startAtUtc = new Date(fromInput.value).toISOString(); + } + + if (forVal === "timespan") { + const val = parseInt(overlay.querySelector("#d-duration").value) || 1; + const unit = parseInt(overlay.querySelector("#d-unit").value); + body.durationMinutes = val * unit; + } else { + body.untilUtc = new Date(untilInput.value).toISOString(); + } + + overlay.remove(); + resolve(body); + }); + + overlay.querySelector("#d-cancel").addEventListener("click", () => { + overlay.remove(); + reject(new Error("Cancelled")); + }); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + overlay.remove(); + reject(new Error("Cancelled")); + } + }); + }); +} + boot();