Replace the browser's confirm() and prompt() pop-ups with in-app dialogs
All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.
A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog)
and a single DialogHost mounted once in App mean call sites just await
it in place of the browser call - no hooks or per-page modal state. Every
call site was already in an async function, so each is a one-line swap.
Each dialog now has a title and a main button that names the action
("Delete", "Stop now", "Run now") instead of "OK", with destructive ones
in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the
page behind stops scrolling, and focus returns to the button that was
clicked. Destructive dialogs start with focus on Cancel so a stray Enter
can't delete anything. Text prompts pre-select their default and disable
the main button until something is typed where it's required, and still
tell cancelling (null) apart from confirming an empty box (""). Clicking
the backdrop cancels, but releasing a text selection over it doesn't.
Dialogs asked for together appear one after another.
Verified in a browser on a test page using the real component and the
app's real stylesheet: Escape, Enter, Tab trapping, focus handling,
required and optional prompts, backdrop clicks, queuing, scroll lock and
dark mode. The 31 call sites themselves were checked by search and
typecheck rather than clicked through in the logged-in app.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88d9c8e097
commit
ad1fb5338f
24 files changed
+314
-39
No files matched your search
@@ -39,6 +39,7 @@ import LogSettings from "./pages/settings/LogSettings";
|
|||||||
import BackupSettings from "./pages/settings/BackupSettings";
|
import BackupSettings from "./pages/settings/BackupSettings";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import { setDateTimeSettings } from "./utils/date";
|
import { setDateTimeSettings } from "./utils/date";
|
||||||
|
import DialogHost from "./components/DialogHost";
|
||||||
import { setPageSize } from "./utils/pageSize";
|
import { setPageSize } from "./utils/pageSize";
|
||||||
|
|
||||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||||
@@ -165,4 +166,5 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
<DialogHost />
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { settle, subscribe, type DialogRequest } from "../utils/dialogs";
|
||||||
|
|
||||||
|
const FOCUSABLE = "button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href]";
|
||||||
|
|
||||||
|
/** Draws the confirm/prompt dialogs asked for through utils/dialogs. Mount once, near the root. */
|
||||||
|
export default function DialogHost() {
|
||||||
|
const [current, setCurrent] = useState<DialogRequest | null>(null);
|
||||||
|
useEffect(() => subscribe(setCurrent), []);
|
||||||
|
if (!current) return null;
|
||||||
|
// Keyed, so back-to-back dialogs each start fresh (an empty text box, the right button focused).
|
||||||
|
return createPortal(<DialogView key={current.id} request={current} />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogView({ request }: { request: DialogRequest }) {
|
||||||
|
const titleId = useId();
|
||||||
|
const inputId = useId();
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const isPrompt = request.kind === "prompt";
|
||||||
|
const danger = request.kind === "confirm" && !!request.danger;
|
||||||
|
const [value, setValue] = useState(request.kind === "prompt" ? (request.defaultValue ?? "") : "");
|
||||||
|
|
||||||
|
const cancel = () => settle(request, false);
|
||||||
|
const blocked = request.kind === "prompt" && !!request.required && !value.trim();
|
||||||
|
|
||||||
|
// On open: stop the page behind from scrolling (without the scrollbar vanishing and shifting the layout), put the
|
||||||
|
// keyboard focus where it's useful, and on close hand it back to whatever had it — the button that was clicked.
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
const { overflow, paddingRight } = document.body.style;
|
||||||
|
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
|
||||||
|
|
||||||
|
if (isPrompt) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
} else if (danger) {
|
||||||
|
cancelRef.current?.focus(); // the safe answer, so a stray Enter doesn't delete anything
|
||||||
|
} else {
|
||||||
|
confirmRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = overflow;
|
||||||
|
document.body.style.paddingRight = paddingRight;
|
||||||
|
previouslyFocused?.focus?.();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
// Keep Tab inside the dialog.
|
||||||
|
const items = Array.from(modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (blocked) return;
|
||||||
|
settle(request, true, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = request.title ?? (isPrompt ? "Enter a value" : "Please confirm");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="modal modal-blur fade show"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
// mousedown rather than click, so selecting text in the box and letting go outside it doesn't close the dialog
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<form className="modal-content" onSubmit={onSubmit}>
|
||||||
|
{danger && <div className="modal-status bg-danger" />}
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title" id={titleId}>
|
||||||
|
{title}
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn-close" aria-label="Close" onClick={cancel} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div style={{ whiteSpace: "pre-line" }}>{request.message}</div>
|
||||||
|
{request.kind === "prompt" && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{request.label && (
|
||||||
|
<label className="form-label" htmlFor={inputId}>
|
||||||
|
{request.label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
ref={inputRef}
|
||||||
|
className="form-control"
|
||||||
|
value={value}
|
||||||
|
placeholder={request.placeholder}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn me-auto" ref={cancelRef} onClick={cancel}>
|
||||||
|
{request.cancelLabel ?? "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={`btn ${danger ? "btn-danger" : "btn-primary"}`} ref={confirmRef} disabled={blocked}>
|
||||||
|
{request.confirmLabel ?? (isPrompt ? "OK" : "Confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop fade show" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { downloadCsv } from "../utils/csv";
|
|||||||
import { readableError } from "../utils/errors";
|
import { readableError } from "../utils/errors";
|
||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import SortableTh from "./SortableTh";
|
import SortableTh from "./SortableTh";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
|
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
|
||||||
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
|
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
|
||||||
@@ -188,7 +189,8 @@ export default function ServerPorts({
|
|||||||
async function removeNote(entry: PortEntry) {
|
async function removeNote(entry: PortEntry) {
|
||||||
if (entry.id === null) return;
|
if (entry.id === null) return;
|
||||||
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
|
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
|
||||||
if (!confirm(what)) return;
|
const reserved = entry.state === "reserved";
|
||||||
|
if (!(await confirmDialog({ title: reserved ? "Remove reservation" : "Clear note", message: what, confirmLabel: reserved ? "Remove" : "Clear", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.servers.ports.remove(serverId, entry.id);
|
await api.servers.ports.remove(serverId, entry.id);
|
||||||
setData(await api.servers.ports.list(serverId));
|
setData(await api.servers.ports.list(serverId));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSortable } from "../hooks/useSortable";
|
|||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
interface LinkForm {
|
interface LinkForm {
|
||||||
serverId: number | "";
|
serverId: number | "";
|
||||||
@@ -133,7 +134,7 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(link: AdminLink) {
|
async function remove(link: AdminLink) {
|
||||||
if (!confirm(`Remove the "${link.label}" link from ${link.serverName}?`)) return;
|
if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link from ${link.serverName}?`, confirmLabel: "Remove", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.servers.removeLink(link.serverId, link.id);
|
await api.servers.removeLink(link.serverId, link.id);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { downloadCsv } from "../utils/csv";
|
|||||||
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||||
import { formatAgo } from "../utils/duration";
|
import { formatAgo } from "../utils/duration";
|
||||||
import { readableError } from "../utils/errors";
|
import { readableError } from "../utils/errors";
|
||||||
|
import { promptDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [
|
const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [
|
||||||
{ kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." },
|
{ kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." },
|
||||||
@@ -72,7 +73,12 @@ export default function Consistency({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ignore(f: ConsistencyFinding) {
|
async function ignore(f: ConsistencyFinding) {
|
||||||
const reason = window.prompt("Why is this fine? (optional — shown in the Ignored list)", "");
|
const reason = await promptDialog({
|
||||||
|
title: "Ignore this finding",
|
||||||
|
message: "Why is this fine? (optional — shown in the Ignored list)",
|
||||||
|
placeholder: "e.g. intentional, or a test machine",
|
||||||
|
confirmLabel: "Ignore",
|
||||||
|
});
|
||||||
if (reason === null) return; // cancelled
|
if (reason === null) return; // cancelled
|
||||||
setBusyKey(f.key);
|
setBusyKey(f.key);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -113,12 +119,14 @@ export default function Consistency({ user }: { user: CurrentUser }) {
|
|||||||
async function excludeAround(f: ConsistencyFinding) {
|
async function excludeAround(f: ConsistencyFinding) {
|
||||||
if (!report || !f.ip) return;
|
if (!report || !f.ip) return;
|
||||||
const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`;
|
const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`;
|
||||||
const range = window.prompt(
|
const range = await promptDialog({
|
||||||
`Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike.
|
title: "Exclude a range",
|
||||||
|
message: "Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike.",
|
||||||
Range (a network like 192.168.16.0/20, or a single address):`,
|
label: "Range (a network like 192.168.16.0/20, or a single address)",
|
||||||
suggestion,
|
defaultValue: suggestion,
|
||||||
);
|
confirmLabel: "Exclude",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
if (range === null || !range.trim()) return;
|
if (range === null || !range.trim()) return;
|
||||||
await saveRanges([...report.excludedRanges, range.trim()]);
|
await saveRanges([...report.excludedRanges, range.trim()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
|||||||
import { useSortable } from "../hooks/useSortable";
|
import { useSortable } from "../hooks/useSortable";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
@@ -62,7 +63,7 @@ export default function DiagLog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleClear() {
|
async function handleClear() {
|
||||||
if (!confirm("Clear the entire diagnostic log? This cannot be undone.")) return;
|
if (!(await confirmDialog({ title: "Clear diagnostic log", message: "Clear the entire diagnostic log? This cannot be undone.", confirmLabel: "Clear log", danger: true }))) return;
|
||||||
setClearing(true);
|
setClearing(true);
|
||||||
try {
|
try {
|
||||||
await api.diagLog.clear();
|
await api.diagLog.clear();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
@@ -182,7 +183,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function removeRecord(r: DnsRecord) {
|
async function removeRecord(r: DnsRecord) {
|
||||||
if (!selectedProviderId || !selectedZone) return;
|
if (!selectedProviderId || !selectedZone) return;
|
||||||
if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return;
|
if (!(await confirmDialog({ title: "Delete DNS record", message: `Delete ${r.type} record "${r.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id);
|
await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id);
|
||||||
@@ -202,7 +203,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeProvider(p: DnsProviderSummary) {
|
async function removeProvider(p: DnsProviderSummary) {
|
||||||
if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return;
|
if (!(await confirmDialog({ title: "Delete DNS provider", message: `Delete provider "${p.name}"? This removes its cached zones and records too.`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.dns.providers.remove(p.id);
|
await api.dns.providers.remove(p.id);
|
||||||
if (selectedProviderId === p.id) setSelectedProviderId(null);
|
if (selectedProviderId === p.id) setSelectedProviderId(null);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
function updateBadge(c: DockhandContainer) {
|
function updateBadge(c: DockhandContainer) {
|
||||||
if (c.updateAvailable === null) return <span className="text-secondary">—</span>;
|
if (c.updateAvailable === null) return <span className="text-secondary">—</span>;
|
||||||
@@ -124,7 +125,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
|
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return;
|
if (action === "stop" && !(await confirmDialog({ title: "Stop container", message: `Stop container "${c.name}"?`, confirmLabel: "Stop", danger: true }))) return;
|
||||||
setActingOnContainer(c.id);
|
setActingOnContainer(c.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSortable } from "../hooks/useSortable";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
function statusBadge(d: DomainRecord) {
|
function statusBadge(d: DomainRecord) {
|
||||||
switch (d.status) {
|
switch (d.status) {
|
||||||
@@ -87,7 +88,7 @@ export default function Domains({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(d: DomainRecord) {
|
async function remove(d: DomainRecord) {
|
||||||
if (!confirm(`Stop tracking ${d.name}?`)) return;
|
if (!(await confirmDialog({ title: "Stop tracking domain", message: `Stop tracking ${d.name}?`, confirmLabel: "Stop tracking", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.domains.remove(d.id);
|
await api.domains.remove(d.id);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import IntegrationEditForm from "../components/IntegrationEditForm";
|
|||||||
import { useSortable } from "../hooks/useSortable";
|
import { useSortable } from "../hooks/useSortable";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<IntegrationType, string> = {
|
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||||
tailscale: "Tailscale",
|
tailscale: "Tailscale",
|
||||||
@@ -79,7 +80,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeIntegration(i: IntegrationSummary) {
|
async function removeIntegration(i: IntegrationSummary) {
|
||||||
if (!confirm(`Delete integration "${i.name}"?`)) return;
|
if (!(await confirmDialog({ title: "Delete integration", message: `Delete integration "${i.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.integrations.remove(i.id);
|
await api.integrations.remove(i.id);
|
||||||
loadIntegrations();
|
loadIntegrations();
|
||||||
|
|||||||
+11
-6
@@ -7,6 +7,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { useSelection } from "../hooks/useSelection";
|
import { useSelection } from "../hooks/useSelection";
|
||||||
|
import { confirmDialog, promptDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
|
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
|
||||||
|
|
||||||
@@ -74,10 +75,14 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function excludeAround(entry: IpamEntry) {
|
async function excludeAround(entry: IpamEntry) {
|
||||||
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
|
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
|
||||||
const range = window.prompt(
|
const range = await promptDialog({
|
||||||
`Hide this range from IP Addresses and the Consistency report — every address in it, not just this one:`,
|
title: "Exclude a range",
|
||||||
suggestion,
|
message: "Hide this range from IP Addresses and the Consistency report — every address in it, not just this one.",
|
||||||
);
|
label: "Range (a network like 172.17.0.0/16, or a single address)",
|
||||||
|
defaultValue: suggestion,
|
||||||
|
confirmLabel: "Exclude",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
if (range === null || !range.trim()) return;
|
if (range === null || !range.trim()) return;
|
||||||
await saveRanges([...excludedRanges, range.trim()]);
|
await saveRanges([...excludedRanges, range.trim()]);
|
||||||
}
|
}
|
||||||
@@ -139,7 +144,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(entry: IpamEntry) {
|
async function remove(entry: IpamEntry) {
|
||||||
if (!confirm(`Delete IP address "${entry.ipAddress}"?`)) return;
|
if (!(await confirmDialog({ title: "Delete IP address", message: `Delete IP address "${entry.ipAddress}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.ipam.remove(entry.id);
|
await api.ipam.remove(entry.id);
|
||||||
@@ -152,7 +157,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
|||||||
async function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const ids = Array.from(selection.selected);
|
const ids = Array.from(selection.selected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
if (!confirm(`Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`)) return;
|
if (!(await confirmDialog({ title: "Delete IP addresses", message: `Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setBulkDeleting(true);
|
setBulkDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
|
|||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { formatAgo } from "../utils/duration";
|
import { formatAgo } from "../utils/duration";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const emptyForm: PortForwardInput = {
|
const emptyForm: PortForwardInput = {
|
||||||
label: "",
|
label: "",
|
||||||
@@ -163,7 +164,7 @@ export default function Ports({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(fwd: PortForward) {
|
async function remove(fwd: PortForward) {
|
||||||
if (!confirm(`Delete port opening "${fwd.label}"?`)) return;
|
if (!(await confirmDialog({ title: "Delete port opening", message: `Delete port opening "${fwd.label}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setForwardError(null);
|
setForwardError(null);
|
||||||
try {
|
try {
|
||||||
await api.ports.forwards.remove(fwd.id);
|
await api.ports.forwards.remove(fwd.id);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
function proxmoxStatusBadge(status: string) {
|
function proxmoxStatusBadge(status: string) {
|
||||||
return status === "running" ? (
|
return status === "running" ? (
|
||||||
@@ -164,7 +165,16 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
|
const kind = g.type === "qemu" ? "VM" : "container";
|
||||||
|
if (
|
||||||
|
action === "stop" &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: `Stop ${kind}`,
|
||||||
|
message: `Stop ${kind} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`,
|
||||||
|
confirmLabel: "Stop now",
|
||||||
|
danger: true,
|
||||||
|
}))
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
setActingOnGuest(g.vmid);
|
setActingOnGuest(g.vmid);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { useSelection } from "../hooks/useSelection";
|
import { useSelection } from "../hooks/useSelection";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
||||||
api_token: "API Token",
|
api_token: "API Token",
|
||||||
@@ -134,7 +135,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(s: SecretRecord) {
|
async function remove(s: SecretRecord) {
|
||||||
if (!confirm(`Delete secret "${s.name}"?`)) return;
|
if (!(await confirmDialog({ title: "Delete secret", message: `Delete secret "${s.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.secrets.remove(s.id);
|
await api.secrets.remove(s.id);
|
||||||
@@ -147,7 +148,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
|||||||
async function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const ids = Array.from(selection.selected);
|
const ids = Array.from(selection.selected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
if (!confirm(`Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`)) return;
|
if (!(await confirmDialog({ title: "Delete secrets", message: `Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setBulkDeleting(true);
|
setBulkDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh";
|
|||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
function semaphoreStatusBadge(template: SemaphoreTemplate) {
|
function semaphoreStatusBadge(template: SemaphoreTemplate) {
|
||||||
const status = template.lastTask?.status;
|
const status = template.lastTask?.status;
|
||||||
@@ -85,7 +86,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function runTemplate(t: SemaphoreTemplate) {
|
async function runTemplate(t: SemaphoreTemplate) {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (!confirm(`Run "${t.name}" now?`)) return;
|
if (!(await confirmDialog({ title: "Run template", message: `Run "${t.name}" now?`, confirmLabel: "Run now" }))) return;
|
||||||
setRunningTemplate(t.id);
|
setRunningTemplate(t.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ServerTags from "../components/ServerTags";
|
|||||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||||
import { formatDateTime } from "../utils/date";
|
import { formatDateTime } from "../utils/date";
|
||||||
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
|
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
|
||||||
([value, label]) => ({ value: value as ScheduleType, label }),
|
([value, label]) => ({ value: value as ScheduleType, label }),
|
||||||
@@ -258,7 +259,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTask(task: TaskRecord) {
|
async function deleteTask(task: TaskRecord) {
|
||||||
if (!confirm(`Delete "${task.name}"?`)) return;
|
if (!(await confirmDialog({ title: "Delete task", message: `Delete "${task.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.tasks.remove(task.id);
|
await api.tasks.remove(task.id);
|
||||||
loadTasks();
|
loadTasks();
|
||||||
@@ -330,7 +331,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeAdminLink(link: ServerLink) {
|
async function removeAdminLink(link: ServerLink) {
|
||||||
if (!confirm(`Remove the "${link.label}" link?`)) return;
|
if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link?`, confirmLabel: "Remove", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.servers.removeLink(serverId, link.id);
|
await api.servers.removeLink(serverId, link.id);
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
@@ -347,7 +348,15 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
|
async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
|
||||||
if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return;
|
if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return;
|
||||||
if (action === "stop" && !confirm(`Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`))
|
if (
|
||||||
|
action === "stop" &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: "Stop server",
|
||||||
|
message: `Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`,
|
||||||
|
confirmLabel: "Stop now",
|
||||||
|
danger: true,
|
||||||
|
}))
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
setActingOnGuest(true);
|
setActingOnGuest(true);
|
||||||
setGuestActionError(null);
|
setGuestActionError(null);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { downloadCsv } from "../utils/csv";
|
|||||||
import { TagBadges } from "../components/ServerTags";
|
import { TagBadges } from "../components/ServerTags";
|
||||||
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
|
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
|
||||||
import { tagBadge, useTagColors } from "../utils/tags";
|
import { tagBadge, useTagColors } from "../utils/tags";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
||||||
const color = colors[type];
|
const color = colors[type];
|
||||||
@@ -85,7 +86,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteServer(id: number) {
|
async function deleteServer(id: number) {
|
||||||
if (!confirm("Remove this server and all its tracked tasks?")) return;
|
if (!(await confirmDialog({ title: "Remove server", message: "Remove this server and all its tracked tasks?", confirmLabel: "Remove", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.servers.remove(id);
|
await api.servers.remove(id);
|
||||||
await loadServers();
|
await loadServers();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { formatDateTime } from "../utils/date";
|
|||||||
import { useSortable } from "../hooks/useSortable";
|
import { useSortable } from "../hooks/useSortable";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
function friendlyUserAgent(ua: string | null): string {
|
function friendlyUserAgent(ua: string | null): string {
|
||||||
if (!ua) return "—";
|
if (!ua) return "—";
|
||||||
@@ -54,7 +55,7 @@ export default function Sessions() {
|
|||||||
const confirmMsg = isSelf
|
const confirmMsg = isSelf
|
||||||
? `This is your current session — revoking it will sign you out immediately. Continue?`
|
? `This is your current session — revoking it will sign you out immediately. Continue?`
|
||||||
: `Revoke ${label}'s session? They'll be signed out immediately.`;
|
: `Revoke ${label}'s session? They'll be signed out immediately.`;
|
||||||
if (!confirm(confirmMsg)) return;
|
if (!(await confirmDialog({ title: isSelf ? "Sign out of this session" : "End session", message: confirmMsg, confirmLabel: isSelf ? "Sign me out" : "Revoke session", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setRevokingId(s.id);
|
setRevokingId(s.id);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
|
|||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { useSelection } from "../hooks/useSelection";
|
import { useSelection } from "../hooks/useSelection";
|
||||||
|
import { confirmDialog } from "../utils/dialogs";
|
||||||
|
|
||||||
const KEY_EXPIRY_WARN_DAYS = 30;
|
const KEY_EXPIRY_WARN_DAYS = 30;
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
async function removeDevice(device: TailscaleDevice) {
|
async function removeDevice(device: TailscaleDevice) {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return;
|
if (!(await confirmDialog({ title: "Remove device", message: `Remove device "${device.label || device.hostname}" from the tailnet?`, confirmLabel: "Remove", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.integrations.tailscale.remove(selectedId, device.id);
|
await api.integrations.tailscale.remove(selectedId, device.id);
|
||||||
loadDevices(selectedId);
|
loadDevices(selectedId);
|
||||||
@@ -119,7 +120,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
const ids = Array.from(selection.selected);
|
const ids = Array.from(selection.selected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
if (!confirm(`Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`)) return;
|
if (!(await confirmDialog({ title: "Remove devices", message: `Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`, confirmLabel: "Remove", danger: true }))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setBulkActing(true);
|
setBulkActing(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { api, type EncryptedExportFile, type ImportResult } from "../../api/client";
|
import { api, type EncryptedExportFile, type ImportResult } from "../../api/client";
|
||||||
|
import { confirmDialog } from "../../utils/dialogs";
|
||||||
|
|
||||||
function downloadJson(filename: string, data: unknown) {
|
function downloadJson(filename: string, data: unknown) {
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||||
@@ -65,7 +66,7 @@ export default function BackupSettings() {
|
|||||||
|
|
||||||
async function handleImport() {
|
async function handleImport() {
|
||||||
if (!importFile) return;
|
if (!importFile) return;
|
||||||
if (!confirm("Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.")) return;
|
if (!(await confirmDialog({ title: "Import backup", message: "Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.", confirmLabel: "Import" }))) return;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
|
import { confirmDialog } from "../../utils/dialogs";
|
||||||
|
|
||||||
export default function CacheSettings() {
|
export default function CacheSettings() {
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
@@ -7,7 +8,7 @@ export default function CacheSettings() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleClear() {
|
async function handleClear() {
|
||||||
if (!confirm("Clear the DNS record cache? All zones will need to be re-synced afterwards.")) return;
|
if (!(await confirmDialog({ title: "Clear DNS cache", message: "Clear the DNS record cache? All zones will need to be re-synced afterwards.", confirmLabel: "Clear cache" }))) return;
|
||||||
setClearing(true);
|
setClearing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setCleared(false);
|
setCleared(false);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type AppSettings } from "../../api/client";
|
import { api, type AppSettings } from "../../api/client";
|
||||||
|
import { confirmDialog } from "../../utils/dialogs";
|
||||||
|
|
||||||
const INTERVAL_OPTIONS: { value: number; label: string }[] = [
|
const INTERVAL_OPTIONS: { value: number; label: string }[] = [
|
||||||
{ value: 1, label: "Every hour" },
|
{ value: 1, label: "Every hour" },
|
||||||
@@ -51,7 +52,7 @@ export default function LogSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePurgeNow() {
|
async function handlePurgeNow() {
|
||||||
if (!confirm(`Delete diagnostic and audit log entries older than ${retentionDays} days now?`)) return;
|
if (!(await confirmDialog({ title: "Delete old log entries", message: `Delete diagnostic and audit log entries older than ${retentionDays} days now?`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
setPurging(true);
|
setPurging(true);
|
||||||
setPurgeError(null);
|
setPurgeError(null);
|
||||||
setPurgeResult(null);
|
setPurgeResult(null);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { api, type TagEntry } from "../../api/client";
|
import { api, type TagEntry } from "../../api/client";
|
||||||
import { readableError } from "../../utils/errors";
|
import { readableError } from "../../utils/errors";
|
||||||
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
|
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
|
||||||
|
import { confirmDialog, promptDialog } from "../../utils/dialogs";
|
||||||
|
|
||||||
const DEFAULT_PICK = "#3b82f6";
|
const DEFAULT_PICK = "#3b82f6";
|
||||||
|
|
||||||
@@ -64,12 +65,20 @@ export default function TagSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function rename(t: TagEntry) {
|
async function rename(t: TagEntry) {
|
||||||
const input = window.prompt(`Rename “${t.name}” to:`, t.name);
|
const input = await promptDialog({ title: "Rename tag", message: `Rename “${t.name}” to:`, defaultValue: t.name, confirmLabel: "Rename", required: true });
|
||||||
if (input === null) return;
|
if (input === null) return;
|
||||||
const to = normalizeTagInput(input);
|
const to = normalizeTagInput(input);
|
||||||
if (!to || to === t.name) return;
|
if (!to || to === t.name) return;
|
||||||
const target = tags?.find((x) => x.name === to);
|
const target = tags?.find((x) => x.name === to);
|
||||||
if (target && !window.confirm(`“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`)) return;
|
if (
|
||||||
|
target &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: "Merge tags",
|
||||||
|
message: `“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`,
|
||||||
|
confirmLabel: "Merge",
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
await run(
|
await run(
|
||||||
() => api.tags.rename(t.name, to),
|
() => api.tags.rename(t.name, to),
|
||||||
(res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`,
|
(res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`,
|
||||||
@@ -78,7 +87,7 @@ export default function TagSettings() {
|
|||||||
|
|
||||||
async function remove(t: TagEntry) {
|
async function remove(t: TagEntry) {
|
||||||
const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : "";
|
const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : "";
|
||||||
if (!window.confirm(`Delete the tag “${t.name}”?${used}`)) return;
|
if (!(await confirmDialog({ title: "Delete tag", message: `Delete the tag “${t.name}”?${used}`, confirmLabel: "Delete", danger: true }))) return;
|
||||||
await run(
|
await run(
|
||||||
() => api.tags.remove(t.name),
|
() => api.tags.remove(t.name),
|
||||||
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
|
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// In-app replacements for window.confirm() and window.prompt(). Call sites just await them, the same way they used to
|
||||||
|
// call the browser's — a single <DialogHost /> (mounted once, in App) draws whichever is asked for. Several asked for
|
||||||
|
// at once are shown one after another.
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
/** What the main button says — name the action ("Delete", "Stop") rather than leaving it as "OK". */
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** Destructive or hard to undo: red button, and the safe choice gets the keyboard focus. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
/** Label for the text box itself, when the message above it isn't enough. */
|
||||||
|
label?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** Disable the main button until something has been typed. */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogRequest =
|
||||||
|
| ({ kind: "confirm"; id: number; resolve: (accepted: boolean) => void } & ConfirmOptions)
|
||||||
|
| ({ kind: "prompt"; id: number; resolve: (value: string | null) => void } & PromptOptions);
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
let queue: DialogRequest[] = [];
|
||||||
|
let listener: ((current: DialogRequest | null) => void) | null = null;
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listener?.(queue[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the host: called with whatever should be on screen now (and straight away with what already is). */
|
||||||
|
export function subscribe(next: (current: DialogRequest | null) => void): () => void {
|
||||||
|
listener = next;
|
||||||
|
emit();
|
||||||
|
return () => {
|
||||||
|
if (listener === next) listener = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the dialog on screen. A cancelled confirm is false and a cancelled prompt is null — as with the browser's own. */
|
||||||
|
export function settle(request: DialogRequest, accepted: boolean, value = ""): void {
|
||||||
|
queue = queue.filter((r) => r !== request);
|
||||||
|
if (request.kind === "confirm") request.resolve(accepted);
|
||||||
|
else request.resolve(accepted ? value : null);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
queue.push({ ...options, kind: "confirm", id: nextId++, resolve });
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptDialog(options: PromptOptions): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
queue.push({ ...options, kind: "prompt", id: nextId++, resolve });
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user