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>
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import type { ReactNode } from 'react'
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import {
|
||
deleteAllLogs,
|
||
deleteLog,
|
||
downloadLogText,
|
||
listLogFiles,
|
||
openLogDownload,
|
||
type LogFile,
|
||
} from '../../api/logs'
|
||
import { deleteAllStats } from '../../api/dashboard'
|
||
import { Confirm } from '../../ui/Confirm'
|
||
import { Raw } from '../../ui/Raw'
|
||
import { LogText, stampWidth } from './LogText'
|
||
import { PermissionButton } from '../../ui/PermissionButton'
|
||
import { Button } from '../../ui/Button'
|
||
import { SectionHeader } from '../../ui/SectionHeader'
|
||
import {Empty, Loading} from '../../ui/Empty'
|
||
import styles from './Logs.module.css'
|
||
import { Body, Panel } from '../../ui/Panel'
|
||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||
import { Notifier } from '../../ui/Notifier'
|
||
|
||
/*
|
||
Logs › View Logs (logs.js:105-268).
|
||
|
||
Four things from upstream that govern this screen:
|
||
|
||
1. **The three delete actions are NOT of the same section.** "delete all logs"
|
||
and deleting a file ask for `Logs.Delete`; "delete all stats" deletes the
|
||
Dashboard's statistics and asks for `Dashboard.Delete`
|
||
(`WebServiceLogsApi.cs:107,123,135`). They sit together on the screen and
|
||
they are different permissions.
|
||
2. **"delete all stats" is ALWAYS offered; "delete all logs" only if there are
|
||
files** (logs.js:119-127). With no files it says "No Log File Was Found".
|
||
3. **The viewer asks for only 2 MB** and the "Download" button for the whole
|
||
file; they are two different calls to the same endpoint.
|
||
4. **A server error is drawn INSIDE the viewer**, formatted as JSON, instead of
|
||
as an alert (logs.js:170-172). It is not an oversight: the endpoint returns
|
||
text and the only way to show the error is in that same space.
|
||
|
||
How the file is read is design, and it is ours (upstream draws a plain `<pre>`
|
||
that grows with the page, index.html:3272):
|
||
|
||
- **The viewer takes the window's height.** It used to be a box capped at 520 px:
|
||
at 1440x980 it ended at y=751 and left 229 px of empty page below it, and at
|
||
1024x768 it pushed the page into a 99 px scroll. Now the screen marks itself
|
||
`data-fills-window`, the Shell makes its body a flex column for it, and the
|
||
height runs down that chain (screen, split, panel, body) to `Raw`'s `fill`: the
|
||
panel ends one page margin above the footer (box 229-864 at 1440x980, 229-652
|
||
at 1024x768) and the page itself does not scroll on a desktop, whatever the
|
||
file's size. Its floor is 240 px, about twelve lines.
|
||
Stacked (720 px and below) the file list sits above it, so the page scrolls
|
||
anyway; there the reader is 70 % of the window, tall enough to read in, short
|
||
enough to leave page around it to scroll by on a touch screen, where a box
|
||
that took the whole screen would hold every swipe.
|
||
- **Long lines wrap, under their own message.** See `ui/Raw` (`wrap`) for the
|
||
measurement and `LogText` for the timestamp and the break points. Nothing is
|
||
added to the text: copying the viewer gives the file back as it came.
|
||
*/
|
||
|
||
|
||
export interface ViewLogsProps {
|
||
/** The section sub-navigation, drawn under the header. */
|
||
tabs?: ReactNode
|
||
token: string | null
|
||
node?: string
|
||
/** `Logs.canDelete`: deleting a log and deleting them all. */
|
||
canDeleteLogs?: boolean
|
||
/** `Dashboard.canDelete`: deleting all the statistics. */
|
||
canDeleteStats?: boolean
|
||
}
|
||
|
||
type Confirmation = 'log' | 'allLogs' | 'allStats'
|
||
|
||
/*
|
||
A body that is really the server's error.
|
||
|
||
Upstream returns it as TEXT through the same endpoint as the file, so there is no
|
||
way to tell it apart by the transport: it is told apart by its shape. What arrives
|
||
is the API's error JSON (`{"status":"error", ...}`), and a log file never starts
|
||
with a brace.
|
||
|
||
It is checked at the START and not by looking for `"error"` inside, because a log
|
||
file is full of the word "error" and we would mark ordinary content as a failure.
|
||
*/
|
||
function isError(text: string | null): boolean {
|
||
return text != null && /^\s*\{\s*"status"\s*:\s*"error"/.test(text)
|
||
}
|
||
|
||
export function ViewLogs({
|
||
tabs,
|
||
token,
|
||
node = '',
|
||
canDeleteLogs = true,
|
||
canDeleteStats = true,
|
||
}: ViewLogsProps) {
|
||
const [files, setFiles] = useState<LogFile[] | null>(null)
|
||
const [open, setOpen] = useState<string | null>(null)
|
||
const [body, setBody] = useState<string | null>(null)
|
||
const [loadingBody, setLoadingBody] = useState(false)
|
||
const [notice, setNotice] = useState<Notice | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [confirm, setConfirm] = useState<Confirmation | null>(null)
|
||
|
||
// A failure on load is not drawn as an empty list; see `dhcp/Leases`.
|
||
const load = useCallback(async (): Promise<LogFile[] | null> => {
|
||
const r = await listLogFiles(token, node)
|
||
if (r.kind === 'ok') {
|
||
setFiles(r.data)
|
||
return r.data
|
||
}
|
||
setFiles([])
|
||
setNotice(noticeFromFailure(r))
|
||
return null
|
||
}, [token, node])
|
||
|
||
/*
|
||
The list is read again whenever the node changes, and an open file follows it
|
||
(logs.js:101-106 and 144-154): the same name is opened on the new node, or the
|
||
viewer closes if that node has no file by that name. Read through a ref so the
|
||
effect does not run again on opening a file.
|
||
*/
|
||
const openRef = useRef(open)
|
||
useEffect(() => {
|
||
openRef.current = open
|
||
}, [open])
|
||
const viewRef = useRef<(fileName: string) => Promise<void>>(async () => {})
|
||
useEffect(() => {
|
||
let live = true
|
||
void (async () => {
|
||
const list = await load()
|
||
const was = openRef.current
|
||
if (!live || was == null || list == null) return
|
||
if (list.some((f) => f.fileName === was)) void viewRef.current(was)
|
||
else {
|
||
setOpen(null)
|
||
setBody(null)
|
||
}
|
||
})()
|
||
return () => {
|
||
live = false
|
||
}
|
||
}, [load])
|
||
|
||
async function view(fileName: string) {
|
||
setOpen(fileName)
|
||
setBody(null)
|
||
setLoadingBody(true)
|
||
const text = await downloadLogText(token, fileName, node)
|
||
setLoadingBody(false)
|
||
setBody(text)
|
||
}
|
||
useEffect(() => {
|
||
viewRef.current = view
|
||
})
|
||
|
||
async function download() {
|
||
if (open == null) return
|
||
setBusy(true)
|
||
await openLogDownload(token, open, node)
|
||
setBusy(false)
|
||
}
|
||
|
||
async function removeLogFile() {
|
||
if (open == null) return
|
||
setConfirm(null)
|
||
setBusy(true)
|
||
const outcome = await deleteLog(token, open, node)
|
||
setBusy(false)
|
||
if (outcome.kind !== 'ok') return
|
||
|
||
await load()
|
||
setOpen(null)
|
||
setBody(null)
|
||
setNotice({
|
||
type: 'success',
|
||
title: 'Log Deleted!',
|
||
text: 'Log file was deleted successfully.',
|
||
})
|
||
}
|
||
|
||
async function removeAllLogs() {
|
||
setConfirm(null)
|
||
setBusy(true)
|
||
const outcome = await deleteAllLogs(token, node)
|
||
setBusy(false)
|
||
if (outcome.kind !== 'ok') return
|
||
|
||
await load()
|
||
setOpen(null)
|
||
setBody(null)
|
||
setNotice({
|
||
type: 'success',
|
||
title: 'Logs Deleted!',
|
||
text: 'All log files were deleted successfully.',
|
||
})
|
||
}
|
||
|
||
async function removeAllStats() {
|
||
setConfirm(null)
|
||
setBusy(true)
|
||
const outcome = await deleteAllStats(token)
|
||
setBusy(false)
|
||
if (outcome.kind !== 'ok') return
|
||
|
||
setNotice({
|
||
type: 'success',
|
||
title: 'Stats Deleted!',
|
||
text: 'All stats files were deleted successfully.',
|
||
})
|
||
}
|
||
|
||
if (files == null) return <Loading />
|
||
|
||
const CONFIRM_TEXT: Record<Confirmation, { title: string; text: string; label: string }> = {
|
||
log: {
|
||
title: 'Delete Log',
|
||
text: `Are you sure you want to permanently delete the log file '${open ?? ''}'?`,
|
||
label: 'Delete',
|
||
},
|
||
allLogs: {
|
||
title: 'Delete All Logs',
|
||
text: 'Are you sure you want to permanently delete all log files?',
|
||
label: 'Delete All Logs',
|
||
},
|
||
allStats: {
|
||
title: 'Delete All Stats',
|
||
text: 'Are you sure you want to permanently delete all stats files?',
|
||
label: 'Delete All Stats',
|
||
},
|
||
}
|
||
|
||
return (
|
||
<div className={styles.viewLogs} data-fills-window="">
|
||
<SectionHeader
|
||
title="Logs"
|
||
subtitle="Server log files and the query log of the installed logging app"
|
||
tabs={tabs}
|
||
actions={<>
|
||
{/*
|
||
Two destructive verbs asking for permissions from DIFFERENT SECTIONS,
|
||
and one condition that is not a permission.
|
||
|
||
`Delete All Logs` only exists when there are files (`logs.js:121`);
|
||
that is its object not being there, not a missing permission, so there it
|
||
does disappear: "disabled, never hidden" is about who you are, not about
|
||
whether there is anything to delete. `Delete All Stats` is offered ALWAYS
|
||
(`logs.js:117`).
|
||
|
||
And the stats one asks for `Dashboard.canDelete`, from another screen: it
|
||
deletes the Dashboard's statistics, not the logs. It is one of the three
|
||
controls, among the nine compared with upstream, that "does not ask for
|
||
the permission of the screen whose name it carries".
|
||
*/}
|
||
{files.length > 0 && (
|
||
<PermissionButton
|
||
variant="danger"
|
||
disabled={busy}
|
||
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
|
||
onClick={() => setConfirm('allLogs')}
|
||
>
|
||
Delete All Logs
|
||
</PermissionButton>
|
||
)}
|
||
<PermissionButton
|
||
variant="danger"
|
||
disabled={busy}
|
||
permission={canDeleteStats ? undefined : 'Dashboard.canDelete'}
|
||
onClick={() => setConfirm('allStats')}
|
||
>
|
||
Delete All Stats
|
||
</PermissionButton></>}
|
||
/>
|
||
|
||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||
|
||
<div className={styles.split}>
|
||
<Panel title="Log Files" description="Server log files by date">
|
||
<Body className={styles.pbList}>
|
||
{files.length === 0 ? (
|
||
<Empty>
|
||
{notice?.type === 'danger'
|
||
? 'Unable to load the log files.'
|
||
: 'No Log File Was Found'}
|
||
</Empty>
|
||
) : (
|
||
<div className={styles.logfiles}>
|
||
{files.map((f) => (
|
||
<button
|
||
key={f.fileName}
|
||
type="button"
|
||
className={styles.logfile}
|
||
aria-current={open === f.fileName}
|
||
onClick={() => void view(f.fileName)}
|
||
>
|
||
<span className={styles.name}>{f.fileName}</span>
|
||
<span className={styles.sz}>[{f.size}]</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Body>
|
||
</Panel>
|
||
|
||
{open != null && (
|
||
<Panel
|
||
className={styles.reader}
|
||
title={<span className={styles.mono}>{open}</span>}
|
||
actions={
|
||
<div className={styles.acts}>
|
||
{/* `Download` asks for no permission at all: it stays enabled. */}
|
||
<Button disabled={busy} onClick={() => void download()}>
|
||
Download
|
||
</Button>
|
||
<PermissionButton
|
||
variant="danger"
|
||
disabled={busy}
|
||
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
|
||
onClick={() => setConfirm('log')}
|
||
>
|
||
Delete
|
||
</PermissionButton>
|
||
</div>
|
||
}
|
||
>
|
||
<Body className={styles.readerBody}>
|
||
{loadingBody ? (
|
||
<Loading />
|
||
) : (
|
||
/*
|
||
The server's error is drawn HERE and not as a notice, because the
|
||
endpoint returns text and this is the only place it fits
|
||
(`logs.js:170-172`). The frame turns `--dan` so it is framed and
|
||
not disguised as the file's content.
|
||
*/
|
||
<Raw
|
||
text={`${open} log file`}
|
||
fill
|
||
height={240}
|
||
wrap
|
||
hang={stampWidth(body ?? '') || 2}
|
||
tone={isError(body) ? 'error' : 'normal'}
|
||
>
|
||
<LogText text={body ?? ''} />
|
||
</Raw>
|
||
)}
|
||
</Body>
|
||
</Panel>
|
||
)}
|
||
</div>
|
||
|
||
<Confirm
|
||
open={confirm !== null}
|
||
title={confirm ? CONFIRM_TEXT[confirm].title : ''}
|
||
text={confirm ? CONFIRM_TEXT[confirm].text : ''}
|
||
label={confirm ? CONFIRM_TEXT[confirm].label : ''}
|
||
busy={busy}
|
||
onClose={() => setConfirm(null)}
|
||
onConfirm={() => {
|
||
if (confirm === 'log') void removeLogFile()
|
||
else if (confirm === 'allLogs') void removeAllLogs()
|
||
else if (confirm === 'allStats') void removeAllStats()
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|