admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog
the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
This commit is contained in:
@@ -9,6 +9,7 @@ import ClientEditDialog from "./ClientEditDialog";
|
||||
import NetworkAssignments from "./NetworkAssignments";
|
||||
import { ClientDisplayName } from "./clientIdentity";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
@@ -31,6 +32,16 @@ function declaredDeleteNote(authority: Authority): string {
|
||||
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
||||
}
|
||||
|
||||
/**
|
||||
* How the confirmation names the row. The address is always there and always
|
||||
* unique, so it carries the sentence; a name the operator recognizes leads when
|
||||
* the row has one.
|
||||
*/
|
||||
function clientLabel(client: Client): string {
|
||||
const name = client.name !== "" ? client.name : client.learned_name;
|
||||
return name === "" ? client.ip : `${name} (${client.ip})`;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
@@ -79,23 +90,11 @@ const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
@@ -112,7 +111,7 @@ export default function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const authority = useAuthority();
|
||||
|
||||
@@ -122,6 +121,12 @@ export default function ClientsPage() {
|
||||
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
||||
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
||||
|
||||
// Authority is polled, so it can turn while the confirmation is open. The
|
||||
// dialog reads it on every render rather than trusting the state that opened
|
||||
// it, and withdraws the answer that would now fail instead of withdrawing the
|
||||
// question: the operator is told why, and only they close the dialog.
|
||||
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
@@ -178,77 +183,39 @@ export default function ClientsPage() {
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
{/* Authority is polled, so it can turn while a confirmation
|
||||
sits open. The confirm path reads it on every render
|
||||
rather than trusting the state that opened it. */}
|
||||
<span {...stylex.props(styles.note)}>
|
||||
{readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: "Deleted clients re-materialize on their next DNS query."}
|
||||
</span>
|
||||
{readOnly && client.hand_edited ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
)}
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
onClick={() => setEditing(client)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
Edit
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(client)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -256,6 +223,24 @@ export default function ClientsPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete client"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: deleteLocked
|
||||
? declaredDeleteNote(authority)
|
||||
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
|
||||
onConfirm={() => {
|
||||
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
<InlineError error={deleteMutation.error} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||
|
||||
Reference in New Issue
Block a user