The live UI did not follow the canvas because its base layer differed: dark palette in hex (now the canvas oklch values), 13 px body text, flat Frame panels. - Tokens: canvas palette, 14 px body, 1.4 line height; Frame without `dense` gets the 3 px ring; Panel takes a description line. - Shell: 56 px brand button with version, 11 px group headings, user card at the foot, versions moved into the header status popover, Home / group / section crumbs, amber "Attention" status when blocking is off or paused. - New KpiStrip and PageTabs; Dashboard KPIs in three frames and a Refresh button. - Zones: KPI strip, tab with count, views, framed table with the canvas columns. - Apps: Installed / App Store tabs and a grid instead of cards (store inline). - Blocking: state banner and tab counts; one "Blocking" title. - Settings: actions in the header, label-and-help rows with the control on the right. - DHCP, Administration, DNS Client, Logs, Cache, About: subtitles, framed tables, KPI strips where the data exists; Login cover with the three-point list. - docs/compose.shnt.example.yaml: production compose with the console image. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { KpiStrip } from '../../ui/KpiStrip'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
downloadAndUpdate,
|
|
getAppConfig,
|
|
listApps,
|
|
uninstallApp,
|
|
type InstalledApp,
|
|
} from '../../api/apps'
|
|
import { type AlertType } from '../../ui/Alert'
|
|
import { Button } from '../../ui/Button'
|
|
import { SectionHeader } from '../../ui/SectionHeader'
|
|
import { AppConfig } from './AppConfig'
|
|
import { AppCard } from './AppCard'
|
|
import { InstallApp } from './InstallApp'
|
|
import { StoreApps } from './StoreApps'
|
|
import { Panel } from '../../ui/Panel'
|
|
import { PageTabs } from '../../ui/PageTabs'
|
|
import { UpdateApp } from './UpdateApp'
|
|
import { Empty, Failure, Loading } from '../../ui/Empty'
|
|
import { Tag } from '../../ui/Tag'
|
|
import { useSort } from '../../ui/Table'
|
|
import { Icon } from '../../ui/Icon'
|
|
import styles from './Apps.module.css'
|
|
import { noticeFromFailure } from '../../lib/notice'
|
|
import { Notifier } from '../../ui/Notifier'
|
|
import { Confirm } from '../../ui/Confirm'
|
|
|
|
/*
|
|
A replica of the Apps tab (apps.js + index.html:807-835).
|
|
|
|
Two things from upstream that are kept even though the redesign mockup shows only
|
|
three buttons per card:
|
|
|
|
1. "Update" and "Store Update" are DIFFERENT actions (apps.js:130-131). The
|
|
first uploads your own zip; the second downloads the store's version.
|
|
Merging them would remove functionality, so "Store Update" stays and only
|
|
appears when the server says `updateAvailable`.
|
|
2. The "More Details" disclosure with the app's classes, their labels and the
|
|
`recordDataTemplate` (apps.js:74-113). That template is what gets copied to
|
|
write an APP record; without it the screen loses real usefulness.
|
|
|
|
What is NOT here: the Enabled/Disabled badge nor the mockup's "Enable" button.
|
|
The API has no such concept (neither `apps/list` nor `WriteAppAsJson` bring
|
|
anything like it), and an installed app is always active. Inventing it would be
|
|
adding functionality.
|
|
*/
|
|
export interface AlertState { type: AlertType; title: string; text: string }
|
|
|
|
type Modal =
|
|
| { kind: 'install' }
|
|
| { kind: 'update'; name: string }
|
|
| { kind: 'config'; name: string; config: string }
|
|
|
|
export function error(outcome: { kind: string; message?: string }): AlertState {
|
|
return noticeFromFailure(outcome)
|
|
}
|
|
|
|
export function Apps({ token }: { token: string | null }) {
|
|
const [apps, setApps] = useState<InstalledApp[] | null>(null)
|
|
const [alert, setAlert] = useState<AlertState | null>(null)
|
|
const [modal, setModal] = useState<Modal | null>(null)
|
|
const [tab, setTab] = useState<'installed' | 'store'>('installed')
|
|
const [busy, setBusy] = useState<string | null>(null)
|
|
/** Whether the last load failed: the gap is not drawn as an empty one. */
|
|
const [failure, setFailure] = useState(false)
|
|
const [pendingUninstall, setPendingUninstall] = useState<string | null>(null)
|
|
|
|
const reload = useCallback(async () => {
|
|
const outcome = await listApps(token)
|
|
if (outcome.kind === 'ok') {
|
|
setApps(outcome.data.response.apps)
|
|
setFailure(false)
|
|
return
|
|
}
|
|
/*
|
|
The data is thrown away, as it was before: this screen does NOT keep the
|
|
previous one, so there is nothing here to "go stale" and it does not carry the
|
|
other collections' strip: inheriting the look is not inheriting behaviour it
|
|
does not have.
|
|
|
|
What does change is HOW the gap is drawn. It said "No apps installed" and
|
|
offered to open the store, which is worse than saying nothing: it not only
|
|
asserts something it does not know (there may be ten apps installed and the call
|
|
may have fallen over), it invites you to act on that false premise.
|
|
*/
|
|
setApps([])
|
|
setFailure(true)
|
|
setAlert(error(outcome))
|
|
}, [token])
|
|
|
|
useEffect(() => {
|
|
void reload()
|
|
}, [reload])
|
|
|
|
/*
|
|
apps.js:425-449. The confirmation and the alert are upstream literals.
|
|
|
|
The confirmation step is `ui/Confirm`, as in the console's other eleven
|
|
destructive actions. Here the browser's native `confirm()` had been left
|
|
behind: the same operating-system dialog the redesign replaced everywhere, on
|
|
an action that uninstalls an application from the server.
|
|
*/
|
|
async function uninstall(name: string) {
|
|
setBusy(name)
|
|
const outcome = await uninstallApp(token, name)
|
|
setBusy(null)
|
|
|
|
if (outcome.kind !== 'ok') {
|
|
setAlert(error(outcome))
|
|
return
|
|
}
|
|
setAlert({
|
|
type: 'success',
|
|
title: 'App Uninstalled!',
|
|
text: `DNS application '${name}' was uninstalled successfully.`,
|
|
})
|
|
await reload()
|
|
}
|
|
|
|
// apps.js:253-290, with `isModal` false: the alert comes out on the page.
|
|
async function updateFromStore(app: InstalledApp) {
|
|
if (!app.updateUrl) return
|
|
|
|
setBusy(app.name)
|
|
const outcome = await downloadAndUpdate(token, app.name, app.updateUrl)
|
|
setBusy(null)
|
|
|
|
if (outcome.kind !== 'ok') {
|
|
setAlert(error(outcome))
|
|
return
|
|
}
|
|
setAlert({
|
|
type: 'success',
|
|
title: 'Store App Updated!',
|
|
text: `DNS application '${app.name}' was updated successfully from DNS App Store.`,
|
|
})
|
|
await reload()
|
|
}
|
|
|
|
// apps.js:459-491. The config is read BEFORE opening the modal, and from the
|
|
// cluster's primary node. `config` can come null; the editor is left empty.
|
|
async function openConfig(name: string) {
|
|
setBusy(name)
|
|
const outcome = await getAppConfig(token, name)
|
|
setBusy(null)
|
|
|
|
if (outcome.kind !== 'ok') {
|
|
setAlert(error(outcome))
|
|
return
|
|
}
|
|
setModal({ kind: 'config', name, config: outcome.data.response.config ?? '' })
|
|
}
|
|
|
|
// The installed-apps count no longer goes in the header: the header pill
|
|
// is for STATE, and a count that looked exactly like it was one of the
|
|
// inconsistencies. What is left here is what really is state: there are updates.
|
|
const withUpdate = (apps ?? []).filter((a) => a.updateAvailable).length
|
|
|
|
/*
|
|
Upstream's installed list is a table and its `Installed Apps` header sorts it
|
|
(`sortTable('tableAppsBody', 0)`). This is a card grid and not a table, so the
|
|
control could not be a column header, but dropping it was not an option either:
|
|
it is the second sort this screen had lost, and `dev/check-parity-sort.mjs` now
|
|
counts them.
|
|
|
|
Same hook as every sortable column in the console, and the same affordance the
|
|
store next door uses.
|
|
*/
|
|
const { rows: sortedApps, toggle } = useSort({ name: (a: InstalledApp) => a.name }, apps ?? [])
|
|
|
|
return (
|
|
<>
|
|
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
|
|
|
<SectionHeader
|
|
title="Apps"
|
|
subtitle="DNS apps installed on this server"
|
|
labels={
|
|
withUpdate > 0 ? (
|
|
<Tag tone="warn">
|
|
{withUpdate === 1 ? '1 update available' : `${withUpdate} updates available`}
|
|
</Tag>
|
|
) : undefined
|
|
}
|
|
actions={
|
|
<>
|
|
<Button variant="primary" onClick={() => setTab('store')}>
|
|
App Store
|
|
</Button>
|
|
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{apps != null && !failure && (
|
|
<KpiStrip
|
|
label="App totals"
|
|
items={[
|
|
{ label: 'Installed', value: apps.length, icon: 'apps' },
|
|
{ label: 'Updates available', value: withUpdate, icon: 'power', tone: withUpdate > 0 ? 'warn' : undefined },
|
|
{ label: 'DNS apps', value: apps.reduce((n, a) => n + a.dnsApps.length, 0), icon: 'dnsclient' },
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
<PageTabs
|
|
label="Apps"
|
|
tabs={[
|
|
{ id: 'installed', label: 'Installed', count: apps?.length },
|
|
{ id: 'store', label: 'App Store' },
|
|
]}
|
|
active={tab}
|
|
onChoose={setTab}
|
|
/>
|
|
|
|
{tab === 'store' ? (
|
|
<StoreApps token={token} onChanged={() => void reload()} />
|
|
) : apps === null ? (
|
|
<Loading />
|
|
) : failure ? (
|
|
/* Solid and with no invitation: whether there are apps is not known, so
|
|
the store is not offered. The server's message goes in the notice
|
|
above. */
|
|
<Failure>Could not load the installed apps.</Failure>
|
|
) : apps.length === 0 ? (
|
|
<Empty
|
|
title="No apps installed"
|
|
actions={
|
|
<Button variant="primary" onClick={() => setTab('store')}>
|
|
Open App Store
|
|
</Button>
|
|
}
|
|
>
|
|
DNS Apps add behaviour to the server (query logging, advanced blocking, split horizon)
|
|
without touching the base configuration. Open the store to see what is available.
|
|
</Empty>
|
|
) : (
|
|
<Panel title="Installed" description="Version, DNS apps and status">
|
|
<div className={styles.gridHead}>
|
|
<button
|
|
type="button"
|
|
className={styles.sortBy}
|
|
aria-label="Sort by name"
|
|
onClick={() => toggle('name')}
|
|
>
|
|
Installed Apps
|
|
<Icon name="sort" size={12} />
|
|
</button>
|
|
<span>Version</span>
|
|
<span>DNS apps</span>
|
|
<span>Status</span>
|
|
<span />
|
|
</div>
|
|
<ul className={styles.rows}>
|
|
{sortedApps.map((app) => (
|
|
<AppCard
|
|
key={app.name}
|
|
app={app}
|
|
busy={busy === app.name}
|
|
onConfig={() => void openConfig(app.name)}
|
|
onUpdate={() => setModal({ kind: 'update', name: app.name })}
|
|
onStoreUpdate={() => void updateFromStore(app)}
|
|
onUninstall={() => setPendingUninstall(app.name)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</Panel>
|
|
)}
|
|
|
|
<Confirm
|
|
open={pendingUninstall !== null}
|
|
title="Uninstall App"
|
|
text={`Are you sure you want to uninstall the DNS application '${pendingUninstall ?? ''}'?`}
|
|
label="Uninstall"
|
|
onClose={() => setPendingUninstall(null)}
|
|
onConfirm={() => pendingUninstall && uninstall(pendingUninstall)}
|
|
/>
|
|
|
|
<InstallApp
|
|
open={modal?.kind === 'install'}
|
|
onOpenChange={(o) => setModal(o ? { kind: 'install' } : null)}
|
|
token={token}
|
|
onInstalled={(name) => {
|
|
setModal(null)
|
|
setAlert({
|
|
type: 'success',
|
|
title: 'App Installed!',
|
|
text: `DNS application '${name}' was installed successfully.`,
|
|
})
|
|
void reload()
|
|
}}
|
|
/>
|
|
<UpdateApp
|
|
open={modal?.kind === 'update'}
|
|
onOpenChange={(o) => setModal(o && modal?.kind === 'update' ? modal : null)}
|
|
token={token}
|
|
name={modal?.kind === 'update' ? modal.name : ''}
|
|
onUpdated={(name) => {
|
|
setModal(null)
|
|
setAlert({
|
|
type: 'success',
|
|
title: 'App Updated!',
|
|
text: `DNS application '${name}' was updated successfully.`,
|
|
})
|
|
void reload()
|
|
}}
|
|
/>
|
|
<AppConfig
|
|
open={modal?.kind === 'config'}
|
|
onOpenChange={(o) => setModal(o && modal?.kind === 'config' ? modal : null)}
|
|
token={token}
|
|
name={modal?.kind === 'config' ? modal.name : ''}
|
|
initialConfig={modal?.kind === 'config' ? modal.config : ''}
|
|
onSaved={(name) => {
|
|
setModal(null)
|
|
setAlert({
|
|
type: 'success',
|
|
title: 'App Config Saved!',
|
|
text: `The DNS application '${name}' config was saved and reloaded successfully.`,
|
|
})
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|