Merge pull request #31 from LD-Reborn/10-change-disable-1h-to-disable-temporary

10 change disable 1h to disable temporary
This commit is contained in:
LD50
2026-07-29 21:07:28 +02:00
committed by GitHub
5 changed files with 295 additions and 32 deletions
+10 -3
View File
@@ -167,11 +167,14 @@ internal static class AdminEndpoints
try try
{ {
var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase); var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase);
TimeSpan? duration = manual TimeSpan? duration = manual
? null ? 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 }); return Results.Ok(new { clientId, disabled = true });
} }
catch (Exception exception) catch (Exception exception)
@@ -673,6 +676,7 @@ internal static class AdminEndpoints
snapshot?.ActiveModels ?? [], snapshot?.ActiveModels ?? [],
snapshot?.ModelsUpdatedAt, snapshot?.ModelsUpdatedAt,
access.IsDisabled, access.IsDisabled,
access.DisabledFromUtc,
access.DisabledUntilUtc, access.DisabledUntilUtc,
access.DisabledManually, access.DisabledManually,
access.DisabledReason, access.DisabledReason,
@@ -880,7 +884,9 @@ internal static class AdminEndpoints
internal sealed record DisableClientRequest( internal sealed record DisableClientRequest(
string? Mode, string? Mode,
int? DurationMinutes, int? DurationMinutes,
string? Reason); string? Reason,
DateTimeOffset? StartAtUtc,
DateTimeOffset? UntilUtc);
internal sealed record ModelActionRequest( internal sealed record ModelActionRequest(
string ClientId, string ClientId,
@@ -929,6 +935,7 @@ internal sealed record ClientSummary(
IReadOnlyList<string> ActiveModels, IReadOnlyList<string> ActiveModels,
DateTimeOffset? ModelsUpdatedAt, DateTimeOffset? ModelsUpdatedAt,
bool Disabled, bool Disabled,
DateTimeOffset? DisabledFromUtc,
DateTimeOffset? DisabledUntilUtc, DateTimeOffset? DisabledUntilUtc,
bool DisabledManually, bool DisabledManually,
string? DisabledReason, string? DisabledReason,
+49 -15
View File
@@ -378,7 +378,7 @@ internal sealed class ManagementStore
using var connection = OpenConnection(); using var connection = OpenConnection();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = """ command.CommandText = """
SELECT disabled_until_utc, disabled_manually, disabled_reason SELECT disabled_until_utc, disabled_manually, disabled_reason, disabled_from_utc
FROM client_controls FROM client_controls
WHERE client_id = $client_id WHERE client_id = $client_id
"""; """;
@@ -393,18 +393,22 @@ internal sealed class ManagementStore
var disabledUntil = ReadNullableDateTimeOffset(reader, 0); var disabledUntil = ReadNullableDateTimeOffset(reader, 0);
var disabledManually = reader.GetInt32(1) != 0; var disabledManually = reader.GetInt32(1) != 0;
var reason = reader.IsDBNull(2) ? null : reader.GetString(2); 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 connection = OpenConnection();
using var command = connection.CreateCommand(); 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(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
@@ -430,19 +434,23 @@ internal sealed class ManagementStore
var disabledUntil = ReadNullableDateTimeOffset(reader, 1); var disabledUntil = ReadNullableDateTimeOffset(reader, 1);
var disabledManually = reader.GetInt32(2) != 0; var disabledManually = reader.GetInt32(2) != 0;
var reason = reader.IsDBNull(3) ? null : reader.GetString(3); var reason = reader.IsDBNull(3) ? null : reader.GetString(3);
var disabledFrom = ReadNullableDateTimeOffset(reader, 4);
result[clientId] = disabledManually var now = DateTimeOffset.UtcNow;
? new ClientAccess(true, null, true, reason) var isScheduled = disabledFrom > now;
: disabledUntil is { } until && until > DateTimeOffset.UtcNow
? new ClientAccess(true, until, false, reason) result[clientId] = disabledManually && !isScheduled
: ClientAccess.Enabled; ? 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; 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(); EnsureAvailable();
@@ -452,7 +460,9 @@ internal sealed class ManagementStore
} }
var now = DateTimeOffset.UtcNow; 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) lock (_lock)
{ {
@@ -462,23 +472,27 @@ internal sealed class ManagementStore
INSERT INTO client_controls ( INSERT INTO client_controls (
client_id, client_id,
disabled_until_utc, disabled_until_utc,
disabled_from_utc,
disabled_manually, disabled_manually,
disabled_reason, disabled_reason,
updated_at_utc) updated_at_utc)
VALUES ( VALUES (
$client_id, $client_id,
$disabled_until_utc, $disabled_until_utc,
$disabled_from_utc,
$disabled_manually, $disabled_manually,
$disabled_reason, $disabled_reason,
$updated_at_utc) $updated_at_utc)
ON CONFLICT(client_id) DO UPDATE SET ON CONFLICT(client_id) DO UPDATE SET
disabled_until_utc = excluded.disabled_until_utc, disabled_until_utc = excluded.disabled_until_utc,
disabled_from_utc = excluded.disabled_from_utc,
disabled_manually = excluded.disabled_manually, disabled_manually = excluded.disabled_manually,
disabled_reason = excluded.disabled_reason, disabled_reason = excluded.disabled_reason,
updated_at_utc = excluded.updated_at_utc updated_at_utc = excluded.updated_at_utc
"""; """;
command.Parameters.AddWithValue("$client_id", clientId); command.Parameters.AddWithValue("$client_id", clientId);
command.Parameters.AddWithValue("$disabled_until_utc", (object?)disabledUntil ?? DBNull.Value); 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_manually", manually ? 1 : 0);
command.Parameters.AddWithValue("$disabled_reason", string.IsNullOrWhiteSpace(reason) ? DBNull.Value : reason.Trim()); command.Parameters.AddWithValue("$disabled_reason", string.IsNullOrWhiteSpace(reason) ? DBNull.Value : reason.Trim());
command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O")); command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O"));
@@ -503,17 +517,20 @@ internal sealed class ManagementStore
INSERT INTO client_controls ( INSERT INTO client_controls (
client_id, client_id,
disabled_until_utc, disabled_until_utc,
disabled_from_utc,
disabled_manually, disabled_manually,
disabled_reason, disabled_reason,
updated_at_utc) updated_at_utc)
VALUES ( VALUES (
$client_id, $client_id,
NULL, NULL,
NULL,
0, 0,
NULL, NULL,
$updated_at_utc) $updated_at_utc)
ON CONFLICT(client_id) DO UPDATE SET ON CONFLICT(client_id) DO UPDATE SET
disabled_until_utc = NULL, disabled_until_utc = NULL,
disabled_from_utc = NULL,
disabled_manually = 0, disabled_manually = 0,
disabled_reason = NULL, disabled_reason = NULL,
updated_at_utc = excluded.updated_at_utc updated_at_utc = excluded.updated_at_utc
@@ -1997,6 +2014,7 @@ internal sealed class ManagementStore
CREATE TABLE IF NOT EXISTS client_controls ( CREATE TABLE IF NOT EXISTS client_controls (
client_id TEXT NOT NULL PRIMARY KEY, client_id TEXT NOT NULL PRIMARY KEY,
disabled_until_utc TEXT NULL, disabled_until_utc TEXT NULL,
disabled_from_utc TEXT NULL,
disabled_manually INTEGER NOT NULL DEFAULT 0, disabled_manually INTEGER NOT NULL DEFAULT 0,
disabled_reason TEXT NULL, disabled_reason TEXT NULL,
updated_at_utc TEXT NOT 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( _logger.LogInformation(
"Loaded {ClientKeyCount} client key(s) from {DatabasePath}.", "Loaded {ClientKeyCount} client key(s) from {DatabasePath}.",
_clientKeysByHash.Count, _clientKeysByHash.Count,
@@ -2406,11 +2439,12 @@ internal sealed class ManagementStore
internal sealed record ClientAccess( internal sealed record ClientAccess(
bool IsDisabled, bool IsDisabled,
DateTimeOffset? DisabledFromUtc,
DateTimeOffset? DisabledUntilUtc, DateTimeOffset? DisabledUntilUtc,
bool DisabledManually, bool DisabledManually,
string? DisabledReason) 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( internal sealed record UserKeyInfo(
+16 -4
View File
@@ -373,14 +373,26 @@ internal static class ReverseProxyEndpoint
private static string GetClientDisabledMessage(string clientId, ClientAccess access) private static string GetClientDisabledMessage(string clientId, ClientAccess access)
{ {
var reason = string.IsNullOrWhiteSpace(access.DisabledReason)
? ""
: $" Reason: {access.DisabledReason.Trim()}.";
if (access.DisabledManually) if (access.DisabledManually)
{ {
return $"Tunnel client '{clientId}' is disabled until it is enabled manually."; return $"Tunnel client '{clientId}' is disabled until it is enabled manually.{reason}";
} }
return access.DisabledUntilUtc is { } disabledUntil if (access.DisabledUntilUtc is { } disabledUntil)
? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}." {
: $"Tunnel client '{clientId}' is disabled."; var from = access.DisabledFromUtc is { } fromUtc
? $" (scheduled from {fromUtc:O})"
: "";
return $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}.{from}{reason}";
}
return access.DisabledFromUtc is { } fromUtc2
? $"Tunnel client '{clientId}' is disabled (scheduled from {fromUtc2:O}).{reason}"
: $"Tunnel client '{clientId}' is disabled.{reason}";
} }
private static async Task<string?> GetRequestedModelAsync(HttpRequest request, PathString proxyPath) private static async Task<string?> GetRequestedModelAsync(HttpRequest request, PathString proxyPath)
+65
View File
@@ -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) { @media (max-width: 520px) {
.nav { .nav {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+155 -10
View File
@@ -94,13 +94,21 @@ content.addEventListener("click", async (event) => {
try { try {
setBusy(button, true); setBusy(button, true);
if (action === "disable-hour") { if (action === "disable-temporary") {
await api(`/clients/${encodeURIComponent(clientId)}/disable`, { setBusy(button, false);
method: "POST", try {
body: { mode: "duration", durationMinutes: 60 } const body = await showDisableModal(clientId);
}); setBusy(button, true);
setNotice(`Disabled ${clientId} for one hour.`); await api(`/clients/${encodeURIComponent(clientId)}/disable`, {
await refresh(); method: "POST",
body
});
setNotice(`Disabled ${clientId}.`);
await refresh();
} catch {
// cancelled
}
return;
} }
if (action === "disable-manual") { if (action === "disable-manual") {
@@ -498,6 +506,7 @@ function clientsTable(clients) {
${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")} ${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")}
</div> </div>
${client.disabled ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""} ${client.disabled ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
${isScheduled(client) ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
</td> </td>
<td>${number(client.pendingRequests)}</td> <td>${number(client.pendingRequests)}</td>
<td> <td>
@@ -513,7 +522,7 @@ function clientsTable(clients) {
</td> </td>
<td> <td>
<div class="actions"> <div class="actions">
<button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button> <button class="button warning" data-action="disable-temporary" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable temporary</button>
<button class="button warning" data-action="disable-manual" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable</button> <button class="button warning" data-action="disable-manual" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable</button>
<button class="button secondary" data-action="enable-client" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "" : "disabled"}>Enable</button> <button class="button secondary" data-action="enable-client" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "" : "disabled"}>Enable</button>
</div> </div>
@@ -1657,11 +1666,24 @@ function emptyState(text) {
} }
function disabledText(client) { function disabledText(client) {
var text = "";
if (client.disabledManually) { if (client.disabledManually) {
return "Until enabled manually"; text = "Until enabled manually";
} else if (client.disabledUntilUtc) {
text = `Until ${formatDate(client.disabledUntilUtc)}`;
} else if (isScheduled(client)) {
text = `Scheduled from ${formatDate(client.disabledFromUtc)}`;
} else {
text = "Disabled";
} }
if (client.disabledReason) {
text += `${escapeHtml(client.disabledReason)}`;
}
return text;
}
return client.disabledUntilUtc ? `Until ${formatDate(client.disabledUntilUtc)}` : "Disabled"; function isScheduled(client) {
return client.disabledFromUtc && new Date(client.disabledFromUtc) > new Date();
} }
function formatDate(value) { function formatDate(value) {
@@ -1723,4 +1745,127 @@ function escapeAttr(value) {
return escapeHtml(value); return escapeHtml(value);
} }
function showDisableModal(clientId) {
return new Promise((resolve, reject) => {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal-dialog">
<h3>Disable client &quot;${escapeHtml(clientId)}&quot;</h3>
<div class="field">
<span class="field-label">When to disable</span>
<div class="radio-row">
<label><input type="radio" name="d-when" value="now" checked> Now</label>
<label><input type="radio" name="d-when" value="later"> Later</label>
</div>
<input type="datetime-local" id="d-from" class="input" disabled>
</div>
<div class="field">
<span class="field-label">For how long</span>
<div class="radio-row">
<label><input type="radio" name="d-for" value="timespan" checked> Timespan</label>
<label><input type="radio" name="d-for" value="until"> Until</label>
</div>
<div id="d-timespan-group" class="field-row">
<input type="number" id="d-duration" class="input" value="1" min="1" style="width:100px">
<select id="d-unit" class="select" style="width:auto">
<option value="1">minute(s)</option>
<option value="60" selected>hour(s)</option>
<option value="1440">day(s)</option>
</select>
</div>
<input type="datetime-local" id="d-until" class="input" style="display:none" disabled>
</div>
<div class="field">
<label for="d-reason">Reason (optional)</label>
<input type="text" id="d-reason" class="input" placeholder="e.g. maintenance">
</div>
<div class="form-row modal-actions">
<button class="button secondary" id="d-cancel" type="button">Cancel</button>
<button class="button warning" id="d-confirm" type="button">Disable</button>
</div>
</div>
`;
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.style.display = val === "later" ? "" : "none";
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(); boot();