diff --git a/.env.example b/.env.example index 2b99525..f1eee80 100644 --- a/.env.example +++ b/.env.example @@ -7,5 +7,8 @@ SG_API_BASE_PATH=/api/v1 # Origin для разрешения относительных URL (HLS playlist и т.д.) при открытии приложения как file:// или вне основного хоста SG_API_FALLBACK_ORIGIN=https://sparkguardian.ru +# Преролл видео в интерактивном режиме (мс): сколько видео записано до старта телеметрии. +SG_INTERACTIVE_PREROLL_MS=4000 + # Только dev-сервер (`ng serve`): куда проксировать `/api/**` SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080 diff --git a/README.md b/README.md index 6116f17..b26bff9 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,149 @@ # Sparkguardian -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6. +Клиентское веб-приложение для просмотра записанных сессий, HLS-потоков и телеметрии (клавиатура, мышь и др.), работающее поверх REST API Sparkguardian. -## Development server +A single-page web client for reviewing recorded sessions, HLS streams, and telemetry (keyboard, mouse, and more), built on top of the Sparkguardian REST API. -To start a local development server, run: +--- -```bash -ng serve -``` +## О проекте -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. +Приложение предназначено для операторов и разработчиков, которым нужно открыть список сессий, перейти к деталям конкретной записи и синхронно смотреть видео с разбором событий на временной шкале. Бэкенд отвечает за хранение чанков, плейлистов и событий; фронтенд подставляет базовый URL API, подгружает данные и отображает их в интерфейсе на базе Taiga UI. -## Code scaffolding +## Основные возможности -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: +- **Список сессий** с пагинацией и созданием новой сессии по названию. +- **Карточка сессии** с вкладками: сводная информация, просмотр записи и телеметрии, интерактивный режим с визуализацией клавиатуры и курсора поверх видео. +- **HLS-воспроизведение** через hls.js, выбор потока (например, экран или веб-камера), если бэкенд отдаёт несколько `stream_type`. +- **Телеметрия**: загрузка разобранных событий с фильтрацией по типу и времени, привязка к окну записи (`started_at` / `ended_at`). +- **Уведомления об ошибках** HTTP с понятными сообщениями для пользователя. -```bash -ng generate component component-name -``` +## Технологии -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: +| Область | Выбор | +|--------|--------| +| Фреймворк | Angular 21, TypeScript | +| UI | Taiga UI, анимации через `@angular/animations` | +| HTTP | `HttpClient`, интерсептор базового URL API | +| Тесты | Vitest через `@angular/build:unit-test`, jsdom | +| Линт | ESLint (`angular-eslint`) | +| Видео | hls.js | -```bash -ng generate --help -``` +Стили опираются на дизайн-токены (CSS-переменные); для компонентов по возможности используются примитивы Taiga UI. -## Building +## Как устроен код -To build the project run: +- **Маршруты**: главная страница — список сессий (`/`); детали — `/sessions/:id`. Остальные пути перенаправляются на список. +- **Слой API**: `SessionsApiService` ходит на эндпоинты вида `/sessions`, `/sessions/:id/events` и т.д.; префикс API задаётся через `API_BASE_URL` (см. ниже). Относительные URL плейлистов HLS разрешаются к origin через `API_ORIGIN`. +- **Окружение**: `src/environments/environment.ts` генерируется скриптом `npm run env:sync` из переменных в `.env` (значения по умолчанию совпадают с `.env.example`). Production-сборка подменяет файл на `environment.prod.ts`. +- **Прокси в разработке**: `ng serve` использует `proxy.conf.cjs`: запросы к `/api/**` уходят на хост из `SG_DEV_PROXY_TARGET` в `.env` (по умолчанию указан в примере). -```bash -ng build -``` +Подробная схема REST описана в `docs/doc_v1.json` (OpenAPI). -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. +## Требования -## Running unit tests +- Node.js версии, совместимой с Angular 21 (см. рекомендации в документации Angular CLI). +- npm (в проекте зафиксирован `packageManager` в `package.json`). -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: +## Локальная настройка -```bash -ng test -``` +1. Скопируйте `.env.example` в `.env` и при необходимости измените переменные (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`, `SG_INTERACTIVE_PREROLL_MS`, для dev — `SG_DEV_PROXY_TARGET`). +2. Установите зависимости: `npm install` или `make install`. +3. Сгенерируйте `environment.ts`: `npm run env:sync` или `make env-sync` (перед `start` и `build` это выполняется автоматически через npm-скрипты `prestart` / `prebuild`). -## Running end-to-end tests +## Запуск и сборка -For end-to-end (e2e) testing, run: +| Задача | Команда | +|--------|---------| +| Dev-сервер с прокси | `npm start` или `make start` | +| Production-сборка | `npm run build` или `make build` | +| Сборка development | `make build-dev` | +| Unit-тесты | `npm test` (в режиме разработки без CI обычно удобен интерактивный режим) | +| Линт | `npm run lint` | +| Очистка артефактов | `make clean` | -```bash -ng e2e -``` +После `npm start` приложение доступно по адресу, который выводит Angular CLI (по умолчанию `http://localhost:4200/`). -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. +## CI -## Additional Resources +В репозитории описан workflow Gitea Actions: `.gitea/workflows/ci.yml` — линт, тесты с `--watch=false`, production build. На сервере должны быть включены Actions и настроен runner. -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. +## Структура каталогов (кратко) + +- `src/app/core` — API, HTTP, модели, уведомления, разбор телеметрии клавиатуры/мыши, подсветка SVG. +- `src/app/features/sessions` — список сессий, детальная страница и вкладки, плеер HLS, выбор потока, детали события. +- `src/environments` — конфигурация окружения. +- `public` — статические ассеты (иконки, SVG для визуализации и т.д.). +- `scripts` — вспомогательные скрипты (`sync-env.cjs`). + +--- + +## About the project + +The app is aimed at operators and developers who need a session list, a focused session view, and a way to watch video while inspecting timed telemetry. The backend owns chunks, playlists, and events; the frontend configures the API base URL, loads data, and presents it through Taiga UI. + +## Features + +- **Session list** with pagination and creating a session with a title. +- **Session detail** with tabs: summary, combined playback and telemetry, and an interactive view with keyboard and cursor overlays on top of the video. +- **HLS playback** via hls.js, with stream selection when multiple `stream_type` entries exist. +- **Telemetry**: parsed events with type and time filters, aligned with the recording window (`started_at` / `ended_at`). +- **HTTP error notifications** with user-facing messages. + +## Tech stack + +| Area | Choice | +|------|--------| +| Framework | Angular 21, TypeScript | +| UI | Taiga UI, `@angular/animations` for motion | +| HTTP | `HttpClient`, API base URL interceptor | +| Tests | Vitest via `@angular/build:unit-test`, jsdom | +| Lint | ESLint (`angular-eslint`) | +| Video | hls.js | + +Styling relies on CSS variables (design tokens); Taiga UI components are preferred where they fit. + +## Architecture + +- **Routes**: home is the session list (`/`); details live at `/sessions/:id`. Unknown paths redirect to the list. +- **API layer**: `SessionsApiService` calls `/sessions`, `/sessions/:id/events`, etc. The API prefix comes from `API_BASE_URL`. Relative HLS playlist URLs are resolved with `API_ORIGIN`. +- **Environment**: `src/environments/environment.ts` is generated by `npm run env:sync` from `.env` (defaults match `.env.example`). Production builds use `environment.prod.ts`. +- **Dev proxy**: `ng serve` loads `proxy.conf.cjs`: `/api/**` is forwarded to `SG_DEV_PROXY_TARGET` from `.env`. + +The REST contract is summarized in `docs/doc_v1.json` (OpenAPI). + +## Prerequisites + +- A Node.js version compatible with Angular 21 (see Angular CLI docs). +- npm (see `packageManager` in `package.json`). + +## Local setup + +1. Copy `.env.example` to `.env` and adjust variables (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`, `SG_INTERACTIVE_PREROLL_MS`, and for local dev `SG_DEV_PROXY_TARGET`). +2. Install dependencies: `npm install` or `make install`. +3. Generate `environment.ts`: `npm run env:sync` or `make env-sync` (also runs automatically before `start` / `build` via npm `prestart` / `prebuild`). + +## Running and building + +| Task | Command | +|------|---------| +| Dev server with proxy | `npm start` or `make start` | +| Production build | `npm run build` or `make build` | +| Development build | `make build-dev` | +| Unit tests | `npm test` | +| Lint | `npm run lint` | +| Clean artifacts | `make clean` | + +After `npm start`, open the URL printed by the CLI (typically `http://localhost:4200/`). + +## CI + +Gitea Actions workflow: `.gitea/workflows/ci.yml` — lint, tests with `--watch=false`, production build. Actions must be enabled and a runner must be available. + +## Repository layout + +- `src/app/core` — API client, HTTP, models, notifications, keyboard/mouse telemetry parsing and SVG highlighting. +- `src/app/features/sessions` — session list, detail page and tabs, HLS player, stream selector, telemetry event drill-down. +- `src/environments` — environment configuration. +- `public` — static assets (fonts, SVG overlays, favicon). +- `scripts` — helpers such as `sync-env.cjs`. diff --git a/public/favicon.ico b/public/favicon.ico index 2c6933e..266779a 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..6084671 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/svg/visual/arrow-keys.svg b/public/svg/visual/arrow-keys.svg new file mode 100644 index 0000000..13792e9 --- /dev/null +++ b/public/svg/visual/arrow-keys.svg @@ -0,0 +1,45 @@ + + + diff --git a/public/svg/visual/keyboard.svg b/public/svg/visual/keyboard.svg index 1ed81f9..00a97b7 100644 --- a/public/svg/visual/keyboard.svg +++ b/public/svg/visual/keyboard.svg @@ -118,6 +118,13 @@ text-align: center; text-anchor: middle; } + #T_stdletters text.T_ru { + font-size: 12px; + letter-spacing: 0.03em; + opacity: 0.9; + text-align: end; + text-anchor: end; + } #T_stdspecial text { font-size: 17px; font-weight: var(--sg-keyboard-font-weight); @@ -127,6 +134,13 @@ text-align: center; text-anchor: middle; } + #T_stdspecial text.T_ru { + font-size: 11px; + letter-spacing: 0.02em; + opacity: 0.9; + text-align: end; + text-anchor: end; + } .T_size_s { font-size: 11px; font-weight: var(--sg-keyboard-font-weight); @@ -308,6 +322,36 @@ B N M + + + й + ц + у + к + е + н + г + ш + щ + з + + ф + ы + в + а + п + р + о + л + д + + я + ч + с + м + и + т + ь @@ -363,6 +407,32 @@ < > ? + + + ё + ! + " + + ; + % + : + ? + * + ( + ) + - + + + + х + ъ + / + + ж + э + + б + ю + . diff --git a/scripts/sync-env.cjs b/scripts/sync-env.cjs index c0da5a2..658ae25 100644 --- a/scripts/sync-env.cjs +++ b/scripts/sync-env.cjs @@ -10,6 +10,7 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); const defaults = { SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru', SG_API_BASE_PATH: '/api/v1', + SG_INTERACTIVE_PREROLL_MS: '4000', }; function val(key) { @@ -20,11 +21,20 @@ function val(key) { return defaults[key]; } +function intVal(key) { + const parsed = Number.parseInt(val(key), 10); + if (Number.isFinite(parsed)) { + return parsed; + } + return Number.parseInt(defaults[key], 10); +} + const content = `// Сгенерировано npm run env:sync — правьте .env и снова запустите sync export const environment = { production: false, apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))}, apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))}, + interactivePrerollMs: ${JSON.stringify(intVal('SG_INTERACTIVE_PREROLL_MS'))}, } as const; `; diff --git a/src/app/core/devtools/dev-log.service.ts b/src/app/core/devtools/dev-log.service.ts index 9642894..40b541e 100644 --- a/src/app/core/devtools/dev-log.service.ts +++ b/src/app/core/devtools/dev-log.service.ts @@ -15,14 +15,19 @@ export interface DevHttpLogDetails { error?: string; } +export interface DevTelemetryLogDetails { + rawEventJson: string; +} + export interface DevLogEntry { id: number; time: string; level: DevLogLevel; - source: 'http' | 'system'; + source: 'http' | 'system' | 'telemetry'; message: string; status?: DevLogStatus; details?: DevHttpLogDetails; + telemetryDetails?: DevTelemetryLogDetails; } @Injectable({ providedIn: 'root' }) @@ -49,4 +54,8 @@ export class DevLogService { clear(): void { this.entries.set([]); } + + clearSource(source: DevLogEntry['source']): void { + this.entries.update((curr) => curr.filter((e) => e.source !== source)); + } } diff --git a/src/app/core/keyboard/keyboard-key-name-map.ts b/src/app/core/keyboard/keyboard-key-name-map.ts index 2947923..27ed405 100644 --- a/src/app/core/keyboard/keyboard-key-name-map.ts +++ b/src/app/core/keyboard/keyboard-key-name-map.ts @@ -58,6 +58,44 @@ function tokenToSvgIds(token: string): string[] { space: ['K_kb6d'], caps: ['K_kb4a'], menu: ['K_kb6m'], + + // Пунктуация по имени (на случай если агент шлёт имя, а не символ) + comma: ['K_kb5j'], + period: ['K_kb5k'], + dot: ['K_kb5k'], + slash: ['K_kb5l'], + semicolon: ['K_kb4l'], + colon: ['K_kb4l'], + quote: ['K_kb4m'], + apostrophe: ['K_kb4m'], + grave: ['K_kb2a'], + backtick: ['K_kb2a'], + tilde: ['K_kb2a'], + minus: ['K_kb2l'], + dash: ['K_kb2l'], + underscore: ['K_kb2l'], + equal: ['K_kb2m'], + equals: ['K_kb2m'], + plus: ['K_kb2m'], + left_bracket: ['K_kb3l'], + leftbracket: ['K_kb3l'], + right_bracket: ['K_kb3m'], + rightbracket: ['K_kb3m'], + backslash: ['K_kb3n'], + pipe: ['K_kb3n'], + + up: ['K_kb7u'], + down: ['K_kb7d'], + left: ['K_kb7l'], + right: ['K_kb7r'], + arrow_up: ['K_kb7u'], + arrow_down: ['K_kb7d'], + arrow_left: ['K_kb7l'], + arrow_right: ['K_kb7r'], + arrowup: ['K_kb7u'], + arrowdown: ['K_kb7d'], + arrowleft: ['K_kb7l'], + arrowright: ['K_kb7r'], }; if (named[t]) { diff --git a/src/app/core/keyboard/keyboard-payload.util.ts b/src/app/core/keyboard/keyboard-payload.util.ts index b9e0a7d..f3cfc0c 100644 --- a/src/app/core/keyboard/keyboard-payload.util.ts +++ b/src/app/core/keyboard/keyboard-payload.util.ts @@ -2,7 +2,7 @@ import type { ParsedEvent } from '../models/api.types'; import { unwrapJsonPayload } from '../../shared/utils/json.util'; import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map'; -import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id'; +import { type KeyboardVkScheme, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id'; export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean { const t = (event.event_type ?? '').toLowerCase(); @@ -44,6 +44,43 @@ export function parseKeyboardVirtualKey(data: unknown): number | null { return null; } +const MAC_VK_SCHEME_HINTS = new Set([ + 'carbon', + 'darwin', + 'mac', + 'mac_os', + 'macos', + 'macos_vk', + 'osx', +]); + +const WINDOWS_VK_SCHEME_HINTS = new Set(['win', 'win32', 'win64', 'windows', 'windows_vk']); + +/** + * Определяет семейство кодов клавиш по полям payload (агент должен выставлять при записи). + * По умолчанию — Windows VK, как раньше. + */ +export function parseKeyboardVkScheme(data: unknown): KeyboardVkScheme { + const raw = unwrapJsonPayload(data); + if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { + const o = raw as Record; + const keys = ['vk_scheme', 'vkScheme', 'platform', 'os', 'os_type', 'OS'] as const; + for (const k of keys) { + const v = o[k]; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + if (MAC_VK_SCHEME_HINTS.has(s)) { + return 'macos'; + } + if (WINDOWS_VK_SCHEME_HINTS.has(s)) { + return 'windows'; + } + } + } + } + return 'windows'; +} + export function eventPayloadJson(data: unknown): string { try { return JSON.stringify(data, null, 2); @@ -55,16 +92,20 @@ export function eventPayloadJson(data: unknown): string { export function parseKeyboardAction(data: unknown): 'press' | 'release' | null { const raw = unwrapJsonPayload(data); if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { - const action = (raw as Record)['action']; + const o = raw as Record; + const action = o['action']; if (typeof action === 'string') { const normalized = action.toLowerCase(); - if (normalized === 'press') { + if (normalized === 'press' || normalized === 'down' || normalized === 'key_down') { return 'press'; } - if (normalized === 'release') { + if (normalized === 'release' || normalized === 'up' || normalized === 'key_up') { return 'release'; } } + if (typeof o['is_down'] === 'boolean') { + return o['is_down'] ? 'press' : 'release'; + } } return null; } @@ -82,7 +123,8 @@ export function parseKeyboardHighlightKeyIds(data: unknown): string[] { } const vk = parseKeyboardVirtualKey(raw); if (vk != null) { - const id = vkToKeyboardSvgKeyId(normalizeVirtualKey(vk)); + const scheme = parseKeyboardVkScheme(raw); + const id = vkToKeyboardSvgKeyId(vk, scheme); return id ? [id] : []; } return []; diff --git a/src/app/core/keyboard/keyboard-svg-highlight.service.ts b/src/app/core/keyboard/keyboard-svg-highlight.service.ts index d72594c..fc89a15 100644 --- a/src/app/core/keyboard/keyboard-svg-highlight.service.ts +++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts @@ -4,35 +4,154 @@ import { Observable, defer, from, shareReplay } from 'rxjs'; import { map } from 'rxjs/operators'; /** - * Файл из `public/svg/visual/keyboard.svg` — URL в приложении `/svg/visual/keyboard.svg`, + * Клавиша с информацией о том, сколько мс прошло с момента нажатия. + * Используется для scrub-анимации: чем больше ageMs, тем сильнее затухание. + */ +export interface KeyTapHighlight { + keyId: string; +} + +export interface KeyHighlightDiff { + /** Newly pressed this frame — animate in with pop. */ + pressed: string[]; + /** Still held from before — static pressed color. */ + held: string[]; + /** Just released this frame — animate back to idle. */ + released: string[]; +} + +/** + * Файлы из `public/svg/visual/*.svg` — URL в приложении `/svg/visual/...`, * не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`. */ -const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg'; +export const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg'; + +/** Отдельный блок стрелок (рядом с мышью в интерактивном режиме). */ +export const ARROW_KEYS_SVG_PATH = '/svg/visual/arrow-keys.svg'; @Injectable({ providedIn: 'root' }) export class KeyboardSvgHighlightService { private readonly sanitizer = inject(DomSanitizer); - private readonly baseSvg$ = defer(() => - from( - fetch(KEYBOARD_SVG_PATH).then((r) => { - if (!r.ok) { - throw new Error(`Не удалось загрузить клавиатуру: ${r.status} ${r.statusText}`); - } - return r.text(); - }), - ), - ).pipe(shareReplay({ bufferSize: 1, refCount: false })); + private readonly baseSvgCache = new Map>(); - svgWithHighlight(keyIds: string[] | null, animated = true): Observable { - return this.baseSvg$.pipe( + private baseSvg$(path: string): Observable { + let cached = this.baseSvgCache.get(path); + if (!cached) { + cached = defer(() => + from( + fetch(path).then((r) => { + if (!r.ok) { + throw new Error(`Не удалось загрузить SVG: ${path} (${r.status} ${r.statusText})`); + } + return r.text(); + }), + ), + ).pipe(shareReplay({ bufferSize: 1, refCount: false })); + this.baseSvgCache.set(path, cached); + } + return cached; + } + + /** + * Interactive timeline mode: animates only the changed keys. + * Pressed keys pop in, held keys stay static, released keys fade back to idle. + */ + svgWithKeyDiff(diff: KeyHighlightDiff, svgPath: string = KEYBOARD_SVG_PATH): Observable { + return this.baseSvg$(svgPath).pipe( + map((svg) => this.injectHighlightDiff(svg, diff)), + map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), + ); + } + + /** + * Интерактивный режим: каждая клавиша подсвечивается пропорционально своему возрасту (ageMs). + * Используется CSS-scrubbing: анимация поставлена на паузу, а animation-delay смещает её + * в нужную точку, чтобы отобразить состояние «только что нажато» → «гаснет». + */ + svgWithKeyTaps(taps: KeyTapHighlight[], svgPath: string = KEYBOARD_SVG_PATH): Observable { + return this.baseSvg$(svgPath).pipe( + map((svg) => this.injectKeyTaps(svg, taps)), + map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), + ); + } + + svgWithHighlight(keyIds: string[] | null, animated = true, svgPath: string = KEYBOARD_SVG_PATH): Observable { + return this.baseSvg$(svgPath).pipe( map((svg) => this.injectHighlight(svg, keyIds ?? [], animated)), map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), ); } + private injectHighlightDiff(svgText: string, diff: KeyHighlightDiff): string { + const pressed = this.validIds(diff.pressed); + const held = this.validIds(diff.held); + const released = this.validIds(diff.released); + + if (pressed.length === 0 && held.length === 0 && released.length === 0) { + return svgText; + } + + const rules: string[] = []; + + for (const id of pressed) { + const s = id.slice(2); + rules.push( + `#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;transform-box:fill-box;transform-origin:center;animation:sgKeyPress 160ms cubic-bezier(0.33,1,0.68,1);}`, + ); + rules.push( + `[id^="T_${s}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;animation:sgKeyFadeIn 160ms cubic-bezier(0.33,1,0.68,1);}`, + ); + rules.push( + `#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;animation:sgKeyFadeIn 160ms cubic-bezier(0.33,1,0.68,1);}`, + ); + } + + for (const id of held) { + const s = id.slice(2); + rules.push( + `#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`, + ); + rules.push(`[id^="T_${s}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`); + rules.push( + `#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`, + ); + } + + for (const id of released) { + const s = id.slice(2); + // No fill override — lets SVG's own per-group CSS restore the correct idle color + // (GlyphKey/ModifKey/OtherKey each have their own fill token). + rules.push( + `#${id}{transform-box:fill-box;transform-origin:center;animation:sgKeyRelease 200ms ease;}`, + ); + rules.push(`[id^="T_${s}"]{animation:sgKeyReleaseInk 200ms ease;}`); + rules.push( + `#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{animation:sgKeyReleaseInk 200ms ease;}`, + ); + } + + rules.push( + `@keyframes sgKeyPress{0%{transform:scale(0.88);opacity:0.5;}55%{transform:scale(1.03);}100%{transform:scale(1);opacity:1;}}`, + ); + rules.push(`@keyframes sgKeyFadeIn{0%{opacity:0.35;}100%{opacity:1;}}`); + rules.push( + `@keyframes sgKeyRelease{0%{transform:scale(1);}40%{transform:scale(0.96);}100%{transform:scale(1);}}`, + ); + rules.push( + `@keyframes sgKeyReleaseInk{0%{fill:var(--sg-keyboard-key-pressed-ink);}100%{fill:var(--sg-keyboard-ink-soft);}}`, + ); + + const styleBlock = ``; + return svgText.replace(/<\/svg>/i, `${styleBlock}`); + } + + private validIds(ids: string[]): string[] { + return [...new Set(ids.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))]; + } + private injectHighlight(svgText: string, keyIds: string[], animated: boolean): string { - const valid = [...new Set(keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))]; + const valid = this.validIds(keyIds); if (valid.length === 0) { return svgText; } @@ -68,6 +187,25 @@ export class KeyboardSvgHighlightService { rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`); } const styleBlock = ``; - return svgText.replace(/]*>/i, (open) => `${open}${styleBlock}`); + return svgText.replace(/<\/svg>/i, `${styleBlock}`); + } + + private injectKeyTaps(svgText: string, taps: KeyTapHighlight[]): string { + const valid = taps.filter((t) => /^K_kb[0-9a-z]+$/i.test(t.keyId)); + if (valid.length === 0) { + return svgText; + } + + const rules: string[] = []; + + for (const { keyId } of valid) { + const s = keyId.slice(2); + rules.push(`#${keyId}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`); + rules.push(`[id^="T_${s}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`); + rules.push(`#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`); + } + + const styleBlock = ``; + return svgText.replace(/<\/svg>/i, `${styleBlock}`); } } diff --git a/src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts b/src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts new file mode 100644 index 0000000..fcf2de3 --- /dev/null +++ b/src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts @@ -0,0 +1,88 @@ +/** + * Соответствие macOS virtual key codes (Carbon `kVK_*` из HIToolbox/Events.h) + * физическим клавишам на SVG-клавиатуре US QWERTY (`public/svg/visual/keyboard.svg`). + * + * Коды — позиционные (ANSI US), не символы Unicode; для другой раскладки символ + * может отличаться, но подсветка физической клавиши останется корректной. + * + * См. также: rsms/kod `virtual_key_codes.h`, Apple Carbon Events.h + */ + +const MAC_VK_TO_SVG_ID: Record = { + // kVK_ANSI_* — буквы и цифры (порядок enum ≠ порядок на клавиатуре) + 0x00: 'K_kb4c', + 0x01: 'K_kb4d', + 0x02: 'K_kb4e', + 0x03: 'K_kb4f', + 0x04: 'K_kb4h', + 0x05: 'K_kb4g', + 0x06: 'K_kb5c', + 0x07: 'K_kb5d', + 0x08: 'K_kb5e', + 0x09: 'K_kb5f', + 0x0b: 'K_kb5g', + 0x0c: 'K_kb3b', + 0x0d: 'K_kb3c', + 0x0e: 'K_kb3d', + 0x0f: 'K_kb3e', + 0x10: 'K_kb3g', + 0x11: 'K_kb3f', + 0x12: 'K_kb2k', + 0x13: 'K_kb2b', + 0x14: 'K_kb2c', + 0x15: 'K_kb2d', + 0x16: 'K_kb2f', + 0x17: 'K_kb2e', + 0x18: 'K_kb2m', + 0x19: 'K_kb2i', + 0x1a: 'K_kb2g', + 0x1b: 'K_kb2l', + 0x1c: 'K_kb2h', + 0x1d: 'K_kb2j', + 0x1e: 'K_kb3m', + 0x1f: 'K_kb3j', + 0x20: 'K_kb3h', + 0x21: 'K_kb3l', + 0x22: 'K_kb3i', + 0x23: 'K_kb3k', + 0x25: 'K_kb4k', + 0x26: 'K_kb4i', + 0x27: 'K_kb4m', + 0x28: 'K_kb4j', + 0x29: 'K_kb4l', + 0x2a: 'K_kb3n', + 0x2b: 'K_kb5j', + 0x2c: 'K_kb5l', + 0x2d: 'K_kb5h', + 0x2e: 'K_kb5i', + 0x2f: 'K_kb5k', + 0x32: 'K_kb2a', + + // Клавиши вне раскладки + 0x24: 'K_kb4n', + 0x30: 'K_kb3a', + 0x31: 'K_kb6d', + 0x33: 'K_kb2n', + 0x36: 'K_kb6l', + 0x37: 'K_kb6b', + 0x38: 'K_kb5a', + 0x39: 'K_kb4a', + 0x3a: 'K_kb6c', + 0x3b: 'K_kb6a', + 0x3c: 'K_kb5m', + 0x3d: 'K_kb6k', + 0x3e: 'K_kb6n', + + /** kVK_LeftArrow … kVK_UpArrow — блок стрелок в интерактивном UI */ + 0x7b: 'K_kb7l', + 0x7c: 'K_kb7r', + 0x7d: 'K_kb7d', + 0x7e: 'K_kb7u', +}; + +/** + * @param vk — код как в NSEvent.keyCode / Carbon kVK_* (не Windows VK). + */ +export function macVkToKeyboardSvgKeyId(vk: number): string | null { + return MAC_VK_TO_SVG_ID[vk] ?? null; +} 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 c3feb4c..b76d130 100644 --- a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts +++ b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts @@ -1,3 +1,7 @@ +import { macVkToKeyboardSvgKeyId } from './macos-vk-to-keyboard-svg-id'; + +export type KeyboardVkScheme = 'windows' | 'macos'; + export function normalizeVirtualKey(vk: number): number { if (vk >= 0x61 && vk <= 0x7a) { return vk - 0x20; @@ -28,7 +32,7 @@ const LETTER_TO_ID: Record = { I: 'K_kb3i', J: 'K_kb4i', K: 'K_kb4j', - L: 'K_kb4l', + L: 'K_kb4k', M: 'K_kb5i', N: 'K_kb5h', O: 'K_kb3j', @@ -45,10 +49,76 @@ const LETTER_TO_ID: Record = { Z: 'K_kb5c', }; +/** Символы пунктуации и shifted-варианты цифр → физическая клавиша SVG. */ +const PUNCT_CHAR_TO_ID: Record = { + // Ряд цифр (shifted) + '!': 'K_kb2b', '@': 'K_kb2c', '#': 'K_kb2d', '$': 'K_kb2e', + '%': 'K_kb2f', '^': 'K_kb2g', '&': 'K_kb2h', '*': 'K_kb2i', + '(': 'K_kb2j', ')': 'K_kb2k', + // Правый край ряда цифр + '-': 'K_kb2l', '_': 'K_kb2l', + '=': 'K_kb2m', '+': 'K_kb2m', + '`': 'K_kb2a', '~': 'K_kb2a', + // Q-ряд, правый край + '[': 'K_kb3l', '{': 'K_kb3l', + ']': 'K_kb3m', '}': 'K_kb3m', + '\\': 'K_kb3n', '|': 'K_kb3n', + // A-ряд, правый край + ';': 'K_kb4l', ':': 'K_kb4l', + "'": 'K_kb4m', '"': 'K_kb4m', + // Z-ряд, правый край + ',': 'K_kb5j', '<': 'K_kb5j', + '.': 'K_kb5k', '>': 'K_kb5k', + '/': 'K_kb5l', '?': 'K_kb5l', +}; + +const RU_CHAR_TO_ID: Record = { + ё: 'K_kb2a', + й: 'K_kb3b', + ц: 'K_kb3c', + у: 'K_kb3d', + к: 'K_kb3e', + е: 'K_kb3f', + н: 'K_kb3g', + г: 'K_kb3h', + ш: 'K_kb3i', + щ: 'K_kb3j', + з: 'K_kb3k', + х: 'K_kb3l', + ъ: 'K_kb3m', + ф: 'K_kb4c', + ы: 'K_kb4d', + в: 'K_kb4e', + а: 'K_kb4f', + п: 'K_kb4g', + р: 'K_kb4h', + о: 'K_kb4i', + л: 'K_kb4j', + д: 'K_kb4k', + ж: 'K_kb4l', + э: 'K_kb4m', + я: 'K_kb5c', + ч: 'K_kb5d', + с: 'K_kb5e', + м: 'K_kb5f', + и: 'K_kb5g', + т: 'K_kb5h', + ь: 'K_kb5i', + б: 'K_kb5j', + ю: 'K_kb5k', + /** ЙЦУКЕН, верхний ряд (Windows) */ + '№': 'K_kb2d', +}; + const EXTRA_VK: Record = { 0x08: 'K_kb2n', 0x09: 'K_kb3a', 0x0d: 'K_kb4n', + /** Windows VK_LEFT / UP / RIGHT / DOWN — отдельный блок стрелок в UI */ + 0x25: 'K_kb7l', + 0x26: 'K_kb7u', + 0x27: 'K_kb7r', + 0x28: 'K_kb7d', 0x10: 'K_kb5a', 0x11: 'K_kb6a', 0x12: 'K_kb6c', @@ -76,7 +146,7 @@ const EXTRA_VK: Record = { 0xde: 'K_kb4m', }; -export function vkToKeyboardSvgKeyId(vk: number): string | null { +function windowsVkToKeyboardSvgKeyId(vk: number): string | null { const k = normalizeVirtualKey(vk); const fromExtra = EXTRA_VK[k]; if (fromExtra) { @@ -93,6 +163,16 @@ export function vkToKeyboardSvgKeyId(vk: number): string | null { return null; } +/** + * @param vk — Windows virtual-key (по умолчанию) либо macOS kVK при `scheme: 'macos'`. + */ +export function vkToKeyboardSvgKeyId(vk: number, scheme: KeyboardVkScheme = 'windows'): string | null { + if (scheme === 'macos') { + return macVkToKeyboardSvgKeyId(vk); + } + return windowsVkToKeyboardSvgKeyId(vk); +} + export function charKeyNameToSvgKeyId(name: string): string | null { const c = name.trim(); if (c.length !== 1) { @@ -105,5 +185,12 @@ export function charKeyNameToSvgKeyId(name: string): string | null { if (ch >= 'A' && ch <= 'Z') { return LETTER_TO_ID[ch] ?? null; } + const ru = c.toLowerCase(); + if (ru in RU_CHAR_TO_ID) { + return RU_CHAR_TO_ID[ru] ?? null; + } + if (c in PUNCT_CHAR_TO_ID) { + return PUNCT_CHAR_TO_ID[c] ?? null; + } return null; } diff --git a/src/app/core/mouse/mouse-payload.util.ts b/src/app/core/mouse/mouse-payload.util.ts index 88fdbbe..281f571 100644 --- a/src/app/core/mouse/mouse-payload.util.ts +++ b/src/app/core/mouse/mouse-payload.util.ts @@ -20,6 +20,37 @@ export function isMouseTelemetryEvent(event: ParsedEvent): boolean { return t.includes('mouse'); } +/** + * Payload распознаётся как перемещение курсора (координаты + action «move», без учёта регистра). + * Сводка телеметрии и фильтр «Исключить перемещения мыши» должны опираться на одну логику. + */ +export function isMouseMovePayload(o: Record): boolean { + const rawAction = o['action']; + const action = + typeof rawAction === 'string' ? rawAction.trim().toLowerCase() : ''; + if (action !== 'move') { + return false; + } + return readNumber(o, 'x') !== null && readNumber(o, 'y') !== null; +} + +/** Событие перемещения курсора (payload или тип события с «move»). */ +export function isMouseMoveTelemetryEvent(event: ParsedEvent): boolean { + const raw = unwrapJsonPayload(event.data); + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + return false; + } + const o = raw as Record; + if (isMouseMovePayload(o)) { + return true; + } + const t = (event.event_type ?? '').toLowerCase(); + if (t.includes('mouse') && t.includes('move') && readNumber(o, 'x') !== null && readNumber(o, 'y') !== null) { + return true; + } + return false; +} + export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[] { const raw = unwrapJsonPayload(data); if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { @@ -32,6 +63,17 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[ return ['wheel']; } + const buttonName = typeof o['button_name'] === 'string' ? o['button_name'].trim().toLowerCase() : ''; + if (buttonName === 'left' || buttonName === 'lmb') { + return ['left']; + } + if (buttonName === 'right' || buttonName === 'rmb') { + return ['right']; + } + if (buttonName === 'middle' || buttonName === 'mmb' || buttonName === 'wheel') { + return ['middle']; + } + const button = readNumber(o, 'button'); const isDown = o['is_down']; if (button == null) { @@ -40,14 +82,19 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[ if (isDown === false) { return []; } - if (button === 0) { - return ['left']; - } + // Primary scheme in backend telemetry is 1-based: 1=left, 2=right, 3=middle. if (button === 1) { - return ['middle']; + return ['left']; } if (button === 2) { return ['right']; } + if (button === 3) { + return ['middle']; + } + // Compatibility fallback for legacy/DOM-style 0-based payloads. + if (button === 0) { + return ['left']; + } return []; } diff --git a/src/app/core/mouse/mouse-svg-highlight.service.ts b/src/app/core/mouse/mouse-svg-highlight.service.ts index f0e6b9a..b20784b 100644 --- a/src/app/core/mouse/mouse-svg-highlight.service.ts +++ b/src/app/core/mouse/mouse-svg-highlight.service.ts @@ -61,7 +61,7 @@ export class MouseSvgHighlightService { : ''; const defsAndStyles = ``; - normalized = normalized.replace(/]*>/i, (open) => `${open}${defsAndStyles}`); + normalized = normalized.replace(/<\/svg>/i, `${defsAndStyles}`); return normalized; } diff --git a/src/app/core/sessions/telemetry-event-summary.handlers.ts b/src/app/core/sessions/telemetry-event-summary.handlers.ts index b7b5927..3133835 100644 --- a/src/app/core/sessions/telemetry-event-summary.handlers.ts +++ b/src/app/core/sessions/telemetry-event-summary.handlers.ts @@ -1,3 +1,4 @@ +import { isMouseMovePayload } from '../mouse/mouse-payload.util'; import { formatTelemetryKeyboardKeySummary, formatTelemetryMouseClickSummary, @@ -24,8 +25,7 @@ export const mouseClickRule: TelemetrySummaryRule = { export const mouseMoveRule: TelemetrySummaryRule = { id: 'mouse-move', - match: (o) => - o['action'] === 'move' && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null, + match: (o) => isMouseMovePayload(o), summarize: (o) => formatTelemetryMouseMoveSummary( readNumericField(o, 'x')!, diff --git a/src/app/features/devtools/dev-console/dev-console.html b/src/app/features/devtools/dev-console/dev-console.html index e2b11ee..65a8473 100644 --- a/src/app/features/devtools/dev-console/dev-console.html +++ b/src/app/features/devtools/dev-console/dev-console.html @@ -26,6 +26,25 @@
{{ entry.message }} + @if (entry.source === 'telemetry' && entry.telemetryDetails) { + + + @if (isExpanded(entry.id)) { +
+
+ Событие (как пришло с API) +
{{ entry.telemetryDetails.rawEventJson }}
+
+
+ } + } + @if (entry.source === 'http' && entry.details) { - @for (t of uniqueTelemetryEventTypes(telemetryState().telemetry); track t) { + @for (t of uniqueTelemetryEventTypes(visibleTelemetryEvents()); track t) { }
- @if (filteredTelemetryEvents(telemetryState().telemetry).length === 0) { + @if (filteredTelemetryEvents(visibleTelemetryEvents()).length === 0) {

Нет событий выбранного типа.

} @else {
@@ -164,7 +166,7 @@ @for ( - row of filteredTelemetryEvents(telemetryState().telemetry); + row of filteredTelemetryEvents(visibleTelemetryEvents()); track $index; let i = $index ) { diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 456072e..83725a4 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -2,4 +2,5 @@ export const environment = { production: true, apiFallbackOrigin: 'https://sparkguardian.ru', apiBasePath: '/api/v1', + interactivePrerollMs: 4000, } as const; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 17719a0..5474064 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -3,4 +3,5 @@ export const environment = { production: false, apiFallbackOrigin: "https://sparkguardian.ru", apiBasePath: "/api/v1", + interactivePrerollMs: 4000, } as const; diff --git a/src/index.html b/src/index.html index fba22b1..0239f34 100644 --- a/src/index.html +++ b/src/index.html @@ -4,8 +4,10 @@ GUARD - + + + diff --git a/src/styles/color-tokens.css b/src/styles/color-tokens.css index 9fcea95..8e61192 100644 --- a/src/styles/color-tokens.css +++ b/src/styles/color-tokens.css @@ -68,7 +68,8 @@ --sg-session-status-unknown-fg: var(--sg-color-text); --sg-session-status-unknown-border: var(--sg-color-border); - /* Встроенная SVG-клавиатура (public/svg/visual/keyboard.svg) */ + /* Клавиатура (SVG + HTML-компонент) */ + --sg-keyboard-body: color-mix(in srgb, var(--sg-color-form-bg) 80%, var(--sg-color-border)); --sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif; --sg-keyboard-font-weight: 400; --sg-keyboard-letter-spacing: 0.03em;