Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
281 lines
7.0 KiB
TypeScript
281 lines
7.0 KiB
TypeScript
import { useId, useState, type FormEvent } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import {
|
|
forwardZoneCreateMutation,
|
|
forwardZoneDeleteMutation,
|
|
forwardZoneUpdateMutation,
|
|
forwardZonesQuery,
|
|
} from "@/lib/queries";
|
|
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
|
import InlineError from "@/lib/InlineError";
|
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
|
import { useCrudForm } from "@/ui/useCrudForm";
|
|
import QueryPanel from "./QueryPanel";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
|
|
const styles = stylex.create({
|
|
formHeading: {
|
|
fontWeight: 500,
|
|
},
|
|
fieldLabel: {
|
|
display: "block",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
fontWeight: 500,
|
|
},
|
|
buttonRow: {
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
},
|
|
toolbar: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: "1rem",
|
|
},
|
|
intro: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
table: {
|
|
width: "100%",
|
|
textAlign: "left",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
headRow: {
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
color: colors.textMuted,
|
|
},
|
|
headCell: {
|
|
paddingBlock: "0.5rem",
|
|
paddingRight: "1rem",
|
|
fontWeight: 500,
|
|
},
|
|
headCellLast: {
|
|
paddingBlock: "0.5rem",
|
|
},
|
|
bodyRow: {
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
},
|
|
cell: {
|
|
paddingBlock: "0.5rem",
|
|
paddingRight: "1rem",
|
|
},
|
|
emptyCell: {
|
|
paddingBlock: "1rem",
|
|
color: colors.textMuted,
|
|
},
|
|
actionCell: {
|
|
paddingBlock: "0.5rem",
|
|
textAlign: "right",
|
|
whiteSpace: "nowrap",
|
|
},
|
|
dangerText: {
|
|
color: colors.danger,
|
|
},
|
|
dimWhenDisabled: {
|
|
opacity: { default: 1, ":disabled": 0.5 },
|
|
},
|
|
});
|
|
|
|
function ZoneForm({
|
|
initial,
|
|
busy,
|
|
error,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
initial?: ForwardZone;
|
|
busy: boolean;
|
|
error: unknown;
|
|
onSubmit: (input: ForwardZoneInput) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const id = useId();
|
|
const [zone, setZone] = useState(initial?.zone ?? "");
|
|
const [resolver, setResolver] = useState(initial?.resolver ?? "");
|
|
|
|
function submit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
|
<h3 {...stylex.props(styles.formHeading)}>
|
|
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
|
|
</h3>
|
|
<div>
|
|
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
|
|
Zone
|
|
</label>
|
|
<input
|
|
id={`${id}-zone`}
|
|
required
|
|
value={zone}
|
|
onChange={(event) => setZone(event.target.value)}
|
|
placeholder="lan.home"
|
|
{...stylex.props(shared.input, shared.focusRing)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
|
|
Resolver
|
|
</label>
|
|
<input
|
|
id={`${id}-resolver`}
|
|
required
|
|
value={resolver}
|
|
onChange={(event) => setResolver(event.target.value)}
|
|
placeholder="udp://192.168.1.1:53"
|
|
{...stylex.props(shared.input, shared.focusRing)}
|
|
/>
|
|
</div>
|
|
<div {...stylex.props(styles.buttonRow)}>
|
|
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
|
|
{busy ? "Saving…" : "Save"}
|
|
</button>
|
|
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<InlineError error={error} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function ZonesTab() {
|
|
const query = useQuery(forwardZonesQuery());
|
|
const {
|
|
create,
|
|
update,
|
|
remove,
|
|
form,
|
|
openForm,
|
|
closeForm,
|
|
onSubmit,
|
|
onDelete,
|
|
pendingDelete,
|
|
confirmPendingDelete,
|
|
cancelPendingDelete,
|
|
} = useCrudForm<ForwardZone, ForwardZoneInput>({
|
|
create: forwardZoneCreateMutation,
|
|
update: forwardZoneUpdateMutation,
|
|
remove: forwardZoneDeleteMutation,
|
|
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<div {...stylex.props(styles.toolbar)}>
|
|
<p {...stylex.props(styles.intro)}>
|
|
Names under these zones go to their own resolver. Changes apply live.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => openForm({ mode: "create" })}
|
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
|
>
|
|
Add zone
|
|
</button>
|
|
</div>
|
|
<InlineError error={remove.error} />
|
|
{form?.mode === "create" && (
|
|
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
|
)}
|
|
<QueryPanel query={query}>
|
|
{(zones) => (
|
|
<div {...stylex.props(shared.tableWrap)}>
|
|
<table {...stylex.props(styles.table)}>
|
|
<thead>
|
|
<tr {...stylex.props(styles.headRow)}>
|
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
|
Zone
|
|
</th>
|
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
|
Resolver
|
|
</th>
|
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{zones.length === 0 && (
|
|
<tr>
|
|
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
|
|
No forward zones yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{zones.map((zone) => (
|
|
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
|
|
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
|
<td colSpan={3}>
|
|
<ZoneForm
|
|
initial={zone}
|
|
busy={update.isPending}
|
|
error={update.error}
|
|
onSubmit={onSubmit}
|
|
onCancel={closeForm}
|
|
/>
|
|
</td>
|
|
) : (
|
|
<>
|
|
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
|
|
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
|
|
<td {...stylex.props(styles.actionCell)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => openForm({ mode: "edit", entity: zone })}
|
|
{...stylex.props(
|
|
shared.rowButton,
|
|
styles.dimWhenDisabled,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDelete(zone)}
|
|
disabled={remove.isPending}
|
|
{...stylex.props(
|
|
shared.rowButton,
|
|
styles.dangerText,
|
|
styles.dimWhenDisabled,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</QueryPanel>
|
|
<ConfirmDialog
|
|
isOpen={pendingDelete !== null}
|
|
title="Delete forward zone"
|
|
message={pendingDelete?.message ?? ""}
|
|
confirmLabel="Delete"
|
|
onConfirm={confirmPendingDelete}
|
|
onCancel={cancelPendingDelete}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|