feat(server): adds temporary disabling functionality
Build & Deploy / build (push) Successful in 1m55s
Build & Deploy / build (push) Successful in 1m55s
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -378,8 +378,16 @@ internal static class ReverseProxyEndpoint
|
|||||||
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.";
|
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return access.DisabledUntilUtc is { } disabledUntil
|
if (access.DisabledUntilUtc is { } disabledUntil)
|
||||||
? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}."
|
{
|
||||||
|
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.";
|
: $"Tunnel client '{clientId}' is disabled.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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") {
|
||||||
@@ -497,6 +505,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>
|
||||||
@@ -512,7 +521,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>
|
||||||
@@ -1659,8 +1668,17 @@ function disabledText(client) {
|
|||||||
if (client.disabledManually) {
|
if (client.disabledManually) {
|
||||||
return "Until enabled manually";
|
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) {
|
function formatDate(value) {
|
||||||
@@ -1722,4 +1740,126 @@ 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 "${escapeHtml(clientId)}"</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.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();
|
||||||
|
|||||||
Reference in New Issue
Block a user