Repository navigation
Админка: логи и подробности в окне поверх страницы #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { useState } from 'react'; | ||
| import { Modal } from '@/components/modal'; | ||
| import { toast } from '@/components/chrome'; | ||
| import { filterLogLines } from '@/lib/logText'; | ||
|
|
||
| const fieldClass = 'h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm text-foreground'; | ||
| const buttonClass = 'h-10 shrink-0 rounded-md bg-secondary px-3 text-sm font-semibold text-secondary-foreground disabled:opacity-50'; | ||
|
|
||
| /** Scrollable monospace text over the page, with a line filter, optional refresh and copy. */ | ||
| export function LogDialog({ | ||
| title, | ||
| text, | ||
| onClose, | ||
| onRefresh, | ||
| refreshing, | ||
| }: { | ||
| title: string; | ||
| text: string; | ||
| onClose: () => void; | ||
| onRefresh?: () => void; | ||
| refreshing?: boolean; | ||
| }) { | ||
| const [query, setQuery] = useState(''); | ||
| const shown = filterLogLines(text, query); | ||
| return ( | ||
| <Modal title={title} onClose={onClose}> | ||
| <div className="flex flex-wrap gap-2 border-b border-border px-4 py-3"> | ||
| <input | ||
| className={fieldClass} | ||
| value={query} | ||
| aria-label="Фильтр" | ||
| placeholder="Фильтр строк" | ||
| autoComplete="off" | ||
| onChange={(e) => setQuery(e.target.value)} | ||
| /> | ||
| {onRefresh ? ( | ||
| <button type="button" className={buttonClass} disabled={refreshing} onClick={onRefresh}> | ||
| {refreshing ? 'Обновление…' : 'Обновить'} | ||
| </button> | ||
| ) : null} | ||
| <button | ||
| type="button" | ||
| className={buttonClass} | ||
| onClick={() => { | ||
| if (!shown) { | ||
| toast('Пусто'); | ||
| return; | ||
| } | ||
| void navigator.clipboard.writeText(shown).then( | ||
| () => toast('Скопировано'), | ||
| () => toast('Не скопировалось'), | ||
| ); | ||
| }} | ||
| > | ||
| Копировать | ||
| </button> | ||
| </div> | ||
| <pre className="min-h-40 flex-1 overflow-auto whitespace-pre-wrap break-words bg-background p-4 font-mono text-xs" aria-busy={refreshing ? true : undefined}> | ||
| {shown || (query.trim() ? 'Нет строк' : '')} | ||
| </pre> | ||
| </Modal> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { useEffect, useId, useRef, type ReactNode } from 'react'; | ||
|
|
||
| const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; | ||
|
|
||
| /** | ||
| * Window over the page. Esc, the close button and a click on the dimmed page close it. | ||
| * Tab stays inside. Focus returns to the control that opened it. | ||
| */ | ||
| export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { | ||
| const titleId = useId(); | ||
| const panel = useRef<HTMLDivElement>(null); | ||
| const closeRef = useRef(onClose); | ||
| closeRef.current = onClose; | ||
|
|
||
| useEffect(() => { | ||
| const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; | ||
| const root = panel.current; | ||
| root?.focus(); | ||
| const onKey = (e: KeyboardEvent) => { | ||
| if (e.key === 'Escape') { | ||
| e.preventDefault(); | ||
| closeRef.current(); | ||
| return; | ||
| } | ||
| if (e.key !== 'Tab' || !root) return; | ||
| const nodes = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)); | ||
| if (!nodes.length) { | ||
| e.preventDefault(); | ||
| return; | ||
| } | ||
| const first = nodes[0]; | ||
| const last = nodes[nodes.length - 1]; | ||
| const active = document.activeElement; | ||
| if (e.shiftKey && (active === first || !root.contains(active))) { | ||
| e.preventDefault(); | ||
| last.focus(); | ||
| } else if (!e.shiftKey && active === last) { | ||
| e.preventDefault(); | ||
| first.focus(); | ||
| } | ||
| }; | ||
| document.addEventListener('keydown', onKey); | ||
| const prevOverflow = document.body.style.overflow; | ||
| document.body.style.overflow = 'hidden'; | ||
| return () => { | ||
| document.removeEventListener('keydown', onKey); | ||
| document.body.style.overflow = prevOverflow; | ||
| previous?.focus(); | ||
| }; | ||
| }, []); | ||
|
|
||
| return ( | ||
| <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center sm:p-4" onClick={() => closeRef.current()}> | ||
| <div | ||
| ref={panel} | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-labelledby={titleId} | ||
| tabIndex={-1} | ||
| className="flex max-h-[100dvh] w-full flex-col overflow-hidden rounded-t-lg border border-border bg-card shadow-lg outline-none sm:max-h-[85vh] sm:max-w-3xl sm:rounded-lg" | ||
| onClick={(e) => e.stopPropagation()} | ||
| > | ||
| <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3"> | ||
| <h2 id={titleId} className="min-w-0 truncate text-lg font-semibold"> | ||
| {title} | ||
| </h2> | ||
| <button type="button" className="h-10 shrink-0 rounded-md bg-secondary px-3 text-sm font-semibold text-secondary-foreground" onClick={() => closeRef.current()}> | ||
| Закрыть | ||
| </button> | ||
| </div> | ||
| <div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /** Line filter for the admin log window. A blank query keeps every line. */ | ||
|
|
||
| export function filterLogLines(text: string, query: string): string { | ||
| const q = query.trim().toLowerCase(); | ||
| if (!q) return text; | ||
| return text | ||
| .split('\n') | ||
| .filter((line) => line.toLowerCase().indexOf(q) >= 0) | ||
| .join('\n'); | ||
| } | ||
|
|
||
| export type HealthLine = { | ||
| name: string; | ||
| ok: boolean | null; | ||
| latencyMs: number; | ||
| lastError?: string; | ||
| }; | ||
|
|
||
| /** Short lines, then the same rows as JSON, for the source-check window. */ | ||
| export function healthReport(rows: HealthLine[]): string { | ||
| const lines = rows.map((row) => { | ||
| const state = row.ok === true ? 'ок' : row.ok === false ? 'нет' : '-'; | ||
| const err = row.lastError ? ' · ' + row.lastError : ''; | ||
| return row.name + ': ' + state + ', ' + row.latencyMs + ' мс' + err; | ||
| }); | ||
| return lines.join('\n') + '\n\n' + JSON.stringify(rows, null, 2); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,9 @@ import { post } from '@/lib/api'; | |
| import type { DeskData, DeskDevice, DeskUser } from '@/lib/desk'; | ||
| import { Stamp } from '@/components/stamp'; | ||
| import { toast } from '@/components/chrome'; | ||
| import { LogDialog } from '@/components/logDialog'; | ||
| import { healthReport } from '@/lib/logText'; | ||
| import type { HealthRow } from '@/lib/desk'; | ||
|
|
||
| function Spark({ rows }: { rows?: { t: string | number; v: number }[] }) { | ||
| const data = rows || []; | ||
|
|
@@ -93,14 +96,7 @@ export function DeskHome({ data, csrf }: { data?: DeskData; csrf: string }) { | |
| <Badge variant={h.ok ? 'ok' : h.ok === false ? 'bad' : 'warn'}>{h.ok ? 'ок' : h.ok === false ? 'нет' : '-'}</Badge> | ||
| </div> | ||
| ))} | ||
| <Button | ||
| variant="secondary" | ||
| onClick={() => | ||
| void post('/admin/api/sources/check', csrf, {}).then((r) => toast(r.ok ? 'Проверено' : r.flash?.error || 'Не вышло')) | ||
| } | ||
| > | ||
| Проверить источники | ||
| </Button> | ||
| <SourceCheck csrf={csrf} label="Проверить источники" /> | ||
| </CardContent> | ||
| </Card> | ||
| </div> | ||
|
|
@@ -207,7 +203,7 @@ export function SourcesDesk({ data, csrf }: { data?: DeskData; csrf: string }) { | |
| </CardContent> | ||
| </Card> | ||
| ))} | ||
| <Button onClick={() => void post('/admin/api/sources/check', csrf, {}).then(() => toast('Проверено'))}>Проверить сейчас</Button> | ||
| <SourceCheck csrf={csrf} label="Проверить сейчас" /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
@@ -370,9 +366,35 @@ type NucBox = { | |
| restart?: boolean; | ||
| }; | ||
|
|
||
| function SourceCheck({ csrf, label }: { csrf: string; label: string }) { | ||
| const [text, setText] = useState<string | null>(null); | ||
| return ( | ||
| <> | ||
| <Button | ||
| variant="secondary" | ||
| onClick={() => | ||
| void post('/admin/api/sources/check', csrf, {}).then((r) => { | ||
| const health = (r as { health?: HealthRow[] }).health; | ||
| if (!r.ok || !health) { | ||
| toast(r.ok ? 'Проверено' : r.flash?.error || 'Не вышло'); | ||
| return; | ||
| } | ||
| setText(healthReport(health)); | ||
| }) | ||
| } | ||
| > | ||
| {label} | ||
| </Button> | ||
| {text != null ? <LogDialog title="Проверка источников" text={text} onClose={() => setText(null)} /> : null} | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| type OpenLog = { name: string; text: string; refreshing: boolean }; | ||
|
|
||
| export function NucDesk({ csrf }: { csrf: string }) { | ||
| const { paused } = useLive(); | ||
| const [logs, setLogs] = useState(''); | ||
| const [log, setLog] = useState<OpenLog | null>(null); | ||
| const query = useQuery({ | ||
| queryKey: ['nuc'], | ||
| refetchInterval: paused ? false : 15000, | ||
|
|
@@ -412,14 +434,7 @@ export function NucDesk({ csrf }: { csrf: string }) { | |
| </div> | ||
| </div> | ||
| <div className="flex gap-2"> | ||
| <Button | ||
| variant="secondary" | ||
| onClick={() => | ||
| void fetch('/admin/api/nuc/logs?name=' + encodeURIComponent(c.name), { credentials: 'same-origin' }) | ||
| .then((r) => r.json()) | ||
| .then((j) => setLogs(c.name + '\n' + (j.lines || j.error || ''))) | ||
| } | ||
| > | ||
| <Button variant="secondary" onClick={() => void openLog(c.name)}> | ||
| Лог | ||
| </Button> | ||
| {c.restart ? ( | ||
|
|
@@ -437,9 +452,28 @@ export function NucDesk({ csrf }: { csrf: string }) { | |
| </CardContent> | ||
| </Card> | ||
| ))} | ||
| {logs ? <pre className="max-h-80 overflow-auto rounded-md border border-border bg-background p-3 font-mono text-xs">{logs}</pre> : null} | ||
| {log ? ( | ||
| <LogDialog | ||
| title={'Лог ' + log.name} | ||
| text={log.text} | ||
| refreshing={log.refreshing} | ||
| onRefresh={() => void openLog(log.name)} | ||
| onClose={() => setLog(null)} | ||
| /> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
|
|
||
| async function openLog(name: string) { | ||
| setLog((cur) => ({ name, text: cur && cur.name === name ? cur.text : '', refreshing: true })); | ||
| try { | ||
| const res = await fetch('/admin/api/nuc/logs?name=' + encodeURIComponent(name), { credentials: 'same-origin' }); | ||
| const j = (await res.json()) as { lines?: string; error?: string }; | ||
| setLog({ name, text: j.lines || j.error || '', refreshing: false }); | ||
| } catch { | ||
| setLog({ name, text: 'логи недоступны', refreshing: false }); | ||
|
Comment on lines
+472
to
+474
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Closing a log while its request is running clears Ignore replies after the user closes the window or starts another request. Prompt To Fix With AIThis is a comment left during a code review.
Path: ops/billing/admin/src/panels/DeskViews.tsx
Line: 472-474
Comment:
**Closed logs open again**
Closing a log while its request is running clears `log`, but `openLog` sets it again when the request finishes or fails. The window pops open again, and an older reply can replace a newer log. `CrashesDesk` has the same issue.
Ignore replies after the user closes the window or starts another request.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| export function RollbackCard({ rows, csrf }: { rows: { id: string; at: number }[]; csrf: string }) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pressing Shift+Tab immediately after opening
Modalcan move focus onto the page behind it. The effect focusesroot, but the backward Tab check handles only the first control or an element outsideroot. Sinceroot.contains(root)is true, neither case matches the initial focus.Include
active === rootin this check, or focus the first control when opening.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!