132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
|
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
|
import { formatTime } from "@/lib/format";
|
|
import type { Client } from "@/lib/types";
|
|
import ClientEditDialog from "./ClientEditDialog";
|
|
import PrefixesEditor from "./PrefixesEditor";
|
|
import InlineError from "@/lib/InlineError";
|
|
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
|
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
|
|
|
const cellClass = "px-3 py-2";
|
|
|
|
/**
|
|
* Deleting an observed row discards runtime state the file never declared, so
|
|
* it stays live under file authority; deleting a hand-edited row contradicts
|
|
* the file and is the one client DELETE the server answers 403 (ruling 7).
|
|
*/
|
|
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
|
|
|
export default function ClientsPage() {
|
|
const { data: clients } = useSuspenseQuery(clientsQuery());
|
|
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
|
const queryClient = useQueryClient();
|
|
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
|
const [editing, setEditing] = useState<Client | null>(null);
|
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
|
const readOnly = useReadOnlyConfig();
|
|
|
|
return (
|
|
<section>
|
|
<h1 className="text-2xl font-semibold">Clients</h1>
|
|
{clients.length === 0 ? (
|
|
<p className="mt-4 text-zinc-500">
|
|
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
|
nothing to create by hand.
|
|
</p>
|
|
) : (
|
|
<div className={tableWrapClass}>
|
|
<table className="w-full min-w-[48rem] text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
|
|
<th className={cellClass}>IP</th>
|
|
<th className={cellClass}>Name</th>
|
|
<th className={cellClass}>Group</th>
|
|
<th className={cellClass}>First seen</th>
|
|
<th className={cellClass}>Last seen</th>
|
|
<th className={cellClass}>
|
|
<span className="sr-only">Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{clients.map((client) => (
|
|
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
|
|
<td className={`${cellClass} font-mono`}>{client.ip}</td>
|
|
<td className={cellClass}>
|
|
{client.name === "" ? <span className="text-zinc-400">—</span> : client.name}
|
|
{client.hand_edited && (
|
|
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
edited
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className={cellClass}>{client.group}</td>
|
|
<td className={cellClass}>{formatTime(client.first_seen)}</td>
|
|
<td className={cellClass}>{formatTime(client.last_seen)}</td>
|
|
<td className={`${cellClass} text-right`}>
|
|
{confirmingId === client.id ? (
|
|
<span className="inline-flex flex-wrap items-center justify-end gap-2">
|
|
<span className="text-xs text-zinc-500">
|
|
Deleted clients re-materialize on their next DNS query.
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setConfirmingId(null);
|
|
deleteMutation.mutate(client.id);
|
|
}}
|
|
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
|
>
|
|
Confirm delete
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmingId(null)}
|
|
className={smallButtonClass}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(client)}
|
|
disabled={readOnly}
|
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
className={`${smallButtonClass} disabled:opacity-50`}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmingId(client.id)}
|
|
disabled={readOnly && client.hand_edited}
|
|
title={
|
|
readOnly && client.hand_edited
|
|
? DECLARED_CLIENT_NOTE
|
|
: undefined
|
|
}
|
|
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
|
|
>
|
|
Delete
|
|
</button>
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<InlineError error={deleteMutation.error} />
|
|
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
|
<PrefixesEditor prefixes={prefixes} groups={groups} />
|
|
</section>
|
|
);
|
|
}
|