Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1381a99ca1 | ||
|
|
abcd49e117 | ||
|
|
84586b5ce2 | ||
|
|
d96c152ae3 | ||
|
|
07c17877ac | ||
|
|
68c3029835 |
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Скопируйте в `.env` и при необходимости измените.
|
||||||
|
# Клиентский бандл: после правок выполните `npm run env:sync`.
|
||||||
|
|
||||||
|
# База API (путь относительно текущего origin в браузере, для file:// — склеивается с SG_API_FALLBACK_ORIGIN)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Количество элементов на одной странице по умолчанию (например, для сессий)
|
||||||
|
SG_DEFAULT_PAGE_LIMIT=10
|
||||||
44
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Gitea Actions: линт, unit-тесты (Vitest/jsdom), production build.
|
||||||
|
# Нужны: включённые Actions в репозитории и зарегистрированный act_runner.
|
||||||
|
# Если шаги с `uses:` не резолвятся, в настройках Gitea укажите прокси GitHub Actions
|
||||||
|
# или замените на полные URL, например: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test -- --watch=false
|
||||||
|
|
||||||
|
- name: Production build
|
||||||
|
run: npm run build
|
||||||
9
.gitignore
vendored
@@ -29,6 +29,9 @@ yarn-error.log
|
|||||||
!.vscode/mcp.json
|
!.vscode/mcp.json
|
||||||
.history/*
|
.history/*
|
||||||
|
|
||||||
|
# Local env (see .env.example)
|
||||||
|
.env
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
.sass-cache/
|
.sass-cache/
|
||||||
@@ -42,3 +45,9 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code / Cursor — правила исключения из контекста
|
||||||
|
.claudeignore
|
||||||
|
.cursorignore
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
40
Makefile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help install env-sync start serve build build-dev watch test clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "SparkGuardian"
|
||||||
|
@echo " make install Установить зависимости (npm install)"
|
||||||
|
@echo " make env-sync Сгенерировать src/environments/environment.ts из .env"
|
||||||
|
@echo " make start Dev-сервер (ng serve + прокси, перед стартом — env:sync)"
|
||||||
|
@echo " make serve то же, что start"
|
||||||
|
@echo " make build Production-сборка (перед сборкой — env:sync)"
|
||||||
|
@echo " make build-dev Сборка в режиме development"
|
||||||
|
@echo " make watch ng build --watch (development)"
|
||||||
|
@echo " make test Unit-тесты (ng test)"
|
||||||
|
@echo " make clean Удалить dist, out-tsc, кэш Angular"
|
||||||
|
|
||||||
|
install:
|
||||||
|
npm install
|
||||||
|
|
||||||
|
env-sync:
|
||||||
|
npm run env:sync
|
||||||
|
|
||||||
|
start serve:
|
||||||
|
npm start
|
||||||
|
|
||||||
|
build:
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
build-dev:
|
||||||
|
npm run env:sync
|
||||||
|
npx ng build --configuration development
|
||||||
|
|
||||||
|
watch:
|
||||||
|
npm run watch
|
||||||
|
|
||||||
|
test:
|
||||||
|
npm test
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist out-tsc .angular/cache
|
||||||
160
README.md
@@ -1,59 +1,149 @@
|
|||||||
# Sparkguardian
|
# 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
|
Стили опираются на дизайн-токены (CSS-переменные); для компонентов по возможности используются примитивы Taiga UI.
|
||||||
ng generate --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
Подробная схема REST описана в `docs/doc_v1.json` (OpenAPI).
|
||||||
ng build
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
1. Скопируйте `.env.example` в `.env` и при необходимости измените переменные (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`, `SG_INTERACTIVE_PREROLL_MS`, для dev — `SG_DEV_PROXY_TARGET`).
|
||||||
ng test
|
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
|
После `npm start` приложение доступно по адресу, который выводит Angular CLI (по умолчанию `http://localhost:4200/`).
|
||||||
ng e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
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`.
|
||||||
|
|||||||
25
angular.json
@@ -22,14 +22,27 @@
|
|||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public"
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@taiga-ui/icons/src",
|
||||||
|
"output": "assets/taiga-ui/icons"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"node_modules/@taiga-ui/styles/taiga-ui-theme.less",
|
||||||
|
"node_modules/@taiga-ui/styles/taiga-ui-fonts.less",
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
@@ -54,18 +67,28 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.cjs"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "sparkguardian:build:production"
|
"buildTarget": "sparkguardian:build:production"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "sparkguardian:build:development"
|
"buildTarget": "sparkguardian:build:development",
|
||||||
|
"proxyConfig": "proxy.conf.cjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:unit-test"
|
"builder": "@angular/build:unit-test"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sessions/{id}/events": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Downloads encrypted .bin chunks from S3, decrypts, and returns parsed events with optional time range filtering.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"telemetry"
|
||||||
|
],
|
||||||
|
"summary": "Get parsed telemetry events",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Start timestamp (Unix ms)",
|
||||||
|
"name": "from",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "End timestamp (Unix ms)",
|
||||||
|
"name": "to",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ParsedEventsResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/sessions/{id}/playlist/{stream_type}.m3u8": {
|
"/sessions/{id}/playlist/{stream_type}.m3u8": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -448,6 +506,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"handler.ParsedEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "keyboard"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1711360200000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ParsedEventsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.ParsedEvent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"handler.SessionDetailResponse": {
|
"handler.SessionDetailResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
845
docs/doc_v2.json
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
{
|
||||||
|
"schemes": [],
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"description": "REST API for SparkProctoring — proctoring session management, telemetry, and video streaming.",
|
||||||
|
"title": "SparkProctoring API",
|
||||||
|
"contact": {},
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"host": "sparkguardian.ru",
|
||||||
|
"basePath": "/api/v1",
|
||||||
|
"paths": {
|
||||||
|
"/me": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns the authenticated user's profile.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Get current user info",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.MeResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Paginated list of all sessions with summary stats.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"summary": "List proctoring sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max results (default 50, max 100)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Offset (default 0)",
|
||||||
|
"name": "offset",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.SessionListResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Only teachers and admins may create sessions. Returns session_key for agent auth.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"summary": "Create a proctoring session",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Session title",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.CreateSessionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.CreateSessionResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns session info, stream metadata, and event counts.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"summary": "Get session details",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.SessionDetailResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/events": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Downloads encrypted .bin chunks from S3, decrypts, and returns parsed events with optional time range filtering.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"telemetry"
|
||||||
|
],
|
||||||
|
"summary": "Get parsed telemetry events",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Start timestamp (Unix ms)",
|
||||||
|
"name": "from",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "End timestamp (Unix ms)",
|
||||||
|
"name": "to",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ParsedEventsResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/fingerprint/full": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"fingerprint"
|
||||||
|
],
|
||||||
|
"summary": "Get fingerprint full snapshot",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/fingerprint/heartbeats": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"fingerprint"
|
||||||
|
],
|
||||||
|
"summary": "Get fingerprint heartbeats",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Start timestamp (Unix ms)",
|
||||||
|
"name": "from",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "End timestamp (Unix ms)",
|
||||||
|
"name": "to",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max entries to return",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/fingerprint/summary": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"fingerprint"
|
||||||
|
],
|
||||||
|
"summary": "Get fingerprint summary",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/playlist/{stream_type}.m3u8": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns M3U8 playlist for a session's video stream.",
|
||||||
|
"produces": [
|
||||||
|
"application/vnd.apple.mpegurl"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"summary": "Get HLS playlist",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"screen",
|
||||||
|
"webcam"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stream type",
|
||||||
|
"name": "stream_type",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/segment/{stream_type}/{chunk_idx}.ts": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Proxies a .ts video segment from S3.",
|
||||||
|
"produces": [
|
||||||
|
"video/mp2t"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"summary": "Get video segment",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"screen",
|
||||||
|
"webcam"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stream type",
|
||||||
|
"name": "stream_type",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Chunk index",
|
||||||
|
"name": "chunk_idx",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/telemetry": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns all telemetry events for a session.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"telemetry"
|
||||||
|
],
|
||||||
|
"summary": "List telemetry events",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.TelemetryEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/upload": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Upload .ts (video) or .bin (event) file for a session. Max 64 MB.",
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"summary": "Upload a video or event chunk",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Chunk index",
|
||||||
|
"name": "chunk_idx",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"screen",
|
||||||
|
"webcam"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stream type (default: screen)",
|
||||||
|
"name": "stream_type",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"description": "Chunk file (.ts or .bin)",
|
||||||
|
"name": "file",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"handler.CreateSessionRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Экзамен по ОС"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.CreateSessionResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-04-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"session_key": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "pending"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Экзамен по ОС"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ErrorResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "NOT_FOUND"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "session not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.MeResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "teacher@example.com"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "teacher"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ParsedEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "keyboard"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1711360200000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ParsedEventsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.ParsedEvent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.SessionDetailResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session": {
|
||||||
|
"$ref": "#/definitions/handler.SessionSummary"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.StreamInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.SessionListResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 50
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 0
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.SessionSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.SessionSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chunks_total": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
},
|
||||||
|
"ended_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"events_total": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 128
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-04-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "pending"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.StreamInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chunk_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 10
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 60000
|
||||||
|
},
|
||||||
|
"playlist_url": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/api/v1/sessions/1/playlist/screen.m3u8"
|
||||||
|
},
|
||||||
|
"stream_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.TelemetryEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-04-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "keyboard"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityDefinitions": {
|
||||||
|
"BearerAuth": {
|
||||||
|
"description": "JWT token with \"Bearer \" prefix",
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header"
|
||||||
|
},
|
||||||
|
"ForwardAuth": {
|
||||||
|
"description": "Authelia Forward Auth — set automatically by Traefik",
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Remote-User",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
eslint.config.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// @ts-check
|
||||||
|
const eslint = require('@eslint/js');
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const tseslint = require('typescript-eslint');
|
||||||
|
const angular = require('angular-eslint');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'**/dist/**',
|
||||||
|
'**/.angular/**',
|
||||||
|
'**/coverage/**',
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/*.min.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
extends: [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
tseslint.configs.stylistic,
|
||||||
|
angular.configs.tsRecommended,
|
||||||
|
],
|
||||||
|
processor: angular.processInlineTemplates,
|
||||||
|
rules: {
|
||||||
|
// Production safety rules: strict on bugs, neutral on visual style.
|
||||||
|
'no-alert': 'error',
|
||||||
|
'no-debugger': 'error',
|
||||||
|
'no-unsafe-finally': 'error',
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/contextual-lifecycle': 'error',
|
||||||
|
'@angular-eslint/no-empty-lifecycle-method': 'error',
|
||||||
|
'@angular-eslint/no-input-rename': 'error',
|
||||||
|
'@angular-eslint/no-output-native': 'error',
|
||||||
|
'@angular-eslint/use-lifecycle-interface': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/template/no-call-expression': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.spec.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
2315
package-lock.json
generated
22
package.json
@@ -3,20 +3,32 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"env:sync": "node scripts/sync-env.cjs",
|
||||||
|
"prestart": "npm run env:sync",
|
||||||
|
"prebuild": "npm run env:sync",
|
||||||
|
"start": "ng serve --proxy-config proxy.conf.cjs",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"prewatch": "npm run env:sync",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test",
|
||||||
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.6.2",
|
"packageManager": "npm@11.6.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/animations": "^21.2.7",
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.0",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.0",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
|
"@taiga-ui/cdk": "^5.1.0",
|
||||||
|
"@taiga-ui/core": "^5.1.0",
|
||||||
|
"@taiga-ui/icons": "^5.1.0",
|
||||||
|
"@taiga-ui/kit": "^5.1.0",
|
||||||
|
"@taiga-ui/styles": "^5.1.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -24,9 +36,15 @@
|
|||||||
"@angular/build": "^21.2.6",
|
"@angular/build": "^21.2.6",
|
||||||
"@angular/cli": "^21.2.6",
|
"@angular/cli": "^21.2.6",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"angular-eslint": "21.3.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"less": "^4.6.4",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
|
"typescript-eslint": "^8.58.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
15
proxy.conf.cjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Прокси для `ng serve`. Целевой backend задаётся в `.env` (SG_DEV_PROXY_TARGET).
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
|
const target = process.env.SG_DEV_PROXY_TARGET || 'http://sparkguardian.ru:8080';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
'/api/**': {
|
||||||
|
target,
|
||||||
|
secure: false,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 900 B |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 878 B |
BIN
public/fonts/TinkoffSans-Bold.ttf
Normal file
BIN
public/fonts/TinkoffSans-Medium.ttf
Normal file
BIN
public/fonts/TinkoffSans-Regular.ttf
Normal file
BIN
public/images/t-bank-hero-final.png
Normal file
|
After Width: | Height: | Size: 8.5 MiB |
BIN
public/images/t-bank-hero-v2.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
public/images/t-bank-hero.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
16
public/svg/logo/logo.svg
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
45
public/svg/visual/arrow-keys.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Chevron icons: Lucide (https://lucide.dev), ISC License, ? Lucide Contributors.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104 88" width="104" height="88" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
<![CDATA[
|
||||||
|
.key-cap {
|
||||||
|
fill: var(--sg-keyboard-key-surface-idle);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
.sg-lucide path,
|
||||||
|
.sg-lucide line,
|
||||||
|
.sg-lucide polyline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke-width: 1.55;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
]]>
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<!-- inverted-T: up / left down right -->
|
||||||
|
<rect id="K_kb7u" class="key-cap" x="36" y="2" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7u" class="sg-lucide" transform="translate(52,18) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect id="K_kb7l" class="key-cap" x="2" y="38" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7l" class="sg-lucide" transform="translate(18,54) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect id="K_kb7d" class="key-cap" x="36" y="38" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7d" class="sg-lucide" transform="translate(52,54) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect id="K_kb7r" class="key-cap" x="70" y="38" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7r" class="sg-lucide" transform="translate(86,54) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
462
public/svg/visual/keyboard.svg
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<!-- Reorganised with TEXT EDITOR (gedit under Ubuntu).
|
||||||
|
This file is the general file for the USA alphanumerical keyboard :
|
||||||
|
- it uses style sheet (css) [see details at section "style"]
|
||||||
|
- it displays the standard pad
|
||||||
|
- and optionnaly
|
||||||
|
* for some keys : the draw and/or the text (↹ and/or TAB …)
|
||||||
|
- keys and inscriptions are geographically referenced : "kb" + line(number) + range(letter) -->
|
||||||
|
|
||||||
|
<svg id="svg2"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
version="1.0"
|
||||||
|
xml:space="preserve"
|
||||||
|
sodipodi:version="0.32"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
inkscape:output_extension="org.inkscape.output.svg.inkscape"
|
||||||
|
|
||||||
|
sodipodi:docname="KB_USA 800px.svg" width="800" height="247" viewBox="0 0 801 248" style="display:inline">
|
||||||
|
|
||||||
|
<metadata
|
||||||
|
id="metadata316">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title />
|
||||||
|
<dc:description>Meta (⌘) and Menu: Lucide icons (https://lucide.dev), ISC License, © Lucide Contributors.</dc:description>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<sodipodi:namedview id="base"
|
||||||
|
inkscape:window-width="1679"
|
||||||
|
inkscape:window-height="1019"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
borderopacity="1.0"
|
||||||
|
bordercolor="#666666"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:guide-bbox="true"
|
||||||
|
inkscape:zoom="0.6"
|
||||||
|
inkscape:cx="400"
|
||||||
|
inkscape:cy="123.5"
|
||||||
|
inkscape:current-layer="layer3"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:snap-bbox="true"
|
||||||
|
inkscape:bbox-nodes="true"
|
||||||
|
inkscape:snap-nodes="true"
|
||||||
|
inkscape:snap-grids="true"
|
||||||
|
inkscape:bbox-paths="false"
|
||||||
|
fit-margin-top="0"
|
||||||
|
fit-margin-left="0"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0"
|
||||||
|
units="px"
|
||||||
|
inkscape:snap-bbox-edge-midpoints="false"
|
||||||
|
inkscape:snap-bbox-midpoints="false"
|
||||||
|
inkscape:snap-global="true"
|
||||||
|
inkscape:snap-page="true"
|
||||||
|
inkscape:snap-smooth-nodes="true">
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs id="defs4">
|
||||||
|
<style id="style6" type="text/css">
|
||||||
|
|
||||||
|
<!-- General : «#» style for «id» ; «.» class style (for particular style in group)
|
||||||
|
DRAW styles : K_ : particular key style
|
||||||
|
TEXT styles : «text» : common style for all text | T_ : text style | _L : large ; _s : very small -->
|
||||||
|
|
||||||
|
<![CDATA[
|
||||||
|
|
||||||
|
text {
|
||||||
|
writing-mode: lr-tb;
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#T_others text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
text-align: start;
|
||||||
|
text-anchor: start;
|
||||||
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
|
}
|
||||||
|
#T_alterninsc text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
#T_alterninsc text.T_alt-left {
|
||||||
|
text-align: start;
|
||||||
|
text-anchor: start;
|
||||||
|
}
|
||||||
|
#T_alterninsc text.T_alt-right {
|
||||||
|
text-align: end;
|
||||||
|
text-anchor: end;
|
||||||
|
}
|
||||||
|
#T_stdletters text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke: none;
|
||||||
|
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);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke: none;
|
||||||
|
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);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke: none;
|
||||||
|
text-align: start;
|
||||||
|
text-anchor: start;
|
||||||
|
}
|
||||||
|
.T_size_L {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#GlyphKey rect,
|
||||||
|
#GlyphKey use {
|
||||||
|
fill: var(--sg-keyboard-key-main);
|
||||||
|
stroke: var(--sg-keyboard-key-stroke);
|
||||||
|
stroke-width: 0.85;
|
||||||
|
}
|
||||||
|
#ModifKey rect {
|
||||||
|
fill: var(--sg-keyboard-key-mod);
|
||||||
|
stroke: var(--sg-keyboard-key-stroke);
|
||||||
|
stroke-width: 0.85;
|
||||||
|
}
|
||||||
|
#OtherKey rect {
|
||||||
|
fill: var(--sg-keyboard-key-other);
|
||||||
|
stroke: var(--sg-keyboard-key-stroke);
|
||||||
|
stroke-width: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lucide: в покое как подписи tab/caps; нажатие — через injectHighlight */
|
||||||
|
.sg-lucide path,
|
||||||
|
.sg-lucide line,
|
||||||
|
.sg-lucide polyline,
|
||||||
|
.sg-lucide rect {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke-width: 1.55;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
]]>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<!-- ======== Keys ======== -->
|
||||||
|
<g inkscape:groupmode="layer" id="layer2" inkscape:label="Keys" style="display:inline" sodipodi:insensitive="true">
|
||||||
|
<g id="GlyphKey">
|
||||||
|
<!-- Num keys - Standard letters keys - Specials glyphs keys - Ponctuation keys -->
|
||||||
|
<rect id="K_kb2a" x="2" y="2" width="48" height="44" rx="6" ry="6" />
|
||||||
|
<use id="K_kb2b" xlink:href="#K_kb2a" transform="translate(54,0)"/>
|
||||||
|
<use id="K_kb2c" xlink:href="#K_kb2a" transform="translate(108,0)"/>
|
||||||
|
<use id="K_kb2d" xlink:href="#K_kb2a" transform="translate(162,0)"/>
|
||||||
|
<use id="K_kb2e" xlink:href="#K_kb2a" transform="translate(216,0)"/>
|
||||||
|
<use id="K_kb2f" xlink:href="#K_kb2a" transform="translate(270,0)"/>
|
||||||
|
<use id="K_kb2g" xlink:href="#K_kb2a" transform="translate(324,0)"/>
|
||||||
|
<use id="K_kb2h" xlink:href="#K_kb2a" transform="translate(378,0)"/>
|
||||||
|
<use id="K_kb2i" xlink:href="#K_kb2a" transform="translate(432,0)"/>
|
||||||
|
<use id="K_kb2j" xlink:href="#K_kb2a" transform="translate(486,0)"/>
|
||||||
|
<use id="K_kb2k" xlink:href="#K_kb2a" transform="translate(540,0)"/>
|
||||||
|
<use id="K_kb2l" xlink:href="#K_kb2a" transform="translate(594,0)"/>
|
||||||
|
<use id="K_kb2m" xlink:href="#K_kb2a" transform="translate(648,0)"/>
|
||||||
|
|
||||||
|
<use id="K_kb3b" xlink:href="#K_kb2a" transform="translate(80,50)"/>
|
||||||
|
<use id="K_kb3c" xlink:href="#K_kb2a" transform="translate(134,50)"/>
|
||||||
|
<use id="K_kb3d" xlink:href="#K_kb2a" transform="translate(188,50)"/>
|
||||||
|
<use id="K_kb3e" xlink:href="#K_kb2a" transform="translate(242,50)"/>
|
||||||
|
<use id="K_kb3f" xlink:href="#K_kb2a" transform="translate(296,50)"/>
|
||||||
|
<use id="K_kb3g" xlink:href="#K_kb2a" transform="translate(350,50)"/>
|
||||||
|
<use id="K_kb3h" xlink:href="#K_kb2a" transform="translate(404,50)"/>
|
||||||
|
<use id="K_kb3i" xlink:href="#K_kb2a" transform="translate(458,50)"/>
|
||||||
|
<use id="K_kb3j" xlink:href="#K_kb2a" transform="translate(512,50)"/>
|
||||||
|
<use id="K_kb3k" xlink:href="#K_kb2a" transform="translate(566,50)"/>
|
||||||
|
<use id="K_kb3l" xlink:href="#K_kb2a" transform="translate(620,50)"/>
|
||||||
|
<use id="K_kb3m" xlink:href="#K_kb2a" transform="translate(674,50)"/>
|
||||||
|
<rect id="K_kb3n" x="730" y="52" width="70" height="44" rx="6" ry="6" />
|
||||||
|
|
||||||
|
<use id="K_kb4b" xlink:href="#K_kb2a" transform="translate(96,100)"/>
|
||||||
|
<use id="K_kb4c" xlink:href="#K_kb2a" transform="translate(150,100)"/>
|
||||||
|
<use id="K_kb4d" xlink:href="#K_kb2a" transform="translate(204,100)"/>
|
||||||
|
<use id="K_kb4e" xlink:href="#K_kb2a" transform="translate(258,100)"/>
|
||||||
|
<use id="K_kb4f" xlink:href="#K_kb2a" transform="translate(312,100)"/>
|
||||||
|
<use id="K_kb4g" xlink:href="#K_kb2a" transform="translate(366,100)"/>
|
||||||
|
<use id="K_kb4h" xlink:href="#K_kb2a" transform="translate(420,100)"/>
|
||||||
|
<use id="K_kb4i" xlink:href="#K_kb2a" transform="translate(474,100)"/>
|
||||||
|
<use id="K_kb4j" xlink:href="#K_kb2a" transform="translate(528,100)"/>
|
||||||
|
<use id="K_kb4k" xlink:href="#K_kb2a" transform="translate(582,100)"/>
|
||||||
|
<use id="K_kb4l" xlink:href="#K_kb2a" transform="translate(636,100)"/>
|
||||||
|
<use id="K_kb4m" xlink:href="#K_kb2a" transform="translate(690,100)"/>
|
||||||
|
|
||||||
|
<use id="K_kb5c" xlink:href="#K_kb2a" transform="translate(116,150)"/>
|
||||||
|
<use id="K_kb5d" xlink:href="#K_kb2a" transform="translate(170,150)"/>
|
||||||
|
<use id="K_kb5e" xlink:href="#K_kb2a" transform="translate(224,150)"/>
|
||||||
|
<use id="K_kb5f" xlink:href="#K_kb2a" transform="translate(278,150)"/>
|
||||||
|
<use id="K_kb5g" xlink:href="#K_kb2a" transform="translate(332,150)"/>
|
||||||
|
<use id="K_kb5h" xlink:href="#K_kb2a" transform="translate(386,150)"/>
|
||||||
|
<use id="K_kb5i" xlink:href="#K_kb2a" transform="translate(440,150)"/>
|
||||||
|
<use id="K_kb5j" xlink:href="#K_kb2a" transform="translate(494,150)"/>
|
||||||
|
<use id="K_kb5k" xlink:href="#K_kb2a" transform="translate(548,150)"/>
|
||||||
|
<use id="K_kb5l" xlink:href="#K_kb2a" transform="translate(602,150)"/>
|
||||||
|
|
||||||
|
<rect id="K_kb6d" x="188" y="202" width="364" height="44" rx="6" ry="6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Modification keys -->
|
||||||
|
<g id="ModifKey">
|
||||||
|
<rect id="K_kb4a" x="2" y="102" width="90" height="44" rx="6" ry="6" />
|
||||||
|
<rect id="K_kb5a" x="2" y="152" width="110" height="44" rx="6" ry="6" />
|
||||||
|
<rect id="K_kb5m" x="658" y="152" width="142" height="44" rx="6" ry="6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Other keys -->
|
||||||
|
<g id="OtherKey">
|
||||||
|
<!-- Backspace --> <rect id="K_kb2n" x="704" y="2" width="96" height="44" rx="6" ry="6" />
|
||||||
|
|
||||||
|
<!-- Tabulation --> <rect id="K_kb3a" x="2" y="52" width="74" height="44" rx="6" ry="6" />
|
||||||
|
|
||||||
|
<!-- Enter key -->
|
||||||
|
<rect id="K_kb4n" x="692" y="102" width="107" height="44" rx="6" ry="6" />
|
||||||
|
|
||||||
|
<!-- Ctrl 1 --> <rect id="K_kb6a" x="2" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
<!-- Win 1 --> <rect id="K_kb6b" x="64" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
<!-- Alt 1 --> <rect id="K_kb6c" x="126" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
<!-- Alt 2 --> <rect id="K_kb6k" x="558" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
<!-- Win 2 --> <rect id="K_kb6l" x="620" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
<!-- Menu --> <rect id="K_kb6m" x="682" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
<!-- Ctrl 2 --> <rect id="K_kb6n" x="744" y="202" width="56" height="44" rx="6" ry="6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<!-- ======== Key signs ======== -->
|
||||||
|
<g inkscape:groupmode="layer" id="DrawedInscr" inkscape:label="DrawedInscr" style="display:inline">
|
||||||
|
|
||||||
|
<!-- Meta / ⌘ — Lucide «command» -->
|
||||||
|
<g id="S_kb6b" class="sg-lucide" transform="translate(82,214) scale(0.8333333333)">
|
||||||
|
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
||||||
|
</g>
|
||||||
|
<use id="S_kb6l" xlink:href="#S_kb6b" transform="translate(556,0)" x="0" y="0" width="1" height="1" />
|
||||||
|
<!-- Menu — Lucide «menu» -->
|
||||||
|
<g id="S_kb6m" class="sg-lucide" transform="translate(700,214) scale(0.8333333333)">
|
||||||
|
<line x1="4" x2="20" y1="12" y2="12" />
|
||||||
|
<line x1="4" x2="20" y1="6" y2="6" />
|
||||||
|
<line x1="4" x2="20" y1="18" y2="18" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<!-- ======== Inscriptions layer ======== -->
|
||||||
|
<g inkscape:groupmode="layer" id="en-us" inkscape:label="incriptions" sodipodi:insensitive="true" style="display:inline">
|
||||||
|
|
||||||
|
<g id="T_stdletters">
|
||||||
|
|
||||||
|
<text id="T_kb3b2" x="96" y="72" >Q</text>
|
||||||
|
<text id="T_kb3c2" x="150" y="72" >W</text>
|
||||||
|
<text id="T_kb3d2" x="204" y="72" >E</text>
|
||||||
|
<text id="T_kb3e2" x="258" y="72" >R</text>
|
||||||
|
<text id="T_kb3f2" x="312" y="72" >T</text>
|
||||||
|
<text id="T_kb3g2" x="366" y="72" >Y</text>
|
||||||
|
<text id="T_kb3h2" x="420" y="72" >U</text>
|
||||||
|
<text id="T_kb3i2" x="474" y="72" >I</text>
|
||||||
|
<text id="T_kb3j2" x="528" y="72" >O</text>
|
||||||
|
<text id="T_kb3k2" x="582" y="72" >P</text>
|
||||||
|
|
||||||
|
<text id="T_kb4c2" x="112" y="122" >A</text>
|
||||||
|
<text id="T_kb4d2" x="166" y="122" >S</text>
|
||||||
|
<text id="T_kb4e2" x="220" y="122" >D</text>
|
||||||
|
<text id="T_kb4f2" x="274" y="122" >F</text>
|
||||||
|
<text id="T_kb4g2" x="328" y="122" >G</text>
|
||||||
|
<text id="T_kb4h2" x="382" y="122" >H</text>
|
||||||
|
<text id="T_kb4i2" x="436" y="122" >J</text>
|
||||||
|
<text id="T_kb4j2" x="490" y="122" >K</text>
|
||||||
|
<text id="T_kb4k2" x="544" y="122" >L</text>
|
||||||
|
|
||||||
|
<text id="T_kb5c2" x="132" y="172" >Z</text>
|
||||||
|
<text id="T_kb5d2" x="186" y="172" >X</text>
|
||||||
|
<text id="T_kb5e2" x="240" y="172" >C</text>
|
||||||
|
<text id="T_kb5f2" x="294" y="172" >V</text>
|
||||||
|
<text id="T_kb5g2" x="348" y="172" >B</text>
|
||||||
|
<text id="T_kb5h2" x="402" y="172" >N</text>
|
||||||
|
<text id="T_kb5i2" x="454" y="172" >M</text>
|
||||||
|
|
||||||
|
<!-- Russian layout: bottom of key, opposite (right) edge -->
|
||||||
|
<text id="T_kb3b_ru" class="T_ru" x="126" y="92" >й</text>
|
||||||
|
<text id="T_kb3c_ru" class="T_ru" x="180" y="92" >ц</text>
|
||||||
|
<text id="T_kb3d_ru" class="T_ru" x="234" y="92" >у</text>
|
||||||
|
<text id="T_kb3e_ru" class="T_ru" x="288" y="92" >к</text>
|
||||||
|
<text id="T_kb3f_ru" class="T_ru" x="342" y="92" >е</text>
|
||||||
|
<text id="T_kb3g_ru" class="T_ru" x="396" y="92" >н</text>
|
||||||
|
<text id="T_kb3h_ru" class="T_ru" x="450" y="92" >г</text>
|
||||||
|
<text id="T_kb3i_ru" class="T_ru" x="504" y="92" >ш</text>
|
||||||
|
<text id="T_kb3j_ru" class="T_ru" x="558" y="92" >щ</text>
|
||||||
|
<text id="T_kb3k_ru" class="T_ru" x="612" y="92" >з</text>
|
||||||
|
|
||||||
|
<text id="T_kb4c_ru" class="T_ru" x="196" y="142" >ф</text>
|
||||||
|
<text id="T_kb4d_ru" class="T_ru" x="250" y="142" >ы</text>
|
||||||
|
<text id="T_kb4e_ru" class="T_ru" x="304" y="142" >в</text>
|
||||||
|
<text id="T_kb4f_ru" class="T_ru" x="358" y="142" >а</text>
|
||||||
|
<text id="T_kb4g_ru" class="T_ru" x="412" y="142" >п</text>
|
||||||
|
<text id="T_kb4h_ru" class="T_ru" x="466" y="142" >р</text>
|
||||||
|
<text id="T_kb4i_ru" class="T_ru" x="520" y="142" >о</text>
|
||||||
|
<text id="T_kb4j_ru" class="T_ru" x="574" y="142" >л</text>
|
||||||
|
<text id="T_kb4k_ru" class="T_ru" x="628" y="142" >д</text>
|
||||||
|
|
||||||
|
<text id="T_kb5c_ru" class="T_ru" x="162" y="192" >я</text>
|
||||||
|
<text id="T_kb5d_ru" class="T_ru" x="216" y="192" >ч</text>
|
||||||
|
<text id="T_kb5e_ru" class="T_ru" x="270" y="192" >с</text>
|
||||||
|
<text id="T_kb5f_ru" class="T_ru" x="324" y="192" >м</text>
|
||||||
|
<text id="T_kb5g_ru" class="T_ru" x="378" y="192" >и</text>
|
||||||
|
<text id="T_kb5h_ru" class="T_ru" x="432" y="192" >т</text>
|
||||||
|
<text id="T_kb5i_ru" class="T_ru" x="486" y="192" >ь</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="T_stdspecial">
|
||||||
|
|
||||||
|
<text id="T_kb2a1" x="14" y="42" class="T_size_L">`</text>
|
||||||
|
<text id="T_kb2b1" x="68" y="42" >1</text>
|
||||||
|
<text id="T_kb2c1" x="122" y="42" >2</text>
|
||||||
|
<text id="T_kb2d1" x="176" y="42" >3</text>
|
||||||
|
<text id="T_kb2e1" x="230" y="42" >4</text>
|
||||||
|
<text id="T_kb2f1" x="284" y="42" >5</text>
|
||||||
|
<text id="T_kb2g1" x="338" y="42" >6</text>
|
||||||
|
<text id="T_kb2h1" x="392" y="42" >7</text>
|
||||||
|
<text id="T_kb2i1" x="446" y="42" >8</text>
|
||||||
|
<text id="T_kb2j1" x="500" y="42" >9</text>
|
||||||
|
<text id="T_kb2k1" x="554" y="42" >0</text>
|
||||||
|
<text id="T_kb2l1" x="610" y="42" >-</text>
|
||||||
|
<text id="T_kb2m1" x="662" y="42" >=</text>
|
||||||
|
|
||||||
|
<text id="T_kb3l1" x="636" y="90" >[</text>
|
||||||
|
<text id="T_kb3m1" x="690" y="90" >]</text>
|
||||||
|
<text id="T_kb3n1" x="744" y="90" >\</text>
|
||||||
|
|
||||||
|
<text id="T_kb4l1" x="598" y="142" >;</text>
|
||||||
|
<text id="T_kb4m1" x="652" y="142" class="T_size_L">'</text>
|
||||||
|
|
||||||
|
<text id="T_kb5j1" x="508" y="190" >,</text>
|
||||||
|
<text id="T_kb5k1" x="562" y="192" >.</text>
|
||||||
|
<text id="T_kb5l1" x="616" y="192" >/</text>
|
||||||
|
|
||||||
|
<!-- Shift level -->
|
||||||
|
|
||||||
|
<text id="T_kb2a2" x="14" y="18" class="T_size_L">~</text>
|
||||||
|
<text id="T_kb2b2" x="68" y="20" >!</text>
|
||||||
|
<text id="T_kb2c2" x="122" y="20" >@</text>
|
||||||
|
<text id="T_kb2d2" x="176" y="20" >#</text>
|
||||||
|
<text id="T_kb2e2" x="230" y="20" >$</text>
|
||||||
|
<text id="T_kb2f2" x="284" y="20" >%</text>
|
||||||
|
<text id="T_kb2g2" x="338" y="22" class="T_size_L">^</text>
|
||||||
|
<text id="T_kb2h2" x="392" y="20" >&</text>
|
||||||
|
<text id="T_kb2i2" x="446" y="20" >∗</text>
|
||||||
|
<text id="T_kb2j2" x="500" y="20" >(</text>
|
||||||
|
<text id="T_kb2k2" x="554" y="20" >)</text>
|
||||||
|
<text id="T_kb2l2" x="608" y="20" >_</text>
|
||||||
|
<text id="T_kb2m2" x="662" y="20" >+</text>
|
||||||
|
|
||||||
|
<text id="T_kb3l2" x="636" y="68" >{</text>
|
||||||
|
<text id="T_kb3m2" x="690" y="68" >}</text>
|
||||||
|
<text id="T_kb3n2" x="744" y="68" >|</text>
|
||||||
|
|
||||||
|
<text id="T_kb4l2" x="598" y="122" >:</text>
|
||||||
|
<text id="T_kb4m2" x="652" y="122" class="T_size_L">"</text>
|
||||||
|
|
||||||
|
<text id="T_kb5j2" x="508" y="170" ><</text>
|
||||||
|
<text id="T_kb5k2" x="562" y="170" >></text>
|
||||||
|
<text id="T_kb5l2" x="616" y="170" >?</text>
|
||||||
|
|
||||||
|
<!-- Russian layout on punctuation / number row (ЙЦУКЕН, Windows) -->
|
||||||
|
<text id="T_kb2a_ru" class="T_ru" x="46" y="42" >ё</text>
|
||||||
|
<text id="T_kb2b_ru" class="T_ru" x="100" y="42" >!</text>
|
||||||
|
<text id="T_kb2c_ru" class="T_ru" x="152" y="42" >"</text>
|
||||||
|
<text id="T_kb2d_ru" class="T_ru" x="206" y="42" >№</text>
|
||||||
|
<text id="T_kb2e_ru" class="T_ru" x="260" y="42" >;</text>
|
||||||
|
<text id="T_kb2f_ru" class="T_ru" x="314" y="42" >%</text>
|
||||||
|
<text id="T_kb2g_ru" class="T_ru" x="368" y="42" >:</text>
|
||||||
|
<text id="T_kb2h_ru" class="T_ru" x="422" y="42" >?</text>
|
||||||
|
<text id="T_kb2i_ru" class="T_ru" x="476" y="42" >*</text>
|
||||||
|
<text id="T_kb2j_ru" class="T_ru" x="530" y="42" >(</text>
|
||||||
|
<text id="T_kb2k_ru" class="T_ru" x="584" y="42" >)</text>
|
||||||
|
<text id="T_kb2l_ru" class="T_ru" x="638" y="42" >-</text>
|
||||||
|
<text id="T_kb2m_ru" class="T_ru" x="692" y="42" >+</text>
|
||||||
|
|
||||||
|
<text id="T_kb3l_ru" class="T_ru" x="666" y="92" >х</text>
|
||||||
|
<text id="T_kb3m_ru" class="T_ru" x="720" y="92" >ъ</text>
|
||||||
|
<text id="T_kb3n_ru" class="T_ru" x="796" y="92" >/</text>
|
||||||
|
|
||||||
|
<text id="T_kb4l_ru" class="T_ru" x="682" y="142" >ж</text>
|
||||||
|
<text id="T_kb4m_ru" class="T_ru" x="736" y="142" >э</text>
|
||||||
|
|
||||||
|
<text id="T_kb5j_ru" class="T_ru" x="540" y="192" >б</text>
|
||||||
|
<text id="T_kb5k_ru" class="T_ru" x="594" y="192" >ю</text>
|
||||||
|
<text id="T_kb5l_ru" class="T_ru" x="648" y="192" >.</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="T_others">
|
||||||
|
<text id="T_kb6a" x="10" y="230" >ctrl</text>
|
||||||
|
<text id="T_kb6c" x="134" y="230" >alt</text>
|
||||||
|
<text id="T_kb6k" x="564" y="230" >alt</text>
|
||||||
|
<text id="T_kb6n" x="750" y="230" >ctrl</text>
|
||||||
|
</g>
|
||||||
|
<!-- ======== alternates inscriptions ======== -->
|
||||||
|
<g inkscape:groupmode="layer" id="sublayer4a" inkscape:label="alternative inscriptions" style="display:inline">
|
||||||
|
<g id="T_alterninsc" >
|
||||||
|
<!-- K_kb2n x=704 w=96 → правый край подписи ~790 -->
|
||||||
|
<text id="T_kb2n" class="T_alt-right" x="790" y="40" >delete</text>
|
||||||
|
<!-- K_kb3a x=2 w=74 -->
|
||||||
|
<text id="T_kb3a" class="T_alt-left" x="10" y="80" >tab</text>
|
||||||
|
<!-- K_kb4n x=692 w=107 -->
|
||||||
|
<text id="T_kb4n" class="T_alt-right" x="789" y="130">return</text>
|
||||||
|
<!-- K_kb4a x=2 w=90 -->
|
||||||
|
<text id="T_kb4a1" class="T_alt-left" x="10" y="128">caps lock</text>
|
||||||
|
<!-- K_kb5a x=2 w=110 -->
|
||||||
|
<text id="T_kb5a" class="T_alt-left" x="10" y="180">shift</text>
|
||||||
|
<!-- K_kb5m x=658 w=142 -->
|
||||||
|
<text id="T_kb5m" class="T_alt-right" x="790" y="180">shift</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 19 KiB |
19
public/svg/visual/mouse.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg width="707" height="1042" viewBox="0 0 707 1042" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css"><![CDATA[
|
||||||
|
text {
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke: none;
|
||||||
|
text-anchor: middle;
|
||||||
|
font-size: 44px;
|
||||||
|
}
|
||||||
|
]]></style>
|
||||||
|
</defs>
|
||||||
|
<path d="M346 80.0889C319.97 83.9545 300 106.395 300 133.5V295.5C300 322.605 319.97 345.045 346 348.91V1042C154.909 1042 4.48005e-06 887.091 0 696V346C0 154.909 154.909 0 346 0V80.0889ZM361 0C552.091 0 707 154.909 707 346V696C707 887.091 552.091 1042 361 1042V349.05C387.516 345.618 408 322.951 408 295.5V133.5C408 106.049 387.516 83.3814 361 79.9492V0Z" fill="#D9D9D9"/>
|
||||||
|
<rect x="312" y="94" width="84" height="242" rx="42" fill="#D9D9D9"/>
|
||||||
|
<text x="210" y="210">ЛКМ</text>
|
||||||
|
<text x="500" y="210">ПКМ</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 947 B |
45
scripts/sync-env.cjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Генерирует `src/environments/environment.ts` из переменных окружения (.env).
|
||||||
|
* Значения по умолчанию совпадают с .env.example.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
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',
|
||||||
|
SG_DEFAULT_PAGE_LIMIT: '10',
|
||||||
|
};
|
||||||
|
|
||||||
|
function val(key) {
|
||||||
|
const v = process.env[key];
|
||||||
|
if (v !== undefined && v !== '') {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
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'))},
|
||||||
|
defaultPageLimit: ${JSON.stringify(intVal('SG_DEFAULT_PAGE_LIMIT'))},
|
||||||
|
} as const;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const outPath = path.join(__dirname, '..', 'src', 'environments', 'environment.ts');
|
||||||
|
fs.writeFileSync(outPath, content, 'utf8');
|
||||||
|
console.log('env:sync →', outPath);
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideTaiga } from '@taiga-ui/core';
|
||||||
|
import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification';
|
||||||
|
|
||||||
|
import { apiBaseUrlInterceptor } from './core/http/api-base-url.interceptor';
|
||||||
|
import { devLogInterceptor } from './core/http/dev-log.interceptor';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes)
|
provideAnimations(),
|
||||||
]
|
provideRouter(routes),
|
||||||
|
provideTaiga(),
|
||||||
|
tuiNotificationOptionsProvider(() => ({
|
||||||
|
block: 'start',
|
||||||
|
inline: 'end',
|
||||||
|
})),
|
||||||
|
provideHttpClient(withInterceptors([apiBaseUrlInterceptor, devLogInterceptor])),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header {
|
||||||
|
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 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: clamp(1.15rem, 2vw, 1.4rem);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.015em;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-sub {
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
color: var(--sg-color-subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--sg-color-subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav-link:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|||||||
358
src/app/app.html
@@ -1,344 +1,22 @@
|
|||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<tui-root>
|
||||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
<div class="shell">
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
<header class="shell-header">
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
<div class="shell-header__inner sg-content-column">
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<a routerLink="/" class="brand">
|
||||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
|
||||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
GUARD
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
|
||||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
|
||||||
--french-violet: oklch(47.66% 0.246 305.88);
|
|
||||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
|
||||||
--hot-red: oklch(61.42% 0.238 15.34);
|
|
||||||
--orange-red: oklch(63.32% 0.24 31.68);
|
|
||||||
|
|
||||||
--gray-900: oklch(19.37% 0.006 300.98);
|
|
||||||
--gray-700: oklch(36.98% 0.014 302.71);
|
|
||||||
--gray-400: oklch(70.9% 0.015 304.04);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
box-sizing: border-box;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
display: block;
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.125rem;
|
|
||||||
color: var(--gray-900);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 100%;
|
|
||||||
letter-spacing: -0.125rem;
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
box-sizing: inherit;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.angular-logo {
|
|
||||||
max-width: 9.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700px;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1 {
|
|
||||||
margin-top: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
|
||||||
margin-inline: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
|
||||||
color: var(--pill-accent);
|
|
||||||
padding-inline: 0.75rem;
|
|
||||||
padding-block: 0.375rem;
|
|
||||||
border-radius: 2.75rem;
|
|
||||||
border: 0;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
font-family: var(--inter-font);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4rem;
|
|
||||||
letter-spacing: -0.00875rem;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill:hover {
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 1) {
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 2) {
|
|
||||||
--pill-accent: var(--electric-violet);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 3) {
|
|
||||||
--pill-accent: var(--french-violet);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 4),
|
|
||||||
.pill-group .pill:nth-child(6n + 5),
|
|
||||||
.pill-group .pill:nth-child(6n + 6) {
|
|
||||||
--pill-accent: var(--hot-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group svg {
|
|
||||||
margin-inline-start: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.73rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links path {
|
|
||||||
transition: fill 0.3s ease;
|
|
||||||
fill: var(--gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links a:hover svg path {
|
|
||||||
fill: var(--gray-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.content {
|
|
||||||
flex-direction: column;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
|
||||||
margin-block: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<div class="content">
|
|
||||||
<div class="left-side">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 982 239"
|
|
||||||
fill="none"
|
|
||||||
class="angular-logo"
|
|
||||||
>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path
|
|
||||||
fill="url(#b)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="url(#c)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<radialGradient
|
|
||||||
id="c"
|
|
||||||
cx="0"
|
|
||||||
cy="0"
|
|
||||||
r="1"
|
|
||||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#FF41F8" />
|
|
||||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
|
||||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="b"
|
|
||||||
x1="0"
|
|
||||||
x2="982"
|
|
||||||
y1="192"
|
|
||||||
y2="192"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#F0060B" />
|
|
||||||
<stop offset="0" stop-color="#F0070C" />
|
|
||||||
<stop offset=".526" stop-color="#CC26D5" />
|
|
||||||
<stop offset="1" stop-color="#7702FF" />
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
<h1>Hello, {{ title() }}</h1>
|
|
||||||
<p>Congratulations! Your app is running. 🎉</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
|
||||||
<div class="right-side">
|
|
||||||
<div class="pill-group">
|
|
||||||
@for (item of [
|
|
||||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
|
||||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
|
||||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
|
||||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
|
||||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
|
||||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
|
||||||
]; track item.title) {
|
|
||||||
<a
|
|
||||||
class="pill"
|
|
||||||
[href]="item.link"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
width="14"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="social-links">
|
|
||||||
<a
|
|
||||||
href="https://github.com/angular/angular"
|
|
||||||
aria-label="Github"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="25"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 25 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Github"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://x.com/angular"
|
|
||||||
aria-label="X"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="X"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
|
||||||
aria-label="Youtube"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="29"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 29 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Youtube"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
</a>
|
||||||
|
<div class="shell-nav">
|
||||||
|
<a routerLink="/" class="shell-sub shell-nav-link">Главная</a>
|
||||||
|
<a routerLink="/sessions" class="shell-sub shell-nav-link">Прокторинг</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</main>
|
<main class="shell-main">
|
||||||
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
|
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
@if (isDev) {
|
||||||
|
<app-dev-console />
|
||||||
|
}
|
||||||
|
</tui-root>
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/landing/landing.component').then((m) => m.LandingComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sessions',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sessions/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/sessions/session-detail/session-detail.component').then(
|
||||||
|
(m) => m.SessionDetailComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: '' },
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => void 0,
|
||||||
|
removeListener: () => void 0,
|
||||||
|
addEventListener: () => void 0,
|
||||||
|
removeEventListener: () => void 0,
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: [...appConfig.providers],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -14,10 +30,10 @@ describe('App', () => {
|
|||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render title', async () => {
|
it('should show app name in header', async () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, sparkguardian');
|
expect(compiled.querySelector('.brand')?.textContent).toContain('GUARD');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { TuiRoot } from '@taiga-ui/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { Component, isDevMode } from '@angular/core';
|
||||||
|
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||||
|
import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css'
|
styleUrl: './app.css',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly title = signal('sparkguardian');
|
protected readonly isDev = isDevMode();
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/app/core/config/api.tokens.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { inject, InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin для разрешения относительных `playlist_url` (HLS).
|
||||||
|
* При `ng serve` — origin dev-сервера; API проксируется (см. `proxy.conf.cjs`, `SG_DEV_PROXY_TARGET` в `.env`).
|
||||||
|
*/
|
||||||
|
export const API_ORIGIN = new InjectionToken<string>('API_ORIGIN', {
|
||||||
|
factory: () => {
|
||||||
|
const doc = inject(DOCUMENT);
|
||||||
|
const loc = doc.defaultView?.location;
|
||||||
|
if (!loc || loc.protocol === 'file:') {
|
||||||
|
return environment.apiFallbackOrigin;
|
||||||
|
}
|
||||||
|
return loc.origin;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовый путь API. Относительный путь работает в dev (прокси) и при деплое на тот же домен.
|
||||||
|
* Для `file://` — абсолютный URL из `environment`.
|
||||||
|
*/
|
||||||
|
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
|
||||||
|
factory: () => {
|
||||||
|
const doc = inject(DOCUMENT);
|
||||||
|
const loc = doc.defaultView?.location;
|
||||||
|
if (loc?.protocol === 'file:') {
|
||||||
|
return `${environment.apiFallbackOrigin}${environment.apiBasePath}`;
|
||||||
|
}
|
||||||
|
return environment.apiBasePath;
|
||||||
|
},
|
||||||
|
});
|
||||||
11
src/app/core/config/app.tokens.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Количество элементов на одной странице по умолчанию (например, при пагинации списков).
|
||||||
|
* Берется из переменной окружения SG_DEFAULT_PAGE_LIMIT (fallback: 10).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PAGE_LIMIT = new InjectionToken<number>('DEFAULT_PAGE_LIMIT', {
|
||||||
|
factory: () => environment.defaultPageLimit,
|
||||||
|
});
|
||||||
61
src/app/core/devtools/dev-log.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type DevLogLevel = 'info' | 'warn' | 'error';
|
||||||
|
export type DevLogStatus = 'pending' | 'ok' | 'error';
|
||||||
|
|
||||||
|
export interface DevHttpLogDetails {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
requestHeaders?: Record<string, string | null>;
|
||||||
|
requestBody?: unknown;
|
||||||
|
statusCode?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
responseHeaders?: Record<string, string | null>;
|
||||||
|
responseBody?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevTelemetryLogDetails {
|
||||||
|
rawEventJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevLogEntry {
|
||||||
|
id: number;
|
||||||
|
time: string;
|
||||||
|
level: DevLogLevel;
|
||||||
|
source: 'http' | 'system' | 'telemetry';
|
||||||
|
message: string;
|
||||||
|
status?: DevLogStatus;
|
||||||
|
details?: DevHttpLogDetails;
|
||||||
|
telemetryDetails?: DevTelemetryLogDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DevLogService {
|
||||||
|
private seq = 0;
|
||||||
|
private readonly max = 300;
|
||||||
|
readonly entries = signal<DevLogEntry[]>([]);
|
||||||
|
|
||||||
|
add(entry: Omit<DevLogEntry, 'id' | 'time'>): void {
|
||||||
|
const withMeta: DevLogEntry = {
|
||||||
|
id: ++this.seq,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
this.entries.update((curr) => [...curr.slice(-(this.max - 1)), withMeta]);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: number, patch: Partial<Omit<DevLogEntry, 'id' | 'time'>>): void {
|
||||||
|
this.entries.update((curr) =>
|
||||||
|
curr.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entries.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSource(source: DevLogEntry['source']): void {
|
||||||
|
this.entries.update((curr) => curr.filter((e) => e.source !== source));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/core/http/api-base-url.interceptor.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { API_BASE_URL } from '../config/api.tokens';
|
||||||
|
|
||||||
|
/** Префиксы путей, которые НЕ проксируются через API base URL (статические ассеты). */
|
||||||
|
const STATIC_ASSET_PREFIXES = ['/svg/', '/fonts/', '/images/'];
|
||||||
|
|
||||||
|
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const base = inject(API_BASE_URL);
|
||||||
|
if (/^https?:\/\//i.test(req.url)) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
const path = req.url.startsWith('/') ? req.url : `/${req.url}`;
|
||||||
|
if (STATIC_ASSET_PREFIXES.some((p) => path.startsWith(p))) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
return next(req.clone({ url: `${base}${path}` }));
|
||||||
|
};
|
||||||
117
src/app/core/http/dev-log.interceptor.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
HttpEvent,
|
||||||
|
HttpHeaders,
|
||||||
|
HttpInterceptorFn,
|
||||||
|
HttpResponse,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { inject, isDevMode } from '@angular/core';
|
||||||
|
import { tap } from 'rxjs';
|
||||||
|
|
||||||
|
import { httpErrorMessage } from './http-error.util';
|
||||||
|
import { DevLogService } from '../devtools/dev-log.service';
|
||||||
|
|
||||||
|
function headersToObject(headers: HttpHeaders): Record<string, string | null> {
|
||||||
|
const out: Record<string, string | null> = {};
|
||||||
|
for (const key of headers.keys()) {
|
||||||
|
out[key] = headers.get(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBody(value: unknown): unknown {
|
||||||
|
if (value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
return `[Blob ${value.type || 'unknown'} ${value.size} bytes]`;
|
||||||
|
}
|
||||||
|
if (value instanceof FormData) {
|
||||||
|
const fields: Record<string, unknown> = {};
|
||||||
|
value.forEach((v, k) => {
|
||||||
|
fields[k] = typeof v === 'string' ? v : `[File ${(v as File).name}]`;
|
||||||
|
});
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return `[ArrayBuffer ${value.byteLength} bytes]`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const devLogInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
if (!isDevMode()) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = inject(DevLogService);
|
||||||
|
const started = performance.now();
|
||||||
|
logs.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'http',
|
||||||
|
status: 'pending',
|
||||||
|
message: `→ ${req.method} ${req.urlWithParams} (pending)`,
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.urlWithParams,
|
||||||
|
requestHeaders: headersToObject(req.headers),
|
||||||
|
requestBody: normalizeBody(req.body),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const entryId = logs.entries().at(-1)?.id;
|
||||||
|
|
||||||
|
return next(req).pipe(
|
||||||
|
tap({
|
||||||
|
next: (event: HttpEvent<unknown>) => {
|
||||||
|
if (!(event instanceof HttpResponse)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ms = Math.round(performance.now() - started);
|
||||||
|
if (entryId) {
|
||||||
|
logs.update(entryId, {
|
||||||
|
level: 'info',
|
||||||
|
status: 'ok',
|
||||||
|
message: `✓ ${req.method} ${req.urlWithParams} [${event.status}] ${ms}ms`,
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.urlWithParams,
|
||||||
|
requestHeaders: headersToObject(req.headers),
|
||||||
|
requestBody: normalizeBody(req.body),
|
||||||
|
statusCode: event.status,
|
||||||
|
durationMs: ms,
|
||||||
|
responseHeaders: headersToObject(event.headers),
|
||||||
|
responseBody: normalizeBody(event.body),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
const ms = Math.round(performance.now() - started);
|
||||||
|
const status = error instanceof HttpErrorResponse ? error.status : undefined;
|
||||||
|
if (entryId) {
|
||||||
|
logs.update(entryId, {
|
||||||
|
level: 'error',
|
||||||
|
status: 'error',
|
||||||
|
message: `✕ ${req.method} ${req.urlWithParams}${status ? ` [${status}]` : ''} ${ms}ms: ${httpErrorMessage(error)}`,
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.urlWithParams,
|
||||||
|
requestHeaders: headersToObject(req.headers),
|
||||||
|
requestBody: normalizeBody(req.body),
|
||||||
|
statusCode: status,
|
||||||
|
durationMs: ms,
|
||||||
|
responseHeaders:
|
||||||
|
error instanceof HttpErrorResponse ? headersToObject(error.headers) : undefined,
|
||||||
|
responseBody:
|
||||||
|
error instanceof HttpErrorResponse ? normalizeBody(error.error) : undefined,
|
||||||
|
error: httpErrorMessage(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
64
src/app/core/http/error-classification.util.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { TimeoutError } from 'rxjs';
|
||||||
|
|
||||||
|
import type { UserErrorKind } from '../notifications/user-error-messages.config';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof HttpErrorResponse) {
|
||||||
|
if (isParseFailureMessage(err.message)) {
|
||||||
|
return 'parse_error';
|
||||||
|
}
|
||||||
|
const s = err.status;
|
||||||
|
if (s === 0) {
|
||||||
|
return 'network';
|
||||||
|
}
|
||||||
|
if (s === 408) {
|
||||||
|
return 'timeout';
|
||||||
|
}
|
||||||
|
if (s === 401) {
|
||||||
|
return 'unauthorized';
|
||||||
|
}
|
||||||
|
if (s === 403) {
|
||||||
|
return 'forbidden';
|
||||||
|
}
|
||||||
|
if (s === 404) {
|
||||||
|
return 'not_found';
|
||||||
|
}
|
||||||
|
if (s === 400) {
|
||||||
|
return 'bad_request';
|
||||||
|
}
|
||||||
|
if (s >= 500) {
|
||||||
|
return 'server_error';
|
||||||
|
}
|
||||||
|
if (s >= 400 && s < 500) {
|
||||||
|
return 'client_error';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const m = err.message;
|
||||||
|
if (isParseFailureMessage(m)) {
|
||||||
|
return 'parse_error';
|
||||||
|
}
|
||||||
|
if (/идентификатор|некорректн/i.test(m)) {
|
||||||
|
return 'invalid_input';
|
||||||
|
}
|
||||||
|
if (/timeout|timed out/i.test(m)) {
|
||||||
|
return 'timeout';
|
||||||
|
}
|
||||||
|
if (/network|failed to fetch|load failed|net::/i.test(m)) {
|
||||||
|
return 'network';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
55
src/app/core/http/http-error.util.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
|
||||||
|
import type { ApiErrorBody } from '../models/api.types';
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeHtmlError(text: string): string | null {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t.startsWith('<!') && !t.startsWith('<html')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pre = t.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
||||||
|
if (pre?.[1]) {
|
||||||
|
return pre[1].trim();
|
||||||
|
}
|
||||||
|
const title = t.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
|
if (title?.[1]) {
|
||||||
|
return title[1].trim();
|
||||||
|
}
|
||||||
|
return 'Сервер вернул HTML вместо JSON (часто редирект на вход или ошибка прокси).';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof HttpErrorResponse) {
|
||||||
|
const body = err.error as ApiErrorBody | string | null | undefined;
|
||||||
|
if (body && typeof body === 'object' && typeof body.error === 'string') {
|
||||||
|
return body.error;
|
||||||
|
}
|
||||||
|
if (typeof body === 'string' && body.length > 0) {
|
||||||
|
const fromHtml = summarizeHtmlError(body);
|
||||||
|
if (fromHtml) {
|
||||||
|
return fromHtml;
|
||||||
|
}
|
||||||
|
return body.length > 200 ? `${body.slice(0, 200)}…` : body;
|
||||||
|
}
|
||||||
|
if (err.message?.includes('Http failure during parsing')) {
|
||||||
|
return 'Ответ не JSON (часто HTML страницы входа или ошибка прокси /api). Перезапустите `npm start` и проверьте proxy.conf.cjs и `SG_DEV_PROXY_TARGET` в `.env`.';
|
||||||
|
}
|
||||||
|
return err.message || `HTTP ${err.status}`;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message.includes('Http failure during parsing')) {
|
||||||
|
return 'Ответ не JSON — проверьте авторизацию и прокси для API.';
|
||||||
|
}
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return 'Неизвестная ошибка';
|
||||||
|
}
|
||||||
137
src/app/core/keyboard/keyboard-key-name-map.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
|
function normalizeKeyToken(token: string): string {
|
||||||
|
return token.trim().toLowerCase().replace(/-/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenToSvgIds(token: string): string[] {
|
||||||
|
const t = normalizeKeyToken(token);
|
||||||
|
if (!t) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const named: Record<string, string[]> = {
|
||||||
|
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'],
|
||||||
|
backspace: ['K_kb2n'],
|
||||||
|
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]) {
|
||||||
|
return named[t]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = charKeyNameToSvgKeyId(t);
|
||||||
|
if (single) {
|
||||||
|
return [single];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyNameModifiersPayloadToSvgIds(o: Record<string, unknown>): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const add = (ids: string[]) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
if (id && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modifiers = typeof o['modifiers'] === 'string' ? o['modifiers'] : '';
|
||||||
|
const keyName = typeof o['key_name'] === 'string' ? o['key_name'] : '';
|
||||||
|
|
||||||
|
for (const part of modifiers.split('+').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
add(tokenToSvgIds(part));
|
||||||
|
}
|
||||||
|
if (keyName.trim()) {
|
||||||
|
add(tokenToSvgIds(keyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
131
src/app/core/keyboard/keyboard-payload.util.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { ParsedEvent } from '../models/api.types';
|
||||||
|
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||||
|
|
||||||
|
import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map';
|
||||||
|
import { type KeyboardVkScheme, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
|
export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
|
||||||
|
const t = (event.event_type ?? '').toLowerCase();
|
||||||
|
return t.includes('keyboard') || t.includes('key');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKeyboardVirtualKey(data: unknown): number | null {
|
||||||
|
if (data == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (data.length >= 1) {
|
||||||
|
const v = data[0];
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
const o = data as Record<string, unknown>;
|
||||||
|
for (const key of ['vk', 'virtualKey', 'VirtualKey', 'keyCode', 'KeyCode', 'wParam']) {
|
||||||
|
const v = o[key];
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
} catch {
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKeyboardAction(data: unknown): 'press' | 'release' | null {
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const action = o['action'];
|
||||||
|
if (typeof action === 'string') {
|
||||||
|
const normalized = action.toLowerCase();
|
||||||
|
if (normalized === 'press' || normalized === 'down' || normalized === 'key_down') {
|
||||||
|
return 'press';
|
||||||
|
}
|
||||||
|
if (normalized === 'release' || normalized === 'up' || normalized === 'key_up') {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof o['is_down'] === 'boolean') {
|
||||||
|
return o['is_down'] ? 'press' : 'release';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (typeof o['key_name'] === 'string' || typeof o['modifiers'] === 'string') {
|
||||||
|
const fromNames = keyNameModifiersPayloadToSvgIds(o);
|
||||||
|
if (fromNames.length > 0) {
|
||||||
|
return fromNames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vk = parseKeyboardVirtualKey(raw);
|
||||||
|
if (vk != null) {
|
||||||
|
const scheme = parseKeyboardVkScheme(raw);
|
||||||
|
const id = vkToKeyboardSvgKeyId(vk, scheme);
|
||||||
|
return id ? [id] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
209
src/app/core/keyboard/keyboard-svg-highlight.service.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { Observable, shareReplay } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клавиша с информацией о том, сколько мс прошло с момента нажатия.
|
||||||
|
* Используется для 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/...`.
|
||||||
|
* `apiBaseUrlInterceptor` пропускает пути с префиксом `/svg/` (STATIC_ASSET_PREFIXES).
|
||||||
|
*/
|
||||||
|
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 http = inject(HttpClient);
|
||||||
|
|
||||||
|
private readonly baseSvgCache = new Map<string, Observable<string>>();
|
||||||
|
|
||||||
|
private baseSvg$(path: string): Observable<string> {
|
||||||
|
let cached = this.baseSvgCache.get(path);
|
||||||
|
if (!cached) {
|
||||||
|
cached = this.http.get(path, { responseType: '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.
|
||||||
|
*
|
||||||
|
* SECURITY: bypassSecurityTrustHtml используется для SVG, загруженных из собственного public/.
|
||||||
|
* НЕ передавать пользовательский контент через этот метод — XSS-риск.
|
||||||
|
*/
|
||||||
|
svgWithKeyDiff(diff: KeyHighlightDiff, svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> {
|
||||||
|
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<SafeHtml> {
|
||||||
|
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<SafeHtml> {
|
||||||
|
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 = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
||||||
|
return svgText.replace(/<\/svg>/i, `${styleBlock}</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = this.validIds(keyIds);
|
||||||
|
if (valid.length === 0) {
|
||||||
|
return svgText;
|
||||||
|
}
|
||||||
|
const rules: string[] = [];
|
||||||
|
for (const id of valid) {
|
||||||
|
const suffix = id.slice(2);
|
||||||
|
if (animated) {
|
||||||
|
rules.push(
|
||||||
|
`#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;transform-box:fill-box;transform-origin:center;animation:sgInputHighlightPop var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;animation:sgInputHighlightFade var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;transform-box:fill-box;transform-origin:center;animation:sgInputHighlightFade var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rules.push(
|
||||||
|
`#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (animated) {
|
||||||
|
rules.push(
|
||||||
|
`@keyframes sgInputHighlightPop{0%{opacity:.45;transform:scale(var(--sg-input-highlight-from-scale));}100%{opacity:1;transform:scale(1);}}`,
|
||||||
|
);
|
||||||
|
rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`);
|
||||||
|
}
|
||||||
|
const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
||||||
|
return svgText.replace(/<\/svg>/i, `${styleBlock}</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
||||||
|
return svgText.replace(/<\/svg>/i, `${styleBlock}</svg>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/core/keyboard/keyboard-transcript.util.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||||
|
import {
|
||||||
|
parseKeyboardHighlightKeyIds,
|
||||||
|
parseKeyboardVirtualKey,
|
||||||
|
parseKeyboardVkScheme,
|
||||||
|
} from './keyboard-payload.util';
|
||||||
|
import { svgKeyboardKeyIdToUsUnshiftedChar, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
|
const MODIFIER_SVG_IDS = new Set<string>([
|
||||||
|
'K_kb5a',
|
||||||
|
'K_kb5m',
|
||||||
|
'K_kb6a',
|
||||||
|
'K_kb6n',
|
||||||
|
'K_kb6c',
|
||||||
|
'K_kb6k',
|
||||||
|
'K_kb6b',
|
||||||
|
'K_kb6l',
|
||||||
|
'K_kb4a',
|
||||||
|
'K_kb6m',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sliceLastCodepoint(s: string): string {
|
||||||
|
if (!s) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const chars = [...s];
|
||||||
|
chars.pop();
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Явный символ из payload агента (если есть).
|
||||||
|
*/
|
||||||
|
function parseExplicitTranscriptChar(data: unknown): string | null {
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const keys = ['char', 'character', 'unicode_char', 'UnicodeChar', 'key_char'] as const;
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = o[k];
|
||||||
|
if (typeof v === 'string' && v.length === 1) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = o['text'];
|
||||||
|
if (typeof text === 'string' && text.length === 1) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonPrintingKeyId(id: string): boolean {
|
||||||
|
return MODIFIER_SVG_IDS.has(id) || id.startsWith('K_kb7');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyKeyIdToBuffer(buffer: string, id: string, data: unknown): string {
|
||||||
|
if (isNonPrintingKeyId(id)) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (id === 'K_kb2n') {
|
||||||
|
return sliceLastCodepoint(buffer);
|
||||||
|
}
|
||||||
|
const fromId = svgKeyboardKeyIdToUsUnshiftedChar(id);
|
||||||
|
if (fromId) {
|
||||||
|
return buffer + fromId;
|
||||||
|
}
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
const vk = parseKeyboardVirtualKey(raw);
|
||||||
|
const scheme = parseKeyboardVkScheme(raw);
|
||||||
|
if (vk != null) {
|
||||||
|
const idVk = vkToKeyboardSvgKeyId(vk, scheme);
|
||||||
|
if (idVk === 'K_kb2n') {
|
||||||
|
return sliceLastCodepoint(buffer);
|
||||||
|
}
|
||||||
|
if (idVk && !isNonPrintingKeyId(idVk)) {
|
||||||
|
const ch = svgKeyboardKeyIdToUsUnshiftedChar(idVk);
|
||||||
|
if (ch) {
|
||||||
|
return buffer + ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одно событие нажатия → обновление буфера «набранного текста».
|
||||||
|
* Учитывает Tab (`\t`), Enter (`\n`), пробел, Backspace; буквы/цифры — US QWERTY без Shift
|
||||||
|
* или явный символ из payload.
|
||||||
|
*/
|
||||||
|
export function applyKeyboardPressToTranscriptBuffer(buffer: string, data: unknown): string {
|
||||||
|
const explicit = parseExplicitTranscriptChar(data);
|
||||||
|
if (explicit !== null) {
|
||||||
|
return buffer + explicit;
|
||||||
|
}
|
||||||
|
const ids = parseKeyboardHighlightKeyIds(data);
|
||||||
|
const nonMod = ids.filter((id) => !isNonPrintingKeyId(id));
|
||||||
|
if (nonMod.length === 0) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (nonMod.length === 1) {
|
||||||
|
return applyKeyIdToBuffer(buffer, nonMod[0]!, data);
|
||||||
|
}
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
const vk = parseKeyboardVirtualKey(raw);
|
||||||
|
const scheme = parseKeyboardVkScheme(raw);
|
||||||
|
if (vk != null) {
|
||||||
|
const idVk = vkToKeyboardSvgKeyId(vk, scheme);
|
||||||
|
if (idVk && !isNonPrintingKeyId(idVk)) {
|
||||||
|
return applyKeyIdToBuffer(buffer, idVk, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
88
src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts
Normal file
@@ -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<number, string> = {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
228
src/app/core/keyboard/vk-to-keyboard-svg-id.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
return vk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDigitMap(): Map<number, string> {
|
||||||
|
const ids = ['K_kb2k', 'K_kb2b', 'K_kb2c', 'K_kb2d', 'K_kb2e', 'K_kb2f', 'K_kb2g', 'K_kb2h', 'K_kb2i', 'K_kb2j'];
|
||||||
|
const m = new Map<number, string>();
|
||||||
|
for (let d = 0; d <= 9; d++) {
|
||||||
|
m.set(0x30 + d, ids[d]!);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIGIT_TO_ID = buildDigitMap();
|
||||||
|
|
||||||
|
const LETTER_TO_ID: Record<string, string> = {
|
||||||
|
A: 'K_kb4c',
|
||||||
|
B: 'K_kb5g',
|
||||||
|
C: 'K_kb5e',
|
||||||
|
D: 'K_kb4e',
|
||||||
|
E: 'K_kb3d',
|
||||||
|
F: 'K_kb4f',
|
||||||
|
G: 'K_kb4g',
|
||||||
|
H: 'K_kb4h',
|
||||||
|
I: 'K_kb3i',
|
||||||
|
J: 'K_kb4i',
|
||||||
|
K: 'K_kb4j',
|
||||||
|
L: 'K_kb4k',
|
||||||
|
M: 'K_kb5i',
|
||||||
|
N: 'K_kb5h',
|
||||||
|
O: 'K_kb3j',
|
||||||
|
P: 'K_kb3k',
|
||||||
|
Q: 'K_kb3b',
|
||||||
|
R: 'K_kb3e',
|
||||||
|
S: 'K_kb4d',
|
||||||
|
T: 'K_kb3f',
|
||||||
|
U: 'K_kb3h',
|
||||||
|
V: 'K_kb5f',
|
||||||
|
W: 'K_kb3c',
|
||||||
|
X: 'K_kb5d',
|
||||||
|
Y: 'K_kb3g',
|
||||||
|
Z: 'K_kb5c',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Символы пунктуации и shifted-варианты цифр → физическая клавиша SVG. */
|
||||||
|
const PUNCT_CHAR_TO_ID: Record<string, string> = {
|
||||||
|
// Ряд цифр (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<string, string> = {
|
||||||
|
ё: '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<number, string> = {
|
||||||
|
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',
|
||||||
|
0x14: 'K_kb4a',
|
||||||
|
0x20: 'K_kb6d',
|
||||||
|
0x5b: 'K_kb6b',
|
||||||
|
0x5c: 'K_kb6l',
|
||||||
|
0x5d: 'K_kb6m',
|
||||||
|
0xa0: 'K_kb5a',
|
||||||
|
0xa1: 'K_kb5m',
|
||||||
|
0xa2: 'K_kb6a',
|
||||||
|
0xa3: 'K_kb6n',
|
||||||
|
0xa4: 'K_kb6c',
|
||||||
|
0xa5: 'K_kb6k',
|
||||||
|
0xba: 'K_kb4l',
|
||||||
|
0xbb: 'K_kb2m',
|
||||||
|
0xbc: 'K_kb5j',
|
||||||
|
0xbd: 'K_kb2l',
|
||||||
|
0xbe: 'K_kb5k',
|
||||||
|
0xbf: 'K_kb5l',
|
||||||
|
0xc0: 'K_kb2a',
|
||||||
|
0xdb: 'K_kb3l',
|
||||||
|
0xdc: 'K_kb3n',
|
||||||
|
0xdd: 'K_kb3m',
|
||||||
|
0xde: 'K_kb4m',
|
||||||
|
};
|
||||||
|
|
||||||
|
function windowsVkToKeyboardSvgKeyId(vk: number): string | null {
|
||||||
|
const k = normalizeVirtualKey(vk);
|
||||||
|
const fromExtra = EXTRA_VK[k];
|
||||||
|
if (fromExtra) {
|
||||||
|
return fromExtra;
|
||||||
|
}
|
||||||
|
const digit = DIGIT_TO_ID.get(k);
|
||||||
|
if (digit) {
|
||||||
|
return digit;
|
||||||
|
}
|
||||||
|
if (k >= 0x41 && k <= 0x5a) {
|
||||||
|
const ch = String.fromCharCode(k);
|
||||||
|
return LETTER_TO_ID[ch] ?? 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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
return DIGIT_TO_ID.get(c.charCodeAt(0)) ?? null;
|
||||||
|
}
|
||||||
|
const ch = c.toUpperCase();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** US QWERTY без Shift по физическому id клавиши (эвристика для восстановления текста). */
|
||||||
|
const SVG_ID_TO_US_UNSHIFTED_CHAR: Record<string, string> = (() => {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
K_kb3a: '\t',
|
||||||
|
K_kb4n: '\n',
|
||||||
|
K_kb6d: ' ',
|
||||||
|
K_kb2a: '`',
|
||||||
|
K_kb2l: '-',
|
||||||
|
K_kb2m: '=',
|
||||||
|
K_kb3l: '[',
|
||||||
|
K_kb3m: ']',
|
||||||
|
K_kb3n: '\\',
|
||||||
|
K_kb4l: ';',
|
||||||
|
K_kb4m: "'",
|
||||||
|
K_kb5j: ',',
|
||||||
|
K_kb5k: '.',
|
||||||
|
K_kb5l: '/',
|
||||||
|
};
|
||||||
|
for (const [letter, id] of Object.entries(LETTER_TO_ID)) {
|
||||||
|
m[id] = letter.toLowerCase();
|
||||||
|
}
|
||||||
|
DIGIT_TO_ID.forEach((id, code) => {
|
||||||
|
m[id] = String.fromCharCode(code);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function svgKeyboardKeyIdToUsUnshiftedChar(id: string): string | null {
|
||||||
|
const c = SVG_ID_TO_US_UNSHIFTED_CHAR[id];
|
||||||
|
return c ?? null;
|
||||||
|
}
|
||||||
102
src/app/core/models/api.types.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/** OpenAPI: handler.ErrorResponse */
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
code?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionRequest {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionResponse {
|
||||||
|
id: number;
|
||||||
|
session_key: string;
|
||||||
|
status: string;
|
||||||
|
title: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
user_id?: string;
|
||||||
|
status: string;
|
||||||
|
started_at?: string;
|
||||||
|
ended_at?: string;
|
||||||
|
chunks_total?: number;
|
||||||
|
events_total?: number;
|
||||||
|
/** в списке сессий с бэка, не всегда в swagger */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionListResponse {
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamInfo {
|
||||||
|
stream_type: string;
|
||||||
|
chunk_count?: number;
|
||||||
|
duration_ms?: number;
|
||||||
|
playlist_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetailResponse {
|
||||||
|
session: SessionSummary;
|
||||||
|
streams: StreamInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryEvent {
|
||||||
|
id: number;
|
||||||
|
session_id: number;
|
||||||
|
user_id?: number;
|
||||||
|
event_type: string;
|
||||||
|
created_at: string;
|
||||||
|
payload?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedEvent {
|
||||||
|
event_type: string;
|
||||||
|
timestamp: number;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedEventsResponse {
|
||||||
|
session_id: number;
|
||||||
|
count: number;
|
||||||
|
events: ParsedEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamType = 'screen' | 'webcam';
|
||||||
|
|
||||||
|
export interface FingerprintHeartbeatPayload {
|
||||||
|
machine_id_hash: string;
|
||||||
|
cpu_model_hash?: string;
|
||||||
|
board_serial_hash?: string;
|
||||||
|
board_uuid_hash?: string;
|
||||||
|
primary_mac_hash?: string;
|
||||||
|
disk_serial_hash?: string;
|
||||||
|
boot_time_ms?: number;
|
||||||
|
uptime_ms?: number;
|
||||||
|
agent_pid?: number;
|
||||||
|
agent_uptime_ms?: number;
|
||||||
|
hostname: string;
|
||||||
|
username: string;
|
||||||
|
tz_offset_min?: number;
|
||||||
|
locale?: string;
|
||||||
|
screen_layout: string;
|
||||||
|
active_iface: string;
|
||||||
|
hypervisor_present: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FingerprintHeartbeat {
|
||||||
|
timestamp_ms: number;
|
||||||
|
payload: FingerprintHeartbeatPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FingerprintHeartbeatsResponse {
|
||||||
|
count: number;
|
||||||
|
session_id: number;
|
||||||
|
heartbeats: FingerprintHeartbeat[];
|
||||||
|
}
|
||||||
89
src/app/core/mouse/mouse-payload.util.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ParsedEvent } from '../models/api.types';
|
||||||
|
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||||
|
import { readNumericField } from '../../shared/utils/number.util';
|
||||||
|
|
||||||
|
export type MouseHighlightTarget = 'left' | 'middle' | 'right' | 'wheel';
|
||||||
|
|
||||||
|
export function isMouseTelemetryEvent(event: ParsedEvent): boolean {
|
||||||
|
const t = (event.event_type ?? '').toLowerCase();
|
||||||
|
return t.includes('mouse');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload распознаётся как перемещение курсора (координаты + action «move», без учёта регистра).
|
||||||
|
* Сводка телеметрии и фильтр «Исключить перемещения мыши» должны опираться на одну логику.
|
||||||
|
*/
|
||||||
|
export function isMouseMovePayload(o: Record<string, unknown>): boolean {
|
||||||
|
const rawAction = o['action'];
|
||||||
|
const action =
|
||||||
|
typeof rawAction === 'string' ? rawAction.trim().toLowerCase() : '';
|
||||||
|
if (action !== 'move') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return readNumericField(o, 'x') !== null && readNumericField(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<string, unknown>;
|
||||||
|
if (isMouseMovePayload(o)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const t = (event.event_type ?? '').toLowerCase();
|
||||||
|
if (t.includes('mouse') && t.includes('move') && readNumericField(o, 'x') !== null && readNumericField(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)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const action = typeof o['action'] === 'string' ? o['action'].toLowerCase() : '';
|
||||||
|
|
||||||
|
if (action.includes('wheel') || action.includes('scroll')) {
|
||||||
|
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 = readNumericField(o, 'button');
|
||||||
|
const isDown = o['is_down'];
|
||||||
|
if (button == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (isDown === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Primary scheme in backend telemetry is 1-based: 1=left, 2=right, 3=middle.
|
||||||
|
if (button === 1) {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
78
src/app/core/mouse/mouse-svg-highlight.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { Observable, shareReplay } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import type { MouseHighlightTarget } from './mouse-payload.util';
|
||||||
|
|
||||||
|
const MOUSE_SVG_PATH = '/svg/visual/mouse.svg';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MouseSvgHighlightService {
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
private readonly baseSvg$ = this.http.get(MOUSE_SVG_PATH, { responseType: 'text' }).pipe(
|
||||||
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: bypassSecurityTrustHtml используется для SVG, загруженных из собственного public/.
|
||||||
|
* НЕ передавать пользовательский контент через этот метод — XSS-риск.
|
||||||
|
*/
|
||||||
|
|
||||||
|
svgWithHighlight(targets: MouseHighlightTarget[] | null, animated = true): Observable<SafeHtml> {
|
||||||
|
return this.baseSvg$.pipe(
|
||||||
|
map((svg) => this.injectHighlight(svg, targets ?? [], animated)),
|
||||||
|
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private injectHighlight(svgText: string, targets: MouseHighlightTarget[], animated: boolean): string {
|
||||||
|
let normalized = this.ensureMouseIds(svgText);
|
||||||
|
const uniq = [...new Set(targets)];
|
||||||
|
const hasLeft = uniq.includes('left');
|
||||||
|
const hasRight = uniq.includes('right');
|
||||||
|
const hasMiddle = uniq.includes('middle') || uniq.includes('wheel');
|
||||||
|
const hasAny = hasLeft || hasRight || hasMiddle;
|
||||||
|
|
||||||
|
let surfaceFill = 'var(--sg-keyboard-key-surface-idle)';
|
||||||
|
if (hasLeft && hasRight) {
|
||||||
|
surfaceFill = 'var(--sg-keyboard-key-pressed-fill)';
|
||||||
|
} else if (hasLeft) {
|
||||||
|
surfaceFill = 'url(#sg-mouse-left-fill)';
|
||||||
|
} else if (hasRight) {
|
||||||
|
surfaceFill = 'url(#sg-mouse-right-fill)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wheelFill = hasMiddle
|
||||||
|
? 'var(--sg-keyboard-key-pressed-fill)'
|
||||||
|
: 'var(--sg-keyboard-key-surface-idle)';
|
||||||
|
const wheelOpacity = hasAny || hasMiddle ? '1' : '0.98';
|
||||||
|
const surfaceAnim = animated && hasAny
|
||||||
|
? 'sgInputHighlightFill var(--sg-input-highlight-duration) var(--sg-input-highlight-easing)'
|
||||||
|
: 'none';
|
||||||
|
const wheelAnim = animated && hasMiddle
|
||||||
|
? 'sgInputHighlightPop var(--sg-input-highlight-duration) var(--sg-input-highlight-easing)'
|
||||||
|
: 'none';
|
||||||
|
const keyframes = animated
|
||||||
|
? `@keyframes sgInputHighlightPop{0%{opacity:.45;transform:scale(var(--sg-input-highlight-from-scale));}100%{opacity:1;transform:scale(1);}}@keyframes sgInputHighlightFill{0%{opacity:.72;}100%{opacity:1;}}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const defsAndStyles = `<defs><linearGradient id="sg-mouse-left-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-surface-idle)"/></linearGradient><linearGradient id="sg-mouse-right-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-pressed-fill)"/></linearGradient></defs><style type="text/css"><![CDATA[#SG_MOUSE_SURFACE{fill:${surfaceFill}!important;animation:${surfaceAnim};}#SG_MOUSE_WHEEL{fill:${wheelFill}!important;opacity:${wheelOpacity};transform-box:fill-box;transform-origin:center;animation:${wheelAnim};}${keyframes}]]></style>`;
|
||||||
|
normalized = normalized.replace(/<\/svg>/i, `${defsAndStyles}</svg>`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMouseIds(svgText: string): string {
|
||||||
|
let next = svgText;
|
||||||
|
if (!next.includes('id="SG_MOUSE_SURFACE"')) {
|
||||||
|
next = next.replace(/<path\b(?![^>]*\bid=)/i, '<path id="SG_MOUSE_SURFACE"');
|
||||||
|
}
|
||||||
|
if (!next.includes('id="SG_MOUSE_WHEEL"')) {
|
||||||
|
next = next.replace(/<rect\b(?![^>]*\bid=)/i, '<rect id="SG_MOUSE_WHEEL"');
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/core/notifications/user-error-messages.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export const USER_ERROR_FRIENDLY_MESSAGES = {
|
||||||
|
network: 'Проверьте интернет-соединение.',
|
||||||
|
timeout: 'Запрос занял слишком много времени. Попробуйте позже.',
|
||||||
|
server_error: 'Уже работаем над этим.',
|
||||||
|
not_found: 'Не удалось найти запрашиваемые данные.',
|
||||||
|
unauthorized: 'Требуется вход в систему.',
|
||||||
|
forbidden: 'Недостаточно прав для этого действия.',
|
||||||
|
bad_request: 'Некорректный запрос. Попробуйте позже.',
|
||||||
|
client_error: 'Не удалось выполнить запрос. Попробуйте позже.',
|
||||||
|
parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.',
|
||||||
|
invalid_input: 'Проверьте введённые данные.',
|
||||||
|
unknown: 'Попробуйте позже.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserErrorKind = keyof typeof USER_ERROR_FRIENDLY_MESSAGES;
|
||||||
|
|
||||||
|
export function friendlyMessageForUserError(kind: UserErrorKind): string {
|
||||||
|
return USER_ERROR_FRIENDLY_MESSAGES[kind];
|
||||||
|
}
|
||||||
49
src/app/core/notifications/user-error-notify.service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { isDevMode, inject, Injectable } from '@angular/core';
|
||||||
|
import { TuiNotificationService } from '@taiga-ui/core/components/notification';
|
||||||
|
|
||||||
|
import { DevLogService } from '../devtools/dev-log.service';
|
||||||
|
import { classifyUserError } from '../http/error-classification.util';
|
||||||
|
import { escapeHtml, httpErrorMessage } from '../http/http-error.util';
|
||||||
|
import { friendlyMessageForUserError } from './user-error-messages.config';
|
||||||
|
|
||||||
|
const ERROR_TOAST_TITLE = 'Что-то пошло не так...';
|
||||||
|
|
||||||
|
@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);
|
||||||
|
const technical = httpErrorMessage(err);
|
||||||
|
|
||||||
|
if (isDevMode()) {
|
||||||
|
this.devLog.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'system',
|
||||||
|
message: `${source}: ${technical}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifications
|
||||||
|
.open(escapeHtml(userSubtitle), {
|
||||||
|
label: ERROR_TOAST_TITLE,
|
||||||
|
appearance: 'negative',
|
||||||
|
autoClose: 10000,
|
||||||
|
})
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/core/services/sessions-api.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { API_ORIGIN } from '../config/api.tokens';
|
||||||
|
import { DEFAULT_PAGE_LIMIT } from '../config/app.tokens';
|
||||||
|
import type {
|
||||||
|
CreateSessionRequest,
|
||||||
|
CreateSessionResponse,
|
||||||
|
FingerprintHeartbeatsResponse,
|
||||||
|
ParsedEventsResponse,
|
||||||
|
SessionDetailResponse,
|
||||||
|
SessionListResponse,
|
||||||
|
StreamType,
|
||||||
|
TelemetryEvent,
|
||||||
|
} from '../models/api.types';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SessionsApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly apiOrigin = inject(API_ORIGIN);
|
||||||
|
private readonly defaultLimit = inject(DEFAULT_PAGE_LIMIT);
|
||||||
|
|
||||||
|
listSessions(limit = this.defaultLimit, offset = 0): Observable<SessionListResponse> {
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('limit', String(limit))
|
||||||
|
.set('offset', String(offset));
|
||||||
|
return this.http.get<SessionListResponse>('/sessions', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(body: CreateSessionRequest): Observable<CreateSessionResponse> {
|
||||||
|
return this.http.post<CreateSessionResponse>('/sessions', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(id: number): Observable<SessionDetailResponse> {
|
||||||
|
return this.http.get<SessionDetailResponse>(`/sessions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTelemetry(sessionId: number): Observable<TelemetryEvent[]> {
|
||||||
|
return this.http.get<TelemetryEvent[]>(`/sessions/${sessionId}/telemetry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getParsedEvents(sessionId: number, from?: number, to?: number): Observable<ParsedEventsResponse> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (typeof from === 'number') {
|
||||||
|
params = params.set('from', String(from));
|
||||||
|
}
|
||||||
|
if (typeof to === 'number') {
|
||||||
|
params = params.set('to', String(to));
|
||||||
|
}
|
||||||
|
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getFingerprintFull(sessionId: number): Observable<unknown> {
|
||||||
|
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/full`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFingerprintSummary(sessionId: number): Observable<unknown> {
|
||||||
|
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFingerprintHeartbeats(sessionId: number, from?: number, to?: number, limit?: number): Observable<FingerprintHeartbeatsResponse> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (typeof from === 'number') params = params.set('from', String(from));
|
||||||
|
if (typeof to === 'number') params = params.set('to', String(to));
|
||||||
|
if (typeof limit === 'number') params = params.set('limit', String(limit));
|
||||||
|
|
||||||
|
return this.http.get<FingerprintHeartbeatsResponse>(`/sessions/${sessionId}/fingerprint/heartbeats`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePlaylistUrl(playlistUrl: string): string {
|
||||||
|
if (/^https?:\/\//i.test(playlistUrl)) {
|
||||||
|
return playlistUrl;
|
||||||
|
}
|
||||||
|
return new URL(playlistUrl, `${this.apiOrigin}/`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadChunk(
|
||||||
|
sessionId: number,
|
||||||
|
chunkIdx: number,
|
||||||
|
file: File,
|
||||||
|
streamType: StreamType = 'screen',
|
||||||
|
bearerToken: string,
|
||||||
|
): Observable<void> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('session_id', String(sessionId));
|
||||||
|
form.set('chunk_idx', String(chunkIdx));
|
||||||
|
form.set('stream_type', streamType);
|
||||||
|
form.set('file', file, file.name);
|
||||||
|
return this.http.post<void>('/upload', form, {
|
||||||
|
headers: { Authorization: `Bearer ${bearerToken}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/core/sessions/session-status-chip-classes.pipe.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
/** finished — без классов (дефолт Taiga). */
|
||||||
|
@Pipe({
|
||||||
|
name: 'sessionStatusChipClasses',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class SessionStatusChipClassesPipe implements PipeTransform {
|
||||||
|
transform(value: string | null | undefined): Record<string, boolean> {
|
||||||
|
const k = value?.trim().toLowerCase() ?? '';
|
||||||
|
if (k === '' || k === 'finished') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (k === 'active') {
|
||||||
|
return { 'status-chip--active': true };
|
||||||
|
}
|
||||||
|
if (k === 'pending') {
|
||||||
|
return { 'status-chip--pending': true };
|
||||||
|
}
|
||||||
|
return { 'status-chip--unknown': true };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/core/sessions/session-status-labels.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const SESSION_STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
active: 'Активная',
|
||||||
|
pending: 'Ожидается',
|
||||||
|
finished: 'Завершена',
|
||||||
|
paused: 'Приостановлена',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
};
|
||||||
33
src/app/core/sessions/session-status.pipe.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { isDevMode, Pipe, PipeTransform, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { DevLogService } from '../devtools/dev-log.service';
|
||||||
|
import { SESSION_STATUS_LABELS } from './session-status-labels.config';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'sessionStatus',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class SessionStatusPipe implements PipeTransform {
|
||||||
|
private readonly devLog = inject(DevLogService);
|
||||||
|
private readonly warnedUnknown = new Set<string>();
|
||||||
|
|
||||||
|
transform(value: string | null | undefined): string {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const key = value.trim();
|
||||||
|
const mapped = SESSION_STATUS_LABELS[key.toLowerCase()];
|
||||||
|
if (mapped !== undefined) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
if (isDevMode() && !this.warnedUnknown.has(key)) {
|
||||||
|
this.warnedUnknown.add(key);
|
||||||
|
this.devLog.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'system',
|
||||||
|
message: `Неизвестный статус сессии (нет подписи в session-status-labels.config): ${key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/core/sessions/telemetry-event-summary.config.ts
Normal file
@@ -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,
|
||||||
|
];
|
||||||
27
src/app/core/sessions/telemetry-event-summary.engine.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { TELEMETRY_SUMMARY_RULES } from './telemetry-event-summary.config';
|
||||||
|
import { fallbackCompactJson, unwrapJsonPayload } from './telemetry-event-summary.payload';
|
||||||
|
|
||||||
|
export function summarizeTelemetryData(data: unknown): string {
|
||||||
|
const raw = unwrapJsonPayload(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<string, unknown>;
|
||||||
|
for (const rule of TELEMETRY_SUMMARY_RULES) {
|
||||||
|
if (rule.match(o)) {
|
||||||
|
try {
|
||||||
|
return rule.summarize(o);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackCompactJson(o);
|
||||||
|
}
|
||||||
52
src/app/core/sessions/telemetry-event-summary.handlers.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { isMouseMovePayload } from '../mouse/mouse-payload.util';
|
||||||
|
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) => isMouseMovePayload(o),
|
||||||
|
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'],
|
||||||
|
),
|
||||||
|
};
|
||||||
13
src/app/core/sessions/telemetry-event-summary.payload.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||||
|
import { readNumericField } from '../../shared/utils/number.util';
|
||||||
|
|
||||||
|
export { unwrapJsonPayload, readNumericField };
|
||||||
|
|
||||||
|
export function fallbackCompactJson(o: Record<string, unknown>): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(o);
|
||||||
|
} catch {
|
||||||
|
return '[объект]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
5
src/app/core/sessions/telemetry-event-summary.types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface TelemetrySummaryRule {
|
||||||
|
readonly id: string;
|
||||||
|
readonly match: (o: Record<string, unknown>) => boolean;
|
||||||
|
readonly summarize: (o: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const TELEMETRY_EVENT_TYPE_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
keyboard: 'Клавиатура',
|
||||||
|
mouse: 'Мышь',
|
||||||
|
};
|
||||||
38
src/app/core/sessions/telemetry-event-type.pipe.ts
Normal file
@@ -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<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, isDevMode, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { DevLogService } from '../../../core/devtools/dev-log.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dev-console',
|
||||||
|
imports: [DatePipe],
|
||||||
|
templateUrl: './dev-console.html',
|
||||||
|
styleUrl: './dev-console.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class DevConsoleComponent {
|
||||||
|
private readonly logs = inject(DevLogService);
|
||||||
|
protected readonly isDev = isDevMode();
|
||||||
|
protected readonly collapsed = signal(false);
|
||||||
|
protected readonly minimized = signal(true);
|
||||||
|
protected readonly entries = this.logs.entries;
|
||||||
|
protected readonly count = computed(() => this.entries().length);
|
||||||
|
protected readonly expandedIds = signal<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!this.isDev || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
this.logs.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'system',
|
||||||
|
message: `${e.message} (${e.filename}:${e.lineno})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const reason =
|
||||||
|
typeof e.reason === 'string'
|
||||||
|
? e.reason
|
||||||
|
: e.reason instanceof Error
|
||||||
|
? e.reason.message
|
||||||
|
: 'Unhandled promise rejection';
|
||||||
|
this.logs.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'system',
|
||||||
|
message: reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleCollapsed(): void {
|
||||||
|
this.collapsed.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected minimize(): void {
|
||||||
|
this.minimized.set(true);
|
||||||
|
this.collapsed.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected restore(): void {
|
||||||
|
this.minimized.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clear(): void {
|
||||||
|
this.logs.clear();
|
||||||
|
this.expandedIds.set({});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleExpanded(id: number): void {
|
||||||
|
this.expandedIds.update((curr) => ({ ...curr, [id]: !curr[id] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isExpanded(id: number): boolean {
|
||||||
|
return !!this.expandedIds()[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pretty(value: unknown): string {
|
||||||
|
if (value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/app/features/devtools/dev-console/dev-console.css
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/*
|
||||||
|
* Dev Console: фиксированная панель для отладки (только isDevMode()).
|
||||||
|
* Цвета определены через CSS-переменные для единства с дизайн-системой.
|
||||||
|
* Компонент намеренно использует тёмную палитру, отличную от основного UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
/* Тёмная палитра dev-console */
|
||||||
|
--dc-bg: color-mix(in srgb, var(--sg-color-text) 96%, var(--sg-color-bg));
|
||||||
|
--dc-bg-header: color-mix(in srgb, var(--sg-color-text) 90%, var(--sg-color-bg));
|
||||||
|
--dc-fg: var(--sg-color-form-bg);
|
||||||
|
--dc-fg-muted: color-mix(in srgb, var(--dc-fg) 65%, transparent);
|
||||||
|
--dc-fg-secondary: color-mix(in srgb, var(--dc-fg) 80%, transparent);
|
||||||
|
--dc-border: rgb(255 255 255 / 8%);
|
||||||
|
--dc-border-btn: rgb(255 255 255 / 18%);
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console-mini {
|
||||||
|
border: 1px solid var(--tui-border-normal);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--dc-bg-header);
|
||||||
|
color: var(--dc-fg);
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console {
|
||||||
|
width: min(760px, calc(100vw - 2rem));
|
||||||
|
max-height: min(46vh, 380px);
|
||||||
|
border: 1px solid var(--tui-border-normal);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--dc-bg);
|
||||||
|
color: var(--dc-fg);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console_collapsed {
|
||||||
|
max-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
background: var(--dc-bg-header);
|
||||||
|
border-bottom: 1px solid var(--dc-border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__count {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--dc-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__header button {
|
||||||
|
border: 1px solid var(--dc-border-btn);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__list {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(min(46vh, 380px) - 2.5rem);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 6rem 3.2rem 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__entry[data-level='warn'] {
|
||||||
|
background: rgb(255 199 0 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__entry[data-level='error'] {
|
||||||
|
background: rgb(217 45 32 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__time {
|
||||||
|
color: var(--dc-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__source {
|
||||||
|
color: var(--dc-fg-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__message {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__expand {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border: 1px solid var(--dc-border-btn);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dc-fg-secondary);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__details {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__meta {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__details details {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--dc-fg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-console__details pre {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: rgb(255 255 255 / 4%);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
98
src/app/features/devtools/dev-console/dev-console.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
@if (isDev) {
|
||||||
|
@if (minimized()) {
|
||||||
|
<button type="button" class="dev-console-mini" (click)="restore()">
|
||||||
|
Dev ({{ count() }})
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<aside class="dev-console" [class.dev-console_collapsed]="collapsed()">
|
||||||
|
<header class="dev-console__header">
|
||||||
|
<strong>Dev Console</strong>
|
||||||
|
<span class="dev-console__count">{{ count() }}</span>
|
||||||
|
<button type="button" (click)="toggleCollapsed()">
|
||||||
|
{{ collapsed() ? 'Развернуть' : 'Свернуть' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" (click)="clear()">Очистить</button>
|
||||||
|
<button type="button" (click)="minimize()">Скрыть</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!collapsed()) {
|
||||||
|
<div class="dev-console__list">
|
||||||
|
@for (entry of entries(); track entry.id) {
|
||||||
|
<div class="dev-console__entry" [attr.data-level]="entry.level">
|
||||||
|
<span class="dev-console__time">
|
||||||
|
{{ entry.time | date: 'HH:mm:ss.SSS' }}
|
||||||
|
</span>
|
||||||
|
<span class="dev-console__source">{{ entry.source }}</span>
|
||||||
|
<div class="dev-console__main">
|
||||||
|
<span class="dev-console__message">{{ entry.message }}</span>
|
||||||
|
|
||||||
|
@if (entry.source === 'telemetry' && entry.telemetryDetails) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dev-console__expand"
|
||||||
|
(click)="toggleExpanded(entry.id)"
|
||||||
|
>
|
||||||
|
{{ isExpanded(entry.id) ? 'Скрыть JSON' : 'Показать JSON' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isExpanded(entry.id)) {
|
||||||
|
<div class="dev-console__details">
|
||||||
|
<details open>
|
||||||
|
<summary>Событие (как пришло с API)</summary>
|
||||||
|
<pre>{{ entry.telemetryDetails.rawEventJson }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (entry.source === 'http' && entry.details) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dev-console__expand"
|
||||||
|
(click)="toggleExpanded(entry.id)"
|
||||||
|
>
|
||||||
|
{{ isExpanded(entry.id) ? 'Скрыть детали' : 'Показать детали' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isExpanded(entry.id)) {
|
||||||
|
<div class="dev-console__details">
|
||||||
|
<div class="dev-console__meta">
|
||||||
|
<span><b>Метод:</b> {{ entry.details.method }}</span>
|
||||||
|
<span><b>Статус:</b> {{ entry.details.statusCode ?? 'ожидание' }}</span>
|
||||||
|
<span><b>Длительность:</b> {{ entry.details.durationMs ?? '—' }} мс</span>
|
||||||
|
</div>
|
||||||
|
<div class="dev-console__meta"><b>URL:</b> {{ entry.details.url }}</div>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>Заголовки запроса</summary>
|
||||||
|
<pre>{{ pretty(entry.details.requestHeaders) }}</pre>
|
||||||
|
</details>
|
||||||
|
<details open>
|
||||||
|
<summary>Тело запроса</summary>
|
||||||
|
<pre>{{ pretty(entry.details.requestBody) }}</pre>
|
||||||
|
</details>
|
||||||
|
<details open>
|
||||||
|
<summary>Заголовки ответа</summary>
|
||||||
|
<pre>{{ pretty(entry.details.responseHeaders) }}</pre>
|
||||||
|
</details>
|
||||||
|
<details open>
|
||||||
|
<summary>Тело ответа</summary>
|
||||||
|
<pre>{{ pretty(entry.details.responseBody) }}</pre>
|
||||||
|
</details>
|
||||||
|
@if (entry.details.error) {
|
||||||
|
<details open>
|
||||||
|
<summary>Ошибка</summary>
|
||||||
|
<pre>{{ entry.details.error }}</pre>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/features/landing/landing.component.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { TuiButton } from '@taiga-ui/core';
|
||||||
|
import { TuiAccordion } from '@taiga-ui/kit';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-landing',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, TuiButton, TuiAccordion],
|
||||||
|
templateUrl: './landing.html',
|
||||||
|
styleUrl: './landing.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class LandingComponent {}
|
||||||
372
src/app/features/landing/landing.css
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
.landing-page {
|
||||||
|
padding-block: 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-page__title {
|
||||||
|
font: var(--tui-typography-heading-h2);
|
||||||
|
margin: 0 0 3.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= HERO SECTION ================= */
|
||||||
|
.hero--vertical {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 3rem;
|
||||||
|
animation: fadeUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
font: var(--tui-typography-heading-h1);
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__description {
|
||||||
|
font: var(--tui-typography-body-l);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__btn {
|
||||||
|
background: var(--sg-color-accent) !important;
|
||||||
|
color: var(--sg-color-text) !important;
|
||||||
|
border-color: var(--sg-color-accent) !important;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0 2.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__visual {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= FEATURES SECTION ================= */
|
||||||
|
.features {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: var(--sg-color-form-bg, #e8edf1);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.4s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
background: var(--sg-filter-chip-bg-hover, #eaeff3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card__icon {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
color: var(--sg-color-placeholder);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card__icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.feature-card__title {
|
||||||
|
font: var(--tui-typography-heading-h4);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card__text {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= TABS SHOWCASE SECTION ================= */
|
||||||
|
.tabs-showcase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card {
|
||||||
|
background: var(--tui-background-base);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border: 1px solid var(--tui-border-normal);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card__number {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card__title {
|
||||||
|
font: var(--tui-typography-heading-h4);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card__text {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= STATS CARDS SECTION ================= */
|
||||||
|
.stats-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-cards__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item {
|
||||||
|
background: var(--sg-color-form-bg, #e8edf1);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item__number {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item__label {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= HOW IT WORKS SECTION ================= */
|
||||||
|
.how-it-works {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.how-it-works__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3rem;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:hover {
|
||||||
|
background: var(--sg-color-form-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__number {
|
||||||
|
font: var(--tui-typography-heading-h3);
|
||||||
|
background: var(--sg-color-accent);
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__title {
|
||||||
|
font: var(--tui-typography-heading-h4);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__text {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= FAQ SECTION ================= */
|
||||||
|
.faq {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__list {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
background: var(--tui-background-base);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove all Taiga UI internal borders from accordion */
|
||||||
|
:host ::ng-deep .faq__list [tuiAccordion] {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .faq__list tui-expand {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__button {
|
||||||
|
font: var(--tui-typography-body-l);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
border: none;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__button + .faq__button {
|
||||||
|
border-top: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__content {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= FOOTER ================= */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__text {
|
||||||
|
font: var(--tui-typography-body-s);
|
||||||
|
color: var(--sg-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= ANIMATIONS ================= */
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= RESPONSIVE ================= */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero--vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__content {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__btn {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-cards__grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/app/features/landing/landing.html
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<div class="landing-page sg-content-column">
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero hero--vertical">
|
||||||
|
<div class="hero__content">
|
||||||
|
<h1 class="hero__title">Умный прокторинг от SparkGuardian</h1>
|
||||||
|
<p class="hero__description">
|
||||||
|
Панель ревьюера для поведенческого анализа экзаменационных сессий. Синхронный просмотр видеозаписи, телеметрии клавиатуры и мыши, аппаратный фингерпринт — в одном окне.
|
||||||
|
</p>
|
||||||
|
<div class="hero__actions">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="l"
|
||||||
|
appearance="accent"
|
||||||
|
routerLink="/sessions"
|
||||||
|
class="hero__btn"
|
||||||
|
>
|
||||||
|
Перейти к сессиям
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero__visual">
|
||||||
|
<img src="/images/t-bank-hero-final.png" alt="SparkGuardian Security" class="hero__image" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<section class="features">
|
||||||
|
<h2 class="landing-page__title">Ключевые инструменты</h2>
|
||||||
|
|
||||||
|
<div class="features__grid">
|
||||||
|
<!-- Card 1: Interactive Mode -->
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-card__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M8 21H16M12 17V21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M7 8L9 10L7 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feature-card__title">Интерактивный режим</h3>
|
||||||
|
<p class="feature-card__text">
|
||||||
|
Синхронный просмотр HLS-видеопотока одновременно с визуализацией нажатий клавиатуры, позиции мыши и восстановленного набранного текста. Единый таймлайн с перемоткой ±5/±10 секунд.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Hardware Fingerprint -->
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-card__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M9 1V4M15 1V4M9 20V23M15 20V23M1 9H4M1 15H4M20 9H23M20 15H23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" fill-opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feature-card__title">Аппаратный фингерпринт</h3>
|
||||||
|
<p class="feature-card__text">
|
||||||
|
Периодические Heartbeat-снимки оборудования: хеш CPU, материнской платы, дисков, MAC-адресов. Автоматическое выявление смены экранов, сетевого адаптера или появления гипервизора.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 3: Heatmaps -->
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-card__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 3V21H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19 9L14 14L10 10L3 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 9H19V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19 9L14 14L10 10L3 17V21H21V9H19Z" fill="currentColor" fill-opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feature-card__title">Тепловая карта активности</h3>
|
||||||
|
<p class="feature-card__text">
|
||||||
|
Визуализация плотности событий телеметрии вдоль таймлайна сессии. Мгновенный переход к подозрительным моментам одним кликом по тепловой карте.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Session Tabs Section (NEW) -->
|
||||||
|
<section class="tabs-showcase">
|
||||||
|
<h2 class="landing-page__title">4 режима анализа в каждой сессии</h2>
|
||||||
|
|
||||||
|
<div class="tabs-grid">
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">01</div>
|
||||||
|
<h4 class="tab-card__title">Просмотр</h4>
|
||||||
|
<p class="tab-card__text">HLS-плеер с переключением потоков (screen / webcam), полная лента телеметрии с фильтрацией по типам событий.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">02</div>
|
||||||
|
<h4 class="tab-card__title">Интерактивный режим</h4>
|
||||||
|
<p class="tab-card__text">Синхронное воспроизведение видео, клавиатуры, мыши и набранного текста. Тепловая карта событий с курсором позиции.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">03</div>
|
||||||
|
<h4 class="tab-card__title">Отпечаток системы</h4>
|
||||||
|
<p class="tab-card__text">Текущая конфигурация устройства и журнал подозрительных изменений: смена экрана, сети, юзера или обнаружение ВМ.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">04</div>
|
||||||
|
<h4 class="tab-card__title">Служебная информация</h4>
|
||||||
|
<p class="tab-card__text">Сырые данные сессии, метаинформация о потоках и подробная статистика по телеметрии для разработчиков.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<section class="stats-cards">
|
||||||
|
<div class="stats-cards__grid">
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">17</div>
|
||||||
|
<div class="stats-card-item__label">параметров оборудования в каждом Heartbeat-снимке</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">5</div>
|
||||||
|
<div class="stats-card-item__label">полей автоматического мониторинга аномалий</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">4</div>
|
||||||
|
<div class="stats-card-item__label">режима анализа в каждой сессии</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">2</div>
|
||||||
|
<div class="stats-card-item__label">видеопотока — экран и камера одновременно</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- How It Works Section -->
|
||||||
|
<section class="how-it-works">
|
||||||
|
<h2 class="landing-page__title">Как это работает</h2>
|
||||||
|
|
||||||
|
<div class="how-it-works__steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__number">1</div>
|
||||||
|
<div class="step__content">
|
||||||
|
<h4 class="step__title">Создание сессии</h4>
|
||||||
|
<p class="step__text">Ревьюер создаёт сессию в панели. Агент на устройстве студента получает session_key и начинает сбор телеметрии и запись видеопотоков.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__number">2</div>
|
||||||
|
<div class="step__content">
|
||||||
|
<h4 class="step__title">Потоковая загрузка данных</h4>
|
||||||
|
<p class="step__text">Видеочанки загружаются через API с bearer-авторизацией. Параллельно поступают события клавиатуры, мыши и периодические Heartbeat-фингерпринты.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__number">3</div>
|
||||||
|
<div class="step__content">
|
||||||
|
<h4 class="step__title">Анализ ревьюером</h4>
|
||||||
|
<p class="step__text">Ревьюер открывает сессию в интерактивном режиме, видит видеозапись синхронно с клавиатурой и мышью, и проверяет журнал аномалий фингерпринта.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ Section -->
|
||||||
|
<section class="faq">
|
||||||
|
<h2 class="landing-page__title">Отвечаем на вопросы</h2>
|
||||||
|
|
||||||
|
<tui-accordion class="faq__list">
|
||||||
|
<button tuiAccordion class="faq__button">Какие данные собирает агент?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Агент записывает видеопотоки (screen и webcam), события клавиатуры (нажатия/отпускания), координаты мыши, и периодически отправляет Heartbeat — снимок аппаратной конфигурации (хеши CPU, дисков, MAC, конфигурация экранов, наличие гипервизора).
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Что такое журнал аномалий?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Система автоматически сравнивает последовательные Heartbeat-снимки и фиксирует все изменения: смену конфигурации экранов, имени пользователя, имени хоста, сетевого адаптера или появление гипервизора. Все изменения отображаются в хронологическом журнале.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Обнаруживает ли система виртуальные машины?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Да. Каждый Heartbeat содержит поле hypervisor_present. Если агент обнаруживает среду виртуализации, в панели ревьюера это отображается как критический флаг с предупреждением.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Как работает интерактивный режим?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
HLS-видео воспроизводится параллельно с визуализацией: виртуальная клавиатура подсвечивает нажатые клавиши, блок мыши показывает позицию курсора, текстовое поле восстанавливает набранный текст из потока событий. Всё синхронизировано через единый таймлайн.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Какие типы видеопотоков поддерживаются?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
На данный момент поддерживаются два типа HLS-потоков: screen (запись экрана) и webcam (запись с камеры). Переключение между ними происходит через селектор потоков в интерфейсе сессии.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Можно ли фильтровать события телеметрии?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Да. В режиме «Просмотр» доступна фильтрация по типу событий (клавиатура, мышь и др.) с отображением количества событий каждого типа. Также можно скрыть события mouse_move для удобства чтения ленты.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
</tui-accordion>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p class="footer__text">© 2026 SparkGuardian. Панель ревьюера для прокторинга экзаменационных сессий.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
137
src/app/features/sessions/hls-player/hls-player.component.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-hls-player',
|
||||||
|
templateUrl: './hls-player.html',
|
||||||
|
styleUrl: './hls-player.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class HlsPlayerComponent {
|
||||||
|
readonly src = input.required<string>();
|
||||||
|
readonly seekToSec = input<number | null>(null);
|
||||||
|
readonly isPlaying = input<boolean | null>(null);
|
||||||
|
readonly currentTimeSecChange = output<number>();
|
||||||
|
readonly durationSecChange = output<number>();
|
||||||
|
readonly playingChange = output<boolean>();
|
||||||
|
|
||||||
|
private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl');
|
||||||
|
private pendingSeekSec: number | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect((onCleanup) => {
|
||||||
|
const url = this.src();
|
||||||
|
const ref = this.videoRef();
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const video = ref.nativeElement;
|
||||||
|
let hls: Hls | null = null;
|
||||||
|
const emitCurrentTime = () => {
|
||||||
|
this.currentTimeSecChange.emit(video.currentTime || 0);
|
||||||
|
};
|
||||||
|
const emitPlaying = () => {
|
||||||
|
this.playingChange.emit(!video.paused);
|
||||||
|
};
|
||||||
|
const emitDuration = () => {
|
||||||
|
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||||
|
this.durationSecChange.emit(video.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPendingSeek = () => {
|
||||||
|
if (this.pendingSeekSec == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
||||||
|
const clamped =
|
||||||
|
duration && duration > 0
|
||||||
|
? Math.max(0, Math.min(this.pendingSeekSec, duration))
|
||||||
|
: Math.max(0, this.pendingSeekSec);
|
||||||
|
video.currentTime = clamped;
|
||||||
|
this.pendingSeekSec = null;
|
||||||
|
this.currentTimeSecChange.emit(video.currentTime || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
});
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = url;
|
||||||
|
}
|
||||||
|
video.addEventListener('timeupdate', emitCurrentTime);
|
||||||
|
video.addEventListener('seeking', emitCurrentTime);
|
||||||
|
video.addEventListener('loadedmetadata', emitDuration);
|
||||||
|
video.addEventListener('durationchange', emitDuration);
|
||||||
|
video.addEventListener('play', emitPlaying);
|
||||||
|
video.addEventListener('pause', emitPlaying);
|
||||||
|
video.addEventListener('loadedmetadata', applyPendingSeek);
|
||||||
|
video.addEventListener('canplay', applyPendingSeek);
|
||||||
|
video.addEventListener('seeked', emitCurrentTime);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
video.removeEventListener('timeupdate', emitCurrentTime);
|
||||||
|
video.removeEventListener('seeking', emitCurrentTime);
|
||||||
|
video.removeEventListener('loadedmetadata', emitDuration);
|
||||||
|
video.removeEventListener('durationchange', emitDuration);
|
||||||
|
video.removeEventListener('play', emitPlaying);
|
||||||
|
video.removeEventListener('pause', emitPlaying);
|
||||||
|
video.removeEventListener('loadedmetadata', applyPendingSeek);
|
||||||
|
video.removeEventListener('canplay', applyPendingSeek);
|
||||||
|
video.removeEventListener('seeked', emitCurrentTime);
|
||||||
|
hls?.destroy();
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
this.pendingSeekSec = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const seekTo = this.seekToSec();
|
||||||
|
const ref = this.videoRef();
|
||||||
|
if (seekTo == null || !ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const video = ref.nativeElement;
|
||||||
|
this.pendingSeekSec = seekTo;
|
||||||
|
if (video.readyState < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
||||||
|
const clamped = duration && duration > 0 ? Math.max(0, Math.min(seekTo, duration)) : Math.max(0, seekTo);
|
||||||
|
if (Math.abs(video.currentTime - clamped) > 0.01) {
|
||||||
|
video.currentTime = clamped;
|
||||||
|
this.currentTimeSecChange.emit(video.currentTime || 0);
|
||||||
|
}
|
||||||
|
this.pendingSeekSec = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const shouldPlay = this.isPlaying();
|
||||||
|
const ref = this.videoRef();
|
||||||
|
if (shouldPlay == null || !ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const video = ref.nativeElement;
|
||||||
|
if (shouldPlay) {
|
||||||
|
void video.play().catch(() => {
|
||||||
|
this.playingChange.emit(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/features/sessions/hls-player/hls-player.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--sg-content-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--tui-radius-l, 0.75rem);
|
||||||
|
background: var(--tui-background-neutral-1, #000);
|
||||||
|
}
|
||||||
1
src/app/features/sessions/hls-player/hls-player.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<video #videoEl class="player" controls playsinline></video>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
interface KeyDef {
|
||||||
|
id: string;
|
||||||
|
/** Primary label (letter, digit, symbol or icon) */
|
||||||
|
en: string;
|
||||||
|
/** Shifted / alt label shown in the top-left corner */
|
||||||
|
alt?: string;
|
||||||
|
/** Russian glyph shown in the top-right corner */
|
||||||
|
ru?: string;
|
||||||
|
/** Width in key-units (1 = standard key). Default: 1 */
|
||||||
|
flex: number;
|
||||||
|
/** Space bar — grows to fill remaining row width */
|
||||||
|
isSpace?: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: KeyDef[][] = [
|
||||||
|
// ── Number row ──────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb2a', en: '`', alt: '~', ru: 'ё', flex: 1 },
|
||||||
|
{ id: 'K_kb2b', en: '1', alt: '!', flex: 1 },
|
||||||
|
{ id: 'K_kb2c', en: '2', alt: '@', flex: 1 },
|
||||||
|
{ id: 'K_kb2d', en: '3', alt: '#', flex: 1 },
|
||||||
|
{ id: 'K_kb2e', en: '4', alt: '$', flex: 1 },
|
||||||
|
{ id: 'K_kb2f', en: '5', alt: '%', flex: 1 },
|
||||||
|
{ id: 'K_kb2g', en: '6', alt: '^', flex: 1 },
|
||||||
|
{ id: 'K_kb2h', en: '7', alt: '&', flex: 1 },
|
||||||
|
{ id: 'K_kb2i', en: '8', alt: '*', flex: 1 },
|
||||||
|
{ id: 'K_kb2j', en: '9', alt: '(', flex: 1 },
|
||||||
|
{ id: 'K_kb2k', en: '0', alt: ')', flex: 1 },
|
||||||
|
{ id: 'K_kb2l', en: '-', alt: '_', flex: 1 },
|
||||||
|
{ id: 'K_kb2m', en: '=', alt: '+', flex: 1 },
|
||||||
|
{ id: 'K_kb2n', en: '⌫', flex: 2 },
|
||||||
|
],
|
||||||
|
// ── Q row ───────────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb3a', en: 'Tab', flex: 1.5 },
|
||||||
|
{ id: 'K_kb3b', en: 'Q', ru: 'Й', flex: 1 },
|
||||||
|
{ id: 'K_kb3c', en: 'W', ru: 'Ц', flex: 1 },
|
||||||
|
{ id: 'K_kb3d', en: 'E', ru: 'У', flex: 1 },
|
||||||
|
{ id: 'K_kb3e', en: 'R', ru: 'К', flex: 1 },
|
||||||
|
{ id: 'K_kb3f', en: 'T', ru: 'Е', flex: 1 },
|
||||||
|
{ id: 'K_kb3g', en: 'Y', ru: 'Н', flex: 1 },
|
||||||
|
{ id: 'K_kb3h', en: 'U', ru: 'Г', flex: 1 },
|
||||||
|
{ id: 'K_kb3i', en: 'I', ru: 'Ш', flex: 1 },
|
||||||
|
{ id: 'K_kb3j', en: 'O', ru: 'Щ', flex: 1 },
|
||||||
|
{ id: 'K_kb3k', en: 'P', ru: 'З', flex: 1 },
|
||||||
|
{ id: 'K_kb3l', en: '[', alt: '{', ru: 'Х', flex: 1 },
|
||||||
|
{ id: 'K_kb3m', en: ']', alt: '}', ru: 'Ъ', flex: 1 },
|
||||||
|
{ id: 'K_kb3n', en: '\\', alt: '|', flex: 1.5 },
|
||||||
|
],
|
||||||
|
// ── A row ───────────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb4a', en: 'Caps', flex: 1.75 },
|
||||||
|
{ id: 'K_kb4c', en: 'A', ru: 'Ф', flex: 1 },
|
||||||
|
{ id: 'K_kb4d', en: 'S', ru: 'Ы', flex: 1 },
|
||||||
|
{ id: 'K_kb4e', en: 'D', ru: 'В', flex: 1 },
|
||||||
|
{ id: 'K_kb4f', en: 'F', ru: 'А', flex: 1 },
|
||||||
|
{ id: 'K_kb4g', en: 'G', ru: 'П', flex: 1 },
|
||||||
|
{ id: 'K_kb4h', en: 'H', ru: 'Р', flex: 1 },
|
||||||
|
{ id: 'K_kb4i', en: 'J', ru: 'О', flex: 1 },
|
||||||
|
{ id: 'K_kb4j', en: 'K', ru: 'Л', flex: 1 },
|
||||||
|
{ id: 'K_kb4k', en: 'L', ru: 'Д', flex: 1 },
|
||||||
|
{ id: 'K_kb4l', en: ';', alt: ':', ru: 'Ж', flex: 1 },
|
||||||
|
{ id: 'K_kb4m', en: "'", alt: '"', ru: 'Э', flex: 1 },
|
||||||
|
{ id: 'K_kb4n', en: '↵', flex: 2.25 },
|
||||||
|
],
|
||||||
|
// ── Z row ───────────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb5a', en: '⇧', flex: 2.25 },
|
||||||
|
{ id: 'K_kb5c', en: 'Z', ru: 'Я', flex: 1 },
|
||||||
|
{ id: 'K_kb5d', en: 'X', ru: 'Ч', flex: 1 },
|
||||||
|
{ id: 'K_kb5e', en: 'C', ru: 'С', flex: 1 },
|
||||||
|
{ id: 'K_kb5f', en: 'V', ru: 'М', flex: 1 },
|
||||||
|
{ id: 'K_kb5g', en: 'B', ru: 'И', flex: 1 },
|
||||||
|
{ id: 'K_kb5h', en: 'N', ru: 'Т', flex: 1 },
|
||||||
|
{ id: 'K_kb5i', en: 'M', ru: 'Ь', flex: 1 },
|
||||||
|
{ id: 'K_kb5j', en: ',', alt: '<', ru: 'Б', flex: 1 },
|
||||||
|
{ id: 'K_kb5k', en: '.', alt: '>', ru: 'Ю', flex: 1 },
|
||||||
|
{ id: 'K_kb5l', en: '/', alt: '?', flex: 1 },
|
||||||
|
{ id: 'K_kb5m', en: '⇧', flex: 2.75 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODIFIER_ROW: KeyDef[] = [
|
||||||
|
{ id: 'K_kb6a', en: 'ctrl', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6c', en: '⌥', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6b', en: '⌘', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6d', en: '', flex: 1, isSpace: true },
|
||||||
|
{ id: 'K_kb6l', en: '⌘', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6k', en: '⌥', flex: 1.5 },
|
||||||
|
{ id: 'K_kb7l', en: '←', flex: 1 },
|
||||||
|
{ id: 'K_kb7u', en: '↑', flex: 1 },
|
||||||
|
{ id: 'K_kb7d', en: '↓', flex: 1 },
|
||||||
|
{ id: 'K_kb7r', en: '→', flex: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-keyboard-view',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './keyboard-view.html',
|
||||||
|
styleUrl: './keyboard-view.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class KeyboardViewComponent {
|
||||||
|
readonly pressedIds = input.required<ReadonlySet<string>>();
|
||||||
|
|
||||||
|
protected readonly rows = ROWS;
|
||||||
|
protected readonly modifierRow = MODIFIER_ROW;
|
||||||
|
}
|
||||||
135
src/app/features/sessions/keyboard-view/keyboard-view.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard {
|
||||||
|
/* Base unit: standard key size */
|
||||||
|
--ku: 2.4rem;
|
||||||
|
/* Gap between keys */
|
||||||
|
--kg: 0.18rem;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--kg);
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Row ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.keyboard__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--kg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__row--modifiers {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.key {
|
||||||
|
--w: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
/* Width formula accounts for the gap absorbed inside wide keys */
|
||||||
|
width: calc(var(--w) * var(--ku) + (var(--w) - 1) * var(--kg));
|
||||||
|
height: var(--ku);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
background: var(--sg-keyboard-key-surface-idle);
|
||||||
|
border-radius: 0.28rem;
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
||||||
|
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
transition:
|
||||||
|
background 0.24s cubic-bezier(0.33, 1, 0.68, 1),
|
||||||
|
box-shadow 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Space bar: fills leftover width in modifier row */
|
||||||
|
.key--space {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pressed state — плавный переход с «серого» idle-surface на акцент */
|
||||||
|
.key--active {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--sg-keyboard-key-pressed-fill) 55%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key labels ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.key__top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shifted/alt label — top-left */
|
||||||
|
.key__alt {
|
||||||
|
font-size: 0.57rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
min-width: 0;
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Russian glyph — top-right */
|
||||||
|
.key__ru {
|
||||||
|
font-size: 0.57rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink-soft);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
text-align: right;
|
||||||
|
min-width: 0;
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main English label — bottom-left */
|
||||||
|
.key__en {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label for special keys (Tab, Caps, Shift, ⌘ …) — centered */
|
||||||
|
.key__label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active key — invert text colors */
|
||||||
|
.key--active .key__alt,
|
||||||
|
.key--active .key__en,
|
||||||
|
.key--active .key__label {
|
||||||
|
color: var(--sg-keyboard-key-pressed-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key--active .key__ru {
|
||||||
|
color: color-mix(in srgb, var(--sg-keyboard-key-pressed-ink) 65%, transparent);
|
||||||
|
}
|
||||||
36
src/app/features/sessions/keyboard-view/keyboard-view.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="keyboard">
|
||||||
|
@for (row of rows; track $index) {
|
||||||
|
<div class="keyboard__row">
|
||||||
|
@for (k of row; track k.id) {
|
||||||
|
<div
|
||||||
|
class="key"
|
||||||
|
[class.key--active]="pressedIds().has(k.id)"
|
||||||
|
[style.--w]="k.flex"
|
||||||
|
>
|
||||||
|
@if (k.alt || k.ru) {
|
||||||
|
<div class="key__top">
|
||||||
|
<span class="key__alt">{{ k.alt ?? '' }}</span>
|
||||||
|
<span class="key__ru">{{ k.ru ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="key__en">{{ k.en }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="key__label">{{ k.en }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="keyboard__row keyboard__row--modifiers">
|
||||||
|
@for (k of modifierRow; track k.id) {
|
||||||
|
<div
|
||||||
|
class="key"
|
||||||
|
[class.key--active]="pressedIds().has(k.id)"
|
||||||
|
[class.key--space]="k.isSpace"
|
||||||
|
[style.--w]="k.isSpace ? null : k.flex"
|
||||||
|
>
|
||||||
|
<span class="key__label">{{ k.en }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
20
src/app/features/sessions/mouse-view/mouse-view.component.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
import type { MouseHighlightTarget } from '../../../core/mouse/mouse-payload.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-mouse-view',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './mouse-view.html',
|
||||||
|
styleUrl: './mouse-view.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MouseViewComponent {
|
||||||
|
readonly targets = input.required<readonly MouseHighlightTarget[]>();
|
||||||
|
|
||||||
|
protected readonly hasLeft = computed(() => this.targets().includes('left'));
|
||||||
|
protected readonly hasRight = computed(() => this.targets().includes('right'));
|
||||||
|
protected readonly hasMiddle = computed(
|
||||||
|
() => this.targets().includes('middle') || this.targets().includes('wheel'),
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/app/features/sessions/mouse-view/mouse-view.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
width: 120px;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 26px 26px 18px 18px;
|
||||||
|
background: var(--sg-keyboard-key-surface-idle);
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons area ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mouse__buttons {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 88px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 10px 1fr;
|
||||||
|
border-bottom: 2px solid var(--sg-keyboard-key-stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__btn {
|
||||||
|
transition: background 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__btn--left {
|
||||||
|
border-radius: 26px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__btn--right {
|
||||||
|
border-radius: 0 26px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse--left .mouse__btn--left {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse--right .mouse__btn--right {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Center column: scroll wheel ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mouse__divider {
|
||||||
|
border-left: 0.5px solid var(--sg-keyboard-key-stroke);
|
||||||
|
border-right: 0.5px solid var(--sg-keyboard-key-stroke);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__wheel {
|
||||||
|
width: 6px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--sg-keyboard-key-stroke);
|
||||||
|
transition: background 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__wheel--active {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lower body: fills remaining height ──────────────────────────────── */
|
||||||
|
|
||||||
|
.mouse__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
14
src/app/features/sessions/mouse-view/mouse-view.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<div
|
||||||
|
class="mouse"
|
||||||
|
[class.mouse--left]="hasLeft()"
|
||||||
|
[class.mouse--right]="hasRight()"
|
||||||
|
>
|
||||||
|
<div class="mouse__buttons">
|
||||||
|
<div class="mouse__btn mouse__btn--left"></div>
|
||||||
|
<div class="mouse__divider">
|
||||||
|
<div class="mouse__wheel" [class.mouse__wheel--active]="hasMiddle()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mouse__btn mouse__btn--right"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mouse__body"></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { clamp } from '../../../shared/utils/math.util';
|
||||||
|
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||||
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
|
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||||
|
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
timeout,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import { DevLogService } from '../../../core/devtools/dev-log.service';
|
||||||
|
import { isKeyboardTelemetryEvent } from '../../../core/keyboard/keyboard-payload.util';
|
||||||
|
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||||
|
import type { ParsedEvent } from '../../../core/models/api.types';
|
||||||
|
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||||
|
import { SessionViewTabComponent } from './session-view-tab/session-view-tab.component';
|
||||||
|
import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component';
|
||||||
|
import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component';
|
||||||
|
import { SessionFingerprintTabComponent } from './session-fingerprint-tab/session-fingerprint-tab.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-session-detail',
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
RouterLink,
|
||||||
|
TuiLink,
|
||||||
|
TuiLoader,
|
||||||
|
TuiTitle,
|
||||||
|
...TuiTabs,
|
||||||
|
SessionViewTabComponent,
|
||||||
|
SessionInteractiveTabComponent,
|
||||||
|
SessionInfoTabComponent,
|
||||||
|
SessionFingerprintTabComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './session-detail.html',
|
||||||
|
styleUrl: './session-detail.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SessionDetailComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
private readonly devLog = inject(DevLogService);
|
||||||
|
|
||||||
|
protected readonly telemetryToMs = signal<number | null>(null);
|
||||||
|
protected readonly recordingStartMs = signal<number | null>(null);
|
||||||
|
protected readonly recordingEndMs = signal<number | null>(null);
|
||||||
|
protected readonly excludeMouseMoves = model(true);
|
||||||
|
protected readonly activeTabIndex = model(0);
|
||||||
|
|
||||||
|
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||||
|
map((p) => Number(p.get('id'))),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly vm$ = this.sessionId$.pipe(
|
||||||
|
switchMap((id) => {
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
this.userErrors.notifyError(new Error('Некорректный идентификатор сессии'), 'Сессия');
|
||||||
|
return of({ status: 'error' as const });
|
||||||
|
}
|
||||||
|
this.activeTabIndex.set(0);
|
||||||
|
return this.api.getSession(id).pipe(
|
||||||
|
map((detail) => ({ status: 'ok' as const, id, detail })),
|
||||||
|
tap((state) => {
|
||||||
|
if (state.status !== 'ok') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = this.toUnixMs(state.detail.session.started_at);
|
||||||
|
const end = this.toUnixMs(state.detail.session.ended_at);
|
||||||
|
this.recordingStartMs.set(start);
|
||||||
|
this.recordingEndMs.set(end);
|
||||||
|
this.telemetryToMs.set(end ?? Date.now());
|
||||||
|
}),
|
||||||
|
catchError((e: HttpErrorResponse) => {
|
||||||
|
this.userErrors.notifyError(e, 'Сессия');
|
||||||
|
return of({ status: 'error' as const });
|
||||||
|
}),
|
||||||
|
startWith({ status: 'loading' as const }),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly telemetry$ = combineLatest([
|
||||||
|
this.sessionId$,
|
||||||
|
toObservable(this.telemetryToMs),
|
||||||
|
toObservable(this.recordingStartMs),
|
||||||
|
toObservable(this.recordingEndMs),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([id, toMs, recordingStartMs, recordingEndMs]) => {
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
return of({
|
||||||
|
status: 'error' as const,
|
||||||
|
toMs: 0,
|
||||||
|
fromMs: 0,
|
||||||
|
telemetry: [] as ParsedEvent[],
|
||||||
|
parsedMeta: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const lowerBound = recordingStartMs ?? 0;
|
||||||
|
const upperBound = recordingEndMs ?? now;
|
||||||
|
const normalizedTo = clamp(toMs ?? upperBound, lowerBound, upperBound);
|
||||||
|
return this.api
|
||||||
|
.getParsedEvents(id, lowerBound, normalizedTo)
|
||||||
|
.pipe(
|
||||||
|
timeout(12000),
|
||||||
|
map((resp) => ({
|
||||||
|
status: 'ok' as const,
|
||||||
|
toMs: normalizedTo,
|
||||||
|
fromMs: lowerBound,
|
||||||
|
telemetry: Array.isArray(resp.events) ? resp.events : [],
|
||||||
|
parsedMeta: {
|
||||||
|
session_id: resp.session_id,
|
||||||
|
count: resp.count,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
tap((result) => {
|
||||||
|
if (!isDevMode() || result.status !== 'ok') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.devLog.clearSource('telemetry');
|
||||||
|
const keyboardEvents = result.telemetry.filter(isKeyboardTelemetryEvent);
|
||||||
|
let rawEventJson: string;
|
||||||
|
try {
|
||||||
|
rawEventJson = JSON.stringify(keyboardEvents, null, 2);
|
||||||
|
} catch {
|
||||||
|
rawEventJson = '(не удалось сериализовать)';
|
||||||
|
}
|
||||||
|
this.devLog.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'telemetry',
|
||||||
|
message: `События клавиатуры · ${keyboardEvents.length} шт.`,
|
||||||
|
telemetryDetails: { rawEventJson },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
catchError((e: unknown) => {
|
||||||
|
this.userErrors.notifyError(e, 'Телеметрия');
|
||||||
|
return of({
|
||||||
|
status: 'error' as const,
|
||||||
|
toMs: normalizedTo,
|
||||||
|
fromMs: lowerBound,
|
||||||
|
telemetry: [] as ParsedEvent[],
|
||||||
|
parsedMeta: undefined,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
startWith({
|
||||||
|
status: 'loading' as const,
|
||||||
|
toMs: normalizedTo,
|
||||||
|
fromMs: lowerBound,
|
||||||
|
telemetry: [] as ParsedEvent[],
|
||||||
|
parsedMeta: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
private toUnixMs(value: string | null | undefined): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
return Number.isFinite(ms) ? ms : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/features/sessions/session-detail/session-detail.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
.back {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
|
||||||
|
.session-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Padding between label and underline (Taiga [tuiTab] has padding: 0 by default). */
|
||||||
|
.session-tabs [tuiTab] {
|
||||||
|
padding-block-end: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Taiga's inactive tab hover shadow is above the tab bar bottom border — remove it.
|
||||||
|
*/
|
||||||
|
.session-tabs [tuiTab]:hover:not(._active) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
79
src/app/features/sessions/session-detail/session-detail.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<div class="page sg-content-column">
|
||||||
|
<nav class="back">
|
||||||
|
<a tuiLink routerLink="/sessions">← К списку сессий</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if (vm$ | async; as state) {
|
||||||
|
@switch (state.status) {
|
||||||
|
@case ('loading') {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="xl" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
<p class="muted">Не удалось загрузить сессию.</p>
|
||||||
|
}
|
||||||
|
@case ('ok') {
|
||||||
|
<h2 tuiTitle="m" class="heading">Сессия {{ state.id }}</h2>
|
||||||
|
|
||||||
|
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||||
|
<button tuiTab type="button">Просмотр</button>
|
||||||
|
<button tuiTab type="button">Интерактивный режим</button>
|
||||||
|
<button tuiTab type="button">Отпечаток системы</button>
|
||||||
|
<button tuiTab type="button">Служебная информация</button>
|
||||||
|
</tui-tabs>
|
||||||
|
|
||||||
|
@if (telemetry$ | async; as telemetryState) {
|
||||||
|
@switch (activeTabIndex()) {
|
||||||
|
@case (0) {
|
||||||
|
<app-session-view-tab
|
||||||
|
[detail]="state.detail"
|
||||||
|
[telemetryState]="telemetryState"
|
||||||
|
[recordingStartMs]="recordingStartMs()"
|
||||||
|
[recordingEndMs]="recordingEndMs()"
|
||||||
|
[(excludeMouseMoves)]="excludeMouseMoves"
|
||||||
|
(telemetryToMsChange)="telemetryToMs.set($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case (1) {
|
||||||
|
@defer {
|
||||||
|
<app-session-interactive-tab
|
||||||
|
[detail]="state.detail"
|
||||||
|
[telemetryEvents]="telemetryState.telemetry"
|
||||||
|
[recordingStartMs]="recordingStartMs()"
|
||||||
|
[recordingEndMs]="recordingEndMs()"
|
||||||
|
[excludeMouseMoves]="excludeMouseMoves()"
|
||||||
|
/>
|
||||||
|
} @placeholder {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="l" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case (2) {
|
||||||
|
@defer {
|
||||||
|
<app-session-fingerprint-tab [sessionId]="state.id" />
|
||||||
|
} @placeholder {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="l" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case (3) {
|
||||||
|
@defer {
|
||||||
|
<app-session-info-tab
|
||||||
|
[detail]="state.detail"
|
||||||
|
[telemetryState]="telemetryState"
|
||||||
|
/>
|
||||||
|
} @placeholder {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="l" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ParsedEvent } from '../../../core/models/api.types';
|
||||||
|
|
||||||
|
export type TelemetryRangeSelection =
|
||||||
|
| { type: 'preset'; seconds: number }
|
||||||
|
| { type: 'end' }
|
||||||
|
| { type: 'custom' };
|
||||||
|
|
||||||
|
export interface TelemetryLoadState {
|
||||||
|
status: 'loading' | 'ok' | 'error';
|
||||||
|
toMs: number;
|
||||||
|
fromMs: number;
|
||||||
|
telemetry: ParsedEvent[];
|
||||||
|
parsedMeta?: { session_id: number; count: number };
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { AsyncPipe, DatePipe } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
|
import { TuiIcon } from '@taiga-ui/core/components/icon';
|
||||||
|
import { catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||||
|
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
|
||||||
|
import type { FingerprintHeartbeat, FingerprintHeartbeatPayload } from '../../../../core/models/api.types';
|
||||||
|
|
||||||
|
export interface Anomaly {
|
||||||
|
timestamp_ms: number;
|
||||||
|
field: string;
|
||||||
|
fieldLabel: string;
|
||||||
|
oldValue: unknown;
|
||||||
|
newValue: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-session-fingerprint-tab',
|
||||||
|
imports: [AsyncPipe, DatePipe, TuiLoader, TuiIcon],
|
||||||
|
templateUrl: './session-fingerprint-tab.html',
|
||||||
|
styleUrl: './session-fingerprint-tab.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SessionFingerprintTabComponent {
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
|
||||||
|
readonly sessionId = input.required<number>();
|
||||||
|
|
||||||
|
protected readonly data$ = toObservable(this.sessionId).pipe(
|
||||||
|
switchMap((id) => {
|
||||||
|
// Параллельно запрашиваем все данные фингерпринта
|
||||||
|
return combineLatest([
|
||||||
|
this.api.getFingerprintSummary(id).pipe(catchError(() => of(null))),
|
||||||
|
this.api.getFingerprintFull(id).pipe(catchError(() => of(null))),
|
||||||
|
this.api.getFingerprintHeartbeats(id, undefined, undefined, 50).pipe(catchError(() => of(null)))
|
||||||
|
]).pipe(
|
||||||
|
map(([summary, full, heartbeats]) => {
|
||||||
|
const hbs = heartbeats?.heartbeats || [];
|
||||||
|
const anomalies = this.detectAnomalies(hbs);
|
||||||
|
const currentConfig = hbs.length > 0 ? hbs[hbs.length - 1].payload : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok' as const,
|
||||||
|
summary,
|
||||||
|
full,
|
||||||
|
heartbeats: hbs,
|
||||||
|
anomalies,
|
||||||
|
currentConfig,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
catchError((e: HttpErrorResponse) => {
|
||||||
|
this.userErrors.notifyError(e, 'Фингерпринт');
|
||||||
|
return of({ status: 'error' as const });
|
||||||
|
}),
|
||||||
|
startWith({ status: 'loading' as const })
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly ANOMALY_FIELDS: ReadonlyArray<{
|
||||||
|
key: keyof FingerprintHeartbeatPayload;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: 'screen_layout', label: 'Конфигурация экранов' },
|
||||||
|
{ key: 'username', label: 'Пользователь системы' },
|
||||||
|
{ key: 'hostname', label: 'Имя компьютера' },
|
||||||
|
{ key: 'active_iface', label: 'Сетевой адаптер' },
|
||||||
|
{ key: 'hypervisor_present', label: 'Виртуализация / Hypervisor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
private detectAnomalies(heartbeats: FingerprintHeartbeat[]): Anomaly[] {
|
||||||
|
if (!heartbeats || heartbeats.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies: Anomaly[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < heartbeats.length; i++) {
|
||||||
|
const prev = heartbeats[i - 1].payload;
|
||||||
|
const curr = heartbeats[i].payload;
|
||||||
|
const timestamp = heartbeats[i].timestamp_ms;
|
||||||
|
|
||||||
|
for (const field of SessionFingerprintTabComponent.ANOMALY_FIELDS) {
|
||||||
|
const oldValue = prev[field.key];
|
||||||
|
const newValue = curr[field.key];
|
||||||
|
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
anomalies.push({
|
||||||
|
timestamp_ms: timestamp,
|
||||||
|
field: field.key,
|
||||||
|
fieldLabel: field.label,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем от новых к старым
|
||||||
|
return anomalies.sort((a, b) => b.timestamp_ms - a.timestamp_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
.fingerprint-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Наследует глобальный .section-title из page-common.css; дополнительных переопределений не требуется */
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Безопасность / Сейф баннер */
|
||||||
|
.safe-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--tui-positive-bg, rgba(74, 201, 155, 0.1));
|
||||||
|
color: var(--tui-positive, #259b6f);
|
||||||
|
border-radius: var(--tui-radius-m);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Журнал аномалий */
|
||||||
|
.anomalies-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.anomaly-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--tui-negative-bg, rgba(239, 68, 68, 0.1));
|
||||||
|
border-left: 4px solid var(--tui-negative, #ef4444);
|
||||||
|
border-radius: var(--tui-radius-m);
|
||||||
|
}
|
||||||
|
.anomaly-time {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
padding-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.anomaly-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.anomaly-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
.cross-out {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
}
|
||||||
|
.arrow-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--tui-negative, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Текущая конфигурация */
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--tui-radius-m);
|
||||||
|
border: 1px solid var(--tui-border-normal);
|
||||||
|
background: var(--tui-background-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-container.danger {
|
||||||
|
border-color: var(--tui-negative, #ef4444);
|
||||||
|
color: var(--tui-negative, #ef4444);
|
||||||
|
}
|
||||||
|
.danger-container.danger .cfg-label {
|
||||||
|
color: var(--tui-negative, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfg-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.danger-container.danger .cfg-icon {
|
||||||
|
color: var(--tui-negative, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfg-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.cfg-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.cfg-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@if (data$ | async; as state) {
|
||||||
|
@switch (state.status) {
|
||||||
|
@case ('loading') {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="xl" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
<p class="muted">Не удалось загрузить данные системы.</p>
|
||||||
|
}
|
||||||
|
@case ('ok') {
|
||||||
|
<div class="fingerprint-container">
|
||||||
|
|
||||||
|
<!-- Текущая конфигурация -->
|
||||||
|
<section class="card config-section">
|
||||||
|
<h3 class="section-title">Текущая конфигурация устройства</h3>
|
||||||
|
@if (state.currentConfig; as config) {
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-item">
|
||||||
|
<tui-icon icon="@tui.monitor" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Экраны</span>
|
||||||
|
<span class="cfg-value">{{ config.screen_layout }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<tui-icon icon="@tui.user" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Пользователь</span>
|
||||||
|
<span class="cfg-value">{{ config.username }} <span class="muted">@{{ config.hostname }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<tui-icon icon="@tui.globe" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Сеть</span>
|
||||||
|
<span class="cfg-value">{{ config.active_iface }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item danger-container" [class.danger]="config.hypervisor_present">
|
||||||
|
<tui-icon [icon]="config.hypervisor_present ? '@tui.triangle-alert' : '@tui.cpu'" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Виртуализация</span>
|
||||||
|
<span class="cfg-value">{{ config.hypervisor_present ? 'ОБНАРУЖЕНА' : 'Нет' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="muted">Данные телеметрии еще не поступили.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ВАЖНО: Аномалии -->
|
||||||
|
<section class="card anomalies-section">
|
||||||
|
<h3 class="section-title">Журнал подозрительных действий</h3>
|
||||||
|
@if (state.anomalies.length === 0) {
|
||||||
|
<div class="safe-banner">
|
||||||
|
<tui-icon icon="@tui.check" class="success-icon" />
|
||||||
|
<span>Никаких подозрительных изменений среды не зафиксировано.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="anomalies-list">
|
||||||
|
@for (anomaly of state.anomalies; track anomaly.timestamp_ms) {
|
||||||
|
<div class="anomaly-item">
|
||||||
|
<div class="anomaly-time">{{ anomaly.timestamp_ms | date:'HH:mm:ss' }}</div>
|
||||||
|
<div class="anomaly-content">
|
||||||
|
<span class="anomaly-label">{{ anomaly.fieldLabel }}:</span>
|
||||||
|
<span class="cross-out">{{ anomaly.oldValue }}</span>
|
||||||
|
<tui-icon icon="@tui.arrow-right" class="arrow-icon"/>
|
||||||
|
<span class="highlight">{{ anomaly.newValue }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
|
||||||
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
|
|
||||||
|
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
|
||||||
|
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||||
|
import type { SessionDetailResponse, StreamInfo } from '../../../../core/models/api.types';
|
||||||
|
import { formatDurationMsHuman } from '../../../../shared/utils/duration.util';
|
||||||
|
import type { TelemetryLoadState } from '../session-detail.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-session-info-tab',
|
||||||
|
imports: [TuiButton],
|
||||||
|
templateUrl: './session-info-tab.html',
|
||||||
|
styleUrl: './session-info-tab.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SessionInfoTabComponent {
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
|
||||||
|
readonly detail = input.required<SessionDetailResponse>();
|
||||||
|
readonly telemetryState = input.required<TelemetryLoadState>();
|
||||||
|
|
||||||
|
protected formatDurationMs(ms: number | null | undefined): string {
|
||||||
|
return formatDurationMsHuman(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected streamResolvedPlaylistUrl(stream: StreamInfo): string {
|
||||||
|
return this.api.resolvePlaylistUrl(stream.playlist_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async copyDetailPayloadJson(): Promise<void> {
|
||||||
|
const text = this.detailPayloadJson();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово');
|
||||||
|
} catch {
|
||||||
|
// clipboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detailPayloadJson(): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(this.detail(), null, 2);
|
||||||
|
} catch {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(10rem, 14rem) 1fr;
|
||||||
|
gap: 0.35rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dt {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
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%;
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-table tbody td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap_flat {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<section class="card" aria-label="Сессия">
|
||||||
|
<h3 class="section-title">Сессия</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Идентификатор</dt>
|
||||||
|
<dd>{{ detail().session.id }}</dd>
|
||||||
|
<dt>ID пользователя</dt>
|
||||||
|
<dd>{{ detail().session.user_id ?? '—' }}</dd>
|
||||||
|
<dt>Статус</dt>
|
||||||
|
<dd><code class="mono">{{ detail().session.status }}</code></dd>
|
||||||
|
<dt>Начало</dt>
|
||||||
|
<dd><code class="mono">{{ detail().session.started_at ?? '—' }}</code></dd>
|
||||||
|
<dt>Окончание</dt>
|
||||||
|
<dd><code class="mono">{{ detail().session.ended_at ?? '—' }}</code></dd>
|
||||||
|
<dt>Всего чанков</dt>
|
||||||
|
<dd>{{ detail().session.chunks_total ?? '—' }}</dd>
|
||||||
|
<dt>Всего событий</dt>
|
||||||
|
<dd>{{ detail().session.events_total ?? '—' }}</dd>
|
||||||
|
<dt>Название</dt>
|
||||||
|
<dd>{{ detail().session.title ?? '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Потоки">
|
||||||
|
<h3 class="section-title">Потоки</h3>
|
||||||
|
@if (detail().streams.length === 0) {
|
||||||
|
<p class="muted">Нет записей о потоках.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="table-wrap table-wrap_flat">
|
||||||
|
<table class="meta-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Чанки</th>
|
||||||
|
<th>Длительность</th>
|
||||||
|
<th>URL видеозаписи</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (s of detail().streams; track s.stream_type) {
|
||||||
|
<tr>
|
||||||
|
<td><code class="mono">{{ s.stream_type }}</code></td>
|
||||||
|
<td>{{ s.chunk_count ?? '—' }}</td>
|
||||||
|
<td>{{ formatDurationMs(s.duration_ms) }}</td>
|
||||||
|
<td class="payload">{{ streamResolvedPlaylistUrl(s) }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Телеметрия, ответ API">
|
||||||
|
<h3 class="section-title">Запрос событий</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>От (мс)</dt>
|
||||||
|
<dd><code class="mono">{{ telemetryState().fromMs }}</code></dd>
|
||||||
|
<dt>До (мс)</dt>
|
||||||
|
<dd><code class="mono">{{ telemetryState().toMs }}</code></dd>
|
||||||
|
<dt>ID сессии</dt>
|
||||||
|
<dd>
|
||||||
|
@if (telemetryState().parsedMeta) {
|
||||||
|
{{ telemetryState().parsedMeta!.session_id }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt>Количество</dt>
|
||||||
|
<dd>
|
||||||
|
@if (telemetryState().parsedMeta) {
|
||||||
|
{{ telemetryState().parsedMeta!.count }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt>Строк в выборке</dt>
|
||||||
|
<dd>{{ telemetryState().telemetry.length }}</dd>
|
||||||
|
<dt>Статус загрузки</dt>
|
||||||
|
<dd><code class="mono">{{ telemetryState().status }}</code></dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Исходный JSON">
|
||||||
|
<div class="json-copy-row">
|
||||||
|
<h3 class="section-title">Исходный JSON</h3>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
(click)="copyDetailPayloadJson()"
|
||||||
|
>
|
||||||
|
Скопировать в буфер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
|
import { TuiTextarea } from '@taiga-ui/kit/components/textarea';
|
||||||
|
|
||||||
|
import { applyKeyboardPressToTranscriptBuffer } from '../../../../core/keyboard/keyboard-transcript.util';
|
||||||
|
import {
|
||||||
|
isKeyboardTelemetryEvent,
|
||||||
|
parseKeyboardAction,
|
||||||
|
parseKeyboardHighlightKeyIds,
|
||||||
|
} from '../../../../core/keyboard/keyboard-payload.util';
|
||||||
|
import {
|
||||||
|
isMouseMoveTelemetryEvent,
|
||||||
|
isMouseTelemetryEvent,
|
||||||
|
parseMouseHighlightTargets,
|
||||||
|
} from '../../../../core/mouse/mouse-payload.util';
|
||||||
|
import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.util';
|
||||||
|
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||||
|
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
||||||
|
import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util';
|
||||||
|
import { clamp } from '../../../../shared/utils/math.util';
|
||||||
|
import { resolveActiveStreamType, resolvePlaylistUrlForType } from '../../../../shared/utils/stream.util';
|
||||||
|
import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
|
||||||
|
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
|
||||||
|
import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.component';
|
||||||
|
import { MouseViewComponent } from '../../mouse-view/mouse-view.component';
|
||||||
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
|
||||||
|
const INTERACTIVE_PREROLL_MS = Number.isFinite(environment.interactivePrerollMs)
|
||||||
|
? Math.max(0, environment.interactivePrerollMs)
|
||||||
|
: 4000;
|
||||||
|
|
||||||
|
/** Минимальная длительность подсветки нажатия (мс); при перекрытии побеждает более позднее нажатие. */
|
||||||
|
const KEY_HIGHLIGHT_MIN_MS = 500;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-session-interactive-tab',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
TuiButton,
|
||||||
|
...TuiTextarea,
|
||||||
|
HlsPlayerComponent,
|
||||||
|
StreamSelectorComponent,
|
||||||
|
KeyboardViewComponent,
|
||||||
|
MouseViewComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './session-interactive-tab.html',
|
||||||
|
styleUrl: './session-interactive-tab.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SessionInteractiveTabComponent {
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
|
||||||
|
readonly detail = input.required<SessionDetailResponse>();
|
||||||
|
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
||||||
|
readonly recordingStartMs = input.required<number | null>();
|
||||||
|
readonly recordingEndMs = input.required<number | null>();
|
||||||
|
readonly excludeMouseMoves = input<boolean>(true);
|
||||||
|
|
||||||
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
|
protected readonly timelineSec = signal(0);
|
||||||
|
protected readonly durationSec = signal<number | null>(null);
|
||||||
|
protected readonly isPlaying = signal(false);
|
||||||
|
protected readonly timelineMaxSec = computed(() => {
|
||||||
|
const videoDuration = this.durationSec();
|
||||||
|
if (videoDuration != null && Number.isFinite(videoDuration) && videoDuration > 0) {
|
||||||
|
return videoDuration;
|
||||||
|
}
|
||||||
|
const start = this.recordingStartMs();
|
||||||
|
const end = this.recordingEndMs();
|
||||||
|
if (start == null || end == null || end <= start) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (end - start) / 1000;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time anchor for timeline -> telemetry conversion.
|
||||||
|
* `session.started_at` can be slightly shifted vs actual stream start,
|
||||||
|
* so we additionally consider the earliest parsed telemetry timestamp.
|
||||||
|
*/
|
||||||
|
private readonly telemetryAnchorStartMs = computed(() => {
|
||||||
|
const sessionStart = this.recordingStartMs();
|
||||||
|
const firstTelemetryTs = this.telemetryEvents().reduce<number | null>((minTs, e) => {
|
||||||
|
const ts = e.timestamp;
|
||||||
|
if (!Number.isFinite(ts)) {
|
||||||
|
return minTs;
|
||||||
|
}
|
||||||
|
return minTs == null ? ts : Math.min(minTs, ts);
|
||||||
|
}, null);
|
||||||
|
if (sessionStart == null) {
|
||||||
|
return firstTelemetryTs;
|
||||||
|
}
|
||||||
|
if (firstTelemetryTs == null) {
|
||||||
|
return sessionStart;
|
||||||
|
}
|
||||||
|
return Math.min(sessionStart, firstTelemetryTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly cursorMs = computed(() => {
|
||||||
|
const start = this.telemetryAnchorStartMs();
|
||||||
|
if (start == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return start + this.timelineSec() * 1000 - INTERACTIVE_PREROLL_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-sorted keyboard events — recomputed only when telemetryEvents changes,
|
||||||
|
* not on every slider tick.
|
||||||
|
*/
|
||||||
|
private readonly sortedKeyboardEvents = computed(() =>
|
||||||
|
this.telemetryEvents()
|
||||||
|
.filter(isKeyboardTelemetryEvent)
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Только `press`. Группировка по целой секунде unix-времени: floor(ts/1000)*1000.
|
||||||
|
* Внутри секунды n нажатий в порядке телеметрии получают непересекающиеся интервалы
|
||||||
|
* [T + i/n·1000, T + (i+1)/n·1000); фактическая подсветка держится минимум KEY_HIGHLIGHT_MIN_MS.
|
||||||
|
*/
|
||||||
|
private readonly keyboardPressDisplaySlots = computed(
|
||||||
|
(): { startMs: number; endMs: number; keyIds: string[]; data: unknown }[] => {
|
||||||
|
const events = this.sortedKeyboardEvents();
|
||||||
|
type RawPress = { ts: number; keyIds: string[]; data: unknown };
|
||||||
|
const raw: RawPress[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (parseKeyboardAction(event.data) !== 'press') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const keyIds = parseKeyboardHighlightKeyIds(event.data);
|
||||||
|
if (keyIds.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ts = event.timestamp;
|
||||||
|
if (!Number.isFinite(ts)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
raw.push({ ts, keyIds, data: event.data });
|
||||||
|
}
|
||||||
|
const out: { startMs: number; endMs: number; keyIds: string[]; data: unknown }[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < raw.length) {
|
||||||
|
const secStartMs = Math.floor(raw[i]!.ts / 1000) * 1000;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < raw.length && Math.floor(raw[j]!.ts / 1000) * 1000 === secStartMs) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
const n = j - i;
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const startMs = secStartMs + (k / n) * 1000;
|
||||||
|
const endMs = secStartMs + ((k + 1) / n) * 1000;
|
||||||
|
out.push({ startMs, endMs, keyIds: raw[i + k]!.keyIds, data: raw[i + k]!.data });
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст, «набранный» к моменту позиции на timeline (порядок с тем же распределением по секунде).
|
||||||
|
*/
|
||||||
|
protected readonly interactiveTypedText = computed((): string => {
|
||||||
|
const cursorMs = this.cursorMs();
|
||||||
|
if (cursorMs == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let buffer = '';
|
||||||
|
for (const slot of this.keyboardPressDisplaySlots()) {
|
||||||
|
if (slot.startMs > cursorMs) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer = applyKeyboardPressToTranscriptBuffer(buffer, slot.data);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-sorted mouse events — recomputed only when telemetryEvents changes.
|
||||||
|
*/
|
||||||
|
private readonly sortedMouseEvents = computed(() =>
|
||||||
|
this.telemetryEvents()
|
||||||
|
.filter((e) => {
|
||||||
|
if (!isMouseTelemetryEvent(e)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !this.excludeMouseMoves() || !isMouseMoveTelemetryEvent(e);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клавиши слота с наибольшим `startMs`, для которого cursorMs попадает в
|
||||||
|
* [startMs, max(endMs, startMs + KEY_HIGHLIGHT_MIN_MS)).
|
||||||
|
*/
|
||||||
|
protected readonly pressedKeyIds = computed((): ReadonlySet<string> => {
|
||||||
|
const cursorMs = this.cursorMs();
|
||||||
|
if (cursorMs == null) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
const slots = this.keyboardPressDisplaySlots();
|
||||||
|
for (let idx = slots.length - 1; idx >= 0; idx--) {
|
||||||
|
const s = slots[idx]!;
|
||||||
|
if (s.startMs > cursorMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const visibleUntilMs = Math.max(s.endMs, s.startMs + KEY_HIGHLIGHT_MIN_MS);
|
||||||
|
if (cursorMs < visibleUntilMs) {
|
||||||
|
return new Set(s.keyIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Set();
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly mouseTargets = computed(() => {
|
||||||
|
const cursorMs = this.cursorMs();
|
||||||
|
if (cursorMs == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Find the last mouse event at or before cursorMs.
|
||||||
|
// Events are sorted ascending; iterate backwards for efficiency.
|
||||||
|
const events = this.sortedMouseEvents();
|
||||||
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
|
const event = events[i]!;
|
||||||
|
if (event.timestamp <= cursorMs) {
|
||||||
|
return parseMouseHighlightTargets(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [] as MouseHighlightTarget[];
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly heatmapBuckets = computed(() => {
|
||||||
|
const BUCKETS_COUNT = 150; // Более точная разбивка (150 баров)
|
||||||
|
const events = this.telemetryEvents();
|
||||||
|
if (!events?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = this.telemetryAnchorStartMs();
|
||||||
|
const durationSec = this.timelineMaxSec();
|
||||||
|
|
||||||
|
if (startMs == null || durationSec <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Array(BUCKETS_COUNT).fill(0);
|
||||||
|
const bucketDurationMs = (durationSec * 1000) / BUCKETS_COUNT;
|
||||||
|
|
||||||
|
let maxCount = 0;
|
||||||
|
for (const e of events) {
|
||||||
|
if (!Number.isFinite(e.timestamp)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Относительное время от старта
|
||||||
|
const offsetMs = e.timestamp - startMs;
|
||||||
|
if (offsetMs < 0 || offsetMs > durationSec * 1000) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketIdx = Math.min(Math.floor(offsetMs / bucketDurationMs), BUCKETS_COUNT - 1);
|
||||||
|
buckets[bucketIdx]++;
|
||||||
|
if (buckets[bucketIdx] > maxCount) {
|
||||||
|
maxCount = buckets[bucketIdx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.map((count, idx) => {
|
||||||
|
// Нормализуем opacity: фоновый цвет почти прозрачен, активные ярче.
|
||||||
|
const rawOpacity = count > 0 ? Math.max(0.15, count / maxCount) : 0.03;
|
||||||
|
return {
|
||||||
|
index: idx,
|
||||||
|
count,
|
||||||
|
opacity: rawOpacity,
|
||||||
|
startSec: (idx * bucketDurationMs) / 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
protected activeStreamType(): string | null {
|
||||||
|
return resolveActiveStreamType(this.detail().streams, this.selectedStreamType());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected playlistUrl(): string | null {
|
||||||
|
return resolvePlaylistUrlForType(this.detail().streams, this.activeStreamType(), this.api);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pickStream(type: string): void {
|
||||||
|
this.selectedStreamType.set(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setTimelineSec(value: string): void {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timelineSec.set(clamp(parsed, 0, this.timelineMaxSec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPlayerCurrentTimeChange(seconds: number): void {
|
||||||
|
if (!Number.isFinite(seconds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timelineSec.set(clamp(seconds, 0, this.timelineMaxSec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPlayerDurationChange(seconds: number): void {
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.durationSec.set(seconds);
|
||||||
|
this.timelineSec.update((current) => clamp(current, 0, this.timelineMaxSec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected shiftTimeline(deltaSec: number): void {
|
||||||
|
this.timelineSec.update((current) => clamp(current + deltaSec, 0, this.timelineMaxSec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected togglePlayback(): void {
|
||||||
|
this.isPlaying.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPlayerPlayingChange(playing: boolean): void {
|
||||||
|
this.isPlaying.set(playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected currentPositionLabel(): string {
|
||||||
|
return `${Math.max(0, Math.round(this.timelineSec()))}с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected endPositionLabel(): string {
|
||||||
|
return `${Math.max(0, Math.round(this.timelineMaxSec()))}с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startTimeLabel(): string {
|
||||||
|
return formatClockTime(this.recordingStartMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected endTimeLabel(): string {
|
||||||
|
const end = this.recordingEndMs();
|
||||||
|
const start = this.recordingStartMs();
|
||||||
|
if (end != null) {
|
||||||
|
return formatClockTime(end);
|
||||||
|
}
|
||||||
|
const max = this.timelineMaxSec();
|
||||||
|
if (start != null && Number.isFinite(max)) {
|
||||||
|
return formatClockTime(start + max * 1000);
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cursorLabel(): string {
|
||||||
|
return formatUnixMs(this.cursorMs());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
.controls-row {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, 1fr) auto minmax(8rem, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-controls_center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-range {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider {
|
||||||
|
width: 100%;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin-top: -0.33rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--sg-color-card-bg);
|
||||||
|
background: var(--sg-filter-chip-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider::-moz-range-track {
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider::-moz-range-thumb {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--sg-color-card-bg);
|
||||||
|
background: var(--sg-filter-chip-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider:focus-visible::-webkit-slider-thumb,
|
||||||
|
.timeline-slider:focus-visible::-moz-range-thumb {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-filter-chip-active-bg) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 4.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn_primary[tuiButton][tuiAppearance][data-appearance='primary'] {
|
||||||
|
background: var(--sg-filter-chip-active-bg);
|
||||||
|
color: var(--sg-filter-chip-active-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn_primary[tuiButton][tuiAppearance][data-appearance='primary']:hover:not(
|
||||||
|
[data-state='disabled']
|
||||||
|
) {
|
||||||
|
background: var(--sg-filter-chip-active-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta_start {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta_end {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta__col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta__col_end {
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.controls-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta_start,
|
||||||
|
.timeline-controls_center,
|
||||||
|
.time-meta_end {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta__col,
|
||||||
|
.time-meta__col_end {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta__position {
|
||||||
|
font: var(--tui-font-text-m);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-meta__clock {
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-preview app-keyboard-view {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-preview app-mouse-view {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse-sidebar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.input-preview {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-transcript-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-transcript-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-container {
|
||||||
|
display: flex;
|
||||||
|
height: 48px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--tui-radius-s);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--tui-background-base-alt, #f5f5f6);
|
||||||
|
border: 1px solid var(--tui-border-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-bucket {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100%;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-bucket:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background-color: var(--tui-primary, #3872c2);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 0 4px var(--tui-primary, rgba(56, 114, 194, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-transcript-hint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<section class="card" aria-label="Интерактивный режим: плеер">
|
||||||
|
@if (detail().streams.length === 0) {
|
||||||
|
<p class="muted">Потоки ещё не готовы.</p>
|
||||||
|
} @else {
|
||||||
|
<app-stream-selector
|
||||||
|
[streams]="detail().streams"
|
||||||
|
[activeType]="activeStreamType()"
|
||||||
|
(typeChange)="pickStream($event)"
|
||||||
|
/>
|
||||||
|
@if (playlistUrl(); as src) {
|
||||||
|
<app-hls-player
|
||||||
|
[src]="src"
|
||||||
|
[seekToSec]="timelineSec()"
|
||||||
|
[isPlaying]="isPlaying()"
|
||||||
|
(currentTimeSecChange)="onPlayerCurrentTimeChange($event)"
|
||||||
|
(durationSecChange)="onPlayerDurationChange($event)"
|
||||||
|
(playingChange)="onPlayerPlayingChange($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Интерактивный режим: timeline">
|
||||||
|
<div class="timeline-range">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="timeline-slider"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
[max]="timelineMaxSec()"
|
||||||
|
[value]="timelineSec()"
|
||||||
|
(input)="setTimelineSec($any($event.target).value)"
|
||||||
|
aria-label="Позиция интерактивного режима по timeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="controls-row">
|
||||||
|
<div class="time-meta time-meta_start">
|
||||||
|
<div class="time-meta__col">
|
||||||
|
<span class="time-meta__position">{{ currentPositionLabel() }}</span>
|
||||||
|
<span class="time-meta__clock">{{ startTimeLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-controls timeline-controls_center">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
class="control-btn"
|
||||||
|
(click)="shiftTimeline(-10)"
|
||||||
|
aria-label="Назад на 10 секунд"
|
||||||
|
>
|
||||||
|
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="m11 17-5-5 5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="m18 17-5-5 5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>-10</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
class="control-btn"
|
||||||
|
(click)="shiftTimeline(-5)"
|
||||||
|
aria-label="Назад на 5 секунд"
|
||||||
|
>
|
||||||
|
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="m15 17-5-5 5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>-5</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="primary"
|
||||||
|
class="control-btn control-btn_primary"
|
||||||
|
(click)="togglePlayback()"
|
||||||
|
[attr.aria-label]="isPlaying() ? 'Пауза' : 'Старт'"
|
||||||
|
>
|
||||||
|
@if (isPlaying()) {
|
||||||
|
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="6" y="4" width="4" height="16" stroke-width="2" />
|
||||||
|
<rect x="14" y="4" width="4" height="16" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
<span>Пауза</span>
|
||||||
|
} @else {
|
||||||
|
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<polygon points="6 4 20 12 6 20 6 4" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Старт</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
class="control-btn"
|
||||||
|
(click)="shiftTimeline(5)"
|
||||||
|
aria-label="Вперед на 5 секунд"
|
||||||
|
>
|
||||||
|
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="m9 17 5-5-5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>+5</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
class="control-btn"
|
||||||
|
(click)="shiftTimeline(10)"
|
||||||
|
aria-label="Вперед на 10 секунд"
|
||||||
|
>
|
||||||
|
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="m6 17 5-5-5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="m13 17 5-5-5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>+10</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="time-meta time-meta_end">
|
||||||
|
<div class="time-meta__col time-meta__col_end">
|
||||||
|
<span class="time-meta__position">{{ endPositionLabel() }}</span>
|
||||||
|
<span class="time-meta__clock">{{ endTimeLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Интерактивный режим: ввод">
|
||||||
|
<div class="input-preview">
|
||||||
|
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
|
||||||
|
<div class="mouse-sidebar">
|
||||||
|
<app-mouse-view [targets]="mouseTargets()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card heatmap-card" aria-label="Интерактивный режим: тепловая карта">
|
||||||
|
<h3 class="heatmap-title">Тепловая карта событий телеметрии</h3>
|
||||||
|
<div class="heatmap-container">
|
||||||
|
@for (bucket of heatmapBuckets(); track bucket.index) {
|
||||||
|
<div
|
||||||
|
class="heatmap-bucket"
|
||||||
|
[style.background-color]="bucket.count > 0 ? 'color-mix(in srgb, var(--sg-color-accent, #ffdb00) ' + (bucket.opacity * 100) + '%, transparent)' : 'transparent'"
|
||||||
|
[attr.title]="'Событий: ' + bucket.count"
|
||||||
|
(click)="setTimelineSec(bucket.startSec.toString())"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
@if (timelineMaxSec() > 0) {
|
||||||
|
<div
|
||||||
|
class="heatmap-cursor"
|
||||||
|
[style.left.%]="(timelineSec() / timelineMaxSec()) * 100"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Интерактивный режим: текст">
|
||||||
|
<div class="interactive-transcript-wrap">
|
||||||
|
<tui-textfield class="interactive-transcript-field sg-tui-textfield">
|
||||||
|
<label tuiLabel for="interactive-transcript">Набранный текст по телеметрии</label>
|
||||||
|
<textarea
|
||||||
|
id="interactive-transcript"
|
||||||
|
tuiTextarea
|
||||||
|
[min]="4"
|
||||||
|
[max]="16"
|
||||||
|
[ngModel]="interactiveTypedText()"
|
||||||
|
[readOnly]="true"
|
||||||
|
tabindex="-1"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
</tui-textfield>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { animate, style, transition, trigger } from '@angular/animations';
|
||||||
|
import { NgClass } from '@angular/common';
|
||||||
|
import { ChangeDetectionStrategy, Component, inject, input, model, output, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TuiCheckbox } from '@taiga-ui/core/components/checkbox';
|
||||||
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
|
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||||
|
|
||||||
|
import { isMouseMoveTelemetryEvent } from '../../../../core/mouse/mouse-payload.util';
|
||||||
|
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 { summarizeTelemetryData } from '../../../../core/sessions/telemetry-event-summary.engine';
|
||||||
|
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||||
|
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
||||||
|
import { formatTimestamp, formatUnixMs as formatUnixMsUtil } from '../../../../shared/utils/date-time.util';
|
||||||
|
import { clamp } from '../../../../shared/utils/math.util';
|
||||||
|
import { resolveActiveStreamType, resolvePlaylistUrlForType } from '../../../../shared/utils/stream.util';
|
||||||
|
import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
|
||||||
|
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
|
||||||
|
import { TelemetryEventDetailComponent } from '../../telemetry-event-detail/telemetry-event-detail.component';
|
||||||
|
import type { TelemetryLoadState, TelemetryRangeSelection } from '../session-detail.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-session-view-tab',
|
||||||
|
imports: [
|
||||||
|
NgClass,
|
||||||
|
FormsModule,
|
||||||
|
TuiButton,
|
||||||
|
TuiCheckbox,
|
||||||
|
TuiChip,
|
||||||
|
TuiLoader,
|
||||||
|
SessionStatusChipClassesPipe,
|
||||||
|
SessionStatusPipe,
|
||||||
|
TelemetryEventTypePipe,
|
||||||
|
HlsPlayerComponent,
|
||||||
|
StreamSelectorComponent,
|
||||||
|
TelemetryEventDetailComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './session-view-tab.html',
|
||||||
|
styleUrl: './session-view-tab.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 SessionViewTabComponent {
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
|
||||||
|
readonly detail = input.required<SessionDetailResponse>();
|
||||||
|
readonly telemetryState = input.required<TelemetryLoadState>();
|
||||||
|
readonly recordingStartMs = input.required<number | null>();
|
||||||
|
readonly recordingEndMs = input.required<number | null>();
|
||||||
|
readonly excludeMouseMoves = model(true);
|
||||||
|
readonly telemetryToMsChange = output<number>();
|
||||||
|
|
||||||
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
|
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
|
||||||
|
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
|
||||||
|
protected readonly customToLocal = signal('');
|
||||||
|
private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' });
|
||||||
|
|
||||||
|
protected activeStreamType(): string | null {
|
||||||
|
return resolveActiveStreamType(this.detail().streams, this.selectedStreamType());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected playlistUrl(): string | null {
|
||||||
|
return resolvePlaylistUrlForType(this.detail().streams, this.activeStreamType(), this.api);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pickStream(type: string): void {
|
||||||
|
this.selectedStreamType.set(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pickTelemetryEventTypeFilter(type: string | null): void {
|
||||||
|
this.telemetryEventTypeFilter.set(type);
|
||||||
|
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 uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const e of events) {
|
||||||
|
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) => this.telemetryEventTypeKey(e) === typeKey).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected visibleTelemetryEvents(): ParsedEvent[] {
|
||||||
|
const events = this.telemetryState().telemetry;
|
||||||
|
if (!this.excludeMouseMoves()) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
return events.filter((e) => !isMouseMoveTelemetryEvent(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
|
||||||
|
const filter = this.telemetryEventTypeFilter();
|
||||||
|
if (filter === null) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
return events.filter((e) => this.telemetryEventTypeKey(e) === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected telemetryEventSummary(event: ParsedEvent): string {
|
||||||
|
return summarizeTelemetryData(event.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected telemetryRowKey(row: ParsedEvent, index: number): string {
|
||||||
|
return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\u0000${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleTelemetryRow(row: ParsedEvent, index: number): void {
|
||||||
|
const key = this.telemetryRowKey(row, index);
|
||||||
|
this.expandedTelemetryRowKey.update((cur) => (cur === key ? null : key));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isTelemetryRowExpanded(row: ParsedEvent, index: number): boolean {
|
||||||
|
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.telemetryToMsChange.emit(clamp(start + seconds * 1000, start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadUntilEndTelemetry(): void {
|
||||||
|
this.customToLocal.set('');
|
||||||
|
this.telemetryRangeSelection.set({ type: 'end' });
|
||||||
|
this.telemetryToMsChange.emit(this.recordingEndMs() ?? Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected applyCustomTo(value: string): void {
|
||||||
|
this.customToLocal.set(value);
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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.telemetryToMsChange.emit(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): string {
|
||||||
|
return `С ${formatUnixMsUtil(this.recordingStartMs())} до ${formatUnixMsUtil(toMs)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatDate(value: string | null | undefined): string {
|
||||||
|
return formatTimestamp(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatUnixMs(value: number | null | undefined): string {
|
||||||
|
return formatUnixMsUtil(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
.session-title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font: var(--tui-font-heading-6);
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-head__side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-exclude-moves {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-exclude-moves__text {
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-picker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-type-tabs {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-type-tabs button {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: min(520px, 70vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap:not(.table-wrap_flat) {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry {
|
||||||
|
width: 100%;
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-col-type {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 14rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--tui-background-accent-1) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-row.telemetry-row_expanded {
|
||||||
|
background: color-mix(in srgb, var(--tui-background-accent-1) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-row-detail td {
|
||||||
|
padding: 0 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-col-summary {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<section class="card summary" aria-label="Сводка">
|
||||||
|
@if (detail().session.title) {
|
||||||
|
<p class="session-title">{{ detail().session.title }}</p>
|
||||||
|
}
|
||||||
|
<div class="summary-row">
|
||||||
|
<span
|
||||||
|
tuiChip
|
||||||
|
size="s"
|
||||||
|
class="status-chip"
|
||||||
|
[ngClass]="detail().session.status | sessionStatusChipClasses"
|
||||||
|
>{{ detail().session.status | sessionStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted small">
|
||||||
|
Начало: {{ formatDate(detail().session.started_at) }} · Окончание:
|
||||||
|
{{ formatDate(detail().session.ended_at) }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Видео">
|
||||||
|
<h3 class="section-title">Трансляция</h3>
|
||||||
|
@if (detail().streams.length === 0) {
|
||||||
|
<p class="muted">Потоки ещё не готовы.</p>
|
||||||
|
} @else {
|
||||||
|
<app-stream-selector
|
||||||
|
[streams]="detail().streams"
|
||||||
|
[activeType]="activeStreamType()"
|
||||||
|
(typeChange)="pickStream($event)"
|
||||||
|
/>
|
||||||
|
@if (playlistUrl(); as src) {
|
||||||
|
<app-hls-player [src]="src" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Телеметрия">
|
||||||
|
<div class="telemetry-head">
|
||||||
|
<div class="telemetry-head__main">
|
||||||
|
<h3 class="section-title">
|
||||||
|
События телеметрии ({{ filteredTelemetryEvents(visibleTelemetryEvents()).length }})
|
||||||
|
</h3>
|
||||||
|
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-head__side">
|
||||||
|
<div class="telemetry-actions">
|
||||||
|
<div class="telemetry-presets">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryRangePresetIs(2)"
|
||||||
|
(click)="selectRecentWindow(2)"
|
||||||
|
>
|
||||||
|
+2с
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryRangePresetIs(10)"
|
||||||
|
(click)="selectRecentWindow(10)"
|
||||||
|
>
|
||||||
|
+10с
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryRangePresetIs(60)"
|
||||||
|
(click)="selectRecentWindow(60)"
|
||||||
|
>
|
||||||
|
+1м
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryRangePresetIs(300)"
|
||||||
|
(click)="selectRecentWindow(300)"
|
||||||
|
>
|
||||||
|
+5м
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryRangePresetIs(900)"
|
||||||
|
(click)="selectRecentWindow(900)"
|
||||||
|
>
|
||||||
|
+15м
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryRangeIsEnd()"
|
||||||
|
(click)="loadUntilEndTelemetry()"
|
||||||
|
>
|
||||||
|
До конца
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="from-picker">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
class="sg-native-input"
|
||||||
|
[value]="customToLocal()"
|
||||||
|
(change)="applyCustomTo($any($event.target).value)"
|
||||||
|
aria-label="Верхняя граница диапазона телеметрии"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="telemetry-exclude-moves">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tuiCheckbox
|
||||||
|
size="s"
|
||||||
|
[ngModel]="excludeMouseMoves()"
|
||||||
|
(ngModelChange)="excludeMouseMoves.set($event)"
|
||||||
|
/>
|
||||||
|
<span class="telemetry-exclude-moves__text">Исключить перемещения мыши</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="telemetry-content">
|
||||||
|
@if (telemetryState().status === 'loading') {
|
||||||
|
<div class="loading-wrap loading-wrap_small">
|
||||||
|
<tui-loader [loading]="true" size="l" />
|
||||||
|
</div>
|
||||||
|
} @else if (telemetryState().status === 'error') {
|
||||||
|
<p class="muted">События телеметрии временно недоступны.</p>
|
||||||
|
} @else {
|
||||||
|
@if (telemetryState().telemetry.length === 0) {
|
||||||
|
<p class="muted">Событий пока нет.</p>
|
||||||
|
} @else if (visibleTelemetryEvents().length === 0) {
|
||||||
|
<p class="muted">После исключения перемещений мыши событий нет.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="telemetry-type-tabs stream-tabs" aria-label="Фильтр по типу события">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryEventTypeFilter() === null"
|
||||||
|
(click)="pickTelemetryEventTypeFilter(null)"
|
||||||
|
>
|
||||||
|
Все ({{ visibleTelemetryEvents().length }})
|
||||||
|
</button>
|
||||||
|
@for (t of uniqueTelemetryEventTypes(visibleTelemetryEvents()); track t) {
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="telemetryEventTypeFilter() === t"
|
||||||
|
(click)="pickTelemetryEventTypeFilter(t)"
|
||||||
|
>
|
||||||
|
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(visibleTelemetryEvents(), t) }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (filteredTelemetryEvents(visibleTelemetryEvents()).length === 0) {
|
||||||
|
<p class="muted">Нет событий выбранного типа.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="telemetry">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Сводка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (
|
||||||
|
row of filteredTelemetryEvents(visibleTelemetryEvents());
|
||||||
|
track row.timestamp + '_' + row.event_type + '_' + $index;
|
||||||
|
let i = $index
|
||||||
|
) {
|
||||||
|
<tr
|
||||||
|
class="telemetry-row"
|
||||||
|
[class.telemetry-row_expanded]="isTelemetryRowExpanded(row, i)"
|
||||||
|
(click)="toggleTelemetryRow(row, i)"
|
||||||
|
>
|
||||||
|
<td>{{ formatUnixMs(row.timestamp) }}</td>
|
||||||
|
<td class="telemetry-col-type">
|
||||||
|
<span tuiChip size="xs">{{ row.event_type | telemetryEventType }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="telemetry-col-summary">{{ telemetryEventSummary(row) }}</td>
|
||||||
|
</tr>
|
||||||
|
@if (isTelemetryRowExpanded(row, i)) {
|
||||||
|
<tr class="telemetry-row-detail" @telemetryEventDetail>
|
||||||
|
<td colspan="3" (click)="$event.stopPropagation()">
|
||||||
|
<app-telemetry-event-detail [event]="row" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { AsyncPipe, NgClass } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
|
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||||
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
|
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||||
|
import { TuiInput } from '@taiga-ui/core/components/input';
|
||||||
|
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||||
|
import { TuiPagination } from '@taiga-ui/kit/components/pagination';
|
||||||
|
import { catchError, map, of, startWith, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
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 { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||||
|
import { DEFAULT_PAGE_LIMIT } from '../../../core/config/app.tokens';
|
||||||
|
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sessions-list',
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
NgClass,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
RouterLink,
|
||||||
|
TuiButton,
|
||||||
|
TuiChip,
|
||||||
|
...TuiInput,
|
||||||
|
TuiLink,
|
||||||
|
TuiLoader,
|
||||||
|
TuiPagination,
|
||||||
|
TuiTitle,
|
||||||
|
SessionStatusChipClassesPipe,
|
||||||
|
SessionStatusPipe,
|
||||||
|
],
|
||||||
|
templateUrl: './sessions-list.html',
|
||||||
|
styleUrl: './sessions-list.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SessionsListComponent {
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
|
||||||
|
protected readonly limit = inject(DEFAULT_PAGE_LIMIT);
|
||||||
|
protected readonly pageIndex = signal(0);
|
||||||
|
|
||||||
|
protected readonly titleControl = new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.minLength(1)],
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly creating = signal(false);
|
||||||
|
|
||||||
|
protected readonly listState$ = toObservable(this.pageIndex).pipe(
|
||||||
|
switchMap((page) =>
|
||||||
|
this.api.listSessions(this.limit, page * this.limit).pipe(
|
||||||
|
map((data) => ({ status: 'ok' as const, data })),
|
||||||
|
catchError((e: HttpErrorResponse) => {
|
||||||
|
this.userErrors.notifyError(e, 'Список сессий');
|
||||||
|
return of({ status: 'error' as const });
|
||||||
|
}),
|
||||||
|
startWith({ status: 'loading' as const }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected onPageIndexChange(index: number): void {
|
||||||
|
this.pageIndex.set(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pageCount(total: number): number {
|
||||||
|
return Math.max(1, Math.ceil(total / this.limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatDate(value: string | null | undefined): string {
|
||||||
|
return formatTimestamp(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createSession(): void {
|
||||||
|
if (this.titleControl.invalid || this.creating()) {
|
||||||
|
this.titleControl.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = this.titleControl.value.trim();
|
||||||
|
this.creating.set(true);
|
||||||
|
this.api.createSession({ title }).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.creating.set(false);
|
||||||
|
void this.router.navigate(['/sessions', res.id]);
|
||||||
|
},
|
||||||
|
error: (e: HttpErrorResponse) => {
|
||||||
|
this.creating.set(false);
|
||||||
|
this.userErrors.notifyError(e, 'Создание сессии');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/features/sessions/sessions-list/sessions-list.css
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
.heading {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-field {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.accent-cta[tuiAppearance][data-appearance='primary'] {
|
||||||
|
min-inline-size: var(--sg-primary-action-min-inline-size);
|
||||||
|
--t-bg: var(--sg-color-accent);
|
||||||
|
background: var(--t-bg);
|
||||||
|
border-color: var(--sg-color-accent);
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-state='disabled']):not(:disabled) {
|
||||||
|
--t-bg: var(--tui-background-accent-1-hover);
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--tui-status-negative);
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-link {
|
||||||
|
font: var(--tui-font-text-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-left: auto;
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
}
|
||||||
83
src/app/features/sessions/sessions-list/sessions-list.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<div class="page sg-content-column">
|
||||||
|
<h2 tuiTitle="m" class="heading">Сессии прокторинга</h2>
|
||||||
|
|
||||||
|
<section class="create card" aria-label="Создать сессию">
|
||||||
|
<h3 class="section-title">Новая сессия</h3>
|
||||||
|
<div class="create-row">
|
||||||
|
<tui-textfield class="create-field sg-tui-textfield">
|
||||||
|
<label tuiLabel for="session-title-input">Название</label>
|
||||||
|
<input
|
||||||
|
id="session-title-input"
|
||||||
|
tuiInput
|
||||||
|
type="text"
|
||||||
|
placeholder="Например, экзамен по ОС"
|
||||||
|
[formControl]="titleControl"
|
||||||
|
/>
|
||||||
|
</tui-textfield>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
class="accent-cta"
|
||||||
|
type="button"
|
||||||
|
appearance="primary"
|
||||||
|
[disabled]="creating() || titleControl.invalid"
|
||||||
|
(click)="createSession()"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (listState$ | async; as state) {
|
||||||
|
@switch (state.status) {
|
||||||
|
@case ('loading') {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="xl" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
<section class="card" aria-label="Список сессий">
|
||||||
|
<p class="muted">Список временно недоступен.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@case ('ok') {
|
||||||
|
<section class="card" aria-label="Список сессий">
|
||||||
|
@if (state.data.sessions.length === 0) {
|
||||||
|
<p class="muted">Сессий пока нет.</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="session-list">
|
||||||
|
@for (s of state.data.sessions; track s.id) {
|
||||||
|
<li class="session-row">
|
||||||
|
<a tuiLink [routerLink]="['/sessions', s.id]" class="session-link">
|
||||||
|
Сессия {{ s.id }}
|
||||||
|
@if (s.title) {
|
||||||
|
— {{ s.title }}
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
tuiChip
|
||||||
|
size="xs"
|
||||||
|
class="status-chip"
|
||||||
|
[ngClass]="s.status | sessionStatusChipClasses"
|
||||||
|
>{{ s.status | sessionStatus }}</span>
|
||||||
|
<span class="muted meta">
|
||||||
|
@if (s.started_at) {
|
||||||
|
{{ formatDate(s.started_at) }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
<footer class="pager">
|
||||||
|
<span class="muted total">Всего: {{ state.data.total }}</span>
|
||||||
|
<tui-pagination
|
||||||
|
[length]="pageCount(state.data.total)"
|
||||||
|
[index]="pageIndex()"
|
||||||
|
(indexChange)="onPageIndexChange($event)"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||||
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
|
|
||||||
|
import type { StreamInfo } from '../../../core/models/api.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-stream-selector',
|
||||||
|
imports: [TuiButton],
|
||||||
|
template: `
|
||||||
|
<div class="stream-tabs">
|
||||||
|
@for (s of streams(); track s.stream_type) {
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
[class.stream-active]="activeType() === s.stream_type"
|
||||||
|
(click)="typeChange.emit(s.stream_type)"
|
||||||
|
>
|
||||||
|
{{ s.stream_type }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class StreamSelectorComponent {
|
||||||
|
readonly streams = input.required<StreamInfo[]>();
|
||||||
|
readonly activeType = input.required<string | null>();
|
||||||
|
readonly typeChange = output<string>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
import {
|
||||||
|
eventPayloadJson,
|
||||||
|
isKeyboardTelemetryEvent,
|
||||||
|
parseKeyboardHighlightKeyIds,
|
||||||
|
parseKeyboardVirtualKey,
|
||||||
|
} 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, 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<ParsedEvent>();
|
||||||
|
|
||||||
|
protected readonly keyboardModel = computed(() => {
|
||||||
|
const e = this.event();
|
||||||
|
if (!isKeyboardTelemetryEvent(e)) {
|
||||||
|
return { kind: 'none' as const };
|
||||||
|
}
|
||||||
|
const keyIds = parseKeyboardHighlightKeyIds(e.data);
|
||||||
|
const vk = parseKeyboardVirtualKey(e.data);
|
||||||
|
return { kind: 'keyboard' as const, vk, keyIds };
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly keyboardSvg$: Observable<SafeHtml | null> = toObservable(this.keyboardModel).pipe(
|
||||||
|
switchMap((m) => (m.kind === 'keyboard' ? this.keyboardSvg.svgWithHighlight(m.keyIds) : of(null))),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected formatTime(ms: number): string {
|
||||||
|
return formatTimestamp(new Date(ms).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected payloadText(e: ParsedEvent): string {
|
||||||
|
return eventPayloadJson(e.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async copyEventPayload(): Promise<void> {
|
||||||
|
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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
.detail {
|
||||||
|
padding: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font: var(--tui-font-text-m);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, 12rem) 1fr;
|
||||||
|
gap: 0.35rem 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-kv dd {
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Подраздел «Служебные данные» внутри «Подробности» */
|
||||||
|
.detail-subsection {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
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 определён глобально в page-common.css */
|
||||||
|
|
||||||
|
.keyboard-block {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0 0 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-svg-host {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--tui-radius-m);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-svg-host ::ng-deep svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .muted и .small определены глобально в page-common.css */
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<div class="detail">
|
||||||
|
@if (keyboardModel().kind === 'keyboard') {
|
||||||
|
<div class="keyboard-block">
|
||||||
|
<h4 class="keyboard-title">Предпросмотр</h4>
|
||||||
|
@if (keyboardSvg$ | async; as svg) {
|
||||||
|
<div class="keyboard-svg-host" [innerHTML]="svg"></div>
|
||||||
|
} @else {
|
||||||
|
<div class="keyboard-loading">
|
||||||
|
<tui-loader [loading]="true" size="m" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (keyboardKeyIds().length === 0) {
|
||||||
|
<p class="muted small">
|
||||||
|
Не удалось сопоставить событие с клавишами на схеме (проверьте поля <code class="mono">key_name</code> /
|
||||||
|
<code class="mono">modifiers</code> или VK).
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="detail-title">Подробности</h4>
|
||||||
|
<dl class="kv detail-kv detail-kv_main">
|
||||||
|
<dt>Тип</dt>
|
||||||
|
<dd>{{ event().event_type | telemetryEventType }}</dd>
|
||||||
|
<dt>Время</dt>
|
||||||
|
<dd>{{ formatTime(event().timestamp) }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="detail-subsection">
|
||||||
|
<tui-accordion class="telemetry-service-accordion" [closeOthers]="true" size="s">
|
||||||
|
<button tuiAccordion type="button">Служебные данные</button>
|
||||||
|
<tui-expand>
|
||||||
|
<div class="telemetry-service-body">
|
||||||
|
@if (keyboardModel().kind === 'keyboard') {
|
||||||
|
<dl class="kv detail-kv telemetry-service-kv">
|
||||||
|
<dt>Виртуальный код (VK)</dt>
|
||||||
|
<dd>
|
||||||
|
@if (keyboardModel().vk !== null && keyboardModel().vk !== undefined) {
|
||||||
|
{{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }})
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
@if (keyboardKeyIds().length > 0) {
|
||||||
|
<dt>Клавиши на схеме</dt>
|
||||||
|
<dd><code class="mono">{{ keyboardKeyIds().join(', ') }}</code></dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
<div class="json-copy-row">
|
||||||
|
<span class="telemetry-json-label">Исходный JSON события</span>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
(click)="copyEventPayload()"
|
||||||
|
>
|
||||||
|
Скопировать в буфер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</tui-expand>
|
||||||
|
</tui-accordion>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
37
src/app/shared/utils/date-time.util.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function formatTimestamp(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUnixMs(value: number | null | undefined): string {
|
||||||
|
if (value == null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return formatTimestamp(new Date(value).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClockTime(valueMs: number | null | undefined): string {
|
||||||
|
if (valueMs == null || !Number.isFinite(valueMs)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(new Date(valueMs));
|
||||||
|
}
|
||||||
46
src/app/shared/utils/duration.util.ts
Normal file
@@ -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(' ');
|
||||||
|
}
|
||||||