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 57614f9..2c6933e 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ 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) }}