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