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` сохранены) | | Tooltip | ReUI Tooltip (роль `tooltip` и `aria-describedby` сохранены) |
| Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) | | Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) |
| Select | ReUI Popover (поиск, группы и набор по буквам сохранены) | | 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 | | Toast | свой (фокус возвращается, пауза под курсором, действие с правом); цвета и шрифты из темы ReUI |
| Тема | палитра shnt Ops в `tokens.css`, Geist, шкала 12/13/14 px, радиусы 8/10/14 px | | Тема | палитра shnt Ops в `tokens.css`, Geist, шкала 12/13/14 px, радиусы 8/10/14 px |
+89 -58
View File
@@ -37,7 +37,8 @@ import {
type Notice, type Notice,
} from './parts' } from './parts'
import tbl from '../../ui/Table.module.css' 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 { Menu, Separator } from '../../ui/Menu'
import { GroupRow } from '../../ui/Form' import { GroupRow } from '../../ui/Form'
import { Notifier } from '../../ui/Notifier' import { Notifier } from '../../ui/Notifier'
@@ -219,51 +220,61 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
</Empty> </Empty>
) : ( ) : (
<> <>
<Table {/* This node's own row, washed in `--acc-bg`. The `Self` in its
stack `State` column stays: the colour finds the row, the word says
header={ which it is. Same treatment as the current session's row. */}
<> <DataTable
<Th field="name" sort={sort} onSort={toggle}>Node Name</Th> data={visibleNodes}
<Th field="ip" sort={sort} onSort={toggle}>IP Address</Th> getRowId={(n) => String(n.id)}
<Th field="url" sort={sort} onSort={toggle}>URL</Th> rowProps={(n) => ({ className: n.state === 'Self' ? tbl.rowSelf : undefined })}
<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}
emptyText="No Node Found" emptyText="No Node Found"
columns={9} stack
> columns={[
{visibleNodes.map((n) => ( {
/* This node's own row, washed in `--acc-bg`. The `Self` in its id: 'name',
`State` column stays: the colour finds the row, the word says ...head({ field: 'name', sort, onSort: toggle, children: 'Node Name' }),
which it is. Same treatment as the current session's row. */ td: () => ({ className: tbl.lead }),
<tr key={n.id} className={n.state === 'Self' ? tbl.rowSelf : undefined}> cell: (n) => (
<td className={tbl.lead}> <>
{n.name} {n.name}
{/* The node's version under its name, as `(v15.6)`: new in {/* The node's version under its name, as `(v15.6)`: new in
v15.6 (cluster.js:180-183). */} v15.6 (cluster.js:180-183). */}
{n.version != null && <div className={tbl.meta}>(v{n.version})</div>} {n.version != null && <div className={tbl.meta}>(v{n.version})</div>}
</td> </>
<td className={styles.mono}> ),
{n.ipAddresses.map((ip) => ( },
{
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> <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="info">{n.type}</Tag>
) : ( ) : (
<Tag tone="warn">Unknown</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> <Tag>Self</Tag>
) : n.state === 'Connected' ? ( ) : n.state === 'Connected' ? (
<Tag tone="ok">Connected</Tag> <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">Unreachable</Tag>
) : ( ) : (
<Tag tone="warn">Unknown</Tag> <Tag tone="warn">Unknown</Tag>
)} )
</td> ),
<td className={styles.nowrap}> },
<RelativeDate iso={n.upSince} /> {
</td> id: 'upSince',
<td className={styles.nowrap}> ...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 {/* The node itself has no "Last Seen": in its row that column
is always empty (cluster.js:177-193). */} is always empty (cluster.js:177-193). */}
{n.state !== 'Self' && <RelativeDate iso={n.lastSeen} />} {n.state !== 'Self' && <RelativeDate iso={n.lastSeen} />}
</td> </>
<td className={styles.nowrap}> ),
{n.state === 'Self' && n.type === 'Secondary' && ( },
{
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} /> <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}> <div className={tbl.actions}>
{/* What can be done with a node depends on whether this {/* What can be done with a node depends on whether this
console is talking to the primary or to a secondary console is talking to the primary or to a secondary
@@ -327,10 +358,10 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
</Menu> </Menu>
)} )}
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.count}> <div className={styles.count}>
<span>{`Total Nodes: ${nodes.length}`}</span> <span>{`Total Nodes: ${nodes.length}`}</span>
</div> </div>
+30 -35
View File
@@ -23,7 +23,8 @@ import {
type Notice, type Notice,
} from './parts' } from './parts'
import tbl from '../../ui/Table.module.css' 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 { Menu } from '../../ui/Menu'
import { Notifier } from '../../ui/Notifier' import { Notifier } from '../../ui/Notifier'
@@ -103,42 +104,36 @@ export function Groups({ tabs, token, onNotice }: Props) {
<Loading /> <Loading />
) : ( ) : (
<> <>
<Table <DataTable
header={ data={visibleGroups}
<> getRowId={(g) => g.name}
<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}
emptyText="No Group Found" emptyText="No Group Found"
columns={3} columns={[
> {
{visibleGroups.map((g) => ( id: 'name',
<tr key={g.name}> ...head({ field: 'name', sort, onSort: toggle, children: 'Name' }),
<td> cell: (g) => (
<button <button type="button" className={styles.link} onClick={() => setDetail(g.name)}>
type="button"
className={styles.link}
onClick={() => setDetail(g.name)}
>
{g.name} {g.name}
</button> </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 // eslint-disable-next-line react/no-array-index-key
<div key={i}>{line}</div> <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}> <div className={tbl.actions}>
<RowAction <RowAction icon="card" name="View Details" onClick={() => setDetail(g.name)} />
icon="card"
name="View Details"
onClick={() => setDetail(g.name)}
/>
<Menu label={`Actions for ${g.name}`}> <Menu label={`Actions for ${g.name}`}>
{(close) => ( {(close) => (
<button type="button" data-variant="danger" onClick={() => { close(); setPendingDelete(g) }}> <button type="button" data-variant="danger" onClick={() => { close(); setPendingDelete(g) }}>
@@ -147,10 +142,10 @@ export function Groups({ tabs, token, onNotice }: Props) {
)} )}
</Menu> </Menu>
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.count}> <div className={styles.count}>
<span>{`Total Groups: ${groups.length}`}</span> <span>{`Total Groups: ${groups.length}`}</span>
</div> </div>
+50 -38
View File
@@ -29,7 +29,8 @@ import {
type Notice, type Notice,
} from './parts' } from './parts'
import tbl from '../../ui/Table.module.css' 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 { Menu } from '../../ui/Menu'
import { Notifier } from '../../ui/Notifier' import { Notifier } from '../../ui/Notifier'
@@ -142,28 +143,21 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
<Loading /> <Loading />
) : ( ) : (
<> <>
<Table {/* My own session's row, washed in `--acc-bg`. The `(current)` in
stack its token cell stays: the colour finds the row, the word says
header={ which it is. Same treatment as this node's row in `Cluster`. */}
<> <DataTable
<Th field="username" sort={sort} onSort={toggle}>Username</Th> data={visibleSessions}
<Th field="session" sort={sort} onSort={toggle}>Session</Th> getRowId={(s) => String(s.partialToken)}
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th> rowProps={(s) => ({ className: s.isCurrentSession ? tbl.rowSelf : undefined })}
<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}
emptyText="No Session Found" emptyText="No Session Found"
columns={6} stack
> columns={[
{visibleSessions.map((s) => ( {
/* My own session's row, washed in `--acc-bg`. The `(current)` in id: 'username',
its token cell stays: the colour finds the row, the word says ...head({ field: 'username', sort, onSort: toggle, children: 'Username' }),
which it is. Same treatment as this node's row in `Cluster`. */ td: () => ({ className: tbl.lead }),
<tr key={s.partialToken} className={s.isCurrentSession ? tbl.rowSelf : undefined}> cell: (s) => (
<td className={tbl.lead}>
<button <button
type="button" type="button"
className={styles.link} className={styles.link}
@@ -171,18 +165,36 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
> >
{s.username} {s.username}
</button> </button>
</td> ),
<td className={tbl.lead}> },
<SessionCell session={s} /> {
</td> id: 'session',
<td className={styles.nowrap}> ...head({ field: 'session', sort, onSort: toggle, children: 'Session' }),
<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} /> td: () => ({ className: tbl.lead }),
</td> cell: (s) => (<SessionCell session={s} />),
<td className={styles.mono}>{s.lastSeenRemoteAddress}</td> },
<td> {
<AgentCell>{s.lastSeenUserAgent}</AgentCell> id: 'lastSeen',
</td> ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
<td className={tbl.actionsCell}> 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}> <div className={tbl.actions}>
{/* auth.js:938: upstream's menu entry says "View User Details", {/* auth.js:938: upstream's menu entry says "View User Details",
not the Users table's "View Details". The frequent action not the Users table's "View Details". The frequent action
@@ -201,10 +213,10 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
)} )}
</Menu> </Menu>
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.count}> <div className={styles.count}>
<span>{`Total Sessions: ${sessions.length}`}</span> <span>{`Total Sessions: ${sessions.length}`}</span>
</div> </div>
+37 -29
View File
@@ -27,7 +27,8 @@ import {
} from './parts' } from './parts'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
import frm from '../../ui/Form.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 { Notifier } from '../../ui/Notifier'
import { Menu } from '../../ui/Menu' import { Menu } from '../../ui/Menu'
@@ -305,30 +306,37 @@ export function UserDetails({ open, username, token, cluster, onClose, onSaved,
</MRow> </MRow>
<p className={styles.sub}>Active Sessions</p> <p className={styles.sub}>Active Sessions</p>
<Table <DataTable
header={ data={visibleSessions}
<> getRowId={(s) => String(s.partialToken)}
<Th field="session" sort={sort} onSort={toggle}>Session</Th> columns={[
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th> {
<Th field="address" sort={sort} onSort={toggle}>Remote Address</Th> id: 'session',
<Th field="agent" sort={sort} onSort={toggle}>User Agent</Th> ...head({ field: 'session', sort, onSort: toggle, children: 'Session' }),
<th className={tbl.actionsCell} /> cell: (s) => (<SessionCell session={s} />),
</> },
} {
> id: 'lastSeen',
{visibleSessions.map((s) => ( ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
<tr key={s.partialToken}> td: () => ({ className: styles.nowrap }),
<td> cell: (s) => (<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} />),
<SessionCell session={s} /> },
</td> {
<td className={styles.nowrap}> id: 'address',
<LastSeenCell date={dateTime(s.lastSeen)} ago={fromNow(s.lastSeen)} /> ...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }),
</td> td: () => ({ className: styles.mono }),
<td className={styles.mono}>{s.lastSeenRemoteAddress}</td> cell: (s) => s.lastSeenRemoteAddress,
<td> },
<AgentCell>{s.lastSeenUserAgent}</AgentCell> {
</td> id: 'agent',
<td className={tbl.actionsCell}> ...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}> <div className={tbl.actions}>
{/* Inside the menu, as in "Administration > Sessions" and as {/* Inside the menu, as in "Administration > Sessions" and as
in upstream, which also puts it in a dropdown in upstream, which also puts it in a dropdown
@@ -347,10 +355,10 @@ export function UserDetails({ open, username, token, cluster, onClose, onSaved,
)} )}
</Menu> </Menu>
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.count}> <div className={styles.count}>
<span>{`Total Sessions: ${sessions.length}`}</span> <span>{`Total Sessions: ${sessions.length}`}</span>
</div> </div>
+64 -57
View File
@@ -25,7 +25,8 @@ import {
type Notice, type Notice,
} from './parts' } from './parts'
import tbl from '../../ui/Table.module.css' 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 { Menu, Separator } from '../../ui/Menu'
import { neverUsed } from '../../api/zones' import { neverUsed } from '../../api/zones'
import { Notifier } from '../../ui/Notifier' import { Notifier } from '../../ui/Notifier'
@@ -172,27 +173,17 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
<Loading /> <Loading />
) : ( ) : (
<> <>
<Table <DataTable
stack data={visibleUsers}
header={ getRowId={(u) => String(u.username)}
<>
<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}
emptyText="No User Found" emptyText="No User Found"
columns={8} stack
> columns={[
{visibleUsers.map((u) => ( {
<tr key={u.username}> id: 'username',
<td className={tbl.lead}> ...head({ field: 'username', sort, onSort: toggle, children: 'Username' }),
td: () => ({ className: tbl.lead }),
cell: (u) => (
<button <button
type="button" type="button"
className={styles.link} className={styles.link}
@@ -200,49 +191,65 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
> >
{u.username} {u.username}
</button> </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 id: 'display',
kinds of thing: two pills and one bare word. The word does not ...head({ field: 'display', sort, onSort: toggle, children: 'Display Name' }),
change: `Remote/SSO` is upstream's, `SSO Managed` in the next cell: (u) => u.displayName,
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> id: 'type',
{u.type === 'RemoteSSO' || u.type === 'RemoteLDAP' ? ( ...head({ field: 'type', sort, onSort: toggle, children: 'Type' }),
cell: (u) => (
u.type === 'RemoteSSO' || u.type === 'RemoteLDAP' ? (
<Tag tone="info">{userType(u)}</Tag> <Tag tone="info">{userType(u)}</Tag>
) : ( ) : (
<Tag>{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> <Tag tone="info">SSO Managed</Tag>
) : u.totpEnabled ? ( ) : u.totpEnabled ? (
<Tag tone="ok">Enabled</Tag> <Tag tone="ok">Enabled</Tag>
) : ( ) : (
<Tag>Disabled</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>Disabled</Tag>
) : ( ) : (
<Tag tone="ok">Enabled</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 id: 'recent',
shows "0000-12-31 23:45:16 from 0.0.0.0" (year zero, because it ...head({ field: 'recent', sort, onSort: toggle, children: 'Recent Login' }),
converts to local time a date older than time zones) and an td: () => ({ className: styles.mono }),
address that does not exist. That is not a datum: it is a cell: (u) => access(u.recentSessionLoggedOn, u.recentSessionRemoteAddress),
sentinel value leaking onto the screen. It says "Never", which is },
what it means, just as was done with the DNSSEC column. {
*/} id: 'previous',
<td className={styles.mono}>{access(u.recentSessionLoggedOn, u.recentSessionRemoteAddress)}</td> ...head({ field: 'previous', sort, onSort: toggle, children: 'Previous Login' }),
<td className={styles.mono}>{access(u.previousSessionLoggedOn, u.previousSessionRemoteAddress)}</td> td: () => ({ className: styles.mono }),
<td className={tbl.actionsCell}> cell: (u) => access(u.previousSessionLoggedOn, u.previousSessionRemoteAddress),
},
{
id: 'c7',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (u) => (
<div className={tbl.actions}> <div className={tbl.actions}>
<RowAction <RowAction
icon="card" icon="card"
@@ -283,10 +290,10 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
)} )}
</Menu> </Menu>
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.count}> <div className={styles.count}>
<span>{`Total Users: ${users.length}`}</span> <span>{`Total Users: ${users.length}`}</span>
</div> </div>
+166 -112
View File
@@ -5,7 +5,8 @@ import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { nextUpdateText } from '../settings/panes/Blocking' import { nextUpdateText } from '../settings/panes/Blocking'
import { SectionHeader } from '../../ui/SectionHeader' import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel' 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 { Field, Input, Select } from '../../ui/Field'
import { Button } from '../../ui/Button' import { Button } from '../../ui/Button'
import { PermissionButton } from '../../ui/PermissionButton' import { PermissionButton } from '../../ui/PermissionButton'
@@ -478,128 +479,96 @@ export function BlockLists({
/* The lists of each group, to say how many it holds. */ /* 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) 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) { /* What the table draws, one item per row: a group heading (its content spans the
return ( whole row) or a line. The grid draws cells, so a heading is a row whose first cell
<tr key={key} className={styles.groupRow}> takes the width of all four and whose other cells are not drawn. */
<th colSpan={4} scope="colgroup"> type Entry = ListLine & { kind: 'block' | 'allow' }
<div className={styles.groupHead}> type Item =
<span className={styles.groupName}>{name}</span> | { key: string; kind: 'heading'; node: ReactNode }
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span> | { key: string; kind: 'line'; l: Entry; i: number }
{controls != null && <span className={styles.spacer} />}
{controls} function heading(key: string, name: string, count: number, controls: ReactNode): Item {
</div> return {
</th> key,
</tr> 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 /* 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. */ moves or switches keeps its row, and the focus stays on the button pressed. */
const seen = new Map<string, number>() const seen = new Map<string, number>()
const rows: ReactNode[] = [] const items: Item[] = []
if (heads.length > 0 && heads[0] > 0) rows.push(headingRow('none', 'No group', groupSize(-1), null)) if (heads.length > 0 && heads[0] > 0) items.push(heading('none', 'No group', groupSize(-1), null))
lines.forEach((l, i) => { lines.forEach((l, i) => {
if (l.kind === 'comment') { if (l.kind === 'comment') {
const g = heads.indexOf(i) const g = heads.indexOf(i)
const name = nameOf(l) const name = nameOf(l)
rows.push( items.push(
edit != null && edit.group === g ? ( edit != null && edit.group === g
<tr key={`H:${g}`} className={styles.groupRow}> ? {
<th colSpan={4} scope="colgroup"> key: `H:${g}`,
<HeadingEditor kind: 'heading',
editing={edit} node: (
verb="Rename" <HeadingEditor
onChange={(value) => setEditing({ ...edit, value, problem: null })} editing={edit}
onSubmit={submitHeading} verb="Rename"
onCancel={cancelEditing} onChange={(value) => setEditing({ ...edit, value, problem: null })}
/> onSubmit={submitHeading}
</th> onCancel={cancelEditing}
</tr> />
) : ( ),
headingRow(`H:${g}`, name, groupSize(g), ( }
<> : 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 })}> <PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
Rename onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
</PermissionButton> Rename
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`} </PermissionButton>
onClick={() => ungroup(g)}> <PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
Ungroup onClick={() => ungroup(g)}>
</PermissionButton> Ungroup
</> </PermissionButton>
)) </>
), )),
) )
return return
} }
const id = `${l.kind}:${l.url}` const id = `${l.kind}:${l.url}`
const n = seen.get(id) ?? 0 const n = seen.get(id) ?? 0
seen.set(id, n + 1) seen.set(id, n + 1)
const name = l.url == null ? null : listName(l.url, named) items.push({ key: `L:${id}:${n}`, kind: 'line', l: l as Entry, i })
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>,
)
}) })
if (edit != null && edit.group === 'new') { if (edit != null && edit.group === 'new') {
rows.push( items.push({
<tr key="H:new" className={styles.groupRow}> key: 'H:new',
<th colSpan={4} scope="colgroup"> kind: 'heading',
<HeadingEditor node: (
editing={edit} <HeadingEditor
verb="Add group" editing={edit}
onChange={(value) => setEditing({ ...edit, value, problem: null })} verb="Add group"
onSubmit={submitHeading} onChange={(value) => setEditing({ ...edit, value, problem: null })}
onCancel={cancelEditing} onSubmit={submitHeading}
/> onCancel={cancelEditing}
</th> />
</tr>, ),
) })
} }
/* 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 ( return (
<> <>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} /> <SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
@@ -778,15 +747,100 @@ export function BlockLists({
) : saved == null ? ( ) : saved == null ? (
<Body><Loading /></Body> <Body><Loading /></Body>
) : ( ) : (
<Table <DataTable<Item>
header={<><th>Enabled</th><th>List</th><th className={styles.kindCell}>Type</th><th /></>} data={items}
isEmpty={lines.length === 0 && edit?.group !== 'new'} getRowId={(item) => item.key}
emptyText="No lists" rowProps={(item) => (item.kind === 'heading' ? { className: styles.groupRow } : undefined)}
columns={4} emptyText={lines.length === 0 && edit?.group !== 'new' ? 'No lists' : undefined}
className={shared.inPanel} className={shared.inPanel}
> columns={[
{rows} {
</Table> 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}> <div className={styles.foot}>
<PermissionButton size="sm" permission={modifyNeed} disabled={loading || edit?.group === 'new'} <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 { appsWithQueryLogs } from '../logs/QueryLogs'
import { RouteLink } from '../../ui/RouteLink' import { RouteLink } from '../../ui/RouteLink'
import { Panel, Body } from '../../ui/Panel' import { Panel, Body } from '../../ui/Panel'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
import { DomainName } from '../../ui/DomainName' import { DomainName } from '../../ui/DomainName'
import { Chip } from '../../ui/Tag' import { Chip } from '../../ui/Tag'
@@ -189,30 +189,39 @@ export function RecentBlocked({
Some blocked queries could not be read. Some blocked queries could not be read.
</div> </div>
)} )}
<Table <DataTable
stack data={state.entries}
header={ getRowId={(e) => String(`${e.responseType}:${e.rowNumber}:${e.timestamp}`)}
<>
<th>Time</th>
<th>Client</th>
<th>Domain</th>
<th>Type</th>
</>
}
isEmpty={state.entries.length === 0}
emptyText="No blocked queries." emptyText="No blocked queries."
columns={4} stack
className={shared.inPanel} className={shared.inPanel}
> columns={[
{state.entries.map((e) => ( {
<tr key={`${e.responseType}:${e.rowNumber}:${e.timestamp}`}> id: 'c0',
<td className={styles.time}>{dateTime(e.timestamp)}</td> header: 'Time',
<td className={styles.mono}>{e.clientIpAddress}</td> td: () => ({ className: styles.time }),
<td className={`${styles.name} ${tbl.lead}`}><DomainName name={e.qname ?? '.'} /></td> cell: (e) => dateTime(e.timestamp),
<td className={tbl.lead}>{e.qtype != null && <Chip>{e.qtype}</Chip>}</td> },
</tr> {
))} id: 'c1',
</Table> 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> </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 { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
import { SectionHeader } from '../../ui/SectionHeader' import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel' import { Panel, Body } from '../../ui/Panel'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import { Pagination } from '../../ui/Pagination' import { Pagination } from '../../ui/Pagination'
import { Segmented } from '../../ui/Segmented' import { Segmented } from '../../ui/Segmented'
import { Menu } from '../../ui/Menu' import { Menu } from '../../ui/Menu'
@@ -522,29 +522,32 @@ export function Rules({
) )
) : ( ) : (
<> <>
<Table <DataTable
header={ data={current.rows}
<> getRowId={(r) => String(`${r.list}:${r.domain}`)}
<th>Domain</th>
<th>Rule</th>
<th />
</>
}
isEmpty={current.rows.length === 0}
emptyText={query === '' ? 'No rules' : 'No rules match this filter'} emptyText={query === '' ? 'No rules' : 'No rules match this filter'}
columns={3}
className={shared.inPanel} className={shared.inPanel}
> columns={[
{current.rows.map((r) => ( {
<tr key={`${r.list}:${r.domain}`}> id: 'c0',
<td className={styles.domain}><DomainName name={r.domain} /></td> header: 'Domain',
<td> td: () => ({ className: styles.domain }),
cell: (r) => (<DomainName name={r.domain} />),
},
{
id: 'c1',
header: 'Rule',
cell: (r) => (
<span className={`${styles.kind} ${styles[r.list]}`}> <span className={`${styles.kind} ${styles[r.list]}`}>
<Icon name={r.list} size={14} /> <Icon name={r.list} size={14} />
{LABEL[r.list]} {LABEL[r.list]}
</span> </span>
</td> ),
<td className={styles.actions}> },
{
id: 'c2',
td: () => ({ className: styles.actions }),
cell: (r) => (
<PermissionButton <PermissionButton
size="sm" size="sm"
variant="danger" variant="danger"
@@ -555,10 +558,10 @@ export function Rules({
> >
Delete Delete
</PermissionButton> </PermissionButton>
</td> ),
</tr> },
))} ]}
</Table> />
{current.totalPages > 1 && ( {current.totalPages > 1 && (
<Pagination <Pagination
window={pageWindow(current.page, current.totalPages)} 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 type { Range, TopEntry } from '../../api/dashboard'
import { TopStats } from '../dashboard/TopStats' import { TopStats } from '../dashboard/TopStats'
import { Panel } from '../../ui/Panel' import { Panel } from '../../ui/Panel'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import { DomainName } from '../../ui/DomainName' import { DomainName } from '../../ui/DomainName'
import { Menu } from '../../ui/Menu' import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button' import { Button } from '../../ui/Button'
@@ -74,30 +74,38 @@ export function TopTable({
</Button> </Button>
} }
> >
<Table <DataTable
header={ data={rows}
<> getRowId={(r) => String(r.name)}
<th>Domain</th>
<th aria-label="Share" />
<th className={styles.hitsHead}>Hits</th>
<th aria-label="Actions" />
</>
}
isEmpty={rows.length === 0}
emptyText={failure ? undefined : 'No domains for this period.'} emptyText={failure ? undefined : 'No domains for this period.'}
columns={4}
className={shared.inPanel} className={shared.inPanel}
> columns={[
{rows.map((r) => ( {
<tr key={r.name}> id: 'c0',
<td className={styles.name}><DomainName name={r.name} /></td> header: 'Domain',
<td> 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}`}> <span className={`${styles.share} ${isBlocked ? styles.shareBlocked : styles.shareTotal}`}>
<i style={{ width: `${top === 0 ? 0 : (r.hits * 100) / top}%` }} /> <i style={{ width: `${top === 0 ? 0 : (r.hits * 100) / top}%` }} />
</span> </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> <Menu label={`Actions for ${r.name}`} quiet>
{(close) => {(close) =>
need == null ? ( need == null ? (
@@ -116,10 +124,10 @@ export function TopTable({
) )
} }
</Menu> </Menu>
</td> ),
</tr> },
))} ]}
</Table> />
<TopStats type={more ? kind : null} range={range} token={token} onClose={() => setMore(false)} /> <TopStats type={more ? kind : null} range={range} token={token} onClose={() => setMore(false)} />
</Panel> </Panel>
) )
+39 -35
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getTop, type Range, type TopKind, type TopEntry } from '../../api/dashboard' import { getTop, type Range, type TopKind, type TopEntry } from '../../api/dashboard'
import { Dialog } from '../../ui/Dialog' import { Dialog } from '../../ui/Dialog'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import { Loading } from '../../ui/Empty' import { Loading } from '../../ui/Empty'
import { Notifier } from '../../ui/Notifier' import { Notifier } from '../../ui/Notifier'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
@@ -104,43 +104,47 @@ export function TopStats({
{loading ? ( {loading ? (
<Loading compact /> <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} className={styles.topTableWrap}
tableClass={styles.topTable} tableClass={styles.topTable}
header={ footer={<th colSpan={3}>
<> {type == null ? '' : `Total ${TITLES[type]}: ${rows.length.toLocaleString()}`}
<th>{type == null ? '' : HEADER[type]}</th> </th>}
<th style={{ width: 110 }}>{type == null ? '' : COUNT[type]}</th> columns={[
<th className={tbl.actionsCell} /> {
</> id: 'c0',
} header: type == null ? '' : HEADER[type],
isEmpty={rows.length === 0} cell: (f) => (
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>
<span className={styles.topName}> <span className={styles.topName}>
{f.name} {f.name}
{f.rateLimited ? ' (rate limited)' : ''} {f.rateLimited ? ' (rate limited)' : ''}
</span> </span>
{isClient && f.domain != null && f.domain !== '' && ( {isClient && f.domain != null && f.domain !== '' && (
<span className={styles.topDomain}>{f.domain}</span> <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> id: 'c1',
</tr> th: { 'style': { width: 110 } },
))} header: type == null ? '' : COUNT[type],
</Table> 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> </Dialog>
) )
+58 -33
View File
@@ -17,7 +17,8 @@ import { Failure, Loading } from '../../ui/Empty'
import { Tag } from '../../ui/Tag' import { Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
import styles from './Dhcp.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 { Menu } from '../../ui/Menu'
import { noticeFromFailure } from '../../lib/notice' import { noticeFromFailure } from '../../lib/notice'
import { Notifier } from '../../ui/Notifier' 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> <Failure>Could not load the list.</Failure>
) : ( ) : (
<> <>
<Table <DataTable
stack data={visibleLeases}
header={ getRowId={(l) => String(`${l.scope}/${l.clientIdentifier}`)}
<>
<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}
emptyText="No Lease Found" emptyText="No Lease Found"
columns={8} stack
> columns={[
{visibleLeases.map((l, i) => ( {
<tr key={`${l.scope}/${l.clientIdentifier}`}> id: 'scope',
<td className={styles.mono}>{l.scope}</td> ...head({ field: 'scope', sort, onSort: toggle, children: 'Scope' }),
<td className={styles.mono}>{l.hardwareAddress}</td> td: () => ({ className: styles.mono }),
<td className={`${styles.mono} ${tbl.lead}`}>{l.address}</td> cell: (l) => l.scope,
<td> },
{
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'}> <Tag tone={l.type === 'Reserved' ? 'neutral' : 'ok'}>
{l.type} {l.type}
</Tag> </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> id: 'host',
<td className={tbl.actionsCell}> ...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}> <div className={tbl.actions}>
{/* dhcp.js:63-64. Which of the two conversions is offered {/* dhcp.js:63-64. Which of the two conversions is offered
depends on the lease's current type. */} depends on the lease's current type. */}
@@ -250,10 +275,10 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
</Menu> </Menu>
)} )}
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.total}> <div className={styles.total}>
{/* The footer is the count and nothing else. When there are no rows, {/* 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 { Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
import styles from './Dhcp.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 { Menu } from '../../ui/Menu'
import { noticeFromFailure } from '../../lib/notice' import { noticeFromFailure } from '../../lib/notice'
import { Notifier } from '../../ui/Notifier' import { Notifier } from '../../ui/Notifier'
@@ -240,26 +241,22 @@ export function Scopes({
<Failure>Could not load the list.</Failure> <Failure>Could not load the list.</Failure>
) : ( ) : (
<> <>
<Table <DataTable
stack data={visibleScopes}
header={ getRowId={(s) => String(s.name)}
<>
<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}
emptyText="No Scope Found" emptyText="No Scope Found"
columns={6} stack
> columns={[
{visibleScopes.map((s) => ( {
<tr key={s.name}> id: 'name',
<td className={`${styles.name} ${tbl.lead}`}>{s.name}</td> ...head({ field: 'name', sort, onSort: toggle, children: 'Name' }),
<td> 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}> <dl className={styles.kv}>
<dt>Range</dt> <dt>Range</dt>
<dd> <dd>
@@ -268,24 +265,40 @@ export function Scopes({
<dt>Mask</dt> <dt>Mask</dt>
<dd>{s.subnetMask}</dd> <dd>{s.subnetMask}</dd>
</dl> </dl>
</td> ),
<td> },
{
id: 'network',
...head({ field: 'network', sort, onSort: toggle, children: 'Network/Broadcast' }),
cell: (s) => (
<dl className={styles.kv}> <dl className={styles.kv}>
<dt>Network</dt> <dt>Network</dt>
<dd>{s.networkAddress}</dd> <dd>{s.networkAddress}</dd>
<dt>Broadcast</dt> <dt>Broadcast</dt>
<dd>{s.broadcastAddress}</dd> <dd>{s.broadcastAddress}</dd>
</dl> </dl>
</td> ),
{/* `interfaceAddress` is OMITTED when null; upstream draws an },
empty cell (dhcp.js:228). */} {
<td className={styles.mono}>{s.interfaceAddress ?? ''}</td> id: 'interface',
<td> ...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'}> <Tag tone={s.enabled ? 'ok' : 'neutral'}>
{s.enabled ? 'Enabled' : 'Disabled'} {s.enabled ? 'Enabled' : 'Disabled'}
</Tag> </Tag>
</td> ),
<td className={tbl.actionsCell}> },
{
id: 'c5',
th: { className: tbl.actionsCell },
td: () => ({ className: tbl.actionsCell }),
cell: (s) => (
<div className={tbl.actions}> <div className={tbl.actions}>
<RowAction <RowAction
icon="edit" icon="edit"
@@ -321,10 +334,10 @@ export function Scopes({
</Menu> </Menu>
)} )}
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.total}> <div className={styles.total}>
{/* The footer is the count and nothing else. When there are no rows, {/* 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 type { DnsRecord } from '../../api/zonelists'
import { rdataEntries, extras, meta, splitTtl, type Entry } from './record' import { rdataEntries, extras, meta, splitTtl, type Entry } from './record'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import styles from './Lists.module.css' 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 }) { /* What each record has open: its signatures (RRSIG) and its glue. The grid draws a row
const [signatures, setSignatures] = useState(false) as separate cells, so the state the cells share is kept here, by the record. */
const [glue, setGlue] = useState(false) type Open = Record<string, { signatures?: boolean; glue?: boolean }>
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>
)
}
export function ResourceRecords({ export function ResourceRecords({
records, records,
@@ -156,24 +92,110 @@ export function ResourceRecords({
withDnssec: boolean withDnssec: boolean
node: string 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 ( return (
<Table <DataTable
stack stack
header={ data={records}
<> getRowId={rowKey}
<th style={{ width: 110 }}>Type</th> columns={[
<th style={{ width: 120 }}>TTL</th> {
<th>Data</th> id: 'type',
{withDnssec && <th style={{ width: 100 }}>DNSSEC</th>} th: { style: { width: 110 } },
<th style={{ width: 120 }}> header: 'Type',
<span className="sr-only" /> td: () => ({ className: tbl.lead }),
</th> 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
{records.map((r, i) => ( return (
<Row key={`${r.name}|${r.type}|${i}`} r={r} withDnssec={withDnssec} node={node} /> <>
))} <Chip>{r.type}</Chip>
</Table> {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 { Loading } from '../../ui/Empty'
import { Check } from '../../ui/Check' import { Check } from '../../ui/Check'
import { Tag } from '../../ui/Tag' import { Tag } from '../../ui/Tag'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import styles from './Logs.module.css' import styles from './Logs.module.css'
import { pageWindow } from '../../lib/pagination' import { pageWindow } from '../../lib/pagination'
import { Pagination } from '../../ui/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`. */} {/* Always all seven, whether or not they appear on this page: see `LEGEND_ROWS`. */}
<RowLegend fold={fold} /> <RowLegend fold={fold} />
<Table <DataTable
data={page.entries}
getRowId={(e) => String(e.rowNumber)}
rowProps={(e) => ({ className: rowClass(e) })}
stack stack
tableClass={styles.queryTable} tableClass={styles.queryTable}
header={ columns={[
<> {
<th>#</th> id: 'c0',
<th>Timestamp</th> header: '#',
<th>Client IP Address</th> td: () => ({ className: styles.mono }),
<th>Protocol</th> cell: (e) => e.rowNumber,
<th>Response Type</th> },
<th>RCODE</th> {
<th>Domain</th> id: 'c1',
<th>Type</th> header: 'Timestamp',
<th>Class</th> td: () => ({ className: `${styles.mono} ${styles.stamp}` }),
<th>Answer</th> cell: (e) => (<Stamp text={dateTime(e.timestamp)} />),
<th className={tbl.actionsCell} /> },
</> {
} id: 'c2',
> header: 'Client IP Address',
{page.entries.map((e) => ( td: () => ({ className: styles.mono }),
<tr key={e.rowNumber} className={rowClass(e)}> cell: (e) => (<Clip text={e.clientIpAddress} className={styles.clipClient} />),
<td className={styles.mono}>{e.rowNumber}</td> },
<td className={`${styles.mono} ${styles.stamp}`}> {
<Stamp text={dateTime(e.timestamp)} /> id: 'c3',
</td> header: 'Protocol',
<td className={styles.mono}> cell: (e) => e.protocol,
<Clip text={e.clientIpAddress} className={styles.clipClient} /> },
</td> {
<td>{e.protocol}</td> id: 'c4',
<td> header: 'Response Type',
cell: (e) => (
<>
{/* The row's colour, in this cell: see `.tone` in Logs.module.css. */} {/* The row's colour, in this cell: see `.tone` in Logs.module.css. */}
<span className={styles.tone}>{e.responseType}</span> <span className={styles.tone}>{e.responseType}</span>
{e.responseRtt != null && ( {e.responseRtt != null && (
<div className={styles.rtt}>({e.responseRtt.toFixed(2)} ms)</div> <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} /> id: 'c5',
</td> header: 'RCODE',
<td className={tbl.lead}>{e.qtype ?? ''}</td> cell: (e) => e.rcode,
<td>{e.qclass ?? ''}</td> },
<td className={`${styles.mono} ${styles.answer}`}> {
<Clip text={e.answer ?? ''} className={styles.clipAnswer} /> id: 'c6',
</td> header: 'Domain',
<td className={tbl.actionsCell}> 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}`}> <Menu label={`Actions for row ${e.rowNumber}`}>
{(close) => { {(close) => {
const verb = verbFor(e.responseType) const verb = verbFor(e.responseType)
@@ -1008,10 +1037,10 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
) )
}} }}
</Menu> </Menu>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.count}> <div className={styles.count}>
<span>{statusText(page)}</span> <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 { deleteSession, type SessionRow } from '../../api/user'
import styles from './MyProfile.module.css' import styles from './MyProfile.module.css'
import tbl from '../../ui/Table.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 { fromNow, dateTime } from '../../lib/dates'
import { noticeFromFailure, type Notice } from '../../lib/notice' import { noticeFromFailure, type Notice } from '../../lib/notice'
import { Notifier } from '../../ui/Notifier' 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 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 that wraps it, so the same sessions table looked two different ways
depending on whether it opened from "My Profile" or "User Details". */} depending on whether it opened from "My Profile" or "User Details". */}
<Table <DataTable
className={styles.narrow} data={groups.rows.map((name) => ({ name }))}
header={ getRowId={(g) => g.name}
<Th field="group" sort={groups.sort} onSort={groups.toggle}>Group</Th>
}
isEmpty={groups.rows.length === 0}
emptyText="No Group Found" emptyText="No Group Found"
columns={1} className={styles.narrow}
> columns={[
{groups.rows.map((g) => ( {
<tr key={g}> id: 'group',
<td>{g}</td> ...head({ field: 'group', sort: groups.sort, onSort: groups.toggle, children: 'Group' }),
</tr> cell: (g) => g.name,
))} },
</Table> ]}
/>
<div className={styles.total}>{`Total Groups: ${profile?.memberOfGroups?.length ?? 0}`}</div> <div className={styles.total}>{`Total Groups: ${profile?.memberOfGroups?.length ?? 0}`}</div>
</div> </div>
<div> <div>
<div className={styles.caption}>Active Sessions</div> <div className={styles.caption}>Active Sessions</div>
<Table <DataTable
header={ data={visibleSessions}
<> getRowId={(row) => String(row.partialToken)}
<Th field="type" sort={sort} onSort={toggle}>Session</Th> columns={[
<Th field="lastSeen" sort={sort} onSort={toggle}>Last Seen</Th> {
<Th field="address" sort={sort} onSort={toggle}>Remote Address</Th> id: 'type',
{/* Upstream has it (`index.html`, the `tbodyMyProfileActiveSessions` ...head({ field: 'type', sort, onSort: toggle, children: 'Session' }),
table: Session · Last Seen · Remote Address · User Agent) and here cell: (row) => (<SessionCell session={row} />),
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. */} id: 'lastSeen',
<Th field="agent" sort={sort} onSort={toggle}>User Agent</Th> ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }),
<th className={tbl.actionsCell} /> cell: (row) => (<LastSeenCell date={dateTime(row.lastSeen)} ago={fromNow(row.lastSeen)} />),
</> },
} {
> id: 'address',
{visibleSessions.map((row) => ( ...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }),
<tr key={row.partialToken}> td: () => ({ className: styles.mono }),
<td> cell: (row) => row.lastSeenRemoteAddress,
<SessionCell session={row} /> },
</td> {
<td> id: 'agent',
<LastSeenCell date={dateTime(row.lastSeen)} ago={fromNow(row.lastSeen)} /> ...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }),
</td> cell: (row) => (<AgentCell>{row.lastSeenUserAgent}</AgentCell>),
<td className={styles.mono}>{row.lastSeenRemoteAddress}</td> },
<td> {
<AgentCell>{row.lastSeenUserAgent}</AgentCell> id: 'c4',
</td> th: { className: tbl.actionsCell },
<td className={tbl.actionsCell}> td: () => ({ className: tbl.actionsCell }),
cell: (row) => (
<div className={tbl.actions}> <div className={tbl.actions}>
{/* In a dropdown, as in the other two sessions tables and as in {/* In a dropdown, as in the other two sessions tables and as in
upstream (`auth.js`, `deleteMySession`). */} upstream (`auth.js`, `deleteMySession`). */}
@@ -276,10 +276,10 @@ export function MyProfile({
)} )}
</Menu> </Menu>
</div> </div>
</td> ),
</tr> },
))} ]}
</Table> />
<div className={styles.total}>Total Sessions: {profile?.sessions?.length ?? 0}</div> <div className={styles.total}>Total Sessions: {profile?.sessions?.length ?? 0}</div>
</div> </div>
+11 -20
View File
@@ -2,7 +2,7 @@ import { Tag } from '../../ui/Tag'
import { useState } from 'react' import { useState } from 'react'
import type { ResourceRecord } from '../../api/records' import type { ResourceRecord } from '../../api/records'
import { recordCells, recordFooter, type Cell } from './record-view' import { recordCells, recordFooter, type Cell } from './record-view'
import { Table } from '../../ui/Table' import { DataTable } from '../../ui/DataTable'
import styles from './Zones.module.css' import styles from './Zones.module.css'
/* /*
@@ -61,25 +61,16 @@ function CellBlock({ cell }: { cell: Cell }) {
case 'table': case 'table':
return ( return (
<Table <DataTable<{ id: string; cells: string[] }>
header={ data={cell.rows.map((cells, i) => ({ id: String(i), cells }))}
<> getRowId={(r) => r.id}
{cell.headers.map((c) => ( columns={cell.headers.map((title, j) => ({
<th key={c}>{c}</th> id: String(j),
))} header: title,
</> td: () => ({ className: styles.mono }),
} cell: (r) => r.cells[j],
> }))}
{cell.rows.map((row, i) => ( />
<tr key={i}>
{row.map((v, j) => (
<td key={j} className={styles.mono}>
{v}
</td>
))}
</tr>
))}
</Table>
) )
case 'pairs': case 'pairs':
+243 -211
View File
@@ -26,7 +26,8 @@ import { Menu, Separator } from '../../ui/Menu'
import { minuteStamp as date } from '../../lib/dates' import { minuteStamp as date } from '../../lib/dates'
import { statusText, pageWindow } from '../../lib/pagination' import { statusText, pageWindow } from '../../lib/pagination'
import tbl from '../../ui/Table.module.css' 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 styles from './Zones.module.css'
import type { Notice, Confirmation } from './types' import type { Notice, Confirmation } from './types'
import { Pagination } from '../../ui/Pagination' 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. // 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} /> 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 ( return (
<> <>
<SectionHeader <SectionHeader
@@ -488,16 +514,21 @@ export function ZoneList({
</Button> </Button>
</div> </div>
<Table <DataTable
stack stack
/* `tableClass` and not `className`: the first reaches the `<table>` and /* `tableClass` and not `className`: the first reaches the `<table>` and
the second stays on the wrapper, which has no border. Measured: with the second stays on the wrapper, which has no border. Measured: with
`className` the `--dan` border did not appear. */ `className` the `--dan` border did not appear. */
tableClass={stale ? styles.stale : undefined} tableClass={stale ? styles.stale : undefined}
header={ data={visibleZones}
<> getRowId={(z) => z.name}
{/* The checkbox is 18 px; it is pressed from anywhere in its cell. */} emptyText="No Zone Found"
<th style={{ width: 38 }} className={tbl.checkCell}> 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> <label>
<input <input
disabled={stale} disabled={stale}
@@ -507,67 +538,68 @@ export function ZoneList({
onChange={(e) => setChecked(e.target.checked ? zones.map((z) => z.name) : [])} onChange={(e) => setChecked(e.target.checked ? zones.map((z) => z.name) : [])}
/> />
</label> </label>
</th> ),
<th style={{ width: 34 }}>#</th> td: () => ({ className: tbl.checkCell }),
<Th field="zone" sort={sort} onSort={toggle}>Zone</Th> cell: (z, i) => zoneCells(rowProps(z, i)).check,
<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> id: 'num',
<Th th: { style: { width: 34 } },
field="serial" header: '#',
sort={sort} td: () => ({ className: `${styles.num2} ${tbl.number}` }),
onSort={toggle} cell: (z, i) => zoneCells(rowProps(z, i)).num,
style={{ width: 110, textAlign: 'right' }} },
> {
Serial id: 'zone',
</Th> ...head({ field: 'zone', sort, onSort: toggle, children: 'Zone' }),
<Th field="expiry" sort={sort} onSort={toggle} style={{ width: 110 }}>Expiry</Th> td: () => ({ className: `${tbl.stacked} ${tbl.lead} ${styles.zoneCell}` }),
<Th field="modified" sort={sort} onSort={toggle} style={{ width: 150 }}> cell: (z, i) => zoneCells(rowProps(z, i)).zone,
Last Modified },
</Th> {
{/* The actions column reserved 230 px with three labels inside; id: 'type',
with icons 120 is enough and the 110 left over go to the data, ...head({ field: 'type', sort, onSort: toggle, style: { width: 120 }, children: 'Type' }),
which is what the table is about. */} td: () => ({ className: tbl.meta }),
<th style={{ width: 120 }} /> cell: (z, i) => zoneCells(rowProps(z, i)).type,
</> },
} {
> id: 'dnssec',
{visibleZones.length === 0 ? ( ...head({ field: 'dnssec', sort, onSort: toggle, style: { width: 90 }, children: 'DNSSEC' }),
<tr> cell: (z, i) => zoneCells(rowProps(z, i)).dnssec,
<td colSpan={10} className={tbl.noRows}> },
No Zone Found {
</td> id: 'status',
</tr> ...head({ field: 'status', sort, onSort: toggle, style: { width: 120 }, children: 'Status' }),
) : ( cell: (z, i) => zoneCells(rowProps(z, i)).status,
visibleZones.map((z, i) => ( },
<ZoneRow {
key={z.name} id: 'serial',
zone={z} ...head({ field: 'serial', sort, onSort: toggle, style: { width: 110, textAlign: 'right' }, children: 'Serial' }),
index={firstRow + i} td: () => ({ className: styles.mono }),
checked={checkedOnes.includes(z.name)} cell: (z, i) => zoneCells(rowProps(z, i)).serial,
stale={stale} },
busy={busy} {
canModify={canModify} id: 'expiry',
canDelete={canDelete} ...head({ field: 'expiry', sort, onSort: toggle, style: { width: 110 }, children: 'Expiry' }),
onCheck={(v) => td: () => ({ className: styles.mono }),
setChecked((m) => (v ? [...m, z.name] : m.filter((n) => n !== z.name))) cell: (z, i) => zoneCells(rowProps(z, i)).expiry,
} },
onOpen={onOpen} {
onEditZoneFile={onEditZoneFile} id: 'modified',
onEnable={enable} ...head({ field: 'modified', sort, onSort: toggle, style: { width: 150 }, children: 'Last Modified' }),
onDisable={disable} td: () => ({ className: `${styles.mono} ${tbl.meta}` }),
onDelete={remove} cell: (z, i) => zoneCells(rowProps(z, i)).modified,
onResync={resync} },
onImport={onImport} /* The actions column reserved 230 px with three labels inside; with icons
onExport={runExport} 120 is enough and the 110 left over go to the data, which is what the
onConvert={onConvert} table is about. */
onClone={onClone} {
onPermissions={onPermissions} id: 'actions',
onOptions={onOptions} th: { style: { width: 120 } },
/> td: () => ({ className: tbl.actionsCell }),
)) cell: (z, i) => zoneCells(rowProps(z, i)).actions,
)} },
</Table> ]}
/>
<div className={`${styles.count} ${styles.countFoot}`}> <div className={`${styles.count} ${styles.countFoot}`}>
<span>{state}</span> <span>{state}</span>
@@ -623,7 +655,7 @@ const KEYS: Keys<Zone> = {
modified: (z) => date(z.lastModified), modified: (z) => date(z.lastModified),
} }
function ZoneRow(p: RowProps) { function zoneCells(p: RowProps) {
const { zone: z } = p const { zone: z } = p
const name = z.name === '' ? '.' : z.name const name = z.name === '' ? '.' : z.name
const state = zoneState(z) const state = zoneState(z)
@@ -650,152 +682,152 @@ function ZoneRow(p: RowProps) {
const catalogLabel = const catalogLabel =
z.catalog != null ? { text: z.catalog, tone: 'neutral' as TagTone } : null z.catalog != null ? { text: z.catalog, tone: 'neutral' as TagTone } : null
return ( return {
<tr> check: (
<td className={tbl.checkCell}> <label>
<label> <input
<input type="checkbox"
type="checkbox" aria-label={`Select ${name}`}
aria-label={`Select ${name}`} /* With stale data there is no selecting: acting in bulk on a list
/* 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
that no longer reflects the server is how the wrong zone gets deleted. */
deleted. */ disabled={p.stale}
disabled={p.stale} checked={p.checked}
checked={p.checked} onChange={(e) => p.onCheck(e.target.checked)}
onChange={(e) => p.onCheck(e.target.checked)} />
/> </label>
</label> ),
</td> num: p.index,
<td className={`${styles.num2} ${tbl.number}`}>{p.index}</td> /* The monospacing goes on the CELL and the button inherits it: the
{/* The monospacing goes on the CELL and the button inherits it: the shared class says `font-family: inherit` precisely for this, and putting
shared class says `font-family: inherit` precisely for this, and putting it on the button depended on which module was emitted last. */
it on the button depended on which module was emitted last. */} zone: (
<td className={`${tbl.stacked} ${tbl.lead} ${styles.zoneCell}`}> <>
<button <button
type="button" type="button"
className={`${styles.zoneLink} ${tbl.entity}`} className={`${styles.zoneLink} ${tbl.entity}`}
onClick={() => p.onOpen(name)} onClick={() => p.onOpen(name)}
> >
<DomainName name={zoneNameOf(z)} /> <DomainName name={zoneNameOf(z)} />
</button> </button>
{catalogLabel && ( {catalogLabel && (
<div className={styles.tags}> <div className={styles.tags}>
<Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag> <Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag>
</div> </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 /* The type goes as text and not as a capsule: with Type, DNSSEC and
single smear and none of the three stood out. */} Status all in capsules, three consecutive columns of pills read as a
<td className={tbl.meta}>{typeLabel(z.type)}</td> single smear and none of the three stood out. */
<td> type: typeLabel(z.type),
dnssec: (
<>
{/* {/*
Upstream leaves the cell EMPTY when the zone is not signed 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 (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 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 time, "which ones do I still have to sign" could only be answered from
the API. the API.
The three states are said with three different texts, not with the same 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 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. 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.
*/} */}
<Separator /> {!signed ? (
<button type="button" data-variant="danger" disabled={!p.canDelete} onClick={() => { close(); p.onDelete(z) }}> <Tag>Unsigned</Tag>
Delete Zone ) : 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> </button>
</> )}
)} {RESYNC.includes(z.type) && (
</Menu> <button type="button" disabled={!p.canModify} onClick={() => { close(); p.onResync(z) }}>
</div> Resync
</td> </button>
</tr> )}
) {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 { Empty, Loading } from '../../ui/Empty'
import { Chip, Tag } from '../../ui/Tag' import { Chip, Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css' 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 styles from './Zones.module.css'
import type { Notice, Confirmation } from './types' import type { Notice, Confirmation } from './types'
import { Pagination } from '../../ui/Pagination' import { Pagination } from '../../ui/Pagination'
@@ -560,85 +561,97 @@ export function ZoneRecords(p: ZoneRecordsProps) {
{pagination} {pagination}
</div> </div>
<Table <DataTable
stack stack
header={ data={onPage}
<> getRowId={(r, i) => `${r.name}|${r.type}|${offset + i}`}
<th style={{ width: 34 }}>#</th> emptyText="No Record Found"
<Th field="name" sort={sort} onSort={toggle} style={{ width: 110 }}>Name</Th> columns={[
<Th field="type" sort={sort} onSort={toggle} style={{ width: 80 }}>Type</Th> {
<Th field="ttl" sort={sort} onSort={toggle} style={{ width: 110 }}>TTL</Th> id: 'num',
<Th field="data" sort={sort} onSort={toggle}>Data</Th> th: { style: { width: 34 } },
{/* The actions column reserved 230 px with three labels inside; header: '#',
with icons 120 is enough and the 110 left over go to the data, td: () => ({ className: styles.num2 }),
which is what the table is about. */} cell: (_r, i) => offset + i + 1,
<th style={{ width: 120 }} /> },
</> {
} id: 'name',
> ...headCell({ field: 'name', sort, onSort: toggle, style: { width: 110 }, children: 'Name' }),
{onPage.length === 0 ? ( td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
<tr> cell: (r) => <DomainName name={relativeName(r.name, zone)} />,
<td colSpan={6} className={tbl.noRows}> },
No Record Found {
</td> id: 'type',
</tr> ...headCell({ field: 'type', sort, onSort: toggle, style: { width: 80 }, children: 'Type' }),
) : ( td: () => ({ className: tbl.lead }),
onPage.map((r, i) => { cell: (r) => (
const actions = rowActions(zoneInfo.type, r.type) <>
return ( <Chip>{r.type}</Chip>
<tr key={`${r.name}|${r.type}|${offset + i}`}> {r.disabled && (
<td className={styles.num2}>{offset + i + 1}</td> <div className={styles.tags}>
<td className={`${styles.mono} ${tbl.lead}`}><DomainName name={relativeName(r.name, zone)} /></td> <Tag>Disabled</Tag>
<td className={tbl.lead}> </div>
<Chip>{r.type}</Chip> )}
{r.disabled && ( </>
<div className={styles.tags}> ),
<Tag>Disabled</Tag> },
</div> {
)} id: 'ttl',
</td> ...headCell({ field: 'ttl', sort, onSort: toggle, style: { width: 110 }, children: 'TTL' }),
<td className={styles.ttl}> td: () => ({ className: styles.ttl }),
{r.ttl} <small>({r.ttlString})</small> cell: (r) => (
</td> <>
<td className={styles.dataCell}> {r.ttl} <small>({r.ttlString})</small>
<DataCell record={r} notifyFailedFor={zoneInfo.notifyFailedFor} /> </>
</td> ),
<td className={tbl.actionsCell}> },
{!actions.hidden && ( {
<div className={tbl.actions}> id: 'data',
<RowAction ...headCell({ field: 'data', sort, onSort: toggle, children: 'Data' }),
icon="edit" td: () => ({ className: styles.dataCell }),
name="Edit Record" cell: (r) => <DataCell record={r} notifyFailedFor={zoneInfo.notifyFailedFor} />,
disabled={!p.canModify || busy} },
onClick={() => p.onEditRecord(zoneInfo, r, records)} /* 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
<RowAction table is about. */
icon="power" {
name={r.disabled ? 'Enable Record' : 'Disable Record'} id: 'actions',
disabled={actions.editingOnly || !p.canModify || busy} th: { style: { width: 120 } },
onClick={() => setRecordState(r, !r.disabled)} td: () => ({ className: tbl.actionsCell }),
/> cell: (r) => {
{/* Delete, inside the menu: the same rule as in the zone const actions = rowActions(zoneInfo.type, r.type)
list. */} return actions.hidden ? null : (
<Menu label={`Actions for ${relativeName(r.name, zone)} ${r.type}`}> <div className={tbl.actions}>
{(close) => ( <RowAction
<button icon="edit"
type="button" name="Edit Record"
disabled={actions.editingOnly || !p.canDelete || busy} disabled={!p.canModify || busy}
onClick={() => { close(); removeRecord(r) }} onClick={() => p.onEditRecord(zoneInfo, r, records)}
> />
Delete Record <RowAction
</button> icon="power"
)} name={r.disabled ? 'Enable Record' : 'Disable Record'}
</Menu> disabled={actions.editingOnly || !p.canModify || busy}
</div> onClick={() => setRecordState(r, !r.disabled)}
)} />
</td> {/* Delete, inside the menu: the same rule as in the zone list. */}
</tr> <Menu label={`Actions for ${relativeName(r.name, zone)} ${r.type}`}>
) {(close) => (
}) <button
)} type="button"
</Table> disabled={actions.editingOnly || !p.canDelete || busy}
onClick={() => { close(); removeRecord(r) }}
>
Delete Record
</button>
)}
</Menu>
</div>
)
},
},
]}
/>
<div className={`${styles.count} ${styles.countFoot}`}> <div className={`${styles.count} ${styles.countFoot}`}>
<span>{text}</span> <span>{text}</span>
+107 -128
View File
@@ -43,7 +43,8 @@ import styles from '../Zones.module.css'
import { External } from '../../../ui/External' import { External } from '../../../ui/External'
import { RFC_NSEC3_ITERATIONS, RFC_NSEC3_SALT } from '../references' import { RFC_NSEC3_ITERATIONS, RFC_NSEC3_SALT } from '../references'
import frm from '../../../ui/Form.module.css' 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 { noticeFromFailure } from '../../../lib/notice'
import { Notifier } from '../../../ui/Notifier' import { Notifier } from '../../../ui/Notifier'
@@ -335,46 +336,111 @@ export function DnssecProperties({
<Loading>Loading DNSSEC properties…</Loading> <Loading>Loading DNSSEC properties…</Loading>
) : ( ) : (
<div className={styles.fields}> <div className={styles.fields}>
<Table <DataTable
header={ data={visibleKeys}
<> getRowId={(k) => String(k.keyTag)}
<Th field="keyTag" sort={sort} onSort={toggle} style={{ width: 80 }}>Key Tag</Th> emptyText="No Key Found"
<Th field="keyType" sort={sort} onSort={toggle} style={{ width: 130 }}>Key Type</Th> columns={[
<Th field="algorithm" sort={sort} onSort={toggle} style={{ width: 150 }}>Algorithm</Th> {
<Th field="state" sort={sort} onSort={toggle} style={{ width: 110 }}>State</Th> id: 'keyTag',
<Th field="changed" sort={sort} onSort={toggle} style={{ width: 150 }}> ...head({ field: 'keyTag', sort, onSort: toggle, style: { width: 80 }, children: 'Key Tag' }),
State Changed td: () => ({ className: styles.mono }),
</Th> cell: (k) => k.keyTag,
<Th field="rollover" sort={sort} onSort={toggle} style={{ width: 150 }}> },
Rollover (days) {
</Th> id: 'keyType',
<th style={{ width: 190 }} /> ...head({ field: 'keyType', sort, onSort: toggle, style: { width: 130 }, children: 'Key Type' }),
</> cell: (k) => k.keyType,
} },
> {
{visibleKeys.length === 0 ? ( id: 'algorithm',
<tr> ...head({ field: 'algorithm', sort, onSort: toggle, style: { width: 150 }, children: 'Algorithm' }),
<td colSpan={7} className={tbl.noRows}> cell: (k) => (
No Key Found <>
</td> {k.algorithm} ({k.algorithmNumber})
</tr> </>
) : ( ),
visibleKeys.map((k) => ( },
<KeyRow {
key={k.keyTag} id: 'state',
privateKey={k} ...head({ field: 'state', sort, onSort: toggle, style: { width: 110 }, children: 'State' }),
busy={busy} cell: (k) => (
rollover={rollovers[k.keyTag] ?? String(k.rolloverDays)} <>
onRollover={(v) => setRollovers((r) => ({ ...r, [k.keyTag]: v }))} {k.state}
onSaveRollover={() => saveRollover(k)} {k.state === 'Active' && k.isRetiring ? ' (retiring)' : ''}
onDelete={() => deleteKey(k)} </>
onActivate={() => activate(k)} ),
onRolloverNow={() => rollover(k)} },
onRetire={() => retire(k)} {
/> id: 'changed',
)) ...head({ field: 'changed', sort, onSort: toggle, style: { width: 150 }, children: 'State Changed' }),
)} td: () => ({ className: styles.mono, style: { fontSize: 11.5 } }),
</Table> 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) => ( {notes.map((n) => (
<Alert key={n} type="info" title="Note!"> <Alert key={n} type="info" title="Note!">
@@ -734,90 +800,3 @@ export function stateNotes(keys: PrivateKey[]): string[] {
return notes 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 { Empty, Loading } from '../../../ui/Empty'
import { minuteStamp as shortDate } from '../../../lib/dates' import { minuteStamp as shortDate } from '../../../lib/dates'
import type { Notice } from '../types' import type { Notice } from '../types'
import { Table } from '../../../ui/Table' import { DataTable } from '../../../ui/DataTable'
import styles from '../Zones.module.css' import styles from '../Zones.module.css'
import { Notifier } from '../../../ui/Notifier' import { Notifier } from '../../../ui/Notifier'
@@ -123,25 +123,28 @@ export function ViewDs({
</dd> </dd>
</dl> </dl>
<Table <DataTable
header={ data={ds.digests}
<> getRowId={(d) => String(d.digestType)}
<th style={{ width: 140 }}>Digest Type</th> columns={[
<th>Digest</th> {
</> id: 'c0',
} th: { 'style': { width: 140 } },
> header: 'Digest Type',
{ds.digests.map((d) => ( td: () => ({ className: styles.mono }),
<tr key={d.digestType}> cell: (d) => (
<td className={styles.mono}> <>
{d.digestType} ({d.digestTypeNumber}) {d.digestType} ({d.digestTypeNumber})
</td> </>
<td> ),
<Long value={d.digest} /> },
</td> {
</tr> id: 'c1',
))} header: 'Digest',
</Table> cell: (d) => (<Long value={d.digest} />),
},
]}
/>
</div> </div>
)) ))
)} )}
+29 -35
View File
@@ -5,7 +5,7 @@ import { Dialog } from '../../../ui/Dialog'
import { Field, Select } from '../../../ui/Field' import { Field, Select } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty' import { Loading } from '../../../ui/Empty'
import type { Notice } from '../types' import type { Notice } from '../types'
import { Table } from '../../../ui/Table' import { DataTable } from '../../../ui/DataTable'
import styles from '../Zones.module.css' import styles from '../Zones.module.css'
import { noticeFromFailure } from '../../../lib/notice' import { noticeFromFailure } from '../../../lib/notice'
import { Notifier } from '../../../ui/Notifier' import { Notifier } from '../../../ui/Notifier'
@@ -181,42 +181,36 @@ function PermissionsTable({
{rows.length === 0 ? ( {rows.length === 0 ? (
<div className={styles.help}>No permissions assigned.</div> <div className={styles.help}>No permissions assigned.</div>
) : ( ) : (
<Table <DataTable
header={ data={rows}
<> getRowId={(f) => f.name}
<th>Name</th> columns={[
<th style={{ width: 70 }}>View</th> { id: 'name', header: 'Name', td: () => ({ className: styles.mono }), cell: (f) => f.name },
<th style={{ width: 70 }}>Modify</th> ...(['canView', 'canModify', 'canDelete'] as const).map((key, n) => ({
<th style={{ width: 70 }}>Delete</th> id: key,
<th style={{ width: 90 }} /> th: { style: { width: 70 } },
</> header: ['View', 'Modify', 'Delete'][n],
} cell: (f: (typeof rows)[number], i: number) => (
> <input
{rows.map((f, i) => ( type="checkbox"
<tr key={f.name}> className={styles.chkPerm}
<td className={styles.mono}>{f.name}</td> aria-label={`${key} for ${f.name}`}
{(['canView', 'canModify', 'canDelete'] as const).map((key) => ( checked={f[key]}
<td key={key}> onChange={(e) => change(i, key, e.target.checked)}
<input />
type="checkbox" ),
className={styles.chkPerm} })),
aria-label={`${key} for ${f.name}`} {
checked={f[key]} id: 'remove',
onChange={(e) => change(i, key, e.target.checked)} th: { style: { width: 90 } },
/> cell: (_f, i) => (
</td> <Button size="sm" onClick={() => onChange(rows.filter((_, j) => j !== i))}>
))}
<td>
<Button
size="sm"
onClick={() => onChange(rows.filter((_, j) => j !== i))}
>
Remove Remove
</Button> </Button>
</td> ),
</tr> },
))} ]}
</Table> />
)} )}
<Field label={addLabel}> <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. */ /* 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 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 // 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. // filter hands back new cells, and React leaves alone attributes it did not set.
useLayoutEffect(() => { 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 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 reasoning in `Table.module.css`) but keeps its name in `aria-label` and in