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>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:40:53 +07:00
1 parent f3531c64f9
commit 4c0a5de7e7
24 files changed
+1611 -1175

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@
| Tooltip | ReUI Tooltip (роль `tooltip` и `aria-describedby` сохранены) |
| Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) |
| Select | ReUI Popover (поиск, группы и набор по буквам сохранены) |
| Table | Рамка таблицы теперь ReUI `DataGrid` (`DataGrid`, `DataGridTableViewport`, `DataGridTableBase`, `DataGridTableHead`, `DataGridTableBody`, `DataGridTableFoot`) с экземпляром TanStack `useTable`. Строки остаются своими `<tr>`/`<td>` внутри тела: колонки как данные не описываются, потому что ячейки несут меню, ссылки, `colSpan` и классы строк. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» сохранены (проверено в браузере на моке: на 1440 px таблица, ниже 560 px карточки, без горизонтальной прокрутки). |
| Table | Все 23 таблицы описаны колонками TanStack: `ui/DataTable.tsx` строит ReUI `DataGrid` из `columns` и `data`, а то, что DataGrid не принимает (класс строки или ячейки, `aria-sort` у `<th>`, `style`, `colSpan`, `role`, `hidden`), навешивает на нарисованные им элементы после каждого рендера. Определения колонок держат стабильную идентичность (иначе ячейки пересоздавались бы при каждом рендере родителя), встроенная пагинация DataGrid выключена (`manualPagination`). В Blocking › Lists строка-заголовок группы это строка, первая ячейка которой растянута на четыре колонки (`colSpan`, `role="columnheader"`, `scope="colgroup"`), а остальные три скрыты. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» работают поверх той же таблицы (проверено в браузере на моке: 1440 px таблица, 390 px карточки, без горизонтальной прокрутки). |
| Toast | свой (фокус возвращается, пауза под курсором, действие с правом); цвета и шрифты из темы ReUI |
| Тема | палитра shnt Ops в `tokens.css`, Geist, шкала 12/13/14 px, радиусы 8/10/14 px |
+89 -58
View File
@@ -37,7 +37,8 @@ import {
type Notice,
} from './parts'
import tbl from '../../ui/Table.module.css'
import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { RowAction, useSort, type Keys, head } from '../../ui/Table'
import { Menu, Separator } from '../../ui/Menu'
import { GroupRow } from '../../ui/Form'
import { Notifier } from '../../ui/Notifier'
@@ -219,51 +220,61 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
</Empty>
) : (
<>
<Table
stack
header={
<>
<Th field="name" sort={sort} onSort={toggle}>Node Name</Th>
<Th field="ip" sort={sort} onSort={toggle}>IP Address</Th>
<Th field="url" sort={sort} onSort={toggle}>URL</Th>
<Th field="type" sort={sort} onSort={toggle}>Type</Th>
<Th field="state" sort={sort} onSort={toggle}>State</Th>
<Th field="upSince" sort={sort} onSort={toggle}>Up Since</Th>
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th>
<Th field="synced" sort={sort} onSort={toggle}>Last Synced</Th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={visibleNodes.length === 0}
{/* This node's own row, washed in `--acc-bg`. The `Self` in its
`State` column stays: the colour finds the row, the word says
which it is. Same treatment as the current session's row. */}
<DataTable
data={visibleNodes}
getRowId={(n) => String(n.id)}
rowProps={(n) => ({ className: n.state === 'Self' ? tbl.rowSelf : undefined })}
emptyText="No Node Found"
columns={9}
>
{visibleNodes.map((n) => (
/* This node's own row, washed in `--acc-bg`. The `Self` in its
`State` column stays: the colour finds the row, the word says
which it is. Same treatment as the current session's row. */
<tr key={n.id} className={n.state === 'Self' ? tbl.rowSelf : undefined}>
<td className={tbl.lead}>
stack
columns={[
{
id: 'name',
...head({ field: 'name', sort, onSort: toggle, children: 'Node Name' }),
td: () => ({ className: tbl.lead }),
cell: (n) => (
<>
{n.name}
{/* The node's version under its name, as `(v15.6)`: new in
v15.6 (cluster.js:180-183). */}
{n.version != null && <div className={tbl.meta}>(v{n.version})</div>}
</td>
<td className={styles.mono}>
{n.ipAddresses.map((ip) => (
{/* The node's version under its name, as `(v15.6)`: new in
v15.6 (cluster.js:180-183). */}
{n.version != null && <div className={tbl.meta}>(v{n.version})</div>}
</>
),
},
{
id: 'ip',
...head({ field: 'ip', sort, onSort: toggle, children: 'IP Address' }),
td: () => ({ className: styles.mono }),
cell: (n) => (
n.ipAddresses.map((ip) => (
<div key={ip}>{ip}</div>
))}
</td>
<td className={styles.mono}>{n.url}</td>
<td>
{n.type === 'Primary' || n.type === 'Secondary' ? (
))
),
},
{
id: 'url',
...head({ field: 'url', sort, onSort: toggle, children: 'URL' }),
td: () => ({ className: styles.mono }),
cell: (n) => n.url,
},
{
id: 'type',
...head({ field: 'type', sort, onSort: toggle, children: 'Type' }),
cell: (n) => (
n.type === 'Primary' || n.type === 'Secondary' ? (
<Tag tone="info">{n.type}</Tag>
) : (
<Tag tone="warn">Unknown</Tag>
)}
</td>
<td>
{n.state === 'Self' ? (
)
),
},
{
id: 'state',
...head({ field: 'state', sort, onSort: toggle, children: 'State' }),
cell: (n) => (
n.state === 'Self' ? (
<Tag>Self</Tag>
) : n.state === 'Connected' ? (
<Tag tone="ok">Connected</Tag>
@@ -271,22 +282,42 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
<Tag tone="warn">Unreachable</Tag>
) : (
<Tag tone="warn">Unknown</Tag>
)}
</td>
<td className={styles.nowrap}>
<RelativeDate iso={n.upSince} />
</td>
<td className={styles.nowrap}>
)
),
},
{
id: 'upSince',
...head({ field: 'upSince', sort, onSort: toggle, children: 'Up Since' }),
td: () => ({ className: styles.nowrap }),
cell: (n) => (<RelativeDate iso={n.upSince} />),
},
{
id: 'lastSeen',
...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
td: () => ({ className: styles.nowrap }),
cell: (n) => (
<>
{/* The node itself has no "Last Seen": in its row that column
is always empty (cluster.js:177-193). */}
{n.state !== 'Self' && <RelativeDate iso={n.lastSeen} />}
</td>
<td className={styles.nowrap}>
{n.state === 'Self' && n.type === 'Secondary' && (
is always empty (cluster.js:177-193). */}
{n.state !== 'Self' && <RelativeDate iso={n.lastSeen} />}
</>
),
},
{
id: 'synced',
...head({ field: 'synced', sort, onSort: toggle, children: 'Last Synced' }),
td: () => ({ className: styles.nowrap }),
cell: (n) => (
n.state === 'Self' && n.type === 'Secondary' && (
<RelativeDate iso={n.configLastSynced} />
)}
</td>
<td className={tbl.actionsCell}>
)
),
},
{
id: 'c8',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (n) => (
<div className={tbl.actions}>
{/* What can be done with a node depends on whether this
console is talking to the primary or to a secondary
@@ -327,10 +358,10 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
</Menu>
)}
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.count}>
<span>{`Total Nodes: ${nodes.length}`}</span>
</div>
+30 -35
View File
@@ -23,7 +23,8 @@ import {
type Notice,
} from './parts'
import tbl from '../../ui/Table.module.css'
import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { head, RowAction, useSort, type Keys } from '../../ui/Table'
import { Menu } from '../../ui/Menu'
import { Notifier } from '../../ui/Notifier'
@@ -103,42 +104,36 @@ export function Groups({ tabs, token, onNotice }: Props) {
<Loading />
) : (
<>
<Table
header={
<>
<Th field="name" sort={sort} onSort={toggle}>Name</Th>
<Th field="description" sort={sort} onSort={toggle}>Description</Th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={visibleGroups.length === 0}
<DataTable
data={visibleGroups}
getRowId={(g) => g.name}
emptyText="No Group Found"
columns={3}
>
{visibleGroups.map((g) => (
<tr key={g.name}>
<td>
<button
type="button"
className={styles.link}
onClick={() => setDetail(g.name)}
>
columns={[
{
id: 'name',
...head({ field: 'name', sort, onSort: toggle, children: 'Name' }),
cell: (g) => (
<button type="button" className={styles.link} onClick={() => setDetail(g.name)}>
{g.name}
</button>
</td>
<td>
{g.description.split('\n').map((line, i) => (
),
},
{
id: 'description',
...head({ field: 'description', sort, onSort: toggle, children: 'Description' }),
cell: (g) =>
g.description.split('\n').map((line, i) => (
// eslint-disable-next-line react/no-array-index-key
<div key={i}>{line}</div>
))}
</td>
<td className={tbl.actionsCell}>
)),
},
{
id: 'actions',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (g) => (
<div className={tbl.actions}>
<RowAction
icon="card"
name="View Details"
onClick={() => setDetail(g.name)}
/>
<RowAction icon="card" name="View Details" onClick={() => setDetail(g.name)} />
<Menu label={`Actions for ${g.name}`}>
{(close) => (
<button type="button" data-variant="danger" onClick={() => { close(); setPendingDelete(g) }}>
@@ -147,10 +142,10 @@ export function Groups({ tabs, token, onNotice }: Props) {
)}
</Menu>
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.count}>
<span>{`Total Groups: ${groups.length}`}</span>
</div>
+50 -38
View File
@@ -29,7 +29,8 @@ import {
type Notice,
} from './parts'
import tbl from '../../ui/Table.module.css'
import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { RowAction, useSort, type Keys, head } from '../../ui/Table'
import { Menu } from '../../ui/Menu'
import { Notifier } from '../../ui/Notifier'
@@ -142,28 +143,21 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
<Loading />
) : (
<>
<Table
stack
header={
<>
<Th field="username" sort={sort} onSort={toggle}>Username</Th>
<Th field="session" sort={sort} onSort={toggle}>Session</Th>
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th>
<Th field="address" sort={sort} onSort={toggle}>Remote Address</Th>
<Th field="agent" sort={sort} onSort={toggle}>User Agent</Th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={visibleSessions.length === 0}
{/* My own session's row, washed in `--acc-bg`. The `(current)` in
its token cell stays: the colour finds the row, the word says
which it is. Same treatment as this node's row in `Cluster`. */}
<DataTable
data={visibleSessions}
getRowId={(s) => String(s.partialToken)}
rowProps={(s) => ({ className: s.isCurrentSession ? tbl.rowSelf : undefined })}
emptyText="No Session Found"
columns={6}
>
{visibleSessions.map((s) => (
/* My own session's row, washed in `--acc-bg`. The `(current)` in
its token cell stays: the colour finds the row, the word says
which it is. Same treatment as this node's row in `Cluster`. */
<tr key={s.partialToken} className={s.isCurrentSession ? tbl.rowSelf : undefined}>
<td className={tbl.lead}>
stack
columns={[
{
id: 'username',
...head({ field: 'username', sort, onSort: toggle, children: 'Username' }),
td: () => ({ className: tbl.lead }),
cell: (s) => (
<button
type="button"
className={styles.link}
@@ -171,18 +165,36 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
>
{s.username}
</button>
</td>
<td className={tbl.lead}>
<SessionCell session={s} />
</td>
<td className={styles.nowrap}>
<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} />
</td>
<td className={styles.mono}>{s.lastSeenRemoteAddress}</td>
<td>
<AgentCell>{s.lastSeenUserAgent}</AgentCell>
</td>
<td className={tbl.actionsCell}>
),
},
{
id: 'session',
...head({ field: 'session', sort, onSort: toggle, children: 'Session' }),
td: () => ({ className: tbl.lead }),
cell: (s) => (<SessionCell session={s} />),
},
{
id: 'lastSeen',
...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
td: () => ({ className: styles.nowrap }),
cell: (s) => (<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} />),
},
{
id: 'address',
...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }),
td: () => ({ className: styles.mono }),
cell: (s) => s.lastSeenRemoteAddress,
},
{
id: 'agent',
...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }),
cell: (s) => (<AgentCell>{s.lastSeenUserAgent}</AgentCell>),
},
{
id: 'c5',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (s) => (
<div className={tbl.actions}>
{/* auth.js:938: upstream's menu entry says "View User Details",
not the Users table's "View Details". The frequent action
@@ -201,10 +213,10 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
)}
</Menu>
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.count}>
<span>{`Total Sessions: ${sessions.length}`}</span>
</div>
+37 -29
View File
@@ -27,7 +27,8 @@ import {
} from './parts'
import tbl from '../../ui/Table.module.css'
import frm from '../../ui/Form.module.css'
import { Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { useSort, type Keys, head } from '../../ui/Table'
import { Notifier } from '../../ui/Notifier'
import { Menu } from '../../ui/Menu'
@@ -305,30 +306,37 @@ export function UserDetails({ open, username, token, cluster, onClose, onSaved,
</MRow>
<p className={styles.sub}>Active Sessions</p>
<Table
header={
<>
<Th field="session" sort={sort} onSort={toggle}>Session</Th>
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th>
<Th field="address" sort={sort} onSort={toggle}>Remote Address</Th>
<Th field="agent" sort={sort} onSort={toggle}>User Agent</Th>
<th className={tbl.actionsCell} />
</>
}
>
{visibleSessions.map((s) => (
<tr key={s.partialToken}>
<td>
<SessionCell session={s} />
</td>
<td className={styles.nowrap}>
<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} />
</td>
<td className={styles.mono}>{s.lastSeenRemoteAddress}</td>
<td>
<AgentCell>{s.lastSeenUserAgent}</AgentCell>
</td>
<td className={tbl.actionsCell}>
<DataTable
data={visibleSessions}
getRowId={(s) => String(s.partialToken)}
columns={[
{
id: 'session',
...head({ field: 'session', sort, onSort: toggle, children: 'Session' }),
cell: (s) => (<SessionCell session={s} />),
},
{
id: 'lastSeen',
...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
td: () => ({ className: styles.nowrap }),
cell: (s) => (<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} />),
},
{
id: 'address',
...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }),
td: () => ({ className: styles.mono }),
cell: (s) => s.lastSeenRemoteAddress,
},
{
id: 'agent',
...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }),
cell: (s) => (<AgentCell>{s.lastSeenUserAgent}</AgentCell>),
},
{
id: 'c4',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (s) => (
<div className={tbl.actions}>
{/* Inside the menu, as in "Administration > Sessions" and as
in upstream, which also puts it in a dropdown
@@ -347,10 +355,10 @@ export function UserDetails({ open, username, token, cluster, onClose, onSaved,
)}
</Menu>
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.count}>
<span>{`Total Sessions: ${sessions.length}`}</span>
</div>
+64 -57
View File
@@ -25,7 +25,8 @@ import {
type Notice,
} from './parts'
import tbl from '../../ui/Table.module.css'
import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { RowAction, useSort, type Keys, head } from '../../ui/Table'
import { Menu, Separator } from '../../ui/Menu'
import { neverUsed } from '../../api/zones'
import { Notifier } from '../../ui/Notifier'
@@ -172,27 +173,17 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
<Loading />
) : (
<>
<Table
stack
header={
<>
<Th field="username" sort={sort} onSort={toggle}>Username</Th>
<Th field="display" sort={sort} onSort={toggle}>Display Name</Th>
<Th field="type" sort={sort} onSort={toggle}>Type</Th>
<Th field="totp" sort={sort} onSort={toggle}>2FA Status</Th>
<Th field="status" sort={sort} onSort={toggle}>Status</Th>
<Th field="recent" sort={sort} onSort={toggle}>Recent Login</Th>
<Th field="previous" sort={sort} onSort={toggle}>Previous Login</Th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={visibleUsers.length === 0}
<DataTable
data={visibleUsers}
getRowId={(u) => String(u.username)}
emptyText="No User Found"
columns={8}
>
{visibleUsers.map((u) => (
<tr key={u.username}>
<td className={tbl.lead}>
stack
columns={[
{
id: 'username',
...head({ field: 'username', sort, onSort: toggle, children: 'Username' }),
td: () => ({ className: tbl.lead }),
cell: (u) => (
<button
type="button"
className={styles.link}
@@ -200,49 +191,65 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
>
{u.username}
</button>
</td>
<td>{u.displayName}</td>
{/* `Type`, `2FA Status` and `Status` are the three columns that
say what a user IS, and they were reading as three different
kinds of thing: two pills and one bare word. The word does not
change: `Remote/SSO` is upstream's, `SSO Managed` in the next
column is its sibling, so it takes the same `info` tone, and
`Remote/LDAP` is the other remote user, so it takes it too. */}
<td>
{u.type === 'RemoteSSO' || u.type === 'RemoteLDAP' ? (
),
},
{
id: 'display',
...head({ field: 'display', sort, onSort: toggle, children: 'Display Name' }),
cell: (u) => u.displayName,
},
{
id: 'type',
...head({ field: 'type', sort, onSort: toggle, children: 'Type' }),
cell: (u) => (
u.type === 'RemoteSSO' || u.type === 'RemoteLDAP' ? (
<Tag tone="info">{userType(u)}</Tag>
) : (
<Tag>{userType(u)}</Tag>
)}
</td>
<td>
{u.type === 'RemoteSSO' ? (
)
),
},
{
id: 'totp',
...head({ field: 'totp', sort, onSort: toggle, children: '2FA Status' }),
cell: (u) => (
u.type === 'RemoteSSO' ? (
<Tag tone="info">SSO Managed</Tag>
) : u.totpEnabled ? (
<Tag tone="ok">Enabled</Tag>
) : (
<Tag>Disabled</Tag>
)}
</td>
<td>
{u.disabled ? (
)
),
},
{
id: 'status',
...head({ field: 'status', sort, onSort: toggle, children: 'Status' }),
cell: (u) => (
u.disabled ? (
<Tag>Disabled</Tag>
) : (
<Tag tone="ok">Enabled</Tag>
)}
</td>
{/*
Upstream formats the date without checking whether it is .NET's
minimum (auth.js:1141), so for a user who has never logged in it
shows "0000-12-31 23:45:16 from 0.0.0.0" (year zero, because it
converts to local time a date older than time zones) and an
address that does not exist. That is not a datum: it is a
sentinel value leaking onto the screen. It says "Never", which is
what it means, just as was done with the DNSSEC column.
*/}
<td className={styles.mono}>{access(u.recentSessionLoggedOn, u.recentSessionRemoteAddress)}</td>
<td className={styles.mono}>{access(u.previousSessionLoggedOn, u.previousSessionRemoteAddress)}</td>
<td className={tbl.actionsCell}>
)
),
},
{
id: 'recent',
...head({ field: 'recent', sort, onSort: toggle, children: 'Recent Login' }),
td: () => ({ className: styles.mono }),
cell: (u) => access(u.recentSessionLoggedOn, u.recentSessionRemoteAddress),
},
{
id: 'previous',
...head({ field: 'previous', sort, onSort: toggle, children: 'Previous Login' }),
td: () => ({ className: styles.mono }),
cell: (u) => access(u.previousSessionLoggedOn, u.previousSessionRemoteAddress),
},
{
id: 'c7',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (u) => (
<div className={tbl.actions}>
<RowAction
icon="card"
@@ -283,10 +290,10 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
)}
</Menu>
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.count}>
<span>{`Total Users: ${users.length}`}</span>
</div>
+166 -112
View File
@@ -5,7 +5,8 @@ import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { nextUpdateText } from '../settings/panes/Blocking'
import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel'
import { RowAction, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { RowAction } from '../../ui/Table'
import { Field, Input, Select } from '../../ui/Field'
import { Button } from '../../ui/Button'
import { PermissionButton } from '../../ui/PermissionButton'
@@ -478,128 +479,96 @@ export function BlockLists({
/* The lists of each group, to say how many it holds. */
const groupSize = (g: number) => (heads[g + 1] ?? lines.length) - (g < 0 ? 0 : heads[g] + 1)
function headingRow(key: string, name: string, count: number, controls: ReactNode) {
return (
<tr key={key} className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<div className={styles.groupHead}>
<span className={styles.groupName}>{name}</span>
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span>
{controls != null && <span className={styles.spacer} />}
{controls}
</div>
</th>
</tr>
)
/* What the table draws, one item per row: a group heading (its content spans the
whole row) or a line. The grid draws cells, so a heading is a row whose first cell
takes the width of all four and whose other cells are not drawn. */
type Entry = ListLine & { kind: 'block' | 'allow' }
type Item =
| { key: string; kind: 'heading'; node: ReactNode }
| { key: string; kind: 'line'; l: Entry; i: number }
function heading(key: string, name: string, count: number, controls: ReactNode): Item {
return {
key,
kind: 'heading',
node: (
<div className={styles.groupHead}>
<span className={styles.groupName}>{name}</span>
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span>
{controls != null && <span className={styles.spacer} />}
{controls}
</div>
),
}
}
/* One row per line. A key that follows the line and not its place, so a list that
moves or switches keeps its row, and the focus stays on the button pressed. */
const seen = new Map<string, number>()
const rows: ReactNode[] = []
if (heads.length > 0 && heads[0] > 0) rows.push(headingRow('none', 'No group', groupSize(-1), null))
const items: Item[] = []
if (heads.length > 0 && heads[0] > 0) items.push(heading('none', 'No group', groupSize(-1), null))
lines.forEach((l, i) => {
if (l.kind === 'comment') {
const g = heads.indexOf(i)
const name = nameOf(l)
rows.push(
edit != null && edit.group === g ? (
<tr key={`H:${g}`} className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<HeadingEditor
editing={edit}
verb="Rename"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
</th>
</tr>
) : (
headingRow(`H:${g}`, name, groupSize(g), (
<>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
Rename
</PermissionButton>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
onClick={() => ungroup(g)}>
Ungroup
</PermissionButton>
</>
))
),
items.push(
edit != null && edit.group === g
? {
key: `H:${g}`,
kind: 'heading',
node: (
<HeadingEditor
editing={edit}
verb="Rename"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
),
}
: heading(`H:${g}`, name, groupSize(g), (
<>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
Rename
</PermissionButton>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
onClick={() => ungroup(g)}>
Ungroup
</PermissionButton>
</>
)),
)
return
}
const id = `${l.kind}:${l.url}`
const n = seen.get(id) ?? 0
seen.set(id, n + 1)
const name = l.url == null ? null : listName(l.url, named)
const faded = !l.enabled ? styles.off : undefined
const what = `${l.kind} list ${l.url ?? ''}`
rows.push(
<tr key={`L:${id}:${n}`}>
<td>
{canToggle(l) && (
<label className={styles.switch}>
<input
type="checkbox"
aria-label={`Enabled ${what}`}
checked={l.enabled}
disabled={off}
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
/>
</label>
)}
</td>
<td className={faded}>
{name != null && <div className={styles.name}>{name}</div>}
<div className={styles.url}><UrlText url={l.url ?? l.raw} /></div>
{/* The kind under the URL where the Type column is folded away. */}
<span className={`${styles.kind} ${styles.kindInline} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</td>
<td className={`${faded ?? ''} ${styles.kindCell}`}>
<span className={`${styles.kind} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</td>
<td className={styles.actions}>
<div className={styles.rowActions}>
<RowAction icon="moveUp" name="Move up" aria-label={`Move up ${what}`}
disabled={off || !canMove(lines, i, -1)} onClick={() => setLines((all) => moveLine(all, i, -1))} />
<RowAction icon="moveDown" name="Move down" aria-label={`Move down ${what}`}
disabled={off || !canMove(lines, i, 1)} onClick={() => setLines((all) => moveLine(all, i, 1))} />
<PermissionButton size="sm" variant="danger" permission={modifyNeed}
aria-label={`Remove ${l.url ?? l.raw}`}
onClick={() => setLines((all) => all.filter((_, j) => j !== i))}>
Remove
</PermissionButton>
</div>
</td>
</tr>,
)
items.push({ key: `L:${id}:${n}`, kind: 'line', l: l as Entry, i })
})
if (edit != null && edit.group === 'new') {
rows.push(
<tr key="H:new" className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<HeadingEditor
editing={edit}
verb="Add group"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
</th>
</tr>,
)
items.push({
key: 'H:new',
kind: 'heading',
node: (
<HeadingEditor
editing={edit}
verb="Add group"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
),
})
}
/* What a line's cells share. */
const lineCells = (l: Entry) => ({
name: l.url == null ? null : listName(l.url, named),
faded: !l.enabled ? styles.off : undefined,
what: `${l.kind} list ${l.url ?? ''}`,
})
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
@@ -778,15 +747,100 @@ export function BlockLists({
) : saved == null ? (
<Body><Loading /></Body>
) : (
<Table
header={<><th>Enabled</th><th>List</th><th className={styles.kindCell}>Type</th><th /></>}
isEmpty={lines.length === 0 && edit?.group !== 'new'}
emptyText="No lists"
columns={4}
<DataTable<Item>
data={items}
getRowId={(item) => item.key}
rowProps={(item) => (item.kind === 'heading' ? { className: styles.groupRow } : undefined)}
emptyText={lines.length === 0 && edit?.group !== 'new' ? 'No lists' : undefined}
className={shared.inPanel}
>
{rows}
</Table>
columns={[
{
id: 'enabled',
header: 'Enabled',
td: (item) =>
item.kind === 'heading'
? { colSpan: 4, scope: 'colgroup', role: 'columnheader' }
: undefined,
cell: (item) => {
if (item.kind === 'heading') return item.node
const { l, i } = item
const { what } = lineCells(l)
return (
canToggle(l) && (
<label className={styles.switch}>
<input
type="checkbox"
aria-label={`Enabled ${what}`}
checked={l.enabled}
disabled={off}
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
/>
</label>
)
)
},
},
{
id: 'list',
header: 'List',
td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: lineCells(item.l).faded }),
cell: (item) => {
if (item.kind === 'heading') return null
const { l } = item
const { name } = lineCells(l)
return (
<>
{name != null && <div className={styles.name}>{name}</div>}
<div className={styles.url}><UrlText url={l.url ?? l.raw} /></div>
{/* The kind under the URL where the Type column is folded away. */}
<span className={`${styles.kind} ${styles.kindInline} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</>
)
},
},
{
id: 'kind',
th: { className: styles.kindCell },
header: 'Type',
td: (item) =>
item.kind === 'heading'
? { hidden: true }
: { className: `${lineCells(item.l).faded ?? ''} ${styles.kindCell}` },
cell: (item) =>
item.kind === 'heading' ? null : (
<span className={`${styles.kind} ${KIND_CLASS[item.l.kind]}`}>
<Icon name={KIND_ICON[item.l.kind]} size={14} />
{KIND_LABEL[item.l.kind]}
</span>
),
},
{
id: 'actions',
td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: styles.actions }),
cell: (item) => {
if (item.kind === 'heading') return null
const { l, i } = item
const { what } = lineCells(l)
return (
<div className={styles.rowActions}>
<RowAction icon="moveUp" name="Move up" aria-label={`Move up ${what}`}
disabled={off || !canMove(lines, i, -1)} onClick={() => setLines((all) => moveLine(all, i, -1))} />
<RowAction icon="moveDown" name="Move down" aria-label={`Move down ${what}`}
disabled={off || !canMove(lines, i, 1)} onClick={() => setLines((all) => moveLine(all, i, 1))} />
<PermissionButton size="sm" variant="danger" permission={modifyNeed}
aria-label={`Remove ${l.url ?? l.raw}`}
onClick={() => setLines((all) => all.filter((_, j) => j !== i))}>
Remove
</PermissionButton>
</div>
)
},
},
]}
/>
)}
<div className={styles.foot}>
<PermissionButton size="sm" permission={modifyNeed} disabled={loading || edit?.group === 'new'}
+32 -23
View File
@@ -5,7 +5,7 @@ import type { QueryLogEntry } from '../../api/logs'
import { appsWithQueryLogs } from '../logs/QueryLogs'
import { RouteLink } from '../../ui/RouteLink'
import { Panel, Body } from '../../ui/Panel'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import tbl from '../../ui/Table.module.css'
import { DomainName } from '../../ui/DomainName'
import { Chip } from '../../ui/Tag'
@@ -189,30 +189,39 @@ export function RecentBlocked({
Some blocked queries could not be read.
</div>
)}
<Table
stack
header={
<>
<th>Time</th>
<th>Client</th>
<th>Domain</th>
<th>Type</th>
</>
}
isEmpty={state.entries.length === 0}
<DataTable
data={state.entries}
getRowId={(e) => String(`${e.responseType}:${e.rowNumber}:${e.timestamp}`)}
emptyText="No blocked queries."
columns={4}
stack
className={shared.inPanel}
>
{state.entries.map((e) => (
<tr key={`${e.responseType}:${e.rowNumber}:${e.timestamp}`}>
<td className={styles.time}>{dateTime(e.timestamp)}</td>
<td className={styles.mono}>{e.clientIpAddress}</td>
<td className={`${styles.name} ${tbl.lead}`}><DomainName name={e.qname ?? '.'} /></td>
<td className={tbl.lead}>{e.qtype != null && <Chip>{e.qtype}</Chip>}</td>
</tr>
))}
</Table>
columns={[
{
id: 'c0',
header: 'Time',
td: () => ({ className: styles.time }),
cell: (e) => dateTime(e.timestamp),
},
{
id: 'c1',
header: 'Client',
td: () => ({ className: styles.mono }),
cell: (e) => e.clientIpAddress,
},
{
id: 'c2',
header: 'Domain',
td: () => ({ className: `${styles.name} ${tbl.lead}` }),
cell: (e) => (<DomainName name={e.qname ?? '.'} />),
},
{
id: 'c3',
header: 'Type',
td: () => ({ className: tbl.lead }),
cell: (e) => e.qtype != null && <Chip>{e.qtype}</Chip>,
},
]}
/>
</>
)}
</Panel>
+25 -22
View File
@@ -3,7 +3,7 @@ import { readRuleExport } from '../../api/blocking'
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { Pagination } from '../../ui/Pagination'
import { Segmented } from '../../ui/Segmented'
import { Menu } from '../../ui/Menu'
@@ -522,29 +522,32 @@ export function Rules({
)
) : (
<>
<Table
header={
<>
<th>Domain</th>
<th>Rule</th>
<th />
</>
}
isEmpty={current.rows.length === 0}
<DataTable
data={current.rows}
getRowId={(r) => String(`${r.list}:${r.domain}`)}
emptyText={query === '' ? 'No rules' : 'No rules match this filter'}
columns={3}
className={shared.inPanel}
>
{current.rows.map((r) => (
<tr key={`${r.list}:${r.domain}`}>
<td className={styles.domain}><DomainName name={r.domain} /></td>
<td>
columns={[
{
id: 'c0',
header: 'Domain',
td: () => ({ className: styles.domain }),
cell: (r) => (<DomainName name={r.domain} />),
},
{
id: 'c1',
header: 'Rule',
cell: (r) => (
<span className={`${styles.kind} ${styles[r.list]}`}>
<Icon name={r.list} size={14} />
{LABEL[r.list]}
</span>
</td>
<td className={styles.actions}>
),
},
{
id: 'c2',
td: () => ({ className: styles.actions }),
cell: (r) => (
<PermissionButton
size="sm"
variant="danger"
@@ -555,10 +558,10 @@ export function Rules({
>
Delete
</PermissionButton>
</td>
</tr>
))}
</Table>
),
},
]}
/>
{current.totalPages > 1 && (
<Pagination
window={pageWindow(current.page, current.totalPages)}
+32 -24
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import type { Range, TopEntry } from '../../api/dashboard'
import { TopStats } from '../dashboard/TopStats'
import { Panel } from '../../ui/Panel'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { DomainName } from '../../ui/DomainName'
import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button'
@@ -74,30 +74,38 @@ export function TopTable({
</Button>
}
>
<Table
header={
<>
<th>Domain</th>
<th aria-label="Share" />
<th className={styles.hitsHead}>Hits</th>
<th aria-label="Actions" />
</>
}
isEmpty={rows.length === 0}
<DataTable
data={rows}
getRowId={(r) => String(r.name)}
emptyText={failure ? undefined : 'No domains for this period.'}
columns={4}
className={shared.inPanel}
>
{rows.map((r) => (
<tr key={r.name}>
<td className={styles.name}><DomainName name={r.name} /></td>
<td>
columns={[
{
id: 'c0',
header: 'Domain',
td: () => ({ className: styles.name }),
cell: (r) => (<DomainName name={r.name} />),
},
{
id: 'c1',
th: { 'aria-label': 'Share' },
cell: (r) => (
<span className={`${styles.share} ${isBlocked ? styles.shareBlocked : styles.shareTotal}`}>
<i style={{ width: `${top === 0 ? 0 : (r.hits * 100) / top}%` }} />
</span>
</td>
<td className={styles.hits}>{r.hits.toLocaleString()}</td>
<td>
),
},
{
id: 'c2',
th: { className: styles.hitsHead },
header: 'Hits',
td: () => ({ className: styles.hits }),
cell: (r) => r.hits.toLocaleString(),
},
{
id: 'c3',
th: { 'aria-label': 'Actions' },
cell: (r) => (
<Menu label={`Actions for ${r.name}`} quiet>
{(close) =>
need == null ? (
@@ -116,10 +124,10 @@ export function TopTable({
)
}
</Menu>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<TopStats type={more ? kind : null} range={range} token={token} onClose={() => setMore(false)} />
</Panel>
)
+39 -35
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { getTop, type Range, type TopKind, type TopEntry } from '../../api/dashboard'
import { Dialog } from '../../ui/Dialog'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { Loading } from '../../ui/Empty'
import { Notifier } from '../../ui/Notifier'
import tbl from '../../ui/Table.module.css'
@@ -104,43 +104,47 @@ export function TopStats({
{loading ? (
<Loading compact />
) : (
<Table
<DataTable
data={rows}
getRowId={(f, i) => String(`${f.name}|${i}`)}
rowProps={(f) => ({ className: f.rateLimited ? styles.limited : undefined })}
emptyText="No Data"
className={styles.topTableWrap}
tableClass={styles.topTable}
header={
<>
<th>{type == null ? '' : HEADER[type]}</th>
<th style={{ width: 110 }}>{type == null ? '' : COUNT[type]}</th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={rows.length === 0}
emptyText="No Data"
columns={3}
footer={
<th colSpan={3}>
{type == null ? '' : `Total ${TITLES[type]}: ${rows.length.toLocaleString()}`}
</th>
}
>
{rows.map((f, i) => (
<tr key={`${f.name}|${i}`} className={f.rateLimited ? styles.limited : undefined}>
<td>
footer={<th colSpan={3}>
{type == null ? '' : `Total ${TITLES[type]}: ${rows.length.toLocaleString()}`}
</th>}
columns={[
{
id: 'c0',
header: type == null ? '' : HEADER[type],
cell: (f) => (
<>
<span className={styles.topName}>
{f.name}
{f.rateLimited ? ' (rate limited)' : ''}
</span>
{isClient && f.domain != null && f.domain !== '' && (
<span className={styles.topDomain}>{f.domain}</span>
)}
</td>
<td className={styles.topCount}>{f.hits.toLocaleString()}</td>
<td className={tbl.actionsCell}>
{type != null && <TopRowMenu kind={type} name={f.name} node={node} action={action} />}
</td>
</tr>
))}
</Table>
{f.name}
{f.rateLimited ? ' (rate limited)' : ''}
</span>
{isClient && f.domain != null && f.domain !== '' && (
<span className={styles.topDomain}>{f.domain}</span>
)}
</>
),
},
{
id: 'c1',
th: { 'style': { width: 110 } },
header: type == null ? '' : COUNT[type],
td: () => ({ className: styles.topCount }),
cell: (f) => f.hits.toLocaleString(),
},
{
id: 'c2',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (f) => type != null && <TopRowMenu kind={type} name={f.name} node={node} action={action} />,
},
]}
/>
)}
</Dialog>
)
+58 -33
View File
@@ -17,7 +17,8 @@ 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 { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
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'
@@ -186,38 +187,62 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
<Failure>Could not load the list.</Failure>
) : (
<>
<Table
stack
header={
<>
<Th field="scope" sort={sort} onSort={toggle}>Scope</Th>
<Th field="mac" sort={sort} onSort={toggle}>MAC Address</Th>
<Th field="address" sort={sort} onSort={toggle}>IP Address</Th>
<Th field="type" sort={sort} onSort={toggle} name="Type" />
<Th field="host" sort={sort} onSort={toggle}>Host Name</Th>
<Th field="obtained" sort={sort} onSort={toggle}>Lease Obtained</Th>
<Th field="expires" sort={sort} onSort={toggle}>Lease Expires</Th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={visibleLeases.length === 0}
<DataTable
data={visibleLeases}
getRowId={(l) => String(`${l.scope}/${l.clientIdentifier}`)}
emptyText="No Lease Found"
columns={8}
>
{visibleLeases.map((l, i) => (
<tr key={`${l.scope}/${l.clientIdentifier}`}>
<td className={styles.mono}>{l.scope}</td>
<td className={styles.mono}>{l.hardwareAddress}</td>
<td className={`${styles.mono} ${tbl.lead}`}>{l.address}</td>
<td>
stack
columns={[
{
id: 'scope',
...head({ field: 'scope', sort, onSort: toggle, children: 'Scope' }),
td: () => ({ className: styles.mono }),
cell: (l) => l.scope,
},
{
id: 'mac',
...head({ field: 'mac', sort, onSort: toggle, children: 'MAC Address' }),
td: () => ({ className: styles.mono }),
cell: (l) => l.hardwareAddress,
},
{
id: 'address',
...head({ field: 'address', sort, onSort: toggle, children: 'IP Address' }),
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (l) => l.address,
},
{
id: 'type',
...head({ field: 'type', sort, onSort: toggle, name: 'Type' }),
cell: (l) => (
<Tag tone={l.type === 'Reserved' ? 'neutral' : 'ok'}>
{l.type}
</Tag>
</td>
<td className={`${styles.mono} ${tbl.lead}`}>{l.hostName}</td>
<td className={styles.date}>{minuteStamp(l.leaseObtained)}</td>
<td className={styles.date}>{minuteStamp(l.leaseExpires)}</td>
<td className={tbl.actionsCell}>
),
},
{
id: 'host',
...head({ field: 'host', sort, onSort: toggle, children: 'Host Name' }),
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (l) => l.hostName,
},
{
id: 'obtained',
...head({ field: 'obtained', sort, onSort: toggle, children: 'Lease Obtained' }),
td: () => ({ className: styles.date }),
cell: (l) => minuteStamp(l.leaseObtained),
},
{
id: 'expires',
...head({ field: 'expires', sort, onSort: toggle, children: 'Lease Expires' }),
td: () => ({ className: styles.date }),
cell: (l) => minuteStamp(l.leaseExpires),
},
{
id: 'c7',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (l, i) => (
<div className={tbl.actions}>
{/* dhcp.js:63-64. Which of the two conversions is offered
depends on the lease's current type. */}
@@ -250,10 +275,10 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
</Menu>
)}
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.total}>
{/* The footer is the count and nothing else. When there are no rows,
+46 -33
View File
@@ -19,7 +19,8 @@ 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 { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
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'
@@ -240,26 +241,22 @@ export function Scopes({
<Failure>Could not load the list.</Failure>
) : (
<>
<Table
stack
header={
<>
<Th field="name" sort={sort} onSort={toggle}>Name</Th>
<Th field="range" sort={sort} onSort={toggle}>Scope Range/Subnet Mask</Th>
<Th field="network" sort={sort} onSort={toggle}>Network/Broadcast</Th>
<Th field="interface" sort={sort} onSort={toggle}>Interface</Th>
<th>Status</th>
<th className={tbl.actionsCell} />
</>
}
isEmpty={visibleScopes.length === 0}
<DataTable
data={visibleScopes}
getRowId={(s) => String(s.name)}
emptyText="No Scope Found"
columns={6}
>
{visibleScopes.map((s) => (
<tr key={s.name}>
<td className={`${styles.name} ${tbl.lead}`}>{s.name}</td>
<td>
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>
@@ -268,24 +265,40 @@ export function Scopes({
<dt>Mask</dt>
<dd>{s.subnetMask}</dd>
</dl>
</td>
<td>
),
},
{
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>
</td>
{/* `interfaceAddress` is OMITTED when null; upstream draws an
empty cell (dhcp.js:228). */}
<td className={styles.mono}>{s.interfaceAddress ?? ''}</td>
<td>
),
},
{
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>
</td>
<td className={tbl.actionsCell}>
),
},
{
id: 'c5',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (s) => (
<div className={tbl.actions}>
<RowAction
icon="edit"
@@ -321,10 +334,10 @@ export function Scopes({
</Menu>
)}
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.total}>
{/* The footer is the count and nothing else. When there are no rows,
+107 -85
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import type { DnsRecord } from '../../api/zonelists'
import { rdataEntries, extras, meta, splitTtl, type Entry } from './record'
import tbl from '../../ui/Table.module.css'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import styles from './Lists.module.css'
/*
@@ -78,73 +78,9 @@ function Inline({ entries, className, bare = false }: {
)
}
function Row({ r, withDnssec, node }: { r: DnsRecord; withDnssec: boolean; node: string }) {
const [signatures, setSignatures] = useState(false)
const [glue, setGlue] = useState(false)
const ttl = splitTtl(r)
const extra = extras(r)
const name = r.nameIdn ?? r.name
// The name always matches the open node; it is only said when it does NOT.
const differentName = r.name !== node && name !== node
return (
<tr>
<td className={tbl.lead}>
<Chip>{r.type}</Chip>
{differentName && <span className={styles.name}>{name === '' ? '<ROOT>' : name}</span>}
</td>
<td className={styles.ttl}>
{ttl.value} {ttl.human && <small>({ttl.human})</small>}
</td>
<td>
<Inline entries={rdataEntries(r.rData)} bare />
{/* The second line: where the answer came from and when it was used, and
then whatever else the record carries (a name server's health, a field
the server adds tomorrow). It was a third and fourth block of
label-over-value rows; it is the grey line now, the same weight as the
metadata beside it, because none of it is the record's data. */}
<div className={styles.meta}>
<span title={r.lastUsedOn}>{meta(r).join(' · ')}</span>
{extra.length > 0 && <Inline entries={extra} className={styles.extra} />}
</div>
{signatures && r.dnssecRecords && (
<pre className={styles.signatures}>{r.dnssecRecords.join('\n')}</pre>
)}
{glue && r.glueRecords && <pre className={styles.signatures}>{r.glueRecords.join('\n')}</pre>}
</td>
{withDnssec && (
<td>
<Tag tone={r.dnssecStatus === 'Secure' ? 'ok' : 'neutral'}>
{r.dnssecStatus ?? '—'}
</Tag>
</td>
)}
<td className={tbl.actionsCell}>
<div className={tbl.actions}>
{r.glueRecords && (
<Button
size="sm"
aria-pressed={glue}
onClick={() => setGlue((v) => !v)}
>
Glue
</Button>
)}
{r.dnssecRecords && (
<Button
size="sm"
aria-pressed={signatures}
onClick={() => setSignatures((v) => !v)}
>
RRSIG
</Button>
)}
</div>
</td>
</tr>
)
}
/* What each record has open: its signatures (RRSIG) and its glue. The grid draws a row
as separate cells, so the state the cells share is kept here, by the record. */
type Open = Record<string, { signatures?: boolean; glue?: boolean }>
export function ResourceRecords({
records,
@@ -156,24 +92,110 @@ export function ResourceRecords({
withDnssec: boolean
node: string
}) {
const [open, setOpen] = useState<Open>({})
const rowKey = (r: DnsRecord, i: number) => `${r.name}|${r.type}|${i}`
const toggle = (key: string, what: 'signatures' | 'glue') =>
setOpen((o) => ({ ...o, [key]: { ...o[key], [what]: !o[key]?.[what] } }))
return (
<Table
<DataTable
stack
header={
<>
<th style={{ width: 110 }}>Type</th>
<th style={{ width: 120 }}>TTL</th>
<th>Data</th>
{withDnssec && <th style={{ width: 100 }}>DNSSEC</th>}
<th style={{ width: 120 }}>
<span className="sr-only" />
</th>
</>
}
>
{records.map((r, i) => (
<Row key={`${r.name}|${r.type}|${i}`} r={r} withDnssec={withDnssec} node={node} />
))}
</Table>
data={records}
getRowId={rowKey}
columns={[
{
id: 'type',
th: { style: { width: 110 } },
header: 'Type',
td: () => ({ className: tbl.lead }),
cell: (r) => {
const name = r.nameIdn ?? r.name
// The name always matches the open node; it is only said when it does NOT.
const differentName = r.name !== node && name !== node
return (
<>
<Chip>{r.type}</Chip>
{differentName && <span className={styles.name}>{name === '' ? '<ROOT>' : name}</span>}
</>
)
},
},
{
id: 'ttl',
th: { style: { width: 120 } },
header: 'TTL',
td: () => ({ className: styles.ttl }),
cell: (r) => {
const ttl = splitTtl(r)
return (
<>
{ttl.value} {ttl.human && <small>({ttl.human})</small>}
</>
)
},
},
{
id: 'data',
header: 'Data',
cell: (r, i) => {
const extra = extras(r)
const here = open[rowKey(r, i)]
return (
<>
<Inline entries={rdataEntries(r.rData)} bare />
{/* The second line: where the answer came from and when it was used, and
then whatever else the record carries (a name server's health, a field
the server adds tomorrow). It was a third and fourth block of
label-over-value rows; it is the grey line now, the same weight as the
metadata beside it, because none of it is the record's data. */}
<div className={styles.meta}>
<span title={r.lastUsedOn}>{meta(r).join(' · ')}</span>
{extra.length > 0 && <Inline entries={extra} className={styles.extra} />}
</div>
{here?.signatures && r.dnssecRecords && (
<pre className={styles.signatures}>{r.dnssecRecords.join('\n')}</pre>
)}
{here?.glue && r.glueRecords && <pre className={styles.signatures}>{r.glueRecords.join('\n')}</pre>}
</>
)
},
},
...(withDnssec
? [
{
id: 'dnssec',
th: { style: { width: 100 } },
header: 'DNSSEC',
cell: (r: DnsRecord) => (
<Tag tone={r.dnssecStatus === 'Secure' ? 'ok' : 'neutral'}>{r.dnssecStatus ?? '—'}</Tag>
),
},
]
: []),
{
id: 'actions',
th: { style: { width: 120 } },
header: <span className="sr-only" />,
td: () => ({ className: tbl.actionsCell }),
cell: (r, i) => {
const key = rowKey(r, i)
return (
<div className={tbl.actions}>
{r.glueRecords && (
<Button size="sm" aria-pressed={!!open[key]?.glue} onClick={() => toggle(key, 'glue')}>
Glue
</Button>
)}
{r.dnssecRecords && (
<Button size="sm" aria-pressed={!!open[key]?.signatures} onClick={() => toggle(key, 'signatures')}>
RRSIG
</Button>
)}
</div>
)
},
},
]}
/>
)
}
+78 -49
View File
@@ -21,7 +21,7 @@ import { toIso, dateTime } from './dates'
import { Loading } from '../../ui/Empty'
import { Check } from '../../ui/Check'
import { Tag } from '../../ui/Tag'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import styles from './Logs.module.css'
import { pageWindow } from '../../lib/pagination'
import { Pagination } from '../../ui/Pagination'
@@ -927,53 +927,82 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
{/* Always all seven, whether or not they appear on this page: see `LEGEND_ROWS`. */}
<RowLegend fold={fold} />
<Table
<DataTable
data={page.entries}
getRowId={(e) => String(e.rowNumber)}
rowProps={(e) => ({ className: rowClass(e) })}
stack
tableClass={styles.queryTable}
header={
<>
<th>#</th>
<th>Timestamp</th>
<th>Client IP Address</th>
<th>Protocol</th>
<th>Response Type</th>
<th>RCODE</th>
<th>Domain</th>
<th>Type</th>
<th>Class</th>
<th>Answer</th>
<th className={tbl.actionsCell} />
</>
}
>
{page.entries.map((e) => (
<tr key={e.rowNumber} className={rowClass(e)}>
<td className={styles.mono}>{e.rowNumber}</td>
<td className={`${styles.mono} ${styles.stamp}`}>
<Stamp text={dateTime(e.timestamp)} />
</td>
<td className={styles.mono}>
<Clip text={e.clientIpAddress} className={styles.clipClient} />
</td>
<td>{e.protocol}</td>
<td>
columns={[
{
id: 'c0',
header: '#',
td: () => ({ className: styles.mono }),
cell: (e) => e.rowNumber,
},
{
id: 'c1',
header: 'Timestamp',
td: () => ({ className: `${styles.mono} ${styles.stamp}` }),
cell: (e) => (<Stamp text={dateTime(e.timestamp)} />),
},
{
id: 'c2',
header: 'Client IP Address',
td: () => ({ className: styles.mono }),
cell: (e) => (<Clip text={e.clientIpAddress} className={styles.clipClient} />),
},
{
id: 'c3',
header: 'Protocol',
cell: (e) => e.protocol,
},
{
id: 'c4',
header: 'Response Type',
cell: (e) => (
<>
{/* The row's colour, in this cell: see `.tone` in Logs.module.css. */}
<span className={styles.tone}>{e.responseType}</span>
{e.responseRtt != null && (
<div className={styles.rtt}>({e.responseRtt.toFixed(2)} ms)</div>
)}
</td>
<td>{e.rcode}</td>
{/* logs.js:518. The root is written with a dot. */}
<td className={`${styles.mono} ${tbl.lead}`}>
<Clip text={e.qname === '' ? '.' : (e.qname ?? '')} className={styles.clipDomain} />
</td>
<td className={tbl.lead}>{e.qtype ?? ''}</td>
<td>{e.qclass ?? ''}</td>
<td className={`${styles.mono} ${styles.answer}`}>
<Clip text={e.answer ?? ''} className={styles.clipAnswer} />
</td>
<td className={tbl.actionsCell}>
<span className={styles.tone}>{e.responseType}</span>
{e.responseRtt != null && (
<div className={styles.rtt}>({e.responseRtt.toFixed(2)} ms)</div>
)}
</>
),
},
{
id: 'c5',
header: 'RCODE',
cell: (e) => e.rcode,
},
{
id: 'c6',
header: 'Domain',
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (e) => (<Clip text={e.qname === '' ? '.' : (e.qname ?? '')} className={styles.clipDomain} />),
},
{
id: 'c7',
header: 'Type',
td: () => ({ className: tbl.lead }),
cell: (e) => e.qtype ?? '',
},
{
id: 'c8',
header: 'Class',
cell: (e) => e.qclass ?? '',
},
{
id: 'c9',
header: 'Answer',
td: () => ({ className: `${styles.mono} ${styles.answer}` }),
cell: (e) => (<Clip text={e.answer ?? ''} className={styles.clipAnswer} />),
},
{
id: 'c10',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (e) => (
<Menu label={`Actions for row ${e.rowNumber}`}>
{(close) => {
const verb = verbFor(e.responseType)
@@ -1008,10 +1037,10 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
)
}}
</Menu>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.count}>
<span>{statusText(page)}</span>
+48 -48
View File
@@ -8,7 +8,8 @@ import { AgentCell, SessionCell, LastSeenCell } from '../../ui/SessionCells'
import { deleteSession, type SessionRow } from '../../api/user'
import styles from './MyProfile.module.css'
import tbl from '../../ui/Table.module.css'
import { Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { useSort, type Keys, head } from '../../ui/Table'
import { fromNow, dateTime } from '../../lib/dates'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { Notifier } from '../../ui/Notifier'
@@ -212,55 +213,54 @@ export function MyProfile({
module had its own with a fourth cell density and without the panel
that wraps it, so the same sessions table looked two different ways
depending on whether it opened from "My Profile" or "User Details". */}
<Table
className={styles.narrow}
header={
<Th field="group" sort={groups.sort} onSort={groups.toggle}>Group</Th>
}
isEmpty={groups.rows.length === 0}
<DataTable
data={groups.rows.map((name) => ({ name }))}
getRowId={(g) => g.name}
emptyText="No Group Found"
columns={1}
>
{groups.rows.map((g) => (
<tr key={g}>
<td>{g}</td>
</tr>
))}
</Table>
className={styles.narrow}
columns={[
{
id: 'group',
...head({ field: 'group', sort: groups.sort, onSort: groups.toggle, children: 'Group' }),
cell: (g) => g.name,
},
]}
/>
<div className={styles.total}>{`Total Groups: ${profile?.memberOfGroups?.length ?? 0}`}</div>
</div>
<div>
<div className={styles.caption}>Active Sessions</div>
<Table
header={
<>
<Th field="type" sort={sort} onSort={toggle}>Session</Th>
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th>
<Th field="address" sort={sort} onSort={toggle}>Remote Address</Th>
{/* Upstream has it (`index.html`, the `tbodyMyProfileActiveSessions`
table: Session · Last Seen · Remote Address · User Agent) and here
it had been lost: it was the only one of the console's three
sessions tables without it, and it is the one that says WHERE each
session is open from. */}
<Th field="agent" sort={sort} onSort={toggle}>User Agent</Th>
<th className={tbl.actionsCell} />
</>
}
>
{visibleSessions.map((row) => (
<tr key={row.partialToken}>
<td>
<SessionCell session={row} />
</td>
<td>
<LastSeenCell date={dateTime(row.lastSeen)} ago={fromNow(row.lastSeen)} />
</td>
<td className={styles.mono}>{row.lastSeenRemoteAddress}</td>
<td>
<AgentCell>{row.lastSeenUserAgent}</AgentCell>
</td>
<td className={tbl.actionsCell}>
<DataTable
data={visibleSessions}
getRowId={(row) => String(row.partialToken)}
columns={[
{
id: 'type',
...head({ field: 'type', sort, onSort: toggle, children: 'Session' }),
cell: (row) => (<SessionCell session={row} />),
},
{
id: 'lastSeen',
...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
cell: (row) => (<LastSeenCell date={dateTime(row.lastSeen)} ago={fromNow(row.lastSeen)} />),
},
{
id: 'address',
...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }),
td: () => ({ className: styles.mono }),
cell: (row) => row.lastSeenRemoteAddress,
},
{
id: 'agent',
...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }),
cell: (row) => (<AgentCell>{row.lastSeenUserAgent}</AgentCell>),
},
{
id: 'c4',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (row) => (
<div className={tbl.actions}>
{/* In a dropdown, as in the other two sessions tables and as in
upstream (`auth.js`, `deleteMySession`). */}
@@ -276,10 +276,10 @@ export function MyProfile({
)}
</Menu>
</div>
</td>
</tr>
))}
</Table>
),
},
]}
/>
<div className={styles.total}>Total Sessions: {profile?.sessions?.length ?? 0}</div>
</div>
+11 -20
View File
@@ -2,7 +2,7 @@ import { Tag } from '../../ui/Tag'
import { useState } from 'react'
import type { ResourceRecord } from '../../api/records'
import { recordCells, recordFooter, type Cell } from './record-view'
import { Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import styles from './Zones.module.css'
/*
@@ -61,25 +61,16 @@ function CellBlock({ cell }: { cell: Cell }) {
case 'table':
return (
<Table
header={
<>
{cell.headers.map((c) => (
<th key={c}>{c}</th>
))}
</>
}
>
{cell.rows.map((row, i) => (
<tr key={i}>
{row.map((v, j) => (
<td key={j} className={styles.mono}>
{v}
</td>
))}
</tr>
))}
</Table>
<DataTable<{ id: string; cells: string[] }>
data={cell.rows.map((cells, i) => ({ id: String(i), cells }))}
getRowId={(r) => r.id}
columns={cell.headers.map((title, j) => ({
id: String(j),
header: title,
td: () => ({ className: styles.mono }),
cell: (r) => r.cells[j],
}))}
/>
)
case 'pairs':
+243 -211
View File
@@ -26,7 +26,8 @@ import { Menu, Separator } from '../../ui/Menu'
import { minuteStamp as date } from '../../lib/dates'
import { statusText, pageWindow } from '../../lib/pagination'
import tbl from '../../ui/Table.module.css'
import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { head, RowAction, useSort, type Keys } from '../../ui/Table'
import styles from './Zones.module.css'
import type { Notice, Confirmation } from './types'
import { Pagination } from '../../ui/Pagination'
@@ -367,6 +368,31 @@ export function ZoneList({
// The last page is asked for with -1: the server works it out itself.
const pagination = <Pagination window={pg} current={pageNumber} last={-1} onGoTo={goTo} />
/* What a row's cells are made from. The grid draws each cell on its own, so each one
asks for the row's props. */
const rowProps = (z: Zone, i: number): RowProps => ({
zone: z,
index: firstRow + i,
checked: checkedOnes.includes(z.name),
stale,
busy,
canModify,
canDelete,
onCheck: (v) => setChecked((m) => (v ? [...m, z.name] : m.filter((n) => n !== z.name))),
onOpen,
onEditZoneFile,
onEnable: enable,
onDisable: disable,
onDelete: remove,
onResync: resync,
onImport,
onExport: runExport,
onConvert,
onClone,
onPermissions,
onOptions,
})
return (
<>
<SectionHeader
@@ -488,16 +514,21 @@ export function ZoneList({
</Button>
</div>
<Table
<DataTable
stack
/* `tableClass` and not `className`: the first reaches the `<table>` and
the second stays on the wrapper, which has no border. Measured: with
`className` the `--dan` border did not appear. */
tableClass={stale ? styles.stale : undefined}
header={
<>
{/* The checkbox is 18 px; it is pressed from anywhere in its cell. */}
<th style={{ width: 38 }} className={tbl.checkCell}>
data={visibleZones}
getRowId={(z) => z.name}
emptyText="No Zone Found"
columns={[
{
id: 'check',
/* The checkbox is 18 px; it is pressed from anywhere in its cell. */
th: { style: { width: 38 }, className: tbl.checkCell },
header: (
<label>
<input
disabled={stale}
@@ -507,67 +538,68 @@ export function ZoneList({
onChange={(e) => setChecked(e.target.checked ? zones.map((z) => z.name) : [])}
/>
</label>
</th>
<th style={{ width: 34 }}>#</th>
<Th field="zone" sort={sort} onSort={toggle}>Zone</Th>
<Th field="type" sort={sort} onSort={toggle} style={{ width: 120 }}>Type</Th>
<Th field="dnssec" sort={sort} onSort={toggle} style={{ width: 90 }}>DNSSEC</Th>
<Th field="status" sort={sort} onSort={toggle} style={{ width: 120 }}>Status</Th>
<Th
field="serial"
sort={sort}
onSort={toggle}
style={{ width: 110, textAlign: 'right' }}
>
Serial
</Th>
<Th field="expiry" sort={sort} onSort={toggle} style={{ width: 110 }}>Expiry</Th>
<Th field="modified" sort={sort} onSort={toggle} style={{ width: 150 }}>
Last Modified
</Th>
{/* The actions column reserved 230 px with three labels inside;
with icons 120 is enough and the 110 left over go to the data,
which is what the table is about. */}
<th style={{ width: 120 }} />
</>
}
>
{visibleZones.length === 0 ? (
<tr>
<td colSpan={10} className={tbl.noRows}>
No Zone Found
</td>
</tr>
) : (
visibleZones.map((z, i) => (
<ZoneRow
key={z.name}
zone={z}
index={firstRow + i}
checked={checkedOnes.includes(z.name)}
stale={stale}
busy={busy}
canModify={canModify}
canDelete={canDelete}
onCheck={(v) =>
setChecked((m) => (v ? [...m, z.name] : m.filter((n) => n !== z.name)))
}
onOpen={onOpen}
onEditZoneFile={onEditZoneFile}
onEnable={enable}
onDisable={disable}
onDelete={remove}
onResync={resync}
onImport={onImport}
onExport={runExport}
onConvert={onConvert}
onClone={onClone}
onPermissions={onPermissions}
onOptions={onOptions}
/>
))
)}
</Table>
),
td: () => ({ className: tbl.checkCell }),
cell: (z, i) => zoneCells(rowProps(z, i)).check,
},
{
id: 'num',
th: { style: { width: 34 } },
header: '#',
td: () => ({ className: `${styles.num2} ${tbl.number}` }),
cell: (z, i) => zoneCells(rowProps(z, i)).num,
},
{
id: 'zone',
...head({ field: 'zone', sort, onSort: toggle, children: 'Zone' }),
td: () => ({ className: `${tbl.stacked} ${tbl.lead} ${styles.zoneCell}` }),
cell: (z, i) => zoneCells(rowProps(z, i)).zone,
},
{
id: 'type',
...head({ field: 'type', sort, onSort: toggle, style: { width: 120 }, children: 'Type' }),
td: () => ({ className: tbl.meta }),
cell: (z, i) => zoneCells(rowProps(z, i)).type,
},
{
id: 'dnssec',
...head({ field: 'dnssec', sort, onSort: toggle, style: { width: 90 }, children: 'DNSSEC' }),
cell: (z, i) => zoneCells(rowProps(z, i)).dnssec,
},
{
id: 'status',
...head({ field: 'status', sort, onSort: toggle, style: { width: 120 }, children: 'Status' }),
cell: (z, i) => zoneCells(rowProps(z, i)).status,
},
{
id: 'serial',
...head({ field: 'serial', sort, onSort: toggle, style: { width: 110, textAlign: 'right' }, children: 'Serial' }),
td: () => ({ className: styles.mono }),
cell: (z, i) => zoneCells(rowProps(z, i)).serial,
},
{
id: 'expiry',
...head({ field: 'expiry', sort, onSort: toggle, style: { width: 110 }, children: 'Expiry' }),
td: () => ({ className: styles.mono }),
cell: (z, i) => zoneCells(rowProps(z, i)).expiry,
},
{
id: 'modified',
...head({ field: 'modified', sort, onSort: toggle, style: { width: 150 }, children: 'Last Modified' }),
td: () => ({ className: `${styles.mono} ${tbl.meta}` }),
cell: (z, i) => zoneCells(rowProps(z, i)).modified,
},
/* The actions column reserved 230 px with three labels inside; with icons
120 is enough and the 110 left over go to the data, which is what the
table is about. */
{
id: 'actions',
th: { style: { width: 120 } },
td: () => ({ className: tbl.actionsCell }),
cell: (z, i) => zoneCells(rowProps(z, i)).actions,
},
]}
/>
<div className={`${styles.count} ${styles.countFoot}`}>
<span>{state}</span>
@@ -623,7 +655,7 @@ const KEYS: Keys<Zone> = {
modified: (z) => date(z.lastModified),
}
function ZoneRow(p: RowProps) {
function zoneCells(p: RowProps) {
const { zone: z } = p
const name = z.name === '' ? '.' : z.name
const state = zoneState(z)
@@ -650,152 +682,152 @@ function ZoneRow(p: RowProps) {
const catalogLabel =
z.catalog != null ? { text: z.catalog, tone: 'neutral' as TagTone } : null
return (
<tr>
<td className={tbl.checkCell}>
<label>
<input
type="checkbox"
aria-label={`Select ${name}`}
/* With stale data there is no selecting: acting in bulk on a list
that no longer reflects the server is how the wrong zone gets
deleted. */
disabled={p.stale}
checked={p.checked}
onChange={(e) => p.onCheck(e.target.checked)}
/>
</label>
</td>
<td className={`${styles.num2} ${tbl.number}`}>{p.index}</td>
{/* The monospacing goes on the CELL and the button inherits it: the
shared class says `font-family: inherit` precisely for this, and putting
it on the button depended on which module was emitted last. */}
<td className={`${tbl.stacked} ${tbl.lead} ${styles.zoneCell}`}>
return {
check: (
<label>
<input
type="checkbox"
aria-label={`Select ${name}`}
/* With stale data there is no selecting: acting in bulk on a list
that no longer reflects the server is how the wrong zone gets
deleted. */
disabled={p.stale}
checked={p.checked}
onChange={(e) => p.onCheck(e.target.checked)}
/>
</label>
),
num: p.index,
/* The monospacing goes on the CELL and the button inherits it: the
shared class says `font-family: inherit` precisely for this, and putting
it on the button depended on which module was emitted last. */
zone: (
<>
<button
type="button"
className={`${styles.zoneLink} ${tbl.entity}`}
onClick={() => p.onOpen(name)}
>
<DomainName name={zoneNameOf(z)} />
</button>
{catalogLabel && (
<div className={styles.tags}>
<Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag>
</div>
)}
</td>
{/* The type goes as text and not as a capsule: with Type, DNSSEC and
Status all in capsules, three consecutive columns of pills read as a
single smear and none of the three stood out. */}
<td className={tbl.meta}>{typeLabel(z.type)}</td>
<td>
type="button"
className={`${styles.zoneLink} ${tbl.entity}`}
onClick={() => p.onOpen(name)}
>
<DomainName name={zoneNameOf(z)} />
</button>
{catalogLabel && (
<div className={styles.tags}>
<Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag>
</div>
)}
</>
),
/* The type goes as text and not as a capsule: with Type, DNSSEC and
Status all in capsules, three consecutive columns of pills read as a
single smear and none of the three stood out. */
type: typeLabel(z.type),
dnssec: (
<>
{/*
Upstream leaves the cell EMPTY when the zone is not signed
(zone.js:721-731), and a blank cell does not say "unsigned": it says "I
do not know" or "it has not loaded". With 240 zones paginated ten at a
time, "which ones do I still have to sign" could only be answered from
the API.
Upstream leaves the cell EMPTY when the zone is not signed
(zone.js:721-731), and a blank cell does not say "unsigned": it says "I
do not know" or "it has not loaded". With 240 zones paginated ten at a
time, "which ones do I still have to sign" could only be answered from
the API.
The three states are said with three different texts, not with the same
text in two colours: without private keys the zone is signed but this
server cannot re-sign it, which is not the same as being signed.
*/}
{!signed ? (
<Tag>Unsigned</Tag>
) : z.hasDnssecPrivateKeys ? (
<Tag tone="info">Signed</Tag>
) : (
<Tag>Signed, no keys</Tag>
)}
</td>
<td>
<Tag tone={stateTone}>{state}</Tag>
</td>
<td className={styles.mono}>{z.soaSerial ?? ' '}</td>
<td className={styles.mono}>{date(z.expiry)}</td>
<td className={`${styles.mono} ${tbl.meta}`}>{date(z.lastModified)}</td>
<td className={tbl.actionsCell}>
<div className={tbl.actions}>
<RowAction
icon="settings"
name="Zone Options"
disabled={!p.canModify || p.busy || !WITH_OPTIONS.includes(z.type)}
onClick={() => p.onOptions(name)}
/>
{/* The same icon for both states: which one applies is said by the
"Status" column, three columns to the left. */}
<RowAction
icon="power"
name={z.disabled ? 'Enable Zone' : 'Disable Zone'}
disabled={!p.canModify || p.busy}
onClick={() => (z.disabled ? p.onEnable(z) : p.onDisable(z))}
/>
<Menu label={`Actions for ${name}`}>
{(close) => (
<>
{/* v15.5 splits the first entry by type (zone.js:829-839 in
v15.5.1): a zone whose records you can edit gets "Edit
Zone" and "Edit Zone File"; any other gets "View Zone",
which opens the same screen. */}
{EDITABLE.includes(z.type) ? (
<>
<button type="button" onClick={() => { close(); p.onOpen(name) }}>
Edit Zone
</button>
<button type="button" onClick={() => { close(); p.onEditZoneFile(name) }}>
Edit Zone File
</button>
</>
) : (
<button type="button" onClick={() => { close(); p.onOpen(name) }}>
View Zone
</button>
)}
{RESYNC.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onResync(z) }}>
Resync
</button>
)}
{IMPORT.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onImport(name) }}>
Import Zone
</button>
)}
{EXPORT.includes(z.type) && (
<button type="button" onClick={() => { close(); void p.onExport(z) }}>
Export Zone
</button>
)}
{CONVERT.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onConvert(name, z.type) }}>
Convert Zone
</button>
)}
{CLONE.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onClone(name) }}>
Clone Zone
</button>
)}
<button type="button" onClick={() => { close(); p.onPermissions(name) }}>
Permissions
</button>
{/*
Delete goes back into the menu, and this time with a reason. It
was in the row so it would cost one click just as on the zone's
own screen, but a detail screen is not a row: there you act on
ONE object you are looking at, and here on one of two hundred and
forty, with "Disable" right next to it and with no undo anywhere
in this console.
The three states are said with three different texts, not with the same
text in two colours: without private keys the zone is signed but this
server cannot re-sign it, which is not the same as being signed.
*/}
<Separator />
<button type="button" data-variant="danger" disabled={!p.canDelete} onClick={() => { close(); p.onDelete(z) }}>
Delete Zone
{!signed ? (
<Tag>Unsigned</Tag>
) : z.hasDnssecPrivateKeys ? (
<Tag tone="info">Signed</Tag>
) : (
<Tag>Signed, no keys</Tag>
)}
</>
),
status: (<Tag tone={stateTone}>{state}</Tag>),
serial: z.soaSerial ?? ' ',
expiry: date(z.expiry),
modified: date(z.lastModified),
actions: (
<div className={tbl.actions}>
<RowAction
icon="settings"
name="Zone Options"
disabled={!p.canModify || p.busy || !WITH_OPTIONS.includes(z.type)}
onClick={() => p.onOptions(name)}
/>
{/* The same icon for both states: which one applies is said by the
"Status" column, three columns to the left. */}
<RowAction
icon="power"
name={z.disabled ? 'Enable Zone' : 'Disable Zone'}
disabled={!p.canModify || p.busy}
onClick={() => (z.disabled ? p.onEnable(z) : p.onDisable(z))}
/>
<Menu label={`Actions for ${name}`}>
{(close) => (
<>
{/* v15.5 splits the first entry by type (zone.js:829-839 in
v15.5.1): a zone whose records you can edit gets "Edit
Zone" and "Edit Zone File"; any other gets "View Zone",
which opens the same screen. */}
{EDITABLE.includes(z.type) ? (
<>
<button type="button" onClick={() => { close(); p.onOpen(name) }}>
Edit Zone
</button>
<button type="button" onClick={() => { close(); p.onEditZoneFile(name) }}>
Edit Zone File
</button>
</>
) : (
<button type="button" onClick={() => { close(); p.onOpen(name) }}>
View Zone
</button>
</>
)}
</Menu>
</div>
</td>
</tr>
)
)}
{RESYNC.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onResync(z) }}>
Resync
</button>
)}
{IMPORT.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onImport(name) }}>
Import Zone
</button>
)}
{EXPORT.includes(z.type) && (
<button type="button" onClick={() => { close(); void p.onExport(z) }}>
Export Zone
</button>
)}
{CONVERT.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onConvert(name, z.type) }}>
Convert Zone
</button>
)}
{CLONE.includes(z.type) && (
<button type="button" disabled={!p.canModify} onClick={() => { close(); p.onClone(name) }}>
Clone Zone
</button>
)}
<button type="button" onClick={() => { close(); p.onPermissions(name) }}>
Permissions
</button>
{/*
Delete goes back into the menu, and this time with a reason. It
was in the row so it would cost one click just as on the zone's
own screen, but a detail screen is not a row: there you act on
ONE object you are looking at, and here on one of two hundred and
forty, with "Disable" right next to it and with no undo anywhere
in this console.
*/}
<Separator />
<button type="button" data-variant="danger" disabled={!p.canDelete} onClick={() => { close(); p.onDelete(z) }}>
Delete Zone
</button>
</>
)}
</Menu>
</div>
),
}
}
+92 -79
View File
@@ -34,7 +34,8 @@ import { SectionHeader } from '../../ui/SectionHeader'
import { Empty, Loading } from '../../ui/Empty'
import { Chip, Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css'
import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { head as headCell, RowAction, useSort, type Keys } from '../../ui/Table'
import styles from './Zones.module.css'
import type { Notice, Confirmation } from './types'
import { Pagination } from '../../ui/Pagination'
@@ -560,85 +561,97 @@ export function ZoneRecords(p: ZoneRecordsProps) {
{pagination}
</div>
<Table
<DataTable
stack
header={
<>
<th style={{ width: 34 }}>#</th>
<Th field="name" sort={sort} onSort={toggle} style={{ width: 110 }}>Name</Th>
<Th field="type" sort={sort} onSort={toggle} style={{ width: 80 }}>Type</Th>
<Th field="ttl" sort={sort} onSort={toggle} style={{ width: 110 }}>TTL</Th>
<Th field="data" sort={sort} onSort={toggle}>Data</Th>
{/* The actions column reserved 230 px with three labels inside;
with icons 120 is enough and the 110 left over go to the data,
which is what the table is about. */}
<th style={{ width: 120 }} />
</>
}
>
{onPage.length === 0 ? (
<tr>
<td colSpan={6} className={tbl.noRows}>
No Record Found
</td>
</tr>
) : (
onPage.map((r, i) => {
const actions = rowActions(zoneInfo.type, r.type)
return (
<tr key={`${r.name}|${r.type}|${offset + i}`}>
<td className={styles.num2}>{offset + i + 1}</td>
<td className={`${styles.mono} ${tbl.lead}`}><DomainName name={relativeName(r.name, zone)} /></td>
<td className={tbl.lead}>
<Chip>{r.type}</Chip>
{r.disabled && (
<div className={styles.tags}>
<Tag>Disabled</Tag>
</div>
)}
</td>
<td className={styles.ttl}>
{r.ttl} <small>({r.ttlString})</small>
</td>
<td className={styles.dataCell}>
<DataCell record={r} notifyFailedFor={zoneInfo.notifyFailedFor} />
</td>
<td className={tbl.actionsCell}>
{!actions.hidden && (
<div className={tbl.actions}>
<RowAction
icon="edit"
name="Edit Record"
disabled={!p.canModify || busy}
onClick={() => p.onEditRecord(zoneInfo, r, records)}
/>
<RowAction
icon="power"
name={r.disabled ? 'Enable Record' : 'Disable Record'}
disabled={actions.editingOnly || !p.canModify || busy}
onClick={() => setRecordState(r, !r.disabled)}
/>
{/* Delete, inside the menu: the same rule as in the zone
list. */}
<Menu label={`Actions for ${relativeName(r.name, zone)} ${r.type}`}>
{(close) => (
<button
type="button"
disabled={actions.editingOnly || !p.canDelete || busy}
onClick={() => { close(); removeRecord(r) }}
>
Delete Record
</button>
)}
</Menu>
</div>
)}
</td>
</tr>
)
})
)}
</Table>
data={onPage}
getRowId={(r, i) => `${r.name}|${r.type}|${offset + i}`}
emptyText="No Record Found"
columns={[
{
id: 'num',
th: { style: { width: 34 } },
header: '#',
td: () => ({ className: styles.num2 }),
cell: (_r, i) => offset + i + 1,
},
{
id: 'name',
...headCell({ field: 'name', sort, onSort: toggle, style: { width: 110 }, children: 'Name' }),
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (r) => <DomainName name={relativeName(r.name, zone)} />,
},
{
id: 'type',
...headCell({ field: 'type', sort, onSort: toggle, style: { width: 80 }, children: 'Type' }),
td: () => ({ className: tbl.lead }),
cell: (r) => (
<>
<Chip>{r.type}</Chip>
{r.disabled && (
<div className={styles.tags}>
<Tag>Disabled</Tag>
</div>
)}
</>
),
},
{
id: 'ttl',
...headCell({ field: 'ttl', sort, onSort: toggle, style: { width: 110 }, children: 'TTL' }),
td: () => ({ className: styles.ttl }),
cell: (r) => (
<>
{r.ttl} <small>({r.ttlString})</small>
</>
),
},
{
id: 'data',
...headCell({ field: 'data', sort, onSort: toggle, children: 'Data' }),
td: () => ({ className: styles.dataCell }),
cell: (r) => <DataCell record={r} notifyFailedFor={zoneInfo.notifyFailedFor} />,
},
/* The actions column reserved 230 px with three labels inside; with icons
120 is enough and the 110 left over go to the data, which is what the
table is about. */
{
id: 'actions',
th: { style: { width: 120 } },
td: () => ({ className: tbl.actionsCell }),
cell: (r) => {
const actions = rowActions(zoneInfo.type, r.type)
return actions.hidden ? null : (
<div className={tbl.actions}>
<RowAction
icon="edit"
name="Edit Record"
disabled={!p.canModify || busy}
onClick={() => p.onEditRecord(zoneInfo, r, records)}
/>
<RowAction
icon="power"
name={r.disabled ? 'Enable Record' : 'Disable Record'}
disabled={actions.editingOnly || !p.canModify || busy}
onClick={() => setRecordState(r, !r.disabled)}
/>
{/* Delete, inside the menu: the same rule as in the zone list. */}
<Menu label={`Actions for ${relativeName(r.name, zone)} ${r.type}`}>
{(close) => (
<button
type="button"
disabled={actions.editingOnly || !p.canDelete || busy}
onClick={() => { close(); removeRecord(r) }}
>
Delete Record
</button>
)}
</Menu>
</div>
)
},
},
]}
/>
<div className={`${styles.count} ${styles.countFoot}`}>
<span>{text}</span>
+107 -128
View File
@@ -43,7 +43,8 @@ import styles from '../Zones.module.css'
import { External } from '../../../ui/External'
import { RFC_NSEC3_ITERATIONS, RFC_NSEC3_SALT } from '../references'
import frm from '../../../ui/Form.module.css'
import { Th, useSort, type Keys, Table } from '../../../ui/Table'
import { DataTable } from '../../../ui/DataTable'
import { head, useSort, type Keys } from '../../../ui/Table'
import { noticeFromFailure } from '../../../lib/notice'
import { Notifier } from '../../../ui/Notifier'
@@ -335,46 +336,111 @@ export function DnssecProperties({
<Loading>Loading DNSSEC properties…</Loading>
) : (
<div className={styles.fields}>
<Table
header={
<>
<Th field="keyTag" sort={sort} onSort={toggle} style={{ width: 80 }}>Key Tag</Th>
<Th field="keyType" sort={sort} onSort={toggle} style={{ width: 130 }}>Key Type</Th>
<Th field="algorithm" sort={sort} onSort={toggle} style={{ width: 150 }}>Algorithm</Th>
<Th field="state" sort={sort} onSort={toggle} style={{ width: 110 }}>State</Th>
<Th field="changed" sort={sort} onSort={toggle} style={{ width: 150 }}>
State Changed
</Th>
<Th field="rollover" sort={sort} onSort={toggle} style={{ width: 150 }}>
Rollover (days)
</Th>
<th style={{ width: 190 }} />
</>
}
>
{visibleKeys.length === 0 ? (
<tr>
<td colSpan={7} className={tbl.noRows}>
No Key Found
</td>
</tr>
) : (
visibleKeys.map((k) => (
<KeyRow
key={k.keyTag}
privateKey={k}
busy={busy}
rollover={rollovers[k.keyTag] ?? String(k.rolloverDays)}
onRollover={(v) => setRollovers((r) => ({ ...r, [k.keyTag]: v }))}
onSaveRollover={() => saveRollover(k)}
onDelete={() => deleteKey(k)}
onActivate={() => activate(k)}
onRolloverNow={() => rollover(k)}
onRetire={() => retire(k)}
/>
))
)}
</Table>
<DataTable
data={visibleKeys}
getRowId={(k) => String(k.keyTag)}
emptyText="No Key Found"
columns={[
{
id: 'keyTag',
...head({ field: 'keyTag', sort, onSort: toggle, style: { width: 80 }, children: 'Key Tag' }),
td: () => ({ className: styles.mono }),
cell: (k) => k.keyTag,
},
{
id: 'keyType',
...head({ field: 'keyType', sort, onSort: toggle, style: { width: 130 }, children: 'Key Type' }),
cell: (k) => k.keyType,
},
{
id: 'algorithm',
...head({ field: 'algorithm', sort, onSort: toggle, style: { width: 150 }, children: 'Algorithm' }),
cell: (k) => (
<>
{k.algorithm} ({k.algorithmNumber})
</>
),
},
{
id: 'state',
...head({ field: 'state', sort, onSort: toggle, style: { width: 110 }, children: 'State' }),
cell: (k) => (
<>
{k.state}
{k.state === 'Active' && k.isRetiring ? ' (retiring)' : ''}
</>
),
},
{
id: 'changed',
...head({ field: 'changed', sort, onSort: toggle, style: { width: 150 }, children: 'State Changed' }),
td: () => ({ className: styles.mono, style: { fontSize: 11.5 } }),
cell: (k) => (
<>
{shortDate(k.stateChangedOn)}
{k.stateReadyBy != null && <div>(ready by: {shortDate(k.stateReadyBy)})</div>}
{k.stateReadyBy == null && k.stateActiveBy != null && (
<div>(active by: {shortDate(k.stateActiveBy)})</div>
)}
</>
),
},
{
id: 'rollover',
...head({ field: 'rollover', sort, onSort: toggle, style: { width: 150 }, children: 'Rollover (days)' }),
cell: (k) =>
keyActions(k).automaticRollover ? (
<div className={styles.inline}>
<Input
mono
className={styles.short}
aria-label={`Rollover days for ${k.keyTag}`}
placeholder="days"
value={rollovers[k.keyTag] ?? String(k.rolloverDays)}
onChange={(e) => setRollovers((r) => ({ ...r, [k.keyTag]: e.target.value }))}
/>
<Button size="sm" disabled={busy} onClick={() => saveRollover(k)}>
Save
</Button>
</div>
) : (
'—'
),
},
{
id: 'actions',
th: { style: { width: 190 } },
td: () => ({ className: tbl.actionsCell }),
cell: (k) => {
const a = keyActions(k)
return (
<div className={tbl.actions}>
{a.remove && (
<Button size="sm" disabled={busy} onClick={() => deleteKey(k)}>
Delete
</Button>
)}
{a.activate && (
<Button size="sm" disabled={busy} onClick={() => activate(k)}>
Activate
</Button>
)}
{a.rollover && (
<Button size="sm" disabled={busy} onClick={() => rollover(k)}>
Rollover
</Button>
)}
{a.retire && (
<Button size="sm" disabled={busy} onClick={() => retire(k)}>
Retire
</Button>
)}
</div>
)
},
},
]}
/>
{notes.map((n) => (
<Alert key={n} type="info" title="Note!">
@@ -734,90 +800,3 @@ export function stateNotes(keys: PrivateKey[]): string[] {
return notes
}
function KeyRow({
privateKey: k,
busy,
rollover,
onRollover,
onSaveRollover,
onDelete,
onActivate,
onRolloverNow,
onRetire,
}: {
privateKey: PrivateKey
busy: boolean
rollover: string
onRollover: (v: string) => void
onSaveRollover: () => void
onDelete: () => void
onActivate: () => void
onRolloverNow: () => void
onRetire: () => void
}) {
const a = keyActions(k)
return (
<tr>
<td className={styles.mono}>{k.keyTag}</td>
<td>{k.keyType}</td>
<td>
{k.algorithm} ({k.algorithmNumber})
</td>
<td>
{k.state}
{k.state === 'Active' && k.isRetiring ? ' (retiring)' : ''}
</td>
<td className={styles.mono} style={{ fontSize: 11.5 }}>
{shortDate(k.stateChangedOn)}
{k.stateReadyBy != null && <div>(ready by: {shortDate(k.stateReadyBy)})</div>}
{k.stateReadyBy == null && k.stateActiveBy != null && (
<div>(active by: {shortDate(k.stateActiveBy)})</div>
)}
</td>
<td>
{a.automaticRollover ? (
<div className={styles.inline}>
<Input
mono
className={styles.short}
aria-label={`Rollover days for ${k.keyTag}`}
placeholder="days"
value={rollover}
onChange={(e) => onRollover(e.target.value)}
/>
<Button size="sm" disabled={busy} onClick={onSaveRollover}>
Save
</Button>
</div>
) : (
'—'
)}
</td>
<td className={tbl.actionsCell}>
<div className={tbl.actions}>
{a.remove && (
<Button size="sm" disabled={busy} onClick={onDelete}>
Delete
</Button>
)}
{a.activate && (
<Button size="sm" disabled={busy} onClick={onActivate}>
Activate
</Button>
)}
{a.rollover && (
<Button size="sm" disabled={busy} onClick={onRolloverNow}>
Rollover
</Button>
)}
{a.retire && (
<Button size="sm" disabled={busy} onClick={onRetire}>
Retire
</Button>
)}
</div>
</td>
</tr>
)
}
+22 -19
View File
@@ -4,7 +4,7 @@ import { Dialog } from '../../../ui/Dialog'
import { Empty, Loading } from '../../../ui/Empty'
import { minuteStamp as shortDate } from '../../../lib/dates'
import type { Notice } from '../types'
import { Table } from '../../../ui/Table'
import { DataTable } from '../../../ui/DataTable'
import styles from '../Zones.module.css'
import { Notifier } from '../../../ui/Notifier'
@@ -123,25 +123,28 @@ export function ViewDs({
</dd>
</dl>
<Table
header={
<>
<th style={{ width: 140 }}>Digest Type</th>
<th>Digest</th>
</>
}
>
{ds.digests.map((d) => (
<tr key={d.digestType}>
<td className={styles.mono}>
<DataTable
data={ds.digests}
getRowId={(d) => String(d.digestType)}
columns={[
{
id: 'c0',
th: { 'style': { width: 140 } },
header: 'Digest Type',
td: () => ({ className: styles.mono }),
cell: (d) => (
<>
{d.digestType} ({d.digestTypeNumber})
</td>
<td>
<Long value={d.digest} />
</td>
</tr>
))}
</Table>
</>
),
},
{
id: 'c1',
header: 'Digest',
cell: (d) => (<Long value={d.digest} />),
},
]}
/>
</div>
))
)}
+29 -35
View File
@@ -5,7 +5,7 @@ import { Dialog } from '../../../ui/Dialog'
import { Field, Select } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
import type { Notice } from '../types'
import { Table } from '../../../ui/Table'
import { DataTable } from '../../../ui/DataTable'
import styles from '../Zones.module.css'
import { noticeFromFailure } from '../../../lib/notice'
import { Notifier } from '../../../ui/Notifier'
@@ -181,42 +181,36 @@ function PermissionsTable({
{rows.length === 0 ? (
<div className={styles.help}>No permissions assigned.</div>
) : (
<Table
header={
<>
<th>Name</th>
<th style={{ width: 70 }}>View</th>
<th style={{ width: 70 }}>Modify</th>
<th style={{ width: 70 }}>Delete</th>
<th style={{ width: 90 }} />
</>
}
>
{rows.map((f, i) => (
<tr key={f.name}>
<td className={styles.mono}>{f.name}</td>
{(['canView', 'canModify', 'canDelete'] as const).map((key) => (
<td key={key}>
<input
type="checkbox"
className={styles.chkPerm}
aria-label={`${key} for ${f.name}`}
checked={f[key]}
onChange={(e) => change(i, key, e.target.checked)}
/>
</td>
))}
<td>
<Button
size="sm"
onClick={() => onChange(rows.filter((_, j) => j !== i))}
>
<DataTable
data={rows}
getRowId={(f) => f.name}
columns={[
{ id: 'name', header: 'Name', td: () => ({ className: styles.mono }), cell: (f) => f.name },
...(['canView', 'canModify', 'canDelete'] as const).map((key, n) => ({
id: key,
th: { style: { width: 70 } },
header: ['View', 'Modify', 'Delete'][n],
cell: (f: (typeof rows)[number], i: number) => (
<input
type="checkbox"
className={styles.chkPerm}
aria-label={`${key} for ${f.name}`}
checked={f[key]}
onChange={(e) => change(i, key, e.target.checked)}
/>
),
})),
{
id: 'remove',
th: { style: { width: 90 } },
cell: (_f, i) => (
<Button size="sm" onClick={() => onChange(rows.filter((_, j) => j !== i))}>
Remove
</Button>
</td>
</tr>
))}
</Table>
),
},
]}
/>
)}
<Field label={addLabel}>
+165
View File
@@ -0,0 +1,165 @@
import { createContext, useContext, useLayoutEffect, useMemo, useRef, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react'
import { useTable } from '@tanstack/react-table'
import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { useStacking } from './Table'
import styles from './Table.module.css'
/*
The console's table on ReUI's DataGrid, with its columns as TanStack columns and its
rows as data.
<DataTable
data={users}
getRowId={(u) => u.username}
columns={[
{ id: 'name', ...head({ field: 'name', sort, onSort: toggle }, 'Username'), cell: (u) => u.username },
{ id: 'actions', th: { className: tbl.actionsCell }, cell: (u) => <Menu …/>, td: () => ({ className: tbl.actionsCell }) },
]}
emptyText="No User Found"
/>
What the grid does not take, the table asks of the DOM afterwards: the grid draws every
`<th>`, `<tr>` and `<td>` itself and has no door for a class on one row, an `aria-sort`
on one header or a `colSpan` on one cell. `th`, `td` and `rowProps` carry those, and
`apply` puts them on the elements the grid drew, after every render, as `Table` does
for the labels of the stacked rows. Sorting stays the screen's (`useSort`): the data
arrives already in order, so the grid sorts nothing.
*/
export interface Col<T> {
id: string
/** What goes inside the `<th>`: normally the result of `head(…)`. */
header?: ReactNode
/** What the `<th>` itself carries: `aria-sort`, a class. */
th?: ThHTMLAttributes<HTMLTableCellElement>
cell: (row: T, index: number) => ReactNode
/** What a row's `<td>` carries: a class. */
td?: (row: T, index: number) => TdHTMLAttributes<HTMLTableCellElement> | undefined
}
const ColumnsContext = createContext<Col<object>[]>([])
function HeaderCell({ column }: { column: { id: string } }) {
const columns = useContext(ColumnsContext)
return <>{columns.find((c) => c.id === column.id)?.header ?? null}</>
}
function BodyCell({ row, column }: { row: { original: object; index: number }; column: { id: string } }) {
const columns = useContext(ColumnsContext)
return <>{columns.find((c) => c.id === column.id)?.cell(row.original, row.index) ?? null}</>
}
type Attrs = Record<string, unknown>
/* React writes a number as pixels, except for the properties that take none. */
const UNITLESS = new Set(['opacity', 'flex', 'flexGrow', 'flexShrink', 'order', 'zIndex', 'lineHeight', 'fontWeight', 'zoom'])
const cssText = (style: Record<string, unknown>) =>
Object.entries(style)
.filter(([, v]) => v != null && v !== false)
.map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}: ${typeof v === 'number' && v !== 0 && !UNITLESS.has(k) ? `${v}px` : v}`)
.join('; ')
/* What was put on an element last time, so a class or an attribute that no longer
applies is taken off again. */
const applied = new WeakMap<Element, { classes: string[]; names: string[] }>()
function apply(el: Element, attrs: Attrs | undefined) {
const before = applied.get(el)
if (before != null) {
el.classList.remove(...before.classes)
for (const name of before.names) el.removeAttribute(name)
}
const classes = String(attrs?.className ?? '').split(/\s+/).filter(Boolean)
const names: string[] = []
for (const [key, value] of Object.entries(attrs ?? {})) {
if (key === 'className' || key === 'children' || value == null || value === false || typeof value === 'function') continue
const name = key === 'colSpan' ? 'colspan' : key === 'rowSpan' ? 'rowspan' : key
el.setAttribute(name, value === true ? '' : key === 'style' && typeof value === 'object' ? cssText(value as Record<string, unknown>) : String(value))
names.push(name)
}
el.classList.add(...classes)
applied.set(el, { classes, names })
}
export function DataTable<T extends object>({
data,
columns,
getRowId,
rowProps,
emptyText,
className,
tableClass,
footer,
stack = false,
}: {
data: T[]
columns: Col<T>[]
getRowId: (row: T, index: number) => string
/** What a row's `<tr>` carries: a class, `data-*`. */
rowProps?: (row: T, index: number) => HTMLAttributes<HTMLTableRowElement> | undefined
/** What to say when there are no rows. Without it the body is blank. */
emptyText?: ReactNode
className?: string
tableClass?: string
/** The cells of a footer row. */
footer?: ReactNode
stack?: boolean
}) {
const wrap = useRef<HTMLDivElement>(null)
useStacking(wrap, stack)
/* The column definitions must keep their identity from one render to the next: the
grid renders a function in `header`/`cell` as a COMPONENT, and a new function every
render would unmount and mount every cell again (a menu closes, a field loses its
focus). So the definitions are built once per set of ids, from two components that
read the current columns from the context below. */
const ids = columns.map((c) => c.id).join('|')
// eslint-disable-next-line react-hooks/exhaustive-deps
const defs = useMemo(() => columns.map((c) => ({ id: c.id, header: HeaderCell, cell: BodyCell })), [ids])
const grid = useTable({ features: dataGridFeatures, columns: defs, data, getRowId, manualPagination: true })
/* After every render: the grid has drawn its elements, now they get what the screen
asked of them. */
useLayoutEffect(() => {
const root = wrap.current
const table = root?.querySelector('table')
if (root == null || table == null) return
for (const c of columns) {
const th = table.tHead?.querySelector(`th[data-col-id="${CSS.escape(c.id)}"]`)
if (th != null) apply(th, c.th as Attrs | undefined)
}
const byId = new Map(data.map((row, i) => [getRowId(row, i), { row, i }]))
for (const tr of Array.from(table.tBodies[0]?.rows ?? [])) {
const found = byId.get(tr.dataset.rowId ?? '')
if (found == null) {
/* The "nothing here" row. */
const cell = tr.cells[0]
if (cell != null && tr.querySelector('[data-slot="data-grid-table-empty"]') != null) cell.classList.add(styles.noRows)
continue
}
apply(tr, rowProps?.(found.row, found.i) as Attrs | undefined)
columns.forEach((c, n) => {
const td = tr.cells[n]
if (td != null) apply(td, c.td?.(found.row, found.i) as Attrs | undefined)
})
}
})
const base = [styles.table, stack && styles.stack, tableClass].filter(Boolean).join(' ')
return (
<div ref={wrap} className={[styles.wrap, stack && styles.stackWrap, className].filter(Boolean).join(' ')}>
<ColumnsContext.Provider value={columns as Col<object>[]}>
<DataGrid
table={grid}
recordCount={data.length}
emptyMessage={emptyText ?? ' '}
tableLayout={{ width: 'auto' }}
tableClassNames={{ base }}
>
<DataGridTable footerContent={footer != null ? <tr>{footer}</tr> : undefined} />
</DataGrid>
</ColumnsContext.Provider>
</div>
)
}
+40 -1
View File
@@ -249,7 +249,7 @@ function place(wrap: HTMLElement, table: HTMLTableElement) {
/* The table is ReUI's, so it is found inside the wrapper rather than held by a ref. */
const tableIn = (wrap: HTMLElement | null) => wrap?.querySelector('table') ?? null
function useStacking(ref: React.RefObject<HTMLDivElement | null>, stack: boolean) {
export function useStacking(ref: React.RefObject<HTMLDivElement | null>, stack: boolean) {
// After every render, because the rows are the screen's: a sort, a page or a
// filter hands back new cells, and React leaves alone attributes it did not set.
useLayoutEffect(() => {
@@ -412,6 +412,45 @@ export function Th({
)
}
/**
* The column-definition twin of `Th`, for `DataTable`: the header's content (the
* sort button, or the plain text) and the attributes its `<th>` carries
* (`aria-sort`). `DataTable` draws the `<th>` itself.
*/
export function head({
field,
sort,
onSort,
name,
children,
...th
}: {
field?: string
sort?: Sort | null
onSort?: (field: string) => void
children?: ReactNode
name?: string
} & React.ThHTMLAttributes<HTMLTableCellElement>): { header: ReactNode; th: React.ThHTMLAttributes<HTMLTableCellElement> } {
if (field == null || onSort == null) return { header: children, th }
const active = sort?.field === field
return {
th: { 'aria-sort': active ? (sort!.desc ? 'descending' : 'ascending') : 'none', ...th },
header: (
<button
type="button"
className={styles.sort}
aria-label={children == null ? name : undefined}
onClick={() => onSort(field)}
>
{children}
<span className={styles.arrow}>
<Icon name={active ? 'chevronDown' : 'sort'} size={12} data-desc={active && !sort!.desc} />
</span>
</button>
),
}
}
/*
The button for a row action. It carries an icon and not a label (see the measured
reasoning in `Table.module.css`) but keeps its name in `aria-label` and in