From 07c17877acd89510096b36fcfe7e84e8d7bf7f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BA=D0=B0=D1=8D=D0=BB=20=D0=9E=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=8F=D0=BD?= Date: Wed, 8 Apr 2026 05:56:47 +0300 Subject: [PATCH] visual and functional upgrade --- package-lock.json | 18 ++ package.json | 3 +- public/favicon.ico | Bin 15086 -> 252990 bytes public/svg/logo/logo.svg | 16 ++ .../visual/keyboard.svg} | 18 +- src/app/app.config.ts | 2 + src/app/app.css | 31 ++- src/app/app.html | 9 +- src/app/app.spec.ts | 2 +- src/app/core/http/api-base-url.interceptor.ts | 4 - .../core/http/error-classification.util.ts | 3 - src/app/core/http/http-error.util.ts | 1 - .../core/keyboard/keyboard-key-name-map.ts | 46 +++- .../core/keyboard/keyboard-payload.util.ts | 6 - .../keyboard-svg-highlight.service.ts | 5 +- .../core/keyboard/vk-to-keyboard-svg-id.ts | 6 - src/app/core/models/api.types.ts | 4 +- .../user-error-messages.config.ts | 15 -- .../user-error-notify.service.ts | 15 +- src/app/core/services/sessions-api.service.ts | 6 - .../session-status-chip-classes.pipe.ts | 4 +- .../sessions/session-status-labels.config.ts | 4 - .../telemetry-event-summary.config.ts | 8 + .../telemetry-event-summary.engine.ts | 27 +++ .../telemetry-event-summary.handlers.ts | 52 +++++ .../telemetry-event-summary.payload.ts | 30 +++ .../sessions/telemetry-event-summary.types.ts | 5 + .../telemetry-event-type-labels.config.ts | 4 + .../sessions/telemetry-event-type.pipe.ts | 38 ++++ .../dev-console/dev-console.component.ts | 2 +- .../hls-player/hls-player.component.ts | 1 - .../sessions/hls-player/hls-player.css | 2 +- .../session-detail.component.ts | 103 ++++++--- .../session-detail/session-detail.css | 204 ++++++++++++++---- .../session-detail/session-detail.html | 137 ++++++++---- .../sessions/sessions-list/sessions-list.css | 14 +- .../sessions/sessions-list/sessions-list.html | 4 +- .../telemetry-event-detail.component.ts | 18 +- .../telemetry-event-detail.css | 55 +++-- .../telemetry-event-detail.html | 74 ++++--- src/app/shared/utils/date-time.util.ts | 4 - src/app/shared/utils/duration.util.ts | 46 ++++ .../telemetry-summary-human-text.util.ts | 83 +++++++ src/environments/environment.prod.ts | 1 - src/index.html | 2 +- src/styles.css | 30 ++- src/styles/color-tokens.css | 38 +++- src/styles/sg-input-fields.css | 6 - 48 files changed, 923 insertions(+), 283 deletions(-) create mode 100644 public/svg/logo/logo.svg rename public/{KB_USA-standard.svg => svg/visual/keyboard.svg} (97%) create mode 100644 src/app/core/sessions/telemetry-event-summary.config.ts create mode 100644 src/app/core/sessions/telemetry-event-summary.engine.ts create mode 100644 src/app/core/sessions/telemetry-event-summary.handlers.ts create mode 100644 src/app/core/sessions/telemetry-event-summary.payload.ts create mode 100644 src/app/core/sessions/telemetry-event-summary.types.ts create mode 100644 src/app/core/sessions/telemetry-event-type-labels.config.ts create mode 100644 src/app/core/sessions/telemetry-event-type.pipe.ts create mode 100644 src/app/shared/utils/duration.util.ts create mode 100644 src/app/shared/utils/telemetry-summary-human-text.util.ts diff --git a/package-lock.json b/package-lock.json index 14b32bc..06f799a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "sparkguardian", "version": "0.0.0", "dependencies": { + "@angular/animations": "^21.2.7", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", @@ -333,6 +334,22 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "21.2.7", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.7.tgz", + "integrity": "sha512-h8tUjQVSWfi2fohzxXeDDTjCfWABioYlPMrV1j98wCcFJad3FSnKCY0/gq8B4X6V81NGV29nEnhPyV0GinUBpQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.7" + } + }, "node_modules/@angular/build": { "version": "21.2.6", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.6.tgz", @@ -4167,6 +4184,7 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-5.1.0.tgz", "integrity": "sha512-3k1asuOgEjDLAtkK/snO8anctfo4vmMsyr16NHcNVDRqvUeRDrWf7sdRD1uXloO0hTrJvVbKR3WnDWxnuTKm8Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": ">=2.8.1" }, diff --git a/package.json b/package.json index 0109c7b..5015b10 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "private": true, "packageManager": "npm@11.6.2", "dependencies": { + "@angular/animations": "^21.2.7", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", @@ -31,10 +32,10 @@ "tslib": "^2.3.0" }, "devDependencies": { - "dotenv": "^16.4.7", "@angular/build": "^21.2.6", "@angular/cli": "^21.2.6", "@angular/compiler-cli": "^21.2.0", + "dotenv": "^16.4.7", "jsdom": "^28.0.0", "less": "^4.6.4", "prettier": "^3.8.1", diff --git a/public/favicon.ico b/public/favicon.ico index 57614f9c967596fad0a3989bec2b1deff33034f6..2c6933eff773381666154f4a79ce0591a6db9132 100644 GIT binary patch literal 252990 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 z%0A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 z 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 Nr__JTFzvMr^FKp4S3v*( diff --git a/public/svg/logo/logo.svg b/public/svg/logo/logo.svg new file mode 100644 index 0000000..01e3c97 --- /dev/null +++ b/public/svg/logo/logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/KB_USA-standard.svg b/public/svg/visual/keyboard.svg similarity index 97% rename from public/KB_USA-standard.svg rename to public/svg/visual/keyboard.svg index 0523350..eabcfe7 100644 --- a/public/KB_USA-standard.svg +++ b/public/svg/visual/keyboard.svg @@ -92,7 +92,7 @@ font-weight: var(--sg-keyboard-font-weight); text-align: start; text-anchor: start; - fill: var(--sg-keyboard-ink); + fill: var(--sg-keyboard-ink-soft); } #T_alterninsc text { font-size: 13px; @@ -113,7 +113,7 @@ font-size: 20px; font-weight: var(--sg-keyboard-font-weight); letter-spacing: 0.04em; - fill: var(--sg-keyboard-ink); + fill: var(--sg-keyboard-ink-soft); stroke: none; text-align: center; text-anchor: middle; @@ -122,7 +122,7 @@ font-size: 17px; font-weight: var(--sg-keyboard-font-weight); letter-spacing: 0.03em; - fill: var(--sg-keyboard-ink); + fill: var(--sg-keyboard-ink-soft); stroke: none; text-align: center; text-anchor: middle; @@ -131,7 +131,7 @@ font-size: 11px; font-weight: var(--sg-keyboard-font-weight); letter-spacing: 0.02em; - fill: var(--sg-keyboard-ink); + fill: var(--sg-keyboard-ink-soft); stroke: none; text-align: start; text-anchor: start; @@ -159,23 +159,17 @@ stroke-width: 0.85; } - /* Встроенные значки Lucide (обводка); Meta — иконка command (⌘) */ + /* Lucide: в покое как подписи tab/caps; нажатие — через injectHighlight */ .sg-lucide path, .sg-lucide line, .sg-lucide polyline, .sg-lucide rect { fill: none; - stroke: var(--sg-keyboard-ink); + stroke: var(--sg-keyboard-ink-soft); stroke-width: 1.55; stroke-linecap: round; stroke-linejoin: round; } - #S_kb6b.sg-lucide path { - stroke: var(--sg-keyboard-ink-soft) !important; - } - #S_kb6m.sg-lucide line { - stroke: var(--sg-keyboard-ink) !important; - } ]]> diff --git a/src/app/app.config.ts b/src/app/app.config.ts index c73f80d..e0d0370 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,6 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { provideTaiga } from '@taiga-ui/core'; import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification'; @@ -11,6 +12,7 @@ import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), + provideAnimations(), provideRouter(routes), provideTaiga(), tuiNotificationOptionsProvider(() => ({ diff --git a/src/app/app.css b/src/app/app.css index 788b826..479d706 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -11,21 +11,40 @@ } .shell-header { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.75rem; - padding: 1rem 1.5rem; border-bottom: 1px solid var(--tui-border-normal); background: var(--tui-background-elevation-1); } +.shell-header__inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 1.75rem; + padding-block: 1rem; + text-align: left; +} + .brand { - font: var(--tui-font-heading-5); + display: inline-flex; + align-items: center; + gap: 0.75rem; + font-family: inherit; + font-size: clamp(1.35rem, 2.4vw, 1.65rem); + font-weight: 500; + line-height: 1.15; + letter-spacing: 0.035em; color: var(--tui-text-primary); text-decoration: none; } +.brand-logo { + display: block; + height: 2rem; + width: auto; + flex-shrink: 0; +} + .brand:hover { color: var(--tui-text-action); } diff --git a/src/app/app.html b/src/app/app.html index f789a01..d894a60 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,8 +1,13 @@
- SparkGuardian - Прокторинг +
+ + + GUARD + + Прокторинг +
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 5e1837f..3b8e3e3 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -34,6 +34,6 @@ describe('App', () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.brand')?.textContent).toContain('SparkGuardian'); + expect(compiled.querySelector('.brand')?.textContent).toContain('GUARD'); }); }); diff --git a/src/app/core/http/api-base-url.interceptor.ts b/src/app/core/http/api-base-url.interceptor.ts index 7e5aff3..3a60749 100644 --- a/src/app/core/http/api-base-url.interceptor.ts +++ b/src/app/core/http/api-base-url.interceptor.ts @@ -3,10 +3,6 @@ import { inject } from '@angular/core'; import { API_BASE_URL } from '../config/api.tokens'; -/** - * Подставляет базовый URL ко всем относительным запросам. - * Абсолютные URL (`http…`) не трогаем — как в Axios с `baseURL`, только через перехватчик. - */ export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => { const base = inject(API_BASE_URL); if (/^https?:\/\//i.test(req.url)) { diff --git a/src/app/core/http/error-classification.util.ts b/src/app/core/http/error-classification.util.ts index 726d016..665934f 100644 --- a/src/app/core/http/error-classification.util.ts +++ b/src/app/core/http/error-classification.util.ts @@ -7,9 +7,6 @@ function isParseFailureMessage(message: string | undefined): boolean { return !!message?.includes('Http failure during parsing'); } -/** - * Определяет категорию ошибки для пользовательского сообщения (без утечки технических деталей). - */ export function classifyUserError(err: unknown): UserErrorKind { if (err instanceof TimeoutError) { return 'timeout'; diff --git a/src/app/core/http/http-error.util.ts b/src/app/core/http/http-error.util.ts index 3f0fb23..bbb7357 100644 --- a/src/app/core/http/http-error.util.ts +++ b/src/app/core/http/http-error.util.ts @@ -2,7 +2,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import type { ApiErrorBody } from '../models/api.types'; -/** Безопасная подстановка текста в разметку с innerHTML (например, Taiga notification). */ export function escapeHtml(text: string): string { return text .replace(/&/g, '&') diff --git a/src/app/core/keyboard/keyboard-key-name-map.ts b/src/app/core/keyboard/keyboard-key-name-map.ts index c5c6106..2947923 100644 --- a/src/app/core/keyboard/keyboard-key-name-map.ts +++ b/src/app/core/keyboard/keyboard-key-name-map.ts @@ -1,24 +1,56 @@ import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id'; -/** - * Токены из `key_name` и из `modifiers` (через "+"), в нижнем регистре. - * Соответствие имён бэкенда → id прямоугольников в KB_USA-standard.svg. - */ +function normalizeKeyToken(token: string): string { + return token.trim().toLowerCase().replace(/-/g, '_'); +} + function tokenToSvgIds(token: string): string[] { - const t = token.trim().toLowerCase(); + const t = normalizeKeyToken(token); if (!t) { return []; } const named: Record = { shift: ['K_kb5a'], + l_shift: ['K_kb5a'], + r_shift: ['K_kb5m'], + left_shift: ['K_kb5a'], + right_shift: ['K_kb5m'], + lshift: ['K_kb5a'], + rshift: ['K_kb5m'], + meta: ['K_kb6b'], super: ['K_kb6b'], + l_super: ['K_kb6b'], + r_super: ['K_kb6l'], win: ['K_kb6b'], windows: ['K_kb6b'], + l_meta: ['K_kb6b'], + r_meta: ['K_kb6l'], + left_meta: ['K_kb6b'], + right_meta: ['K_kb6l'], + l_win: ['K_kb6b'], + r_win: ['K_kb6l'], + left_win: ['K_kb6b'], + right_win: ['K_kb6l'], + control: ['K_kb6a'], ctrl: ['K_kb6a'], + l_control: ['K_kb6a'], + r_control: ['K_kb6n'], + left_control: ['K_kb6a'], + right_control: ['K_kb6n'], + l_ctrl: ['K_kb6a'], + r_ctrl: ['K_kb6n'], + left_ctrl: ['K_kb6a'], + right_ctrl: ['K_kb6n'], + alt: ['K_kb6c'], + l_alt: ['K_kb6c'], + r_alt: ['K_kb6k'], + left_alt: ['K_kb6c'], + right_alt: ['K_kb6k'], + tab: ['K_kb3a'], enter: ['K_kb4n'], return: ['K_kb4n'], @@ -40,10 +72,6 @@ function tokenToSvgIds(token: string): string[] { return []; } -/** - * Формат бэкенда: `{ key_name, modifiers: "Shift+Meta", action }` и т.п. - * Собирает уникальные id клавиш для подсветки (модификаторы + основная клавиша). - */ export function keyNameModifiersPayloadToSvgIds(o: Record): string[] { const out: string[] = []; const seen = new Set(); diff --git a/src/app/core/keyboard/keyboard-payload.util.ts b/src/app/core/keyboard/keyboard-payload.util.ts index 9f7c3d5..4b93a80 100644 --- a/src/app/core/keyboard/keyboard-payload.util.ts +++ b/src/app/core/keyboard/keyboard-payload.util.ts @@ -8,9 +8,6 @@ export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean { return t.includes('keyboard') || t.includes('key'); } -/** - * Извлекает виртуальный код клавиши из payload (массив чисел, объект с полями vk/keyCode и т.д.). - */ export function parseKeyboardVirtualKey(data: unknown): number | null { if (data == null) { return null; @@ -65,9 +62,6 @@ function unwrapJsonPayload(data: unknown): unknown { return data; } -/** - * Id элементов `K_kb*` для подсветки: сначала объект `key_name` / `modifiers`, иначе VK из массива/полей. - */ export function parseKeyboardHighlightKeyIds(data: unknown): string[] { const raw = unwrapJsonPayload(data); if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { diff --git a/src/app/core/keyboard/keyboard-svg-highlight.service.ts b/src/app/core/keyboard/keyboard-svg-highlight.service.ts index 8dc0168..3945414 100644 --- a/src/app/core/keyboard/keyboard-svg-highlight.service.ts +++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts @@ -4,10 +4,10 @@ import { Observable, defer, from, shareReplay } from 'rxjs'; import { map } from 'rxjs/operators'; /** - * Файл из `public/KB_USA-standard.svg` — отдаётся с корня приложения (`/KB_USA-standard.svg`), + * Файл из `public/svg/visual/keyboard.svg` — URL в приложении `/svg/visual/keyboard.svg`, * не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`. */ -const KEYBOARD_SVG_PATH = '/KB_USA-standard.svg'; +const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg'; @Injectable({ providedIn: 'root' }) export class KeyboardSvgHighlightService { @@ -31,7 +31,6 @@ export class KeyboardSvgHighlightService { ); } - /** Подсветка: заливка `--sg-keyboard-key-pressed-fill` (акцент), подписи/иконки `--sg-keyboard-key-pressed-ink`. */ private injectHighlight(svgText: string, keyIds: string[]): string { const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)); if (valid.length === 0) { diff --git a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts index 994dd55..c3feb4c 100644 --- a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts +++ b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts @@ -1,7 +1,3 @@ -/** - * Соответствие Windows Virtual Key → id элемента в `public/KB_USA-standard.svg` (K_kb*). - * Раскладка US QWERTY, без блока F1–F12 (на схеме их нет). - */ export function normalizeVirtualKey(vk: number): number { if (vk >= 0x61 && vk <= 0x7a) { return vk - 0x20; @@ -49,7 +45,6 @@ const LETTER_TO_ID: Record = { Z: 'K_kb5c', }; -/** OEM и прочие VK (Windows). */ const EXTRA_VK: Record = { 0x08: 'K_kb2n', 0x09: 'K_kb3a', @@ -98,7 +93,6 @@ export function vkToKeyboardSvgKeyId(vk: number): string | null { return null; } -/** Одна буква A–Z или цифра 0–9 как в подписи клавиши (`key_name`). */ export function charKeyNameToSvgKeyId(name: string): string | null { const c = name.trim(); if (c.length !== 1) { diff --git a/src/app/core/models/api.types.ts b/src/app/core/models/api.types.ts index 55433bc..cfb98b5 100644 --- a/src/app/core/models/api.types.ts +++ b/src/app/core/models/api.types.ts @@ -1,4 +1,4 @@ -/** Соответствует `handler.ErrorResponse` из OpenAPI. */ +/** OpenAPI: handler.ErrorResponse */ export interface ApiErrorBody { code?: string; error?: string; @@ -24,7 +24,7 @@ export interface SessionSummary { ended_at?: string; chunks_total?: number; events_total?: number; - /** Не в swagger, но бэкенд может отдавать для списка. */ + /** в списке сессий с бэка, не всегда в swagger */ title?: string; } diff --git a/src/app/core/notifications/user-error-messages.config.ts b/src/app/core/notifications/user-error-messages.config.ts index b555ca5..6bd36fc 100644 --- a/src/app/core/notifications/user-error-messages.config.ts +++ b/src/app/core/notifications/user-error-messages.config.ts @@ -1,29 +1,14 @@ -/** - * Типы ошибок для подбора дружелюбного текста во всплывающих уведомлениях. - * Соответствие «тип → фраза» задаётся здесь; технические детали — только в Dev Console. - */ export const USER_ERROR_FRIENDLY_MESSAGES = { - /** Нет сети / соединение сброшено (часто HTTP 0). */ network: 'Проверьте интернет-соединение.', - /** Таймаут запроса (в т.ч. RxJS timeout, HTTP 408). */ timeout: 'Запрос занял слишком много времени. Попробуйте позже.', - /** Ответ сервера 5xx. */ server_error: 'Уже работаем над этим.', - /** 404. */ not_found: 'Не удалось найти запрашиваемые данные.', - /** 401. */ unauthorized: 'Требуется вход в систему.', - /** 403. */ forbidden: 'Недостаточно прав для этого действия.', - /** 400. */ bad_request: 'Некорректный запрос. Попробуйте позже.', - /** Прочие 4xx (кроме перечисленных выше). */ client_error: 'Не удалось выполнить запрос. Попробуйте позже.', - /** Не JSON / ошибка разбора ответа. */ parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.', - /** Локальная валидация (неверный id и т.п.). */ invalid_input: 'Проверьте введённые данные.', - /** Не удалось отнести к категории. */ unknown: 'Попробуйте позже.', } as const; diff --git a/src/app/core/notifications/user-error-notify.service.ts b/src/app/core/notifications/user-error-notify.service.ts index 5eff160..656971d 100644 --- a/src/app/core/notifications/user-error-notify.service.ts +++ b/src/app/core/notifications/user-error-notify.service.ts @@ -8,14 +8,23 @@ import { friendlyMessageForUserError } from './user-error-messages.config'; const ERROR_TOAST_TITLE = 'Что-то пошло не так...'; -/** - * Показывает пользователю обобщённое уведомление; точный текст ошибки — только в DevLog (в dev). - */ @Injectable({ providedIn: 'root' }) export class UserErrorNotifyService { private readonly notifications = inject(TuiNotificationService); private readonly devLog = inject(DevLogService); + notifySuccess(message: string, label: string): void { + this.notifications + .open(escapeHtml(message), { + label, + appearance: 'positive', + autoClose: 4000, + closable: true, + size: 'm', + }) + .subscribe(); + } + notifyError(err: unknown, source: string): void { const kind = classifyUserError(err); const userSubtitle = friendlyMessageForUserError(kind); diff --git a/src/app/core/services/sessions-api.service.ts b/src/app/core/services/sessions-api.service.ts index ff82622..bf92273 100644 --- a/src/app/core/services/sessions-api.service.ts +++ b/src/app/core/services/sessions-api.service.ts @@ -48,9 +48,6 @@ export class SessionsApiService { return this.http.get(`/sessions/${sessionId}/events`, { params }); } - /** - * Относительный `playlist_url` из API нужно разрешить относительно хоста (`API_ORIGIN`). - */ resolvePlaylistUrl(playlistUrl: string): string { if (/^https?:\/\//i.test(playlistUrl)) { return playlistUrl; @@ -58,9 +55,6 @@ export class SessionsApiService { return new URL(playlistUrl, `${this.apiOrigin}/`).href; } - /** - * Ручка для агента (Bearer). В веб-интерфейсе обычно не нужна — оставлена для отладки/скриптов. - */ uploadChunk( sessionId: number, chunkIdx: number, diff --git a/src/app/core/sessions/session-status-chip-classes.pipe.ts b/src/app/core/sessions/session-status-chip-classes.pipe.ts index 53fb74f..20a1fef 100644 --- a/src/app/core/sessions/session-status-chip-classes.pipe.ts +++ b/src/app/core/sessions/session-status-chip-classes.pipe.ts @@ -1,8 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; -/** - * Классы-модификаторы для чипа статуса. `finished` — без модификатора (дефолтный вид Taiga). - */ +/** finished — без классов (дефолт Taiga). */ @Pipe({ name: 'sessionStatusChipClasses', standalone: true, diff --git a/src/app/core/sessions/session-status-labels.config.ts b/src/app/core/sessions/session-status-labels.config.ts index c94b1ea..8ab5c9f 100644 --- a/src/app/core/sessions/session-status-labels.config.ts +++ b/src/app/core/sessions/session-status-labels.config.ts @@ -1,7 +1,3 @@ -/** - * Отображаемые подписи для статусов сессий (значения с API — обычно в нижнем регистре). - * Неизвестный ключ в UI показывается как есть; в dev — предупреждение в консоли разработчика. - */ export const SESSION_STATUS_LABELS: Readonly> = { active: 'Активная', pending: 'Ожидается', diff --git a/src/app/core/sessions/telemetry-event-summary.config.ts b/src/app/core/sessions/telemetry-event-summary.config.ts new file mode 100644 index 0000000..3799819 --- /dev/null +++ b/src/app/core/sessions/telemetry-event-summary.config.ts @@ -0,0 +1,8 @@ +import { keyboardKeyRule, mouseClickRule, mouseMoveRule } from './telemetry-event-summary.handlers'; +import type { TelemetrySummaryRule } from './telemetry-event-summary.types'; + +export const TELEMETRY_SUMMARY_RULES: readonly TelemetrySummaryRule[] = [ + mouseClickRule, + mouseMoveRule, + keyboardKeyRule, +]; diff --git a/src/app/core/sessions/telemetry-event-summary.engine.ts b/src/app/core/sessions/telemetry-event-summary.engine.ts new file mode 100644 index 0000000..3a4eb00 --- /dev/null +++ b/src/app/core/sessions/telemetry-event-summary.engine.ts @@ -0,0 +1,27 @@ +import { TELEMETRY_SUMMARY_RULES } from './telemetry-event-summary.config'; +import { fallbackCompactJson, unwrapTelemetryPayload } from './telemetry-event-summary.payload'; + +export function summarizeTelemetryData(data: unknown): string { + const raw = unwrapTelemetryPayload(data); + if (raw === null || raw === undefined) { + return '—'; + } + if (Array.isArray(raw)) { + return raw.map((v) => String(v)).join(', '); + } + if (typeof raw !== 'object') { + return String(raw); + } + + const o = raw as Record; + for (const rule of TELEMETRY_SUMMARY_RULES) { + if (rule.match(o)) { + try { + return rule.summarize(o); + } catch { + // ignore + } + } + } + return fallbackCompactJson(o); +} diff --git a/src/app/core/sessions/telemetry-event-summary.handlers.ts b/src/app/core/sessions/telemetry-event-summary.handlers.ts new file mode 100644 index 0000000..b7b5927 --- /dev/null +++ b/src/app/core/sessions/telemetry-event-summary.handlers.ts @@ -0,0 +1,52 @@ +import { + formatTelemetryKeyboardKeySummary, + formatTelemetryMouseClickSummary, + formatTelemetryMouseMoveSummary, +} from '../../shared/utils/telemetry-summary-human-text.util'; +import { readNumericField } from './telemetry-event-summary.payload'; +import type { TelemetrySummaryRule } from './telemetry-event-summary.types'; + +export const mouseClickRule: TelemetrySummaryRule = { + id: 'mouse-click', + match: (o) => + o['action'] === 'click' && + readNumericField(o, 'x') !== null && + readNumericField(o, 'y') !== null && + readNumericField(o, 'button') !== null, + summarize: (o) => + formatTelemetryMouseClickSummary( + readNumericField(o, 'x')!, + readNumericField(o, 'y')!, + readNumericField(o, 'button')!, + o['is_down'] === true, + ), +}; + +export const mouseMoveRule: TelemetrySummaryRule = { + id: 'mouse-move', + match: (o) => + o['action'] === 'move' && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null, + summarize: (o) => + formatTelemetryMouseMoveSummary( + readNumericField(o, 'x')!, + readNumericField(o, 'y')!, + readNumericField(o, 'button'), + ), +}; + +export const keyboardKeyRule: TelemetrySummaryRule = { + id: 'keyboard-key', + match: (o) => { + const a = o['action']; + if (a !== 'press' && a !== 'release') { + return false; + } + return typeof o['key_name'] === 'string'; + }, + summarize: (o) => + formatTelemetryKeyboardKeySummary( + o['action'] === 'press' ? 'press' : 'release', + String(o['key_name']), + o['modifiers'], + ), +}; diff --git a/src/app/core/sessions/telemetry-event-summary.payload.ts b/src/app/core/sessions/telemetry-event-summary.payload.ts new file mode 100644 index 0000000..73f1f76 --- /dev/null +++ b/src/app/core/sessions/telemetry-event-summary.payload.ts @@ -0,0 +1,30 @@ +export function unwrapTelemetryPayload(data: unknown): unknown { + if (typeof data === 'string') { + try { + return JSON.parse(data) as unknown; + } catch { + return data; + } + } + return data; +} + +export function readNumericField(o: Record, key: string): number | null { + const v = o[key]; + if (typeof v === 'number' && Number.isFinite(v)) { + return v; + } + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +export function fallbackCompactJson(o: Record): string { + try { + return JSON.stringify(o); + } catch { + return '[объект]'; + } +} diff --git a/src/app/core/sessions/telemetry-event-summary.types.ts b/src/app/core/sessions/telemetry-event-summary.types.ts new file mode 100644 index 0000000..8734eed --- /dev/null +++ b/src/app/core/sessions/telemetry-event-summary.types.ts @@ -0,0 +1,5 @@ +export interface TelemetrySummaryRule { + readonly id: string; + readonly match: (o: Record) => boolean; + readonly summarize: (o: Record) => string; +} diff --git a/src/app/core/sessions/telemetry-event-type-labels.config.ts b/src/app/core/sessions/telemetry-event-type-labels.config.ts new file mode 100644 index 0000000..c43e230 --- /dev/null +++ b/src/app/core/sessions/telemetry-event-type-labels.config.ts @@ -0,0 +1,4 @@ +export const TELEMETRY_EVENT_TYPE_LABELS: Readonly> = { + keyboard: 'Клавиатура', + mouse: 'Мышь', +}; diff --git a/src/app/core/sessions/telemetry-event-type.pipe.ts b/src/app/core/sessions/telemetry-event-type.pipe.ts new file mode 100644 index 0000000..5c698b8 --- /dev/null +++ b/src/app/core/sessions/telemetry-event-type.pipe.ts @@ -0,0 +1,38 @@ +import { isDevMode, Pipe, PipeTransform, inject } from '@angular/core'; + +import { DevLogService } from '../devtools/dev-log.service'; +import { TELEMETRY_EVENT_TYPE_LABELS } from './telemetry-event-type-labels.config'; + +@Pipe({ + name: 'telemetryEventType', + standalone: true, +}) +export class TelemetryEventTypePipe implements PipeTransform { + private readonly devLog = inject(DevLogService); + private readonly warnedUnknown = new Set(); + + transform(value: unknown): string { + if (value == null || value === '') { + return '—'; + } + const raw = typeof value === 'string' ? value : String(value); + const trimmed = raw.trim(); + if (trimmed === '') { + return '—'; + } + const lookup = trimmed.toLowerCase(); + const mapped = TELEMETRY_EVENT_TYPE_LABELS[lookup]; + if (mapped !== undefined && String(mapped).trim() !== '') { + return String(mapped).trim(); + } + if (isDevMode() && !this.warnedUnknown.has(lookup)) { + this.warnedUnknown.add(lookup); + this.devLog.add({ + level: 'warn', + source: 'system', + message: `Неизвестный тип события телеметрии (нет подписи в telemetry-event-type-labels.config): ${trimmed}`, + }); + } + return trimmed; + } +} diff --git a/src/app/features/devtools/dev-console/dev-console.component.ts b/src/app/features/devtools/dev-console/dev-console.component.ts index 6db4871..0dddba8 100644 --- a/src/app/features/devtools/dev-console/dev-console.component.ts +++ b/src/app/features/devtools/dev-console/dev-console.component.ts @@ -14,7 +14,7 @@ export class DevConsoleComponent { private readonly logs = inject(DevLogService); protected readonly isDev = isDevMode(); protected readonly collapsed = signal(false); - protected readonly minimized = signal(false); + protected readonly minimized = signal(true); protected readonly entries = this.logs.entries; protected readonly count = computed(() => this.entries().length); protected readonly expandedIds = signal>({}); diff --git a/src/app/features/sessions/hls-player/hls-player.component.ts b/src/app/features/sessions/hls-player/hls-player.component.ts index 08140fe..450777b 100644 --- a/src/app/features/sessions/hls-player/hls-player.component.ts +++ b/src/app/features/sessions/hls-player/hls-player.component.ts @@ -15,7 +15,6 @@ import Hls from 'hls.js'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class HlsPlayerComponent { - /** Полный URL плейлиста `.m3u8`. */ readonly src = input.required(); private readonly videoRef = viewChild>('videoEl'); diff --git a/src/app/features/sessions/hls-player/hls-player.css b/src/app/features/sessions/hls-player/hls-player.css index 056c86a..c700fe0 100644 --- a/src/app/features/sessions/hls-player/hls-player.css +++ b/src/app/features/sessions/hls-player/hls-player.css @@ -1,7 +1,7 @@ :host { display: block; width: 100%; - max-width: 960px; + max-width: var(--sg-content-max-width); } .player { diff --git a/src/app/features/sessions/session-detail/session-detail.component.ts b/src/app/features/sessions/session-detail/session-detail.component.ts index 6115703..b7af02a 100644 --- a/src/app/features/sessions/session-detail/session-detail.component.ts +++ b/src/app/features/sessions/session-detail/session-detail.component.ts @@ -1,3 +1,4 @@ +import { animate, style, transition, trigger } from '@angular/animations'; import { AsyncPipe, NgClass } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core'; @@ -14,12 +15,20 @@ import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, sw import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service'; import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe'; import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe'; +import { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe'; import type { ParsedEvent, SessionDetailResponse, StreamInfo } from '../../../core/models/api.types'; +import { summarizeTelemetryData } from '../../../core/sessions/telemetry-event-summary.engine'; import { SessionsApiService } from '../../../core/services/sessions-api.service'; import { formatTimestamp } from '../../../shared/utils/date-time.util'; +import { formatDurationMsHuman } from '../../../shared/utils/duration.util'; import { HlsPlayerComponent } from '../hls-player/hls-player.component'; import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component'; +type TelemetryRangeSelection = + | { type: 'preset'; seconds: number } + | { type: 'end' } + | { type: 'custom' }; + @Component({ selector: 'app-session-detail', imports: [ @@ -35,11 +44,29 @@ import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemet TuiTitle, SessionStatusChipClassesPipe, SessionStatusPipe, + TelemetryEventTypePipe, TelemetryEventDetailComponent, ], templateUrl: './session-detail.html', styleUrl: './session-detail.css', changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('telemetryEventDetail', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translateY(-0.4rem)' }), + animate( + '220ms cubic-bezier(0.33, 1, 0.68, 1)', + style({ opacity: 1, transform: 'translateY(0)' }), + ), + ]), + transition(':leave', [ + animate( + '170ms cubic-bezier(0.4, 0, 1, 1)', + style({ opacity: 0, transform: 'translateY(-0.3rem)' }), + ), + ]), + ]), + ], }) export class SessionDetailComponent { private readonly route = inject(ActivatedRoute); @@ -51,16 +78,14 @@ export class SessionDetailComponent { private readonly recordingEndMs = signal(null); protected readonly customToLocal = signal(''); - /** Выбранный тип потока (или первая вкладка по умолчанию в шаблоне). */ + private readonly telemetryRangeSelection = signal({ type: 'end' }); + protected readonly selectedStreamType = signal(null); - /** 0 — просмотр, 1 — служебная информация. */ protected readonly activeTabIndex = model(0); - /** null — все типы событий в таблице телеметрии. */ protected readonly telemetryEventTypeFilter = signal(null); - /** Раскрытая строка телеметрии: ключ или null. */ protected readonly expandedTelemetryRowKey = signal(null); private readonly sessionId$ = this.route.paramMap.pipe( @@ -88,6 +113,8 @@ export class SessionDetailComponent { const end = this.toUnixMs(state.detail.session.ended_at); this.recordingStartMs.set(start); this.recordingEndMs.set(end); + this.telemetryRangeSelection.set({ type: 'end' }); + this.customToLocal.set(''); // Дефолт для телеметрии: до текущего момента (или конца записи, если завершена). if (this.telemetryToMs() === null) { this.telemetryToMs.set(end ?? Date.now()); @@ -202,8 +229,16 @@ export class SessionDetailComponent { this.expandedTelemetryRowKey.set(null); } + protected telemetryEventTypeKey(event: ParsedEvent): string { + const t = event.event_type; + if (t == null || t === '') { + return ''; + } + return String(t).trim().toLowerCase(); + } + protected telemetryRowKey(row: ParsedEvent, index: number): string { - return `${row.timestamp}\u0000${row.event_type}\u0000${index}`; + return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\u0000${index}`; } protected toggleTelemetryRow(row: ParsedEvent, index: number): void { @@ -215,17 +250,16 @@ export class SessionDetailComponent { return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index); } - /** Уникальные значения `event_type` (пустая строка — отдельная вкладка). */ protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] { const set = new Set(); for (const e of events) { - set.add(e.event_type ?? ''); + set.add(this.telemetryEventTypeKey(e)); } return [...set].sort((a, b) => a.localeCompare(b, 'ru')); } protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number { - return events.filter((e) => (e.event_type ?? '') === typeKey).length; + return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length; } protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] { @@ -233,33 +267,16 @@ export class SessionDetailComponent { if (filter === null) { return events; } - return events.filter((e) => (e.event_type ?? '') === filter); + return events.filter((e) => this.telemetryEventTypeKey(e) === filter); } - protected telemetryTypeTabLabel(typeKey: string): string { - return typeKey === '' ? 'Без типа' : typeKey; - } - - protected eventDataPreview(event: ParsedEvent): string { - const data = event.data; - if (data === null || data === undefined) { - return '—'; - } - if (Array.isArray(data)) { - return data.map((v) => String(v)).join(', '); - } - if (typeof data === 'object') { - try { - return JSON.stringify(data); - } catch { - return '[object]'; - } - } - return String(data); + protected telemetryEventSummary(event: ParsedEvent): string { + return summarizeTelemetryData(event.data); } protected selectRecentWindow(seconds: number): void { this.customToLocal.set(''); + this.telemetryRangeSelection.set({ type: 'preset', seconds }); const start = this.recordingStartMs() ?? Date.now(); const end = this.recordingEndMs() ?? Date.now(); this.telemetryToMs.set(this.clamp(start + seconds * 1000, start, end)); @@ -267,6 +284,7 @@ export class SessionDetailComponent { protected loadUntilEndTelemetry(): void { this.customToLocal.set(''); + this.telemetryRangeSelection.set({ type: 'end' }); const end = this.recordingEndMs() ?? Date.now(); this.telemetryToMs.set(end); } @@ -278,17 +296,27 @@ export class SessionDetailComponent { } const ms = new Date(value).getTime(); if (Number.isFinite(ms)) { + this.telemetryRangeSelection.set({ type: 'custom' }); const start = this.recordingStartMs() ?? Date.now(); const end = this.recordingEndMs() ?? Date.now(); this.telemetryToMs.set(this.clamp(ms, start, end)); } } + protected telemetryRangePresetIs(seconds: number): boolean { + const s = this.telemetryRangeSelection(); + return s.type === 'preset' && s.seconds === seconds; + } + + protected telemetryRangeIsEnd(): boolean { + return this.telemetryRangeSelection().type === 'end'; + } + protected telemetryRangeLabel(toMs: number | null): string { return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`; } - protected detailPayloadJson(detail: SessionDetailResponse): string { + private detailPayloadJson(detail: SessionDetailResponse): string { try { return JSON.stringify(detail, null, 2); } catch { @@ -296,11 +324,18 @@ export class SessionDetailComponent { } } - protected formatDurationMs(ms: number | null | undefined): string { - if (ms === null || ms === undefined || !Number.isFinite(ms)) { - return '—'; + protected async copyDetailPayloadJson(detail: SessionDetailResponse): Promise { + const text = this.detailPayloadJson(detail); + try { + await navigator.clipboard.writeText(text); + this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово'); + } catch { + // clipboard } - return `${ms} ms`; + } + + protected formatDurationMs(ms: number | null | undefined): string { + return formatDurationMsHuman(ms); } protected streamResolvedPlaylistUrl(stream: StreamInfo): string { diff --git a/src/app/features/sessions/session-detail/session-detail.css b/src/app/features/sessions/session-detail/session-detail.css index 6ad8a09..ef50cb8 100644 --- a/src/app/features/sessions/session-detail/session-detail.css +++ b/src/app/features/sessions/session-detail/session-detail.css @@ -1,8 +1,6 @@ .page { - max-width: 960px; - margin: 0 auto; - padding: 1.5rem 1rem 3rem; - box-sizing: border-box; + padding-top: 1.5rem; + padding-bottom: 3rem; } .back { @@ -66,36 +64,33 @@ font-size: 0.92em; } -.json-block { - margin: 0.75rem 0 0; - padding: 1rem; - max-height: min(360px, 50vh); - overflow: auto; - border-radius: var(--tui-radius-m); - background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary)); - font-size: 0.8rem; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; +.json-copy-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; +} + +.json-copy-row .section-title { + margin: 0; } .meta-table { width: 100%; - border-collapse: collapse; font: var(--tui-font-text-s); } -.meta-table th, -.meta-table td { +.meta-table tbody td { padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; - border-bottom: 1px solid var(--tui-border-normal); } .meta-table th { + text-align: left; font-weight: 600; - color: var(--tui-text-secondary); + color: var(--tui-text-primary); } .table-wrap_flat { @@ -133,14 +128,41 @@ display: flex; flex-wrap: wrap; justify-content: space-between; + align-items: flex-start; gap: 0.75rem; margin-bottom: 0.75rem; } +.telemetry-content { + display: flex; + flex-direction: column; + min-height: 520px; +} + +.telemetry-content > .loading-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; +} + +.telemetry-head__main { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; + flex: 1; +} + .telemetry-head .section-title { margin: 0; } +.telemetry-head .telemetry-range { + margin: 0; +} + .telemetry-actions { display: flex; align-items: center; @@ -184,53 +206,143 @@ margin-bottom: 1rem; } -.stream-tabs button { +.stream-tabs button, +.telemetry-presets button { transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, - outline-color 0.15s ease; + box-shadow 0.15s ease; +} + +/* Неактивный чип */ +.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active), +.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active) { + border-radius: 624.9375rem; + font-weight: 400; + background: var(--sg-filter-chip-bg); + color: var(--sg-filter-chip-fg); + border: 1px solid transparent; + outline: none; + box-shadow: none; } -/* Контраст к карточке — жёлтый акцент при наведении (специфичнее secondary appearance Taiga) */ .stream-tabs - button[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not([data-state='disabled']) { - background: var(--sg-color-accent); - color: var(--sg-color-text); - border-color: color-mix(in srgb, var(--sg-color-accent) 78%, black); - outline: 2px solid transparent; + button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not( + [data-state='disabled'] + ), +.telemetry-presets + button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not( + [data-state='disabled'] + ) { + background: var(--sg-filter-chip-bg-hover); + color: var(--sg-filter-chip-fg); + border-color: transparent; + outline: none !important; + box-shadow: none !important; } -.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] { - outline: 2px solid var(--sg-color-accent); - background: color-mix(in srgb, var(--sg-color-accent) 22%, white); - border-color: var(--sg-color-accent); - color: var(--sg-color-text); +/* Активный чип */ +.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'], +.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] { + border-radius: 624.9375rem; + font-weight: 400; + background: var(--sg-filter-chip-active-bg); + color: var(--sg-filter-chip-active-fg); + border: 1px solid transparent; + outline: none; + box-shadow: none; +} + +.stream-tabs + button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not( + [data-state='disabled'] + ), +.telemetry-presets + button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not( + [data-state='disabled'] + ) { + background: var(--sg-filter-chip-active-bg-hover); + color: var(--sg-filter-chip-active-fg); + border-color: transparent; + outline: none !important; + box-shadow: none !important; +} + +/* + * Taiga оставляет :focus после клика и может показать обводку/тень с задержкой — убираем для мыши. + * Клавиатура: лёгкое кольцо только при :focus-visible. + */ +.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible), +.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible) { + outline: none !important; + box-shadow: none !important; +} + +.stream-tabs + button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']), +.telemetry-presets + button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']) { + outline: none !important; + box-shadow: none !important; +} + +.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible, +.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible { + outline: 2px solid var(--sg-filter-chip-active-bg); + outline-offset: 2px; + box-shadow: none; +} + +.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible, +.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible { + outline-color: var(--sg-filter-chip-active-bg-hover); } .table-wrap { overflow: auto; - max-height: min(480px, 70vh); + max-height: min(520px, 70vh); +} + +.table-wrap:not(.table-wrap_flat) { + min-height: 520px; } .telemetry { width: 100%; - border-collapse: collapse; font: var(--tui-font-text-s); } -.telemetry th, -.telemetry td { +.telemetry th { + text-align: left; + color: var(--tui-text-primary); +} + +.telemetry tbody td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--tui-border-normal); + vertical-align: top; } -.telemetry th { - position: sticky; - top: 0; - background: var(--tui-background-elevation-1); - z-index: 1; +/* Длинные неизвестные event_type — перенос, без разъезда таблицы */ +.telemetry-col-type { + min-width: 0; + max-width: 14rem; + overflow-wrap: anywhere; + word-break: break-word; + vertical-align: top; +} + +.telemetry-type-tabs { + overflow-wrap: anywhere; +} + +.telemetry-type-tabs button { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + text-align: start; } .telemetry-row { @@ -252,7 +364,9 @@ vertical-align: top; } -.payload { - font-family: ui-monospace, monospace; - word-break: break-all; +.telemetry-col-summary { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; + vertical-align: top; } diff --git a/src/app/features/sessions/session-detail/session-detail.html b/src/app/features/sessions/session-detail/session-detail.html index df2a686..3a972b4 100644 --- a/src/app/features/sessions/session-detail/session-detail.html +++ b/src/app/features/sessions/session-detail/session-detail.html @@ -1,4 +1,4 @@ -
+
@@ -14,7 +14,7 @@

Не удалось загрузить сессию.

} @case ('ok') { -

Сессия {{ state.id }}

+

Сессия {{ state.id }}

@@ -68,34 +68,88 @@
-

- События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }}) -

+
+

+ События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }}) +

+

{{ telemetryRangeLabel(telemetryState.toMs) }}

+
- {{ telemetryRangeLabel(telemetryState.toMs) }}
- - - - - - + + + + + +
+
@if (telemetryState.status === 'loading') {
@@ -126,7 +180,7 @@ [class.stream-active]="telemetryEventTypeFilter() === t" (click)="pickTelemetryEventTypeFilter(t)" > - {{ telemetryTypeTabLabel(t) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }}) + {{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }}) }
@@ -139,13 +193,13 @@ Время Тип - Payload + Сводка @for ( row of filteredTelemetryEvents(telemetryState.telemetry); - track row.timestamp + '-' + row.event_type + '-' + $index; + track $index; let i = $index ) { {{ formatUnixMs(row.timestamp) }} - {{ row.event_type || '—' }} - {{ eventDataPreview(row) }} + + {{ row.event_type | telemetryEventType }} + + {{ telemetryEventSummary(row) }} @if (isTelemetryRowExpanded(row, i)) { - + @@ -171,6 +227,7 @@ } } } +
} @@ -206,21 +263,19 @@ - - - - - + + + + - @for (stream of state.detail.streams; track stream.stream_type) { + @for (s of state.detail.streams; track s.stream_type) { - - - - - + + + + } @@ -260,8 +315,18 @@
-

Исходный JSON

-
{{ detailPayloadJson(state.detail) }}
+
+

Исходный JSON

+ +
} } diff --git a/src/app/features/sessions/sessions-list/sessions-list.css b/src/app/features/sessions/sessions-list/sessions-list.css index c99a397..7d17d6b 100644 --- a/src/app/features/sessions/sessions-list/sessions-list.css +++ b/src/app/features/sessions/sessions-list/sessions-list.css @@ -1,8 +1,6 @@ .page { - max-width: 960px; - margin: 0 auto; - padding: 1.5rem 1rem 3rem; - box-sizing: border-box; + padding-top: 1.5rem; + padding-bottom: 3rem; } .heading { @@ -109,11 +107,3 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat .total { font: var(--tui-font-text-s); } - -/* Активная страница пагинации (Taiga 5: активная кнопка — appearance primary) */ -tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] { - --t-bg: var(--sg-color-accent); - background: var(--t-bg); - border-color: var(--sg-color-accent); - color: var(--sg-color-text); -} diff --git a/src/app/features/sessions/sessions-list/sessions-list.html b/src/app/features/sessions/sessions-list/sessions-list.html index 4ce9341..6c6c492 100644 --- a/src/app/features/sessions/sessions-list/sessions-list.html +++ b/src/app/features/sessions/sessions-list/sessions-list.html @@ -1,5 +1,5 @@ -
-

Сессии прокторинга

+
+

Сессии прокторинга

Новая сессия

diff --git a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts index e4d8990..c897bb0 100644 --- a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts @@ -2,7 +2,9 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { SafeHtml } from '@angular/platform-browser'; +import { TuiButton } from '@taiga-ui/core/components/button'; import { TuiLoader } from '@taiga-ui/core/components/loader'; +import { TuiAccordion } from '@taiga-ui/kit/components/accordion'; import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -14,17 +16,20 @@ import { } from '../../../core/keyboard/keyboard-payload.util'; import { KeyboardSvgHighlightService } from '../../../core/keyboard/keyboard-svg-highlight.service'; import type { ParsedEvent } from '../../../core/models/api.types'; +import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service'; +import { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe'; import { formatTimestamp } from '../../../shared/utils/date-time.util'; @Component({ selector: 'app-telemetry-event-detail', - imports: [AsyncPipe, TuiLoader], + imports: [AsyncPipe, TuiButton, TuiLoader, TelemetryEventTypePipe, ...TuiAccordion], templateUrl: './telemetry-event-detail.html', styleUrl: './telemetry-event-detail.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class TelemetryEventDetailComponent { private readonly keyboardSvg = inject(KeyboardSvgHighlightService); + private readonly userErrors = inject(UserErrorNotifyService); readonly event = input.required(); @@ -50,7 +55,16 @@ export class TelemetryEventDetailComponent { return eventPayloadJson(e.data); } - /** Удобно в шаблоне, где нет сужения типа для `keyboardModel()`. */ + protected async copyEventPayload(): Promise { + const text = this.payloadText(this.event()); + try { + await navigator.clipboard.writeText(text); + this.userErrors.notifySuccess('JSON события скопирован в буфер обмена.', 'Готово'); + } catch { + // clipboard + } + } + protected keyboardKeyIds(): string[] { const m = this.keyboardModel(); return m.kind === 'keyboard' ? m.keyIds : []; diff --git a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css index f25a43d..357441f 100644 --- a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css @@ -2,6 +2,10 @@ padding: 0.5rem 0 0; } +.detail-section { + margin: 0; +} + .detail-title { margin: 0 0 0.75rem; font: var(--tui-font-text-m); @@ -13,10 +17,14 @@ display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr; gap: 0.35rem 1rem; - margin: 0 0 1rem; + margin: 0; font: var(--tui-font-text-s); } +.detail-kv_main { + margin-bottom: 0.65rem; +} + .detail-kv dt { margin: 0; color: var(--tui-text-tertiary); @@ -27,17 +35,36 @@ word-break: break-word; } -.payload-json { +/* Подраздел «Служебные данные» внутри «Подробности» */ +.detail-subsection { margin: 0; - padding: 0.75rem 1rem; - max-height: 200px; - overflow: auto; - border-radius: var(--tui-radius-m); - background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary)); - font-size: 0.8rem; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; + padding-left: 0.65rem; + border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary)); +} + +.telemetry-service-accordion { + inline-size: 100%; +} + +.telemetry-service-body { + padding: 0; +} + +.telemetry-service-kv { + margin: 0.65rem 0 0.75rem; +} + +.json-copy-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; +} + +.telemetry-json-label { + font: var(--tui-font-text-s); + color: var(--tui-text-secondary); } .mono { @@ -46,9 +73,9 @@ } .keyboard-block { - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--tui-border-normal); + margin: 0 0 1rem; + padding: 0 0 0.75rem; + border-bottom: 1px solid var(--tui-border-normal); } .keyboard-title { diff --git a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html index 50a563a..2e95449 100644 --- a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html @@ -1,31 +1,7 @@
-

Подробности

-
-
Тип
-
{{ event().event_type || '—' }}
-
Время
-
{{ formatTime(event().timestamp) }}
- @if (keyboardModel().kind === 'keyboard') { -
Виртуальный код (VK)
-
- @if (keyboardModel().vk != null) { - {{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }}) - } @else { - — - } -
- @if (keyboardKeyIds().length > 0) { -
Клавиши на схеме
-
{{ keyboardKeyIds().join(', ') }}
- } - } -
Данные
-
{{ payloadText(event()) }}
-
- @if (keyboardModel().kind === 'keyboard') {
-

Клавиатура (US)

+

Предпросмотр

@if (keyboardSvg$ | async; as svg) {
} @else { @@ -41,4 +17,52 @@ }
} + +
+

Подробности

+
+
Тип
+
{{ event().event_type | telemetryEventType }}
+
Время
+
{{ formatTime(event().timestamp) }}
+
+ +
+ + + +
+ @if (keyboardModel().kind === 'keyboard') { +
+
Виртуальный код (VK)
+
+ @if (keyboardModel().vk != null) { + {{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }}) + } @else { + — + } +
+ @if (keyboardKeyIds().length > 0) { +
Клавиши на схеме
+
{{ keyboardKeyIds().join(', ') }}
+ } +
+ } +
+ Данные события (JSON) + +
+
+
+
+
+
diff --git a/src/app/shared/utils/date-time.util.ts b/src/app/shared/utils/date-time.util.ts index 82cb377..abb3c03 100644 --- a/src/app/shared/utils/date-time.util.ts +++ b/src/app/shared/utils/date-time.util.ts @@ -1,7 +1,3 @@ -/** - * Преобразует ISO-время (timestamptz), например `2026-04-06T23:58:27Z`, - * в человекочитаемый локальный формат. - */ export function formatTimestamp(value: string | null | undefined): string { if (!value) { return '—'; diff --git a/src/app/shared/utils/duration.util.ts b/src/app/shared/utils/duration.util.ts new file mode 100644 index 0000000..939b670 --- /dev/null +++ b/src/app/shared/utils/duration.util.ts @@ -0,0 +1,46 @@ +const NBSP = '\u00a0'; + +export function formatDurationMsHuman(ms: number | null | undefined): string { + if (ms === null || ms === undefined || !Number.isFinite(ms)) { + return '—'; + } + + const rounded = Math.round(ms); + if (rounded < 0) { + return '—'; + } + if (rounded === 0) { + return `0${NBSP}с`; + } + + if (rounded < 1000) { + return `${rounded}${NBSP}мс`; + } + + const secFloat = rounded / 1000; + if (secFloat < 60) { + const hasFraction = rounded % 1000 !== 0; + const text = hasFraction + ? secFloat.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 1 }) + : String(Math.round(secFloat)); + return `${text}${NBSP}с`; + } + + const totalSec = Math.floor(rounded / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + + const parts: string[] = []; + if (h > 0) { + parts.push(`${h}${NBSP}ч`); + } + if (m > 0) { + parts.push(`${m}${NBSP}мин`); + } + if (s > 0 || parts.length === 0) { + parts.push(`${s}${NBSP}с`); + } + + return parts.join(' '); +} diff --git a/src/app/shared/utils/telemetry-summary-human-text.util.ts b/src/app/shared/utils/telemetry-summary-human-text.util.ts new file mode 100644 index 0000000..f679a3d --- /dev/null +++ b/src/app/shared/utils/telemetry-summary-human-text.util.ts @@ -0,0 +1,83 @@ +export function formatTelemetryMouseButtonLabel(button: number): string { + switch (button) { + case 1: + return 'левой'; + case 2: + return 'правой'; + case 3: + return 'средней'; + default: + return `дополнительной (${button})`; + } +} + +export function formatTelemetryKeyboardKeyDisplay(key: string): string { + const t = key.trim(); + if (t.toUpperCase() === 'META') { + return 'Meta'; + } + if (t.length === 1) { + return t.toUpperCase(); + } + return t; +} + +export function formatTelemetryModifierTokens(modifiers: string): string { + return modifiers + .split('+') + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .map((t) => { + const u = t.toLowerCase(); + if (u === 'meta') { + return '⌘'; + } + if (u === 'shift') { + return 'Shift'; + } + if (u === 'control' || u === 'ctrl') { + return 'Ctrl'; + } + if (u === 'alt') { + return 'Alt'; + } + return t; + }) + .join(' + '); +} + +export function formatTelemetryMouseClickSummary( + x: number, + y: number, + button: number, + isDown: boolean, +): string { + const phase = isDown ? 'Нажатие' : 'Отпускание'; + const btn = formatTelemetryMouseButtonLabel(button); + return `${phase} ${btn} кнопки мыши в точке (${x}, ${y})`; +} + +export function formatTelemetryMouseMoveSummary(x: number, y: number, button: number | null): string { + if (button !== null) { + return `Перемещение указателя — (${x}, ${y}), кнопка ${button}`; + } + return `Перемещение указателя — (${x}, ${y})`; +} + +export function formatTelemetryKeyboardKeySummary( + action: 'press' | 'release', + keyNameRaw: string, + modifiersRaw: unknown, +): string { + const actionRu = action === 'press' ? 'Нажатие' : 'Отпускание'; + const keyName = formatTelemetryKeyboardKeyDisplay(keyNameRaw); + const modStr = modifiersRaw == null ? '' : String(modifiersRaw).trim(); + const noMods = + modStr === '' || modStr.toLowerCase() === 'none' || modStr.toLowerCase() === 'null'; + + let line = `${actionRu} клавиши «${keyName}»`; + if (!noMods) { + line += ` — ${formatTelemetryModifierTokens(modStr)}`; + } + return line; +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 9e11d42..456072e 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,4 +1,3 @@ -/** Production-сборка (см. fileReplacements в angular.json). При деплое при необходимости меняйте здесь или генерируйте шагом CI. */ export const environment = { production: true, apiFallbackOrigin: 'https://sparkguardian.ru', diff --git a/src/index.html b/src/index.html index 5960314..fba22b1 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Sparkguardian + GUARD diff --git a/src/styles.css b/src/styles.css index 03ada7a..d84641b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2,7 +2,6 @@ @import './styles/session-status-chips.css'; @import './styles/sg-input-fields.css'; -/* Базовая вёрстка под Taiga UI: тема и шрифты подключаются в angular.json */ @font-face { font-family: 'Tinkoff Sans'; src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype'); @@ -41,6 +40,14 @@ body { font-family: 'Tinkoff Sans', sans-serif; } +.sg-content-column { + box-sizing: border-box; + width: 100%; + max-width: var(--sg-content-max-width); + margin-inline: auto; + padding-inline: var(--sg-page-padding-inline); +} + *, *::before, *::after { @@ -66,3 +73,24 @@ textarea::placeholder { tui-notification-alert [tuiTitle] { font-weight: 500; } + +/* + * Пагинация Taiga: активная страница — primary внутри чужого компонента; + * стили из sessions-list не доходят из‑за ViewEncapsulation — только глобально. + */ +tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] { + --t-bg: var(--sg-filter-chip-active-bg) !important; + background: var(--t-bg) !important; + border-color: var(--sg-filter-chip-active-bg) !important; + color: var(--sg-filter-chip-active-fg) !important; +} + +tui-pagination + button.t-button[tuiButton][tuiAppearance][data-appearance='primary']:hover:not(:disabled):not( + [data-state='disabled'] + ) { + --t-bg: var(--sg-filter-chip-active-bg-hover) !important; + background: var(--t-bg) !important; + border-color: var(--sg-filter-chip-active-bg-hover) !important; + color: var(--sg-filter-chip-active-fg) !important; +} diff --git a/src/styles/color-tokens.css b/src/styles/color-tokens.css index 70979e5..cadcd1d 100644 --- a/src/styles/color-tokens.css +++ b/src/styles/color-tokens.css @@ -24,16 +24,28 @@ --sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent); --sg-color-danger: #d92d20; - /* Taiga accent palette override (used by primary/secondary appearances, including pagination active item) */ + /* Ширина и отступы основного контента (шапка, страницы с классом .page) */ + /* От 1000px: боковые поля по умолчанию; уже 999px — 48px (media ниже). */ + --sg-content-max-width: 1104px; + --sg-page-padding-inline: 1rem; + + /* + * Чипы-категории (фильтры телеметрии, пресеты диапазона, вкладки потоков на просмотре). + * Совпадают с полями там, где цвета те же: фон неактивного = --sg-color-textfield-bg. + */ + --sg-filter-chip-bg: #f3f4f7; + --sg-filter-chip-bg-hover: #eaeff3; + --sg-filter-chip-fg: #313131; + --sg-filter-chip-active-bg: #158eff; + --sg-filter-chip-active-bg-hover: #0070ff; + --sg-filter-chip-active-fg: #ffffff; + + /* Taiga accent palette override (primary-кнопки и т.п.; активная страница пагинации переопределена в sessions-list) */ --tui-background-accent-1: var(--sg-color-accent); --tui-background-accent-1-hover: color-mix(in srgb, var(--sg-color-accent) 92%, black); --tui-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black); --tui-text-primary-on-accent-1: var(--sg-color-text); - /* - * Bridge to Taiga tokens used in the app. - * This keeps styling centralized and avoids hardcoding colors in components. - */ --tui-background-base: var(--sg-color-bg); --tui-background-elevation-1: var(--sg-color-card-bg); --tui-text-primary: var(--sg-color-text); @@ -48,15 +60,15 @@ --sg-session-status-active-fg: #166534; --sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent); - --sg-session-status-pending-bg: color-mix(in srgb, #d97706 18%, var(--sg-color-card-bg)); - --sg-session-status-pending-fg: #92400e; - --sg-session-status-pending-border: color-mix(in srgb, #d97706 34%, transparent); + --sg-session-status-pending-bg: color-mix(in srgb, #eab308 18%, var(--sg-color-card-bg)); + --sg-session-status-pending-fg: #713f12; + --sg-session-status-pending-border: color-mix(in srgb, #eab308 34%, transparent); --sg-session-status-unknown-bg: color-mix(in srgb, var(--sg-color-text) 10%, var(--sg-color-card-bg)); --sg-session-status-unknown-fg: var(--sg-color-text); --sg-session-status-unknown-border: var(--sg-color-border); - /* Встроенная SVG-клавиатура (public/KB_USA-standard.svg) */ + /* Встроенная SVG-клавиатура (public/svg/visual/keyboard.svg) */ --sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif; --sg-keyboard-font-weight: 400; --sg-keyboard-letter-spacing: 0.03em; @@ -70,7 +82,7 @@ --sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle); --sg-keyboard-key-other: var(--sg-keyboard-key-surface-idle); --sg-keyboard-key-stroke: color-mix(in srgb, var(--sg-color-border) 65%, transparent); - /* Символы на клавишах — основной тёмный текст (тот же оттенок, что раньше был заливкой нажатой) */ + /* Базовые глифы — ink-soft; контрастный «чёрный» — --sg-keyboard-ink (см. подсветку нажатий) */ --sg-keyboard-ink: var(--sg-color-text); --sg-keyboard-ink-soft: var(--tui-text-tertiary); /* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */ @@ -78,3 +90,9 @@ --sg-keyboard-key-pressed-ink: var(--sg-color-text); --sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill); } + +@media (max-width: 999px) { + :root { + --sg-page-padding-inline: 48px; + } +} diff --git a/src/styles/sg-input-fields.css b/src/styles/sg-input-fields.css index e95fe2e..964bc8e 100644 --- a/src/styles/sg-input-fields.css +++ b/src/styles/sg-input-fields.css @@ -1,9 +1,3 @@ -/* - * Единый вид полей ввода SparkGuardian. - * Taiga: добавьте class="sg-tui-textfield" на . - * Нативные input/textarea/select: class="sg-native-input". - */ - /* --- tui-textfield (Taiga Input) --- */ tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] { --tui-focus: var(--sg-color-textfield-focus-border);
Тип потокаЧанковДлительность (мс)URL плейлиста (как в API)URL плейлиста (абсолютный)ТипЧанкиДлительностьURL видеозаписи
{{ stream.stream_type }}{{ stream.chunk_count ?? '—' }}{{ formatDurationMs(stream.duration_ms) }}{{ stream.playlist_url }}{{ streamResolvedPlaylistUrl(stream) }}{{ s.stream_type }}{{ s.chunk_count ?? '—' }}{{ formatDurationMs(s.duration_ms) }}{{ streamResolvedPlaylistUrl(s) }}