Files
technitium-console/src/screens/logs/ViewLogs.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

369 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}