Files
technitium-console/src/screens/apps/Apps.tsx
T
DenozordecandClaude Sonnet 5.5 87281a87d3
CI / check (push) Failing after 37s
CI / image (push) Skipped
Match the Technitium canvas: palette, shell, KPI strips, framed tables
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 <noreply@anthropic.com>
2026-10-27 12:47:27 +07:00

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.`,
})
}}
/>
</>
)
}