Files
technitium-console/src/screens/dhcp/Scopes.tsx
T
DenozordecandClaude Sonnet 5.5 4c0a5de7e7 Describe the tables as TanStack columns
Add ui/DataTable, which builds ReUI's DataGrid from `columns` and `data`, and move
all 23 tables onto it. What the grid does not take (a class on a row or cell,
`aria-sort`, `style`, `colSpan`, `role`, `hidden`) is put on the elements it drew
after every render. The column definitions keep their identity between renders, so
cells are not mounted again, and the grid's own paging is off.

The stacked rows on a phone, the column labels and the sort order of the original
work as before. In Blocking > Lists a group heading is a row whose first cell spans
the four columns. docs/reui-migration-plan.md records it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 00:40:53 +07:00

372 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
deleteScope,
disableScope,
enableScope,
getScope,
listScopes,
setScope,
type DhcpScopeRow,
} from '../../api/dhcp'
import { Confirm } from '../../ui/Confirm'
import { Button } from '../../ui/Button'
import { SectionHeader } from '../../ui/SectionHeader'
import { errorNotice, type Notice } from './notices'
import { formFromScope, blankForm, type ScopeForm as Form } from './model'
import { ScopeForm } from './ScopeForm'
import { Failure, Loading } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css'
import styles from './Dhcp.module.css'
import { DataTable } from '../../ui/DataTable'
import { RowAction, useSort, type Keys, head } from '../../ui/Table'
import { Menu } from '../../ui/Menu'
import { noticeFromFailure } from '../../lib/notice'
import { Notifier } from '../../ui/Notifier'
/*
DHCP › Scopes (dhcp.js:201-684).
Four asymmetries of upstream's that look like oversights and are not, and that
are replicated as they are:
1. **Enabling a scope does NOT ask for confirmation; disabling it does**
(dhcp.js:583 vs 615). Only the path that cuts the service asks.
2. **Deleting does not reload the list either**: it removes the row and
recalculates the footer (dhcp.js:662-668). Enable, disable and save do
reload.
3. **The form replaces the table**, it does not open in a modal.
4. **The permission to delete a scope is `Delete`, not `Modify`**
(`WebServiceDhcpApi.cs:761`), while enabling and disabling are `Modify`.
Saving is `Modify`.
*/
export interface ScopesProps {
/** The section sub-navigation, drawn under the header. */
tabs?: ReactNode
token: string | null
node?: string
/** `DhcpServer.canModify`: save, enable and disable. */
canModify?: boolean
/** `DhcpServer.canDelete`: deleting a scope. */
canDelete?: boolean
/** Told whether the node select must be locked: while an existing scope is
* being edited, as dhcp.js:479 disables it and 217 enables it again. */
onLockNode?: (locked: boolean) => void
}
type Editing = { title: 'Add Scope' | 'Edit Scope'; form: Form }
/* `sortTable('tableDhcpScopesBody', 0..3)`. It sorts by the cell's text, which
in the two composite columns is their two label/value pairs. */
const KEYS: Keys<DhcpScopeRow> = {
name: (s) => s.name,
range: (s) => `Range ${s.startingAddress} - ${s.endingAddress} Mask ${s.subnetMask}`,
network: (s) => `Network ${s.networkAddress} Broadcast ${s.broadcastAddress}`,
interface: (s) => s.interfaceAddress ?? '',
}
export function Scopes({
tabs,
token,
node = '',
canModify = true,
canDelete = true,
onLockNode,
}: ScopesProps) {
const [scopes, setScopes] = useState<DhcpScopeRow[] | null>(null)
/*
Whether the last load failed.
The data is thrown away, as it was before (this screen does not keep the
previous one), so there is nothing here to "go stale" and it does not carry the
other collections' strip: inheriting the archetype's look is not inheriting
behaviour it does not have.
What does change is HOW the gap is drawn. It used to say "No Scope Found" when
the call had fallen over, which is the same thing a server with no scopes shows.
Now the failure is said, with the message the server sent.
*/
const [failure, setFailure] = useState(false)
const [notice, setNotice] = useState<Notice | null>(null)
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState<Editing | null>(null)
/* Only an existing scope locks the node: Add Scope leaves it alone. */
const locked = editing?.title === 'Edit Scope'
useEffect(() => {
onLockNode?.(locked)
}, [locked, onLockNode])
useEffect(() => () => onLockNode?.(false), [onLockNode])
const [confirm, setConfirm] = useState<{ action: 'disable' | 'delete'; name: string } | null>(
null,
)
// A failure on load is not drawn as an empty list; see `Leases`.
const load = useCallback(async () => {
setScopes(null)
const r = await listScopes(token, node)
if (r.kind === 'ok') {
setScopes(r.data)
setFailure(false)
return
}
setScopes([])
setFailure(true)
setNotice(noticeFromFailure(r))
}, [token, node])
useEffect(() => {
void load()
}, [load])
// The hook goes BEFORE any return: otherwise it would stop being called as soon
// as the table is loading.
const { rows: visibleScopes, sort, toggle } = useSort(KEYS, scopes ?? [])
async function edit(name: string) {
setBusy(true)
const s = await getScope(token, name, node)
setBusy(false)
if (s == null) return
setEditing({ title: 'Edit Scope', form: formFromScope(s) })
}
async function save(body: Record<string, string>) {
setBusy(true)
const outcome = await setScope(token, body, node)
setBusy(false)
if (outcome.kind !== 'ok') {
setNotice(errorNotice(outcome))
return
}
setEditing(null)
await load()
setNotice({
type: 'success',
title: 'Scope Saved!',
text: 'DHCP Scope was saved successfully.',
})
}
async function enable(name: string) {
setBusy(true)
const outcome = await enableScope(token, name, node)
setBusy(false)
if (outcome.kind !== 'ok') return
await load()
setNotice({
type: 'success',
title: 'Scope Enabled!',
text: 'DHCP Scope was enabled successfully.',
})
}
async function disable(name: string) {
setConfirm(null)
setBusy(true)
const outcome = await disableScope(token, name, node)
setBusy(false)
if (outcome.kind !== 'ok') return
await load()
setNotice({
type: 'success',
title: 'Scope Disabled!',
text: 'DHCP Scope was disabled successfully.',
})
}
async function remove(name: string) {
setConfirm(null)
setBusy(true)
const outcome = await deleteScope(token, name, node)
setBusy(false)
if (outcome.kind !== 'ok') return
// dhcp.js:662. It removes the row; it does not ask for the list again.
setScopes((prev) => prev?.filter((s) => s.name !== name) ?? prev)
setNotice({
type: 'success',
title: 'Scope Deleted!',
text: 'DHCP Scope was deleted successfully.',
})
}
if (editing != null) {
return (
<div className={styles.wrap}>
<Notifier notice={notice} onClose={() => setNotice(null)} />
<ScopeForm
key={editing.title + editing.form.oldName}
title={editing.title}
initial={editing.form}
busy={busy}
onSave={(body) => void save(body)}
onCancel={() => {
setEditing(null)
void load()
}}
onNotice={(e) => setNotice({ type: 'warning', title: e.title, text: e.text })}
/>
</div>
)
}
if (scopes == null) return <Loading />
return (
<div className={styles.wrap}>
<SectionHeader
title="DHCP"
tabs={tabs}
actions={<>{canModify && (
<Button
variant="primary"
onClick={() => {
setNotice(null)
setEditing({ title: 'Add Scope', form: blankForm() })
}}
>
Add Scope
</Button>
)}</>}
/>
<Notifier notice={notice} onClose={() => setNotice(null)} />
{failure ? (
/* Solid, not dashed, and with no count: how many there are is not known.
The server's message goes in the notice at the top. */
<Failure>Could not load the list.</Failure>
) : (
<>
<DataTable
data={visibleScopes}
getRowId={(s) => String(s.name)}
emptyText="No Scope Found"
stack
columns={[
{
id: 'name',
...head({ field: 'name', sort, onSort: toggle, children: 'Name' }),
td: () => ({ className: `${styles.name} ${tbl.lead}` }),
cell: (s) => s.name,
},
{
id: 'range',
...head({ field: 'range', sort, onSort: toggle, children: 'Scope Range/Subnet Mask' }),
cell: (s) => (
<dl className={styles.kv}>
<dt>Range</dt>
<dd>
{s.startingAddress} - {s.endingAddress}
</dd>
<dt>Mask</dt>
<dd>{s.subnetMask}</dd>
</dl>
),
},
{
id: 'network',
...head({ field: 'network', sort, onSort: toggle, children: 'Network/Broadcast' }),
cell: (s) => (
<dl className={styles.kv}>
<dt>Network</dt>
<dd>{s.networkAddress}</dd>
<dt>Broadcast</dt>
<dd>{s.broadcastAddress}</dd>
</dl>
),
},
{
id: 'interface',
...head({ field: 'interface', sort, onSort: toggle, children: 'Interface' }),
td: () => ({ className: styles.mono }),
cell: (s) => s.interfaceAddress ?? '',
},
{
id: 'c4',
header: 'Status',
cell: (s) => (
<Tag tone={s.enabled ? 'ok' : 'neutral'}>
{s.enabled ? 'Enabled' : 'Disabled'}
</Tag>
),
},
{
id: 'c5',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (s) => (
<div className={tbl.actions}>
<RowAction
icon="edit"
name="Edit Scope"
disabled={busy}
onClick={() => void edit(s.name)}
/>
{canModify && (
/* dhcp.js:615. Enabling asks nothing; disabling does. */
<RowAction
icon="power"
name={s.enabled ? 'Disable Scope' : 'Enable Scope'}
disabled={busy}
onClick={() =>
s.enabled
? setConfirm({ action: 'disable', name: s.name })
: void enable(s.name)
}
/>
)}
{canDelete && (
<Menu label={`Actions for ${s.name}`}>
{(close) => (
<button
type="button"
data-variant="danger"
disabled={busy}
onClick={() => { close(); setConfirm({ action: 'delete', name: s.name }) }}
>
Delete Scope
</button>
)}
</Menu>
)}
</div>
),
},
]}
/>
<div className={styles.total}>
{/* The footer is the count and nothing else. When there are no rows,
the one that says so is the table itself, with its centred row, like
the rest of the console and like upstream (`dhcp.js:74`); here it was
left floating outside the panel, under a table with a blank body. */}
<span>{`Total Scopes: ${scopes.length}`}</span>
</div>
</>
)}
<Confirm
open={confirm !== null}
title={confirm?.action === 'delete' ? 'Delete Scope' : 'Disable Scope'}
text={
confirm?.action === 'delete'
? `Are you sure you want to delete the DHCP scope '${confirm.name}'?`
: `Are you sure you want to disable the DHCP scope '${confirm?.name ?? ''}'?`
}
label={confirm?.action === 'delete' ? 'Delete' : 'Disable'}
busy={busy}
onClose={() => setConfirm(null)}
onConfirm={() => {
if (!confirm) return
if (confirm.action === 'delete') void remove(confirm.name)
else void disable(confirm.name)
}}
/>
</div>
)
}