Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The English translation covers the latest versions only; the full history (in Ru

## Unreleased

- [tech] Admin: NUC logs, a source check, the DHT probe, a crash report and monitor details open in a window over the page. A log has a filter, refresh and copy

## 0.30.1-beta.6

- [phone] The embedded 2160 Player is updated to 0.2.26. The seek bar is full width with equal side gutters, elapsed time on the left and remaining on the right (both show --:-- until duration is known), and buffering shows a spinner in the centre button
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## Не выпущено

- [tech] Админка: логи NUC, проверка источников, отчёт DHT, отчёт о сбое и подробности мониторинга открываются в окне поверх страницы. У лога есть фильтр, обновление и копирование

## 0.30.1-beta.6

- [phone] Встроенный плеер 2160 обновлён до 0.2.26. Полоса перемотки на всю ширину с одинаковыми боковыми отступами, прошедшее слева и остаток справа (пока длина неизвестна, обе стороны --:--), при буферизации в центре спиннер
Expand Down
63 changes: 63 additions & 0 deletions ops/billing/admin/src/components/logDialog.tsx
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>
);
}
75 changes: 75 additions & 0 deletions ops/billing/admin/src/components/modal.tsx
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))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Focus leaves the window

Pressing Shift+Tab immediately after opening Modal can move focus onto the page behind it. The effect focuses root, but the backward Tab check handles only the first control or an element outside root. Since root.contains(root) is true, neither case matches the initial focus.

Include active === root in this check, or focus the first control when opening.

Suggested change
if (e.shiftKey && (active === first || !root.contains(active))) {
if (e.shiftKey && (active === root || active === first || !root.contains(active))) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: ops/billing/admin/src/components/modal.tsx
Line: 34

Comment:
**Focus leaves the window**

Pressing Shift+Tab immediately after opening `Modal` can move focus onto the page behind it. The effect focuses `root`, but the backward Tab check handles only the first control or an element outside `root`. Since `root.contains(root)` is true, neither case matches the initial focus.

Include `active === root` in this check, or focus the first control when opening.

```suggestion
      if (e.shiftKey && (active === root || active === first || !root.contains(active))) {
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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!

Fix in Cursor

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>
);
}
27 changes: 27 additions & 0 deletions ops/billing/admin/src/lib/logText.ts
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);
}
16 changes: 16 additions & 0 deletions ops/billing/admin/src/lib/monitorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,19 @@ export function buildView(groups: { id: string; title: string }[], checks: Monit
const order = new Map(groups.map((g, i) => [g.id, i]));
return out.sort((a, b) => (a.failed ? 0 : 1) - (b.failed ? 0 : 1) || (order.get(a.id) || 0) - (order.get(b.id) || 0));
}

function statusWord(ok: boolean | null) {
if (ok === true) return 'ок';
if (ok === false) return 'ошибка';
return 'нет данных';
}

/** Readable lines plus the raw check, for the monitor detail window. */
export function monitorDetailText(row: MonitorCheck): string {
const lines = [row.name, 'id: ' + row.id, 'статус: ' + statusWord(row.ok), 'результат: ' + (row.detail || '-')];
if (row.context) lines.push('контекст: ' + row.context);
lines.push('время ответа: ' + (row.ms || 0) + ' мс');
if (row.path) lines.push('путь: ' + row.path);
if (row.target) lines.push('адрес: ' + row.target);
return lines.join('\n') + '\n\n' + JSON.stringify(row, null, 2);
}
72 changes: 53 additions & 19 deletions ops/billing/admin/src/panels/DeskViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ? (
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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.

Fix in Cursor

}
}
}

export function RollbackCard({ rows, csrf }: { rows: { id: string; at: number }[]; csrf: string }) {
Expand Down
10 changes: 6 additions & 4 deletions ops/billing/admin/src/panels/DhtPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { api } from '@/lib/api';
import { createLoadGate } from '@/lib/dhtLoad';
import { LogDialog } from '@/components/logDialog';
import { probeText, type DhtProbeResult } from '@/lib/dhtProbe';
import { when } from '@/lib/text';

Expand Down Expand Up @@ -89,7 +90,7 @@ function Bars({ rows, field, label, color }: { rows: DhtPoint[]; field: 'request
export function DhtPanel({ csrf }: { csrf: string }) {
const [data, setData] = useState<DhtPayload | null>(null);
const [err, setErr] = useState('');
const [probe, setProbe] = useState<ProbeResult | null>(null);
const [probeView, setProbeView] = useState<string | null>(null);
const [probing, setProbing] = useState(false);
const loads = useRef(createLoadGate());

Expand Down Expand Up @@ -128,10 +129,11 @@ export function DhtPanel({ csrf }: { csrf: string }) {
headers: { 'content-type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ csrf }),
});
setProbe(res);
setProbeView(probeText(res) + '\n\n' + JSON.stringify(res, null, 2));
await load();
} catch (e) {
setProbe({ ok: false, error: e instanceof Error ? e.message : 'проверка' });
const fail: ProbeResult = { ok: false, error: e instanceof Error ? e.message : 'проверка' };
setProbeView(probeText(fail) + '\n\n' + JSON.stringify(fail, null, 2));
} finally {
setProbing(false);
}
Expand Down Expand Up @@ -173,7 +175,7 @@ export function DhtPanel({ csrf }: { csrf: string }) {
{mem ? <span className="text-muted-foreground">память {mem}</span> : null}
</div>
<p className="text-muted-foreground">Лимит: {limitText(data.limit)}. Меняется в «Настройках». Обновление само, раз в 20 с.</p>
{probe ? <p>{probeText(probe)}</p> : null}
{probeView != null ? <LogDialog title="Проверка DHT" text={probeView} onClose={() => setProbeView(null)} /> : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<div className="text-muted-foreground">Запросов за 24 ч</div>
Expand Down
Loading
Loading