milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useRouter, useSearch } from "@tanstack/react-router";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
|
||||
|
||||
export function safeRedirect(raw: string | undefined): string {
|
||||
if (raw === undefined) return "/";
|
||||
@@ -94,13 +95,13 @@ export default function LoginPage() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || lockedOut}
|
||||
className="w-full rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
className={`w-full ${largePrimaryButtonClass}`}
|
||||
>
|
||||
{busy ? "Logging in…" : "Log in"}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
|
||||
const INPUT_CLASS =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
|
||||
interface BlocklistFormProps {
|
||||
initial?: Blocklist;
|
||||
@@ -45,7 +43,7 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
|
||||
required
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -58,27 +56,24 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
className={focusRing}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
{initial === undefined ? "Add source" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
>
|
||||
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -13,9 +13,15 @@ import {
|
||||
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import SourceStatusSection from "./SourceStatusSection";
|
||||
|
||||
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
import {
|
||||
dangerLinkButtonClass,
|
||||
focusRing,
|
||||
linkButtonClass,
|
||||
primaryButtonClass,
|
||||
tableWrapClass,
|
||||
tdClass,
|
||||
thClass,
|
||||
} from "@/ui/classes";
|
||||
|
||||
export default function BlocklistsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -66,7 +72,7 @@ export default function BlocklistsPage() {
|
||||
type="button"
|
||||
onClick={() => updateNow.mutate()}
|
||||
disabled={updateNow.isPending}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{updateNow.isPending ? "Updating…" : "Update now"}
|
||||
</button>
|
||||
@@ -81,18 +87,18 @@ export default function BlocklistsPage() {
|
||||
{blocklists.length === 0 ? (
|
||||
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full min-w-max border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>Name</th>
|
||||
<th className={TH_CLASS}>URL</th>
|
||||
<th className={TH_CLASS}>Enabled</th>
|
||||
<th className={TH_CLASS}>Domains</th>
|
||||
<th className={TH_CLASS}>Wildcards</th>
|
||||
<th className={TH_CLASS}>Skipped regex</th>
|
||||
<th className={TH_CLASS}>Last updated</th>
|
||||
<th className={TH_CLASS}>
|
||||
<th className={thClass}>Name</th>
|
||||
<th className={thClass}>URL</th>
|
||||
<th className={thClass}>Enabled</th>
|
||||
<th className={thClass}>Domains</th>
|
||||
<th className={thClass}>Wildcards</th>
|
||||
<th className={thClass}>Skipped regex</th>
|
||||
<th className={thClass}>Last updated</th>
|
||||
<th className={thClass}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -100,7 +106,7 @@ export default function BlocklistsPage() {
|
||||
<tbody>
|
||||
{blocklists.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
{b.is_suggested && (
|
||||
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
@@ -108,32 +114,33 @@ export default function BlocklistsPage() {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<span className="block max-w-72 truncate" title={b.url}>
|
||||
{b.url}
|
||||
</span>
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
className={focusRing}
|
||||
/>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{b.domain_count}</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{b.wildcard_count}</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{b.skipped_regex_count}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
|
||||
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
|
||||
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
|
||||
<td className={tdClass}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(b)}
|
||||
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"
|
||||
className={linkButtonClass}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -141,7 +148,7 @@ export default function BlocklistsPage() {
|
||||
type="button"
|
||||
onClick={() => deleteBlocklist(b)}
|
||||
disabled={remove.isPending}
|
||||
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { SourceStatus } from "@/lib/types";
|
||||
|
||||
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
import { tdClass, thClass } from "@/ui/classes";
|
||||
|
||||
function formatAttempt(unixSeconds: number): string {
|
||||
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
|
||||
@@ -28,26 +26,26 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
|
||||
<table className="w-full min-w-max border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>Source</th>
|
||||
<th className={TH_CLASS}>State</th>
|
||||
<th className={TH_CLASS}>Last attempt</th>
|
||||
<th className={TH_CLASS}>Last success</th>
|
||||
<th className={TH_CLASS}>Domains</th>
|
||||
<th className={TH_CLASS}>Wildcards</th>
|
||||
<th className={TH_CLASS}>Skipped regex</th>
|
||||
<th className={TH_CLASS}>Last error</th>
|
||||
<th className={thClass}>Source</th>
|
||||
<th className={thClass}>State</th>
|
||||
<th className={thClass}>Last attempt</th>
|
||||
<th className={thClass}>Last success</th>
|
||||
<th className={thClass}>Domains</th>
|
||||
<th className={thClass}>Wildcards</th>
|
||||
<th className={thClass}>Skipped regex</th>
|
||||
<th className={thClass}>Last error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
|
||||
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
|
||||
{source.url}
|
||||
</span>
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<span
|
||||
className={
|
||||
source.loaded
|
||||
@@ -58,12 +56,12 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
|
||||
{source.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className={TD_CLASS}>{formatAttempt(source.last_attempt)}</td>
|
||||
<td className={TD_CLASS}>{formatAttempt(source.last_success)}</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{source.domains}</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{source.wildcards}</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{source.skipped_regex}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td>
|
||||
<td className={tdClass}>{formatAttempt(source.last_success)}</td>
|
||||
<td className={`${tdClass} tabular-nums`}>{source.domains}</td>
|
||||
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td>
|
||||
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td>
|
||||
<td className={tdClass}>
|
||||
{source.last_error === "" ? (
|
||||
<span className="text-zinc-400">—</span>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { clientUpdateMutation } from "@/lib/queries";
|
||||
import type { Client, Group } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
@@ -10,8 +11,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
|
||||
|
||||
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -44,7 +44,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
className={dialogInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
@@ -53,7 +53,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
<select
|
||||
value={String(groupId)}
|
||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
||||
className={inputClass}
|
||||
className={dialogInputClass}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={String(group.id)}>
|
||||
@@ -64,18 +64,10 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
</label>
|
||||
<InlineError error={mutation.error} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
>
|
||||
<button type="button" onClick={onClose} className={buttonClass}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,9 @@ 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";
|
||||
|
||||
const cellClass = "px-3 py-2";
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||
@@ -29,7 +28,7 @@ export default function ClientsPage() {
|
||||
nothing to create by hand.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<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">
|
||||
@@ -70,14 +69,14 @@ export default function ClientsPage() {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
className={buttonClass}
|
||||
className={smallButtonClass}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -87,14 +86,14 @@ export default function ClientsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
className={buttonClass}
|
||||
className={smallButtonClass}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -4,14 +4,13 @@ import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
||||
|
||||
interface Props {
|
||||
prefixes: ClientPrefix[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||
|
||||
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
@@ -50,7 +49,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
|
||||
}
|
||||
className={`${inputClass} w-52`}
|
||||
className={`${smallInputClass} w-52`}
|
||||
/>
|
||||
<select
|
||||
aria-label={`Group for prefix ${index + 1}`}
|
||||
@@ -58,7 +57,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
|
||||
}
|
||||
className={inputClass}
|
||||
className={smallInputClass}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={String(group.id)}>
|
||||
@@ -75,12 +74,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
|
||||
}
|
||||
className={`${inputClass} w-20`}
|
||||
className={`${smallInputClass} w-20`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "remove", index })}
|
||||
className="rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:text-red-400"
|
||||
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@@ -98,7 +97,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "add", groupId: defaultGroupId })}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
className={buttonClass}
|
||||
>
|
||||
Add prefix
|
||||
</button>
|
||||
@@ -106,7 +105,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || mutation.isPending}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Save prefixes
|
||||
</button>
|
||||
@@ -117,7 +116,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
className={buttonClass}
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
|
||||
import type { Period } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
import DiskCard from "./DiskCard";
|
||||
import HealthBanners from "./HealthBanners";
|
||||
import StatCards from "./StatCards";
|
||||
@@ -20,7 +21,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
|
||||
type="button"
|
||||
aria-pressed={option === period}
|
||||
onClick={() => onChange(option)}
|
||||
className={`rounded px-2.5 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
|
||||
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${
|
||||
option === period
|
||||
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
@@ -33,18 +34,6 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
|
||||
);
|
||||
}
|
||||
|
||||
function InlineError({ error, onRetry }: { error: unknown; onRetry: () => void }) {
|
||||
const message = error instanceof ApiError ? error.message : "request failed";
|
||||
return (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
|
||||
Failed to load: {message}{" "}
|
||||
<button type="button" onClick={onRetry} className="font-medium underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton({ height }: { height: number }) {
|
||||
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
|
||||
|
||||
interface Props {
|
||||
groupId: number;
|
||||
@@ -46,6 +47,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
className={focusRing}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
@@ -60,16 +62,12 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
onClick={() =>
|
||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||
}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Save sources
|
||||
</button>
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
>
|
||||
<button type="button" onClick={() => setSelected(null)} className={buttonClass}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -10,15 +10,12 @@ import {
|
||||
import type { Blocklist, Group } from "@/lib/types";
|
||||
import GroupSourcesEditor from "./GroupSourcesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
|
||||
|
||||
const DEFAULT_GROUP_ID = 1;
|
||||
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
||||
|
||||
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:border-zinc-700";
|
||||
const primaryButtonClass =
|
||||
"rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
|
||||
|
||||
export default function GroupsPage() {
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
@@ -47,7 +44,7 @@ export default function GroupsPage() {
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
className={inputClass}
|
||||
className={smallInputClass}
|
||||
/>
|
||||
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
|
||||
Create
|
||||
@@ -94,10 +91,10 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
aria-label={`New name for ${group.name}`}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
className={smallInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={updateMutation.isPending} className={buttonClass}>
|
||||
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
@@ -106,7 +103,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
setName(group.name);
|
||||
setRenaming(false);
|
||||
}}
|
||||
className={buttonClass}
|
||||
className={groupButtonClass}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -119,6 +116,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={updateMutation.isPending}
|
||||
className={focusRing}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: group.id,
|
||||
@@ -133,7 +131,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
className={buttonClass}
|
||||
className={groupButtonClass}
|
||||
>
|
||||
Sources
|
||||
</button>
|
||||
@@ -146,7 +144,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
setName(group.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
className={buttonClass}
|
||||
className={groupButtonClass}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
@@ -159,11 +157,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
setConfirming(false);
|
||||
deleteMutation.mutate(group.id);
|
||||
}}
|
||||
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirming(false)} className={buttonClass}>
|
||||
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
@@ -173,7 +171,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
disabled={isDefault}
|
||||
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||
onClick={() => setConfirming(true)}
|
||||
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||
import { RING_CAPACITY } from "./ringBuffer";
|
||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes";
|
||||
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
const toolbarButtonClass = `${buttonClass} font-medium`;
|
||||
|
||||
function StatusPill({ status }: { status: StreamStatus }) {
|
||||
const styles: Record<StreamStatus, [string, string]> = {
|
||||
@@ -30,7 +30,12 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">Live</h1>
|
||||
<StatusPill status={live.status} />
|
||||
<button type="button" onClick={live.toggleFreeze} aria-pressed={live.frozen} className={buttonClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={live.toggleFreeze}
|
||||
aria-pressed={live.frozen}
|
||||
className={toolbarButtonClass}
|
||||
>
|
||||
{live.frozen ? "Resume" : "Freeze"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -73,11 +78,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
The connection failed repeatedly — possibly too many live viewers (the server caps streams per
|
||||
address), or the server is unreachable.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={live.retry}
|
||||
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
|
||||
>
|
||||
<button type="button" onClick={live.retry} className={retryButtonClass}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,7 +92,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
|
||||
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
|
||||
<table className="w-full text-sm">
|
||||
<QueryTableHead />
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import RecordsTab from "@/features/local/RecordsTab";
|
||||
import ZonesTab from "@/features/local/ZonesTab";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
type Tab = "records" | "zones";
|
||||
|
||||
@@ -25,7 +26,7 @@ function TabButton({
|
||||
aria-controls={controls}
|
||||
aria-selected={selected}
|
||||
onClick={onClick}
|
||||
className={`-mb-px border-b-2 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
|
||||
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
|
||||
selected
|
||||
? "border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
localRecordCreateMutation,
|
||||
localRecordDeleteMutation,
|
||||
@@ -8,20 +8,18 @@ import {
|
||||
} from "@/lib/queries";
|
||||
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { useCrudForm } from "@/ui/useCrudForm";
|
||||
import {
|
||||
formCardClass,
|
||||
inputClass,
|
||||
largeButtonClass,
|
||||
largePrimaryButtonClass,
|
||||
rowButtonClass,
|
||||
tableWrapClass,
|
||||
} from "@/ui/classes";
|
||||
|
||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||
|
||||
const inputClass =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const primaryButtonClass =
|
||||
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||
const secondaryButtonClass =
|
||||
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
const rowButtonClass =
|
||||
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
|
||||
|
||||
type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord };
|
||||
|
||||
function RecordForm({
|
||||
initial,
|
||||
busy,
|
||||
@@ -49,10 +47,7 @@ function RecordForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
>
|
||||
<form onSubmit={submit} className={formCardClass}>
|
||||
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
|
||||
@@ -114,10 +109,10 @@ function RecordForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
|
||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -128,50 +123,29 @@ function RecordForm({
|
||||
|
||||
export default function RecordsTab() {
|
||||
const records = useSuspenseQuery(localRecordsQuery()).data;
|
||||
const queryClient = useQueryClient();
|
||||
const create = useMutation(localRecordCreateMutation(queryClient));
|
||||
const update = useMutation(localRecordUpdateMutation(queryClient));
|
||||
const remove = useMutation(localRecordDeleteMutation(queryClient));
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
function openForm(next: FormState) {
|
||||
create.reset();
|
||||
update.reset();
|
||||
setForm(next);
|
||||
}
|
||||
|
||||
function onSubmit(input: LocalRecordInput) {
|
||||
if (form === null) return;
|
||||
if (form.mode === "create") {
|
||||
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||
} else {
|
||||
update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(record: LocalRecord) {
|
||||
if (!window.confirm(`Delete record "${record.name}"?`)) return;
|
||||
remove.mutate(record.id);
|
||||
}
|
||||
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
|
||||
LocalRecord,
|
||||
LocalRecordInput
|
||||
>({
|
||||
create: localRecordCreateMutation,
|
||||
update: localRecordUpdateMutation,
|
||||
remove: localRecordDeleteMutation,
|
||||
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
|
||||
Add record
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<RecordForm
|
||||
busy={create.isPending}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => setForm(null)}
|
||||
/>
|
||||
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
)}
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
||||
@@ -202,14 +176,14 @@ export default function RecordsTab() {
|
||||
)}
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
||||
{form?.mode === "edit" && form.record.id === record.id ? (
|
||||
{form?.mode === "edit" && form.entity.id === record.id ? (
|
||||
<td colSpan={5}>
|
||||
<RecordForm
|
||||
initial={record}
|
||||
busy={update.isPending}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => setForm(null)}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
@@ -221,7 +195,7 @@ export default function RecordsTab() {
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", record })}
|
||||
onClick={() => openForm({ mode: "edit", entity: record })}
|
||||
className={rowButtonClass}
|
||||
>
|
||||
Edit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
forwardZoneCreateMutation,
|
||||
forwardZoneDeleteMutation,
|
||||
@@ -8,17 +8,15 @@ import {
|
||||
} from "@/lib/queries";
|
||||
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
|
||||
const inputClass =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const primaryButtonClass =
|
||||
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||
const secondaryButtonClass =
|
||||
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
const rowButtonClass =
|
||||
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
|
||||
|
||||
type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
|
||||
import { useCrudForm } from "@/ui/useCrudForm";
|
||||
import {
|
||||
formCardClass,
|
||||
inputClass,
|
||||
largeButtonClass,
|
||||
largePrimaryButtonClass,
|
||||
rowButtonClass,
|
||||
tableWrapClass,
|
||||
} from "@/ui/classes";
|
||||
|
||||
function ZoneForm({
|
||||
initial,
|
||||
@@ -43,10 +41,7 @@ function ZoneForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
>
|
||||
<form onSubmit={submit} className={formCardClass}>
|
||||
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
|
||||
@@ -75,10 +70,10 @@ function ZoneForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
|
||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -89,31 +84,15 @@ function ZoneForm({
|
||||
|
||||
export default function ZonesTab() {
|
||||
const zones = useSuspenseQuery(forwardZonesQuery()).data;
|
||||
const queryClient = useQueryClient();
|
||||
const create = useMutation(forwardZoneCreateMutation(queryClient));
|
||||
const update = useMutation(forwardZoneUpdateMutation(queryClient));
|
||||
const remove = useMutation(forwardZoneDeleteMutation(queryClient));
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
function openForm(next: FormState) {
|
||||
create.reset();
|
||||
update.reset();
|
||||
setForm(next);
|
||||
}
|
||||
|
||||
function onSubmit(input: ForwardZoneInput) {
|
||||
if (form === null) return;
|
||||
if (form.mode === "create") {
|
||||
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||
} else {
|
||||
update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(zone: ForwardZone) {
|
||||
if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
|
||||
remove.mutate(zone.id);
|
||||
}
|
||||
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
|
||||
ForwardZone,
|
||||
ForwardZoneInput
|
||||
>({
|
||||
create: forwardZoneCreateMutation,
|
||||
update: forwardZoneUpdateMutation,
|
||||
remove: forwardZoneDeleteMutation,
|
||||
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -121,20 +100,15 @@ export default function ZonesTab() {
|
||||
<p className="text-sm text-zinc-500">
|
||||
Names under these zones go to their own resolver. Changes apply live.
|
||||
</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
|
||||
Add zone
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<ZoneForm
|
||||
busy={create.isPending}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => setForm(null)}
|
||||
/>
|
||||
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
)}
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
||||
@@ -159,14 +133,14 @@ export default function ZonesTab() {
|
||||
)}
|
||||
{zones.map((zone) => (
|
||||
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
||||
{form?.mode === "edit" && form.zone.id === zone.id ? (
|
||||
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
||||
<td colSpan={3}>
|
||||
<ZoneForm
|
||||
initial={zone}
|
||||
busy={update.isPending}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => setForm(null)}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
@@ -176,7 +150,7 @@ export default function ZonesTab() {
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", zone })}
|
||||
onClick={() => openForm({ mode: "edit", entity: zone })}
|
||||
className={rowButtonClass}
|
||||
>
|
||||
Edit
|
||||
|
||||
@@ -3,9 +3,7 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||
import type { Group, LookupResult } from "@/lib/types";
|
||||
|
||||
const inputClass =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
|
||||
|
||||
interface Submitted {
|
||||
domain: string;
|
||||
@@ -189,11 +187,7 @@ export default function LookupPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
disabled={lookup.isFetching}
|
||||
>
|
||||
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
|
||||
Look up
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, insetFocusRing } from "@/ui/classes";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
@@ -34,8 +35,7 @@ function useNowSeconds(active: boolean): number {
|
||||
return now;
|
||||
}
|
||||
|
||||
const BUTTON_CLASS =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600";
|
||||
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
|
||||
|
||||
export default function PauseWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -52,7 +52,7 @@ export default function PauseWidget() {
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<button type="button" disabled className={BUTTON_CLASS}>
|
||||
<button type="button" disabled className={triggerButtonClass}>
|
||||
Pause
|
||||
</button>
|
||||
);
|
||||
@@ -69,7 +69,7 @@ export default function PauseWidget() {
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
className={BUTTON_CLASS}
|
||||
className={triggerButtonClass}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
@@ -92,7 +92,7 @@ export default function PauseWidget() {
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
className={BUTTON_CLASS}
|
||||
className={triggerButtonClass}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
@@ -111,7 +111,7 @@ export default function PauseWidget() {
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-800"
|
||||
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
@@ -5,11 +5,10 @@ import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { qtypeName } from "./qtype";
|
||||
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes";
|
||||
|
||||
const inputClass =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
const filterInputClass = `mt-1 w-full ${smallInputClass}`;
|
||||
const toolbarButtonClass = `${buttonClass} font-medium`;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
@@ -134,7 +133,7 @@ export default function QueryLogPage() {
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
className={inputClass}
|
||||
className={filterInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
@@ -143,12 +142,16 @@ export default function QueryLogPage() {
|
||||
type="text"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
className={inputClass}
|
||||
className={filterInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Status
|
||||
<select value={blocked} onChange={(event) => setBlocked(event.target.value)} className={inputClass}>
|
||||
<select
|
||||
value={blocked}
|
||||
onChange={(event) => setBlocked(event.target.value)}
|
||||
className={filterInputClass}
|
||||
>
|
||||
<option value="any">All</option>
|
||||
<option value="blocked">Blocked only</option>
|
||||
<option value="allowed">Allowed only</option>
|
||||
@@ -160,7 +163,7 @@ export default function QueryLogPage() {
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(event) => setSince(event.target.value)}
|
||||
className={inputClass}
|
||||
className={filterInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
@@ -169,14 +172,14 @@ export default function QueryLogPage() {
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
className={inputClass}
|
||||
className={filterInputClass}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
|
||||
<button type="submit" className={buttonClass}>
|
||||
<button type="submit" className={toolbarButtonClass}>
|
||||
Apply filters
|
||||
</button>
|
||||
<button type="button" onClick={clearFilters} className={buttonClass}>
|
||||
<button type="button" onClick={clearFilters} className={toolbarButtonClass}>
|
||||
Clear
|
||||
</button>
|
||||
{base.isFetching && (
|
||||
@@ -197,7 +200,7 @@ export default function QueryLogPage() {
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
|
||||
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
|
||||
<table className="w-full text-sm">
|
||||
<QueryTableHead />
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@@ -219,7 +222,7 @@ export default function QueryLogPage() {
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
className={buttonClass}
|
||||
className={toolbarButtonClass}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
|
||||
@@ -4,11 +4,7 @@ import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
|
||||
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
const INPUT_CLASS =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
||||
|
||||
export default function RulesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -44,16 +40,16 @@ export default function RulesPage() {
|
||||
{rules.length === 0 ? (
|
||||
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full min-w-max border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>Pattern</th>
|
||||
<th className={TH_CLASS}>Kind</th>
|
||||
<th className={TH_CLASS}>Action</th>
|
||||
<th className={TH_CLASS}>Group</th>
|
||||
<th className={TH_CLASS}>Created</th>
|
||||
<th className={TH_CLASS}>
|
||||
<th className={thClass}>Pattern</th>
|
||||
<th className={thClass}>Kind</th>
|
||||
<th className={thClass}>Action</th>
|
||||
<th className={thClass}>Group</th>
|
||||
<th className={thClass}>Created</th>
|
||||
<th className={thClass}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -61,9 +57,9 @@ export default function RulesPage() {
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className={`${TD_CLASS} font-medium`}>{rule.pattern}</td>
|
||||
<td className={TD_CLASS}>{rule.kind}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
|
||||
<td className={tdClass}>{rule.kind}</td>
|
||||
<td className={tdClass}>
|
||||
<span
|
||||
className={
|
||||
rule.action === "allow"
|
||||
@@ -74,14 +70,14 @@ export default function RulesPage() {
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className={TD_CLASS}>{rule.group}</td>
|
||||
<td className={TD_CLASS}>{formatTime(rule.created_at)}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>{rule.group}</td>
|
||||
<td className={tdClass}>{formatTime(rule.created_at)}</td>
|
||||
<td className={tdClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteRule(rule)}
|
||||
disabled={remove.isPending}
|
||||
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -107,7 +103,7 @@ export default function RulesPage() {
|
||||
value={pattern}
|
||||
onChange={(event) => setPattern(event.target.value)}
|
||||
placeholder="ads.example.com or *.example.com"
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
@@ -119,7 +115,7 @@ export default function RulesPage() {
|
||||
id="rule-kind"
|
||||
value={kind}
|
||||
onChange={(event) => setKind(event.target.value as RuleKind)}
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="exact">exact</option>
|
||||
<option value="wildcard">wildcard</option>
|
||||
@@ -133,7 +129,7 @@ export default function RulesPage() {
|
||||
id="rule-action"
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value as RuleAction)}
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="allow">allow</option>
|
||||
<option value="block">block</option>
|
||||
@@ -147,7 +143,7 @@ export default function RulesPage() {
|
||||
id="rule-group"
|
||||
value={groupId}
|
||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
@@ -157,11 +153,7 @@ export default function RulesPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<button type="submit" disabled={create.isPending} className={primaryButtonClass}>
|
||||
{create.isPending ? "Creating…" : "Create rule"}
|
||||
</button>
|
||||
<InlineError error={create.error} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
export default function RestartBanner() {
|
||||
const raised = useRestartBanner();
|
||||
@@ -12,7 +13,7 @@ export default function RestartBanner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissRestartBanner}
|
||||
className="rounded border border-amber-400 px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-amber-700"
|
||||
className={`rounded border border-amber-400 px-2 py-1 text-xs ${focusRing} dark:border-amber-700`}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "./restartBanner";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||
export function patchRequiresRestart(patch: SettingsPatch): boolean {
|
||||
@@ -120,8 +121,7 @@ const SECTIONS: readonly SectionDef[] = [
|
||||
];
|
||||
|
||||
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
|
||||
const INPUT_CLASS =
|
||||
"rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
||||
|
||||
function FieldRow({
|
||||
section,
|
||||
@@ -143,7 +143,7 @@ function FieldRow({
|
||||
type="checkbox"
|
||||
checked={value as boolean}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
className={focusRing}
|
||||
/>
|
||||
<label htmlFor={id} className={LABEL_CLASS}>
|
||||
{def.key}
|
||||
@@ -161,7 +161,7 @@ function FieldRow({
|
||||
id={id}
|
||||
value={value as string}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={fieldInputClass}
|
||||
>
|
||||
{def.kind.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
@@ -184,7 +184,7 @@ function FieldRow({
|
||||
type="number"
|
||||
value={Number.isNaN(numeric) ? "" : numeric}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
className={INPUT_CLASS}
|
||||
className={fieldInputClass}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -199,7 +199,7 @@ function FieldRow({
|
||||
type="text"
|
||||
value={value as string}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={fieldInputClass}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -280,7 +280,7 @@ export default function SettingsPage() {
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={fieldInputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -293,7 +293,7 @@ export default function SettingsPage() {
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={fieldInputClass}
|
||||
/>
|
||||
</div>
|
||||
{password !== "" && (
|
||||
@@ -316,7 +316,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveDisabled}
|
||||
className="rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800"
|
||||
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
|
||||
>
|
||||
{mutation.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
|
||||
const INPUT_CLASS =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
|
||||
const DEFAULT_PRIORITY = "100";
|
||||
|
||||
@@ -57,7 +55,7 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="udp://1.1.1.1:53"
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -70,7 +68,7 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -83,30 +81,27 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
|
||||
value={tlsName}
|
||||
onChange={(event) => setTlsName(event.target.value)}
|
||||
placeholder="one.one.one.one"
|
||||
className={INPUT_CLASS}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
className={focusRing}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
>
|
||||
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -5,9 +5,7 @@ import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation,
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "../settings/restartBanner";
|
||||
import UpstreamForm from "./UpstreamForm";
|
||||
|
||||
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
||||
|
||||
export default function UpstreamsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -59,15 +57,15 @@ export default function UpstreamsPage() {
|
||||
{upstreams.length === 0 ? (
|
||||
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full min-w-max border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>URL</th>
|
||||
<th className={TH_CLASS}>Priority</th>
|
||||
<th className={TH_CLASS}>Enabled</th>
|
||||
<th className={TH_CLASS}>TLS name</th>
|
||||
<th className={TH_CLASS}>
|
||||
<th className={thClass}>URL</th>
|
||||
<th className={thClass}>Priority</th>
|
||||
<th className={thClass}>Enabled</th>
|
||||
<th className={thClass}>TLS name</th>
|
||||
<th className={thClass}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -75,28 +73,29 @@ export default function UpstreamsPage() {
|
||||
<tbody>
|
||||
{upstreams.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>
|
||||
<span className="block max-w-72 truncate font-medium" title={u.url}>
|
||||
{u.url}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{u.priority}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={`${tdClass} tabular-nums`}>{u.priority}</td>
|
||||
<td className={tdClass}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
className={focusRing}
|
||||
/>
|
||||
</td>
|
||||
<td className={TD_CLASS}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
<td className={tdClass}>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(u)}
|
||||
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"
|
||||
className={linkButtonClass}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -104,7 +103,7 @@ export default function UpstreamsPage() {
|
||||
type="button"
|
||||
onClick={() => deleteUpstream(u)}
|
||||
disabled={remove.isPending}
|
||||
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import InlineError from "./InlineError";
|
||||
|
||||
test("no retry button without onRetry", () => {
|
||||
render(<InlineError error={new ApiError(409, "already exists")} />);
|
||||
expect(screen.getByRole("alert").textContent).toBe("already exists");
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||
});
|
||||
|
||||
test("onRetry renders a focusable retry button that calls back", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Retry" });
|
||||
expect(button.className).toContain("focus-visible:outline-2");
|
||||
fireEvent.click(button);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a null error renders nothing even with onRetry", () => {
|
||||
const { container } = render(<InlineError error={null} onRetry={() => undefined} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
/** Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with countdown. */
|
||||
export default function InlineError({ error }: { error: unknown }) {
|
||||
/**
|
||||
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
|
||||
* countdown. Pass `onRetry` to append a retry button for a failed query.
|
||||
*/
|
||||
export default function InlineError({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
|
||||
const [remaining, setRemaining] = useState<number | null>(retryAfter);
|
||||
|
||||
@@ -34,6 +38,14 @@ export default function InlineError({ error }: { error: unknown }) {
|
||||
return (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{message}
|
||||
{onRetry !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
<button type="button" onClick={onRetry} className={`font-medium underline ${focusRing}`}>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-5
@@ -27,6 +27,7 @@ import {
|
||||
upstreamHealthQuery,
|
||||
upstreamsQuery,
|
||||
} from "@/lib/queries";
|
||||
import { retryButtonClass } from "@/ui/classes";
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
@@ -64,11 +65,7 @@ function RouteError({ error }: ErrorComponentProps) {
|
||||
>
|
||||
<h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void router.invalidate()}
|
||||
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
|
||||
>
|
||||
<button type="button" onClick={() => void router.invalidate()} className={retryButtonClass}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import InlineError from "@/lib/InlineError";
|
||||
import { versionQuery } from "@/lib/queries";
|
||||
import PauseWidget from "../features/pause/PauseWidget";
|
||||
import RestartBanner from "../features/settings/RestartBanner";
|
||||
import { buttonClass, focusRing } from "@/ui/classes";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
@@ -38,7 +39,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
className:
|
||||
"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100",
|
||||
}}
|
||||
className="block rounded px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
className={`block rounded px-3 py-1.5 ${focusRing}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
@@ -73,7 +74,7 @@ function LogoutButton() {
|
||||
(logoutError: unknown) => setError(logoutError),
|
||||
);
|
||||
}}
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||
className={buttonClass}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
@@ -97,7 +98,7 @@ export default function AppShell() {
|
||||
<header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 md:hidden dark:border-zinc-700"
|
||||
className={`${buttonClass} md:hidden`}
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="mobile-nav"
|
||||
onClick={() => setDrawerOpen((open) => !open)}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The shared Tailwind class vocabulary (milestone 18, ruling 10).
|
||||
*
|
||||
* Every interactive element must carry `focusRing`; that is the milestone-9
|
||||
* accessibility floor. Compose these constants for one-off variants
|
||||
* (`` `${buttonClass} md:hidden` ``) instead of re-spelling the literal.
|
||||
*/
|
||||
|
||||
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||
|
||||
/** The ring drawn inside the element, for controls flush against a panel edge. */
|
||||
export const insetFocusRing = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600";
|
||||
|
||||
export const inputClass = `mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
||||
export const smallInputClass = `rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
||||
|
||||
export const buttonClass = `rounded border border-zinc-300 px-3 py-1.5 text-sm ${focusRing} dark:border-zinc-700`;
|
||||
export const smallButtonClass = `rounded border border-zinc-300 px-2 py-1 text-sm ${focusRing} dark:border-zinc-700`;
|
||||
export const largeButtonClass = `rounded border border-zinc-300 px-3 py-2 font-medium ${focusRing} dark:border-zinc-700`;
|
||||
|
||||
export const primaryButtonClass = `rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${focusRing}`;
|
||||
export const largePrimaryButtonClass = `rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 ${focusRing}`;
|
||||
|
||||
export const rowButtonClass = `rounded px-2 py-1 text-sm text-blue-600 ${focusRing} dark:text-blue-400`;
|
||||
export const linkButtonClass = `text-sm font-medium text-blue-600 ${focusRing} dark:text-blue-400`;
|
||||
export const dangerLinkButtonClass = `text-sm font-medium text-red-600 disabled:opacity-50 ${focusRing} dark:text-red-400`;
|
||||
export const retryButtonClass = `mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 ${focusRing} dark:border-red-800 dark:text-red-200`;
|
||||
|
||||
export const thClass = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
export const tdClass = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
export const tableWrapClass = "mt-4 overflow-x-auto";
|
||||
export const formCardClass = "mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800";
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
|
||||
import { useCrudForm } from "./useCrudForm";
|
||||
|
||||
interface Row {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const created: string[] = [];
|
||||
const updated: { id: number; input: string }[] = [];
|
||||
const removed: number[] = [];
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useCrudForm<Row, string>({
|
||||
create: (_qc: QC) => ({
|
||||
mutationFn: async (input: string) => {
|
||||
created.push(input);
|
||||
},
|
||||
}),
|
||||
update: (_qc: QC) => ({
|
||||
mutationFn: async (vars: { id: number; input: string }) => {
|
||||
updated.push(vars);
|
||||
},
|
||||
}),
|
||||
remove: (_qc: QC) => ({
|
||||
mutationFn: async (id: number) => {
|
||||
removed.push(id);
|
||||
},
|
||||
}),
|
||||
confirmDelete: (row) => `Delete "${row.name}"?`,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
return { hook, created, updated, removed };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test("the create form submits through the create mutation and closes", async () => {
|
||||
const { hook, created, updated } = setup();
|
||||
act(() => hook.result.current.openForm({ mode: "create" }));
|
||||
expect(hook.result.current.form).toEqual({ mode: "create" });
|
||||
|
||||
act(() => hook.result.current.onSubmit("alpha"));
|
||||
await waitFor(() => expect(hook.result.current.form).toBeNull());
|
||||
expect(created).toEqual(["alpha"]);
|
||||
expect(updated).toEqual([]);
|
||||
});
|
||||
|
||||
test("the edit form submits the row id through the update mutation", async () => {
|
||||
const { hook, created, updated } = setup();
|
||||
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 7, name: "beta" } }));
|
||||
|
||||
act(() => hook.result.current.onSubmit("beta2"));
|
||||
await waitFor(() => expect(hook.result.current.form).toBeNull());
|
||||
expect(updated).toEqual([{ id: 7, input: "beta2" }]);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
test("submitting with no form open does nothing", () => {
|
||||
const { hook, created, updated } = setup();
|
||||
act(() => hook.result.current.onSubmit("ignored"));
|
||||
expect(created).toEqual([]);
|
||||
expect(updated).toEqual([]);
|
||||
});
|
||||
|
||||
test("openForm clears a stale create error so the reopened form starts clean", async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useCrudForm<Row, string>({
|
||||
create: (_qc: QC) => ({ mutationFn: () => Promise.reject(new Error("boom")) }),
|
||||
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||
remove: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||
confirmDelete: () => "?",
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => hook.result.current.openForm({ mode: "create" }));
|
||||
act(() => hook.result.current.onSubmit("alpha"));
|
||||
await waitFor(() => expect(hook.result.current.create.error).not.toBeNull());
|
||||
expect(hook.result.current.form).toEqual({ mode: "create" });
|
||||
|
||||
act(() => hook.result.current.openForm({ mode: "create" }));
|
||||
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
|
||||
});
|
||||
|
||||
test("delete asks for confirmation and only removes when confirmed", async () => {
|
||||
const { hook, removed } = setup();
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("confirm", confirm);
|
||||
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
|
||||
expect(removed).toEqual([]);
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
await waitFor(() => expect(removed).toEqual([3]));
|
||||
});
|
||||
|
||||
test("closeForm closes whichever form is open", () => {
|
||||
const { hook } = setup();
|
||||
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 1, name: "delta" } }));
|
||||
act(() => hook.result.current.closeForm());
|
||||
expect(hook.result.current.form).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions } from "@tanstack/react-query";
|
||||
|
||||
/** Which form is open: none, the create form, or the edit form for one row. */
|
||||
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
|
||||
|
||||
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
|
||||
|
||||
export interface CrudFormSpec<Entity extends { id: number }, Input> {
|
||||
create: MutationFactory<Input>;
|
||||
update: MutationFactory<{ id: number; input: Input }>;
|
||||
remove: MutationFactory<number>;
|
||||
/** The `window.confirm` text shown before a delete. */
|
||||
confirmDelete: (entity: Entity) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
|
||||
* three mutations, the open-form state, and the three handlers. The field JSX
|
||||
* and the table stay in the caller.
|
||||
*/
|
||||
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
|
||||
const queryClient = useQueryClient();
|
||||
const create = useMutation(spec.create(queryClient));
|
||||
const update = useMutation(spec.update(queryClient));
|
||||
const remove = useMutation(spec.remove(queryClient));
|
||||
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
|
||||
|
||||
function openForm(next: CrudFormState<Entity>) {
|
||||
create.reset();
|
||||
update.reset();
|
||||
setForm(next);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setForm(null);
|
||||
}
|
||||
|
||||
function onSubmit(input: Input) {
|
||||
if (form === null) return;
|
||||
if (form.mode === "create") {
|
||||
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||
} else {
|
||||
update.mutate({ id: form.entity.id, input }, { onSuccess: () => setForm(null) });
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(entity: Entity) {
|
||||
if (!window.confirm(spec.confirmDelete(entity))) return;
|
||||
remove.mutate(entity.id);
|
||||
}
|
||||
|
||||
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
|
||||
}
|
||||
Reference in New Issue
Block a user