visual and functional upgrade
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "sparkguardian",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.7",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
@@ -333,6 +334,22 @@
|
||||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "21.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.7.tgz",
|
||||
"integrity": "sha512-h8tUjQVSWfi2fohzxXeDDTjCfWABioYlPMrV1j98wCcFJad3FSnKCY0/gq8B4X6V81NGV29nEnhPyV0GinUBpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "21.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
"version": "21.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.6.tgz",
|
||||
@@ -4167,6 +4184,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-5.1.0.tgz",
|
||||
"integrity": "sha512-3k1asuOgEjDLAtkK/snO8anctfo4vmMsyr16NHcNVDRqvUeRDrWf7sdRD1uXloO0hTrJvVbKR3WnDWxnuTKm8Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": ">=2.8.1"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"private": true,
|
||||
"packageManager": "npm@11.6.2",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.7",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
@@ -31,10 +32,10 @@
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"@angular/build": "^21.2.6",
|
||||
"@angular/cli": "^21.2.6",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"jsdom": "^28.0.0",
|
||||
"less": "^4.6.4",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 247 KiB |
16
public/svg/logo/logo.svg
Normal file
16
public/svg/logo/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.1 KiB |
@@ -92,7 +92,7 @@
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
text-align: start;
|
||||
text-anchor: start;
|
||||
fill: var(--sg-keyboard-ink);
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
}
|
||||
#T_alterninsc text {
|
||||
font-size: 13px;
|
||||
@@ -113,7 +113,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.04em;
|
||||
fill: var(--sg-keyboard-ink);
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-align: center;
|
||||
text-anchor: middle;
|
||||
@@ -122,7 +122,7 @@
|
||||
font-size: 17px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.03em;
|
||||
fill: var(--sg-keyboard-ink);
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-align: center;
|
||||
text-anchor: middle;
|
||||
@@ -131,7 +131,7 @@
|
||||
font-size: 11px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.02em;
|
||||
fill: var(--sg-keyboard-ink);
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-align: start;
|
||||
text-anchor: start;
|
||||
@@ -159,23 +159,17 @@
|
||||
stroke-width: 0.85;
|
||||
}
|
||||
|
||||
/* Встроенные значки Lucide (обводка); Meta — иконка command (⌘) */
|
||||
/* Lucide: в покое как подписи tab/caps; нажатие — через injectHighlight */
|
||||
.sg-lucide path,
|
||||
.sg-lucide line,
|
||||
.sg-lucide polyline,
|
||||
.sg-lucide rect {
|
||||
fill: none;
|
||||
stroke: var(--sg-keyboard-ink);
|
||||
stroke: var(--sg-keyboard-ink-soft);
|
||||
stroke-width: 1.55;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
#S_kb6b.sg-lucide path {
|
||||
stroke: var(--sg-keyboard-ink-soft) !important;
|
||||
}
|
||||
#S_kb6m.sg-lucide line {
|
||||
stroke: var(--sg-keyboard-ink) !important;
|
||||
}
|
||||
|
||||
]]>
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,5 +1,6 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideTaiga } from '@taiga-ui/core';
|
||||
import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification';
|
||||
@@ -11,6 +12,7 @@ import { routes } from './app.routes';
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideAnimations(),
|
||||
provideRouter(routes),
|
||||
provideTaiga(),
|
||||
tuiNotificationOptionsProvider(() => ({
|
||||
|
||||
@@ -11,21 +11,40 @@
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
background: var(--tui-background-elevation-1);
|
||||
}
|
||||
|
||||
.shell-header__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1.75rem;
|
||||
padding-block: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font: var(--tui-font-heading-5);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: clamp(1.35rem, 2.4vw, 1.65rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.035em;
|
||||
color: var(--tui-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
color: var(--tui-text-action);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<tui-root>
|
||||
<div class="shell">
|
||||
<header class="shell-header">
|
||||
<a routerLink="/" class="brand">SparkGuardian</a>
|
||||
<div class="shell-header__inner sg-content-column">
|
||||
<a routerLink="/" class="brand">
|
||||
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
|
||||
GUARD
|
||||
</a>
|
||||
<span class="shell-sub">Прокторинг</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="shell-main">
|
||||
<router-outlet />
|
||||
|
||||
@@ -34,6 +34,6 @@ describe('App', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.brand')?.textContent).toContain('SparkGuardian');
|
||||
expect(compiled.querySelector('.brand')?.textContent).toContain('GUARD');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,6 @@ import { inject } from '@angular/core';
|
||||
|
||||
import { API_BASE_URL } from '../config/api.tokens';
|
||||
|
||||
/**
|
||||
* Подставляет базовый URL ко всем относительным запросам.
|
||||
* Абсолютные URL (`http…`) не трогаем — как в Axios с `baseURL`, только через перехватчик.
|
||||
*/
|
||||
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const base = inject(API_BASE_URL);
|
||||
if (/^https?:\/\//i.test(req.url)) {
|
||||
|
||||
@@ -7,9 +7,6 @@ function isParseFailureMessage(message: string | undefined): boolean {
|
||||
return !!message?.includes('Http failure during parsing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет категорию ошибки для пользовательского сообщения (без утечки технических деталей).
|
||||
*/
|
||||
export function classifyUserError(err: unknown): UserErrorKind {
|
||||
if (err instanceof TimeoutError) {
|
||||
return 'timeout';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
import type { ApiErrorBody } from '../models/api.types';
|
||||
|
||||
/** Безопасная подстановка текста в разметку с innerHTML (например, Taiga notification). */
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||
|
||||
/**
|
||||
* Токены из `key_name` и из `modifiers` (через "+"), в нижнем регистре.
|
||||
* Соответствие имён бэкенда → id прямоугольников в KB_USA-standard.svg.
|
||||
*/
|
||||
function normalizeKeyToken(token: string): string {
|
||||
return token.trim().toLowerCase().replace(/-/g, '_');
|
||||
}
|
||||
|
||||
function tokenToSvgIds(token: string): string[] {
|
||||
const t = token.trim().toLowerCase();
|
||||
const t = normalizeKeyToken(token);
|
||||
if (!t) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const named: Record<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'],
|
||||
@@ -40,10 +72,6 @@ function tokenToSvgIds(token: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Формат бэкенда: `{ key_name, modifiers: "Shift+Meta", action }` и т.п.
|
||||
* Собирает уникальные id клавиш для подсветки (модификаторы + основная клавиша).
|
||||
*/
|
||||
export function keyNameModifiersPayloadToSvgIds(o: Record<string, unknown>): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -8,9 +8,6 @@ export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
|
||||
return t.includes('keyboard') || t.includes('key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает виртуальный код клавиши из payload (массив чисел, объект с полями vk/keyCode и т.д.).
|
||||
*/
|
||||
export function parseKeyboardVirtualKey(data: unknown): number | null {
|
||||
if (data == null) {
|
||||
return null;
|
||||
@@ -65,9 +62,6 @@ function unwrapJsonPayload(data: unknown): unknown {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Id элементов `K_kb*` для подсветки: сначала объект `key_name` / `modifiers`, иначе VK из массива/полей.
|
||||
*/
|
||||
export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
||||
const raw = unwrapJsonPayload(data);
|
||||
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Observable, defer, from, shareReplay } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Файл из `public/KB_USA-standard.svg` — отдаётся с корня приложения (`/KB_USA-standard.svg`),
|
||||
* Файл из `public/svg/visual/keyboard.svg` — URL в приложении `/svg/visual/keyboard.svg`,
|
||||
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
|
||||
*/
|
||||
const KEYBOARD_SVG_PATH = '/KB_USA-standard.svg';
|
||||
const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KeyboardSvgHighlightService {
|
||||
@@ -31,7 +31,6 @@ export class KeyboardSvgHighlightService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Подсветка: заливка `--sg-keyboard-key-pressed-fill` (акцент), подписи/иконки `--sg-keyboard-key-pressed-ink`. */
|
||||
private injectHighlight(svgText: string, keyIds: string[]): string {
|
||||
const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id));
|
||||
if (valid.length === 0) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Соответствие Windows Virtual Key → id элемента в `public/KB_USA-standard.svg` (K_kb*).
|
||||
* Раскладка US QWERTY, без блока F1–F12 (на схеме их нет).
|
||||
*/
|
||||
export function normalizeVirtualKey(vk: number): number {
|
||||
if (vk >= 0x61 && vk <= 0x7a) {
|
||||
return vk - 0x20;
|
||||
@@ -49,7 +45,6 @@ const LETTER_TO_ID: Record<string, string> = {
|
||||
Z: 'K_kb5c',
|
||||
};
|
||||
|
||||
/** OEM и прочие VK (Windows). */
|
||||
const EXTRA_VK: Record<number, string> = {
|
||||
0x08: 'K_kb2n',
|
||||
0x09: 'K_kb3a',
|
||||
@@ -98,7 +93,6 @@ export function vkToKeyboardSvgKeyId(vk: number): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Одна буква A–Z или цифра 0–9 как в подписи клавиши (`key_name`). */
|
||||
export function charKeyNameToSvgKeyId(name: string): string | null {
|
||||
const c = name.trim();
|
||||
if (c.length !== 1) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Соответствует `handler.ErrorResponse` из OpenAPI. */
|
||||
/** OpenAPI: handler.ErrorResponse */
|
||||
export interface ApiErrorBody {
|
||||
code?: string;
|
||||
error?: string;
|
||||
@@ -24,7 +24,7 @@ export interface SessionSummary {
|
||||
ended_at?: string;
|
||||
chunks_total?: number;
|
||||
events_total?: number;
|
||||
/** Не в swagger, но бэкенд может отдавать для списка. */
|
||||
/** в списке сессий с бэка, не всегда в swagger */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
/**
|
||||
* Типы ошибок для подбора дружелюбного текста во всплывающих уведомлениях.
|
||||
* Соответствие «тип → фраза» задаётся здесь; технические детали — только в Dev Console.
|
||||
*/
|
||||
export const USER_ERROR_FRIENDLY_MESSAGES = {
|
||||
/** Нет сети / соединение сброшено (часто HTTP 0). */
|
||||
network: 'Проверьте интернет-соединение.',
|
||||
/** Таймаут запроса (в т.ч. RxJS timeout, HTTP 408). */
|
||||
timeout: 'Запрос занял слишком много времени. Попробуйте позже.',
|
||||
/** Ответ сервера 5xx. */
|
||||
server_error: 'Уже работаем над этим.',
|
||||
/** 404. */
|
||||
not_found: 'Не удалось найти запрашиваемые данные.',
|
||||
/** 401. */
|
||||
unauthorized: 'Требуется вход в систему.',
|
||||
/** 403. */
|
||||
forbidden: 'Недостаточно прав для этого действия.',
|
||||
/** 400. */
|
||||
bad_request: 'Некорректный запрос. Попробуйте позже.',
|
||||
/** Прочие 4xx (кроме перечисленных выше). */
|
||||
client_error: 'Не удалось выполнить запрос. Попробуйте позже.',
|
||||
/** Не JSON / ошибка разбора ответа. */
|
||||
parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.',
|
||||
/** Локальная валидация (неверный id и т.п.). */
|
||||
invalid_input: 'Проверьте введённые данные.',
|
||||
/** Не удалось отнести к категории. */
|
||||
unknown: 'Попробуйте позже.',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,14 +8,23 @@ import { friendlyMessageForUserError } from './user-error-messages.config';
|
||||
|
||||
const ERROR_TOAST_TITLE = 'Что-то пошло не так...';
|
||||
|
||||
/**
|
||||
* Показывает пользователю обобщённое уведомление; точный текст ошибки — только в DevLog (в dev).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserErrorNotifyService {
|
||||
private readonly notifications = inject(TuiNotificationService);
|
||||
private readonly devLog = inject(DevLogService);
|
||||
|
||||
notifySuccess(message: string, label: string): void {
|
||||
this.notifications
|
||||
.open(escapeHtml(message), {
|
||||
label,
|
||||
appearance: 'positive',
|
||||
autoClose: 4000,
|
||||
closable: true,
|
||||
size: 'm',
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
notifyError(err: unknown, source: string): void {
|
||||
const kind = classifyUserError(err);
|
||||
const userSubtitle = friendlyMessageForUserError(kind);
|
||||
|
||||
@@ -48,9 +48,6 @@ export class SessionsApiService {
|
||||
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Относительный `playlist_url` из API нужно разрешить относительно хоста (`API_ORIGIN`).
|
||||
*/
|
||||
resolvePlaylistUrl(playlistUrl: string): string {
|
||||
if (/^https?:\/\//i.test(playlistUrl)) {
|
||||
return playlistUrl;
|
||||
@@ -58,9 +55,6 @@ export class SessionsApiService {
|
||||
return new URL(playlistUrl, `${this.apiOrigin}/`).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручка для агента (Bearer). В веб-интерфейсе обычно не нужна — оставлена для отладки/скриптов.
|
||||
*/
|
||||
uploadChunk(
|
||||
sessionId: number,
|
||||
chunkIdx: number,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Классы-модификаторы для чипа статуса. `finished` — без модификатора (дефолтный вид Taiga).
|
||||
*/
|
||||
/** finished — без классов (дефолт Taiga). */
|
||||
@Pipe({
|
||||
name: 'sessionStatusChipClasses',
|
||||
standalone: true,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Отображаемые подписи для статусов сессий (значения с API — обычно в нижнем регистре).
|
||||
* Неизвестный ключ в UI показывается как есть; в dev — предупреждение в консоли разработчика.
|
||||
*/
|
||||
export const SESSION_STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
active: 'Активная',
|
||||
pending: 'Ожидается',
|
||||
|
||||
8
src/app/core/sessions/telemetry-event-summary.config.ts
Normal file
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
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, unwrapTelemetryPayload } from './telemetry-event-summary.payload';
|
||||
|
||||
export function summarizeTelemetryData(data: unknown): string {
|
||||
const raw = unwrapTelemetryPayload(data);
|
||||
if (raw === null || raw === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((v) => String(v)).join(', ');
|
||||
}
|
||||
if (typeof raw !== 'object') {
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
const o = raw as Record<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
52
src/app/core/sessions/telemetry-event-summary.handlers.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
formatTelemetryKeyboardKeySummary,
|
||||
formatTelemetryMouseClickSummary,
|
||||
formatTelemetryMouseMoveSummary,
|
||||
} from '../../shared/utils/telemetry-summary-human-text.util';
|
||||
import { readNumericField } from './telemetry-event-summary.payload';
|
||||
import type { TelemetrySummaryRule } from './telemetry-event-summary.types';
|
||||
|
||||
export const mouseClickRule: TelemetrySummaryRule = {
|
||||
id: 'mouse-click',
|
||||
match: (o) =>
|
||||
o['action'] === 'click' &&
|
||||
readNumericField(o, 'x') !== null &&
|
||||
readNumericField(o, 'y') !== null &&
|
||||
readNumericField(o, 'button') !== null,
|
||||
summarize: (o) =>
|
||||
formatTelemetryMouseClickSummary(
|
||||
readNumericField(o, 'x')!,
|
||||
readNumericField(o, 'y')!,
|
||||
readNumericField(o, 'button')!,
|
||||
o['is_down'] === true,
|
||||
),
|
||||
};
|
||||
|
||||
export const mouseMoveRule: TelemetrySummaryRule = {
|
||||
id: 'mouse-move',
|
||||
match: (o) =>
|
||||
o['action'] === 'move' && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null,
|
||||
summarize: (o) =>
|
||||
formatTelemetryMouseMoveSummary(
|
||||
readNumericField(o, 'x')!,
|
||||
readNumericField(o, 'y')!,
|
||||
readNumericField(o, 'button'),
|
||||
),
|
||||
};
|
||||
|
||||
export const keyboardKeyRule: TelemetrySummaryRule = {
|
||||
id: 'keyboard-key',
|
||||
match: (o) => {
|
||||
const a = o['action'];
|
||||
if (a !== 'press' && a !== 'release') {
|
||||
return false;
|
||||
}
|
||||
return typeof o['key_name'] === 'string';
|
||||
},
|
||||
summarize: (o) =>
|
||||
formatTelemetryKeyboardKeySummary(
|
||||
o['action'] === 'press' ? 'press' : 'release',
|
||||
String(o['key_name']),
|
||||
o['modifiers'],
|
||||
),
|
||||
};
|
||||
30
src/app/core/sessions/telemetry-event-summary.payload.ts
Normal file
30
src/app/core/sessions/telemetry-event-summary.payload.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function unwrapTelemetryPayload(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function readNumericField(o: Record<string, unknown>, key: string): number | null {
|
||||
const v = o[key];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function fallbackCompactJson(o: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(o);
|
||||
} catch {
|
||||
return '[объект]';
|
||||
}
|
||||
}
|
||||
5
src/app/core/sessions/telemetry-event-summary.types.ts
Normal file
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
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;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export class DevConsoleComponent {
|
||||
private readonly logs = inject(DevLogService);
|
||||
protected readonly isDev = isDevMode();
|
||||
protected readonly collapsed = signal(false);
|
||||
protected readonly minimized = signal(false);
|
||||
protected readonly minimized = signal(true);
|
||||
protected readonly entries = this.logs.entries;
|
||||
protected readonly count = computed(() => this.entries().length);
|
||||
protected readonly expandedIds = signal<Record<number, boolean>>({});
|
||||
|
||||
@@ -15,7 +15,6 @@ import Hls from 'hls.js';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HlsPlayerComponent {
|
||||
/** Полный URL плейлиста `.m3u8`. */
|
||||
readonly src = input.required<string>();
|
||||
|
||||
private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
max-width: var(--sg-content-max-width);
|
||||
}
|
||||
|
||||
.player {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { AsyncPipe, NgClass } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core';
|
||||
@@ -14,12 +15,20 @@ import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, sw
|
||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
|
||||
import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe';
|
||||
import { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe';
|
||||
import type { ParsedEvent, SessionDetailResponse, StreamInfo } from '../../../core/models/api.types';
|
||||
import { summarizeTelemetryData } from '../../../core/sessions/telemetry-event-summary.engine';
|
||||
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||
import { formatDurationMsHuman } from '../../../shared/utils/duration.util';
|
||||
import { HlsPlayerComponent } from '../hls-player/hls-player.component';
|
||||
import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component';
|
||||
|
||||
type TelemetryRangeSelection =
|
||||
| { type: 'preset'; seconds: number }
|
||||
| { type: 'end' }
|
||||
| { type: 'custom' };
|
||||
|
||||
@Component({
|
||||
selector: 'app-session-detail',
|
||||
imports: [
|
||||
@@ -35,11 +44,29 @@ import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemet
|
||||
TuiTitle,
|
||||
SessionStatusChipClassesPipe,
|
||||
SessionStatusPipe,
|
||||
TelemetryEventTypePipe,
|
||||
TelemetryEventDetailComponent,
|
||||
],
|
||||
templateUrl: './session-detail.html',
|
||||
styleUrl: './session-detail.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger('telemetryEventDetail', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0, transform: 'translateY(-0.4rem)' }),
|
||||
animate(
|
||||
'220ms cubic-bezier(0.33, 1, 0.68, 1)',
|
||||
style({ opacity: 1, transform: 'translateY(0)' }),
|
||||
),
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate(
|
||||
'170ms cubic-bezier(0.4, 0, 1, 1)',
|
||||
style({ opacity: 0, transform: 'translateY(-0.3rem)' }),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class SessionDetailComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -51,16 +78,14 @@ export class SessionDetailComponent {
|
||||
private readonly recordingEndMs = signal<number | null>(null);
|
||||
protected readonly customToLocal = signal('');
|
||||
|
||||
/** Выбранный тип потока (или первая вкладка по умолчанию в шаблоне). */
|
||||
private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' });
|
||||
|
||||
protected readonly selectedStreamType = signal<string | null>(null);
|
||||
|
||||
/** 0 — просмотр, 1 — служебная информация. */
|
||||
protected readonly activeTabIndex = model(0);
|
||||
|
||||
/** null — все типы событий в таблице телеметрии. */
|
||||
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
|
||||
|
||||
/** Раскрытая строка телеметрии: ключ или null. */
|
||||
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
|
||||
|
||||
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||
@@ -88,6 +113,8 @@ export class SessionDetailComponent {
|
||||
const end = this.toUnixMs(state.detail.session.ended_at);
|
||||
this.recordingStartMs.set(start);
|
||||
this.recordingEndMs.set(end);
|
||||
this.telemetryRangeSelection.set({ type: 'end' });
|
||||
this.customToLocal.set('');
|
||||
// Дефолт для телеметрии: до текущего момента (или конца записи, если завершена).
|
||||
if (this.telemetryToMs() === null) {
|
||||
this.telemetryToMs.set(end ?? Date.now());
|
||||
@@ -202,8 +229,16 @@ export class SessionDetailComponent {
|
||||
this.expandedTelemetryRowKey.set(null);
|
||||
}
|
||||
|
||||
protected telemetryEventTypeKey(event: ParsedEvent): string {
|
||||
const t = event.event_type;
|
||||
if (t == null || t === '') {
|
||||
return '';
|
||||
}
|
||||
return String(t).trim().toLowerCase();
|
||||
}
|
||||
|
||||
protected telemetryRowKey(row: ParsedEvent, index: number): string {
|
||||
return `${row.timestamp}\u0000${row.event_type}\u0000${index}`;
|
||||
return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\u0000${index}`;
|
||||
}
|
||||
|
||||
protected toggleTelemetryRow(row: ParsedEvent, index: number): void {
|
||||
@@ -215,17 +250,16 @@ export class SessionDetailComponent {
|
||||
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
|
||||
}
|
||||
|
||||
/** Уникальные значения `event_type` (пустая строка — отдельная вкладка). */
|
||||
protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const e of events) {
|
||||
set.add(e.event_type ?? '');
|
||||
set.add(this.telemetryEventTypeKey(e));
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'ru'));
|
||||
}
|
||||
|
||||
protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number {
|
||||
return events.filter((e) => (e.event_type ?? '') === typeKey).length;
|
||||
return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length;
|
||||
}
|
||||
|
||||
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
|
||||
@@ -233,33 +267,16 @@ export class SessionDetailComponent {
|
||||
if (filter === null) {
|
||||
return events;
|
||||
}
|
||||
return events.filter((e) => (e.event_type ?? '') === filter);
|
||||
return events.filter((e) => this.telemetryEventTypeKey(e) === filter);
|
||||
}
|
||||
|
||||
protected telemetryTypeTabLabel(typeKey: string): string {
|
||||
return typeKey === '' ? 'Без типа' : typeKey;
|
||||
}
|
||||
|
||||
protected eventDataPreview(event: ParsedEvent): string {
|
||||
const data = event.data;
|
||||
if (data === null || data === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((v) => String(v)).join(', ');
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return '[object]';
|
||||
}
|
||||
}
|
||||
return String(data);
|
||||
protected telemetryEventSummary(event: ParsedEvent): string {
|
||||
return summarizeTelemetryData(event.data);
|
||||
}
|
||||
|
||||
protected selectRecentWindow(seconds: number): void {
|
||||
this.customToLocal.set('');
|
||||
this.telemetryRangeSelection.set({ type: 'preset', seconds });
|
||||
const start = this.recordingStartMs() ?? Date.now();
|
||||
const end = this.recordingEndMs() ?? Date.now();
|
||||
this.telemetryToMs.set(this.clamp(start + seconds * 1000, start, end));
|
||||
@@ -267,6 +284,7 @@ export class SessionDetailComponent {
|
||||
|
||||
protected loadUntilEndTelemetry(): void {
|
||||
this.customToLocal.set('');
|
||||
this.telemetryRangeSelection.set({ type: 'end' });
|
||||
const end = this.recordingEndMs() ?? Date.now();
|
||||
this.telemetryToMs.set(end);
|
||||
}
|
||||
@@ -278,17 +296,27 @@ export class SessionDetailComponent {
|
||||
}
|
||||
const ms = new Date(value).getTime();
|
||||
if (Number.isFinite(ms)) {
|
||||
this.telemetryRangeSelection.set({ type: 'custom' });
|
||||
const start = this.recordingStartMs() ?? Date.now();
|
||||
const end = this.recordingEndMs() ?? Date.now();
|
||||
this.telemetryToMs.set(this.clamp(ms, start, end));
|
||||
}
|
||||
}
|
||||
|
||||
protected telemetryRangePresetIs(seconds: number): boolean {
|
||||
const s = this.telemetryRangeSelection();
|
||||
return s.type === 'preset' && s.seconds === seconds;
|
||||
}
|
||||
|
||||
protected telemetryRangeIsEnd(): boolean {
|
||||
return this.telemetryRangeSelection().type === 'end';
|
||||
}
|
||||
|
||||
protected telemetryRangeLabel(toMs: number | null): string {
|
||||
return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`;
|
||||
}
|
||||
|
||||
protected detailPayloadJson(detail: SessionDetailResponse): string {
|
||||
private detailPayloadJson(detail: SessionDetailResponse): string {
|
||||
try {
|
||||
return JSON.stringify(detail, null, 2);
|
||||
} catch {
|
||||
@@ -296,11 +324,18 @@ export class SessionDetailComponent {
|
||||
}
|
||||
}
|
||||
|
||||
protected formatDurationMs(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms)) {
|
||||
return '—';
|
||||
protected async copyDetailPayloadJson(detail: SessionDetailResponse): Promise<void> {
|
||||
const text = this.detailPayloadJson(detail);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово');
|
||||
} catch {
|
||||
// clipboard
|
||||
}
|
||||
return `${ms} ms`;
|
||||
}
|
||||
|
||||
protected formatDurationMs(ms: number | null | undefined): string {
|
||||
return formatDurationMsHuman(ms);
|
||||
}
|
||||
|
||||
protected streamResolvedPlaylistUrl(stream: StreamInfo): string {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
box-sizing: border-box;
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
@@ -66,36 +64,33 @@
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.json-block {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 1rem;
|
||||
max-height: min(360px, 50vh);
|
||||
overflow: auto;
|
||||
border-radius: var(--tui-radius-m);
|
||||
background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary));
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
.json-copy-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.json-copy-row .section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.meta-table th,
|
||||
.meta-table td {
|
||||
.meta-table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.meta-table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--tui-text-secondary);
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
.table-wrap_flat {
|
||||
@@ -133,14 +128,41 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.telemetry-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.telemetry-content > .loading-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.telemetry-head__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.telemetry-head .section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.telemetry-head .telemetry-range {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.telemetry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -184,53 +206,143 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stream-tabs button {
|
||||
.stream-tabs button,
|
||||
.telemetry-presets button {
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
outline-color 0.15s ease;
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
/* Неактивный чип */
|
||||
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active),
|
||||
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active) {
|
||||
border-radius: 624.9375rem;
|
||||
font-weight: 400;
|
||||
background: var(--sg-filter-chip-bg);
|
||||
color: var(--sg-filter-chip-fg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Контраст к карточке — жёлтый акцент при наведении (специфичнее secondary appearance Taiga) */
|
||||
.stream-tabs
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not([data-state='disabled']) {
|
||||
background: var(--sg-color-accent);
|
||||
color: var(--sg-color-text);
|
||||
border-color: color-mix(in srgb, var(--sg-color-accent) 78%, black);
|
||||
outline: 2px solid transparent;
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
),
|
||||
.telemetry-presets
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
) {
|
||||
background: var(--sg-filter-chip-bg-hover);
|
||||
color: var(--sg-filter-chip-fg);
|
||||
border-color: transparent;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] {
|
||||
outline: 2px solid var(--sg-color-accent);
|
||||
background: color-mix(in srgb, var(--sg-color-accent) 22%, white);
|
||||
border-color: var(--sg-color-accent);
|
||||
color: var(--sg-color-text);
|
||||
/* Активный чип */
|
||||
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'],
|
||||
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] {
|
||||
border-radius: 624.9375rem;
|
||||
font-weight: 400;
|
||||
background: var(--sg-filter-chip-active-bg);
|
||||
color: var(--sg-filter-chip-active-fg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stream-tabs
|
||||
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
),
|
||||
.telemetry-presets
|
||||
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
) {
|
||||
background: var(--sg-filter-chip-active-bg-hover);
|
||||
color: var(--sg-filter-chip-active-fg);
|
||||
border-color: transparent;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Taiga оставляет :focus после клика и может показать обводку/тень с задержкой — убираем для мыши.
|
||||
* Клавиатура: лёгкое кольцо только при :focus-visible.
|
||||
*/
|
||||
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible),
|
||||
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible) {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.stream-tabs
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']),
|
||||
.telemetry-presets
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']) {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
|
||||
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
|
||||
outline: 2px solid var(--sg-filter-chip-active-bg);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
|
||||
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
|
||||
outline-color: var(--sg-filter-chip-active-bg-hover);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
max-height: min(480px, 70vh);
|
||||
max-height: min(520px, 70vh);
|
||||
}
|
||||
|
||||
.table-wrap:not(.table-wrap_flat) {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.telemetry {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.telemetry th,
|
||||
.telemetry td {
|
||||
.telemetry th {
|
||||
text-align: left;
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
.telemetry tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.telemetry th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--tui-background-elevation-1);
|
||||
z-index: 1;
|
||||
/* Длинные неизвестные event_type — перенос, без разъезда таблицы */
|
||||
.telemetry-col-type {
|
||||
min-width: 0;
|
||||
max-width: 14rem;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.telemetry-type-tabs {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.telemetry-type-tabs button {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.telemetry-row {
|
||||
@@ -252,7 +364,9 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.payload {
|
||||
font-family: ui-monospace, monospace;
|
||||
word-break: break-all;
|
||||
.telemetry-col-summary {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="page">
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back">
|
||||
<a tuiLink routerLink="/">← К списку сессий</a>
|
||||
</nav>
|
||||
@@ -14,7 +14,7 @@
|
||||
<p class="muted">Не удалось загрузить сессию.</p>
|
||||
}
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle class="heading">Сессия {{ state.id }}</h2>
|
||||
<h2 tuiTitle="m" class="heading">Сессия {{ state.id }}</h2>
|
||||
|
||||
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Просмотр</button>
|
||||
@@ -68,34 +68,88 @@
|
||||
|
||||
<section class="card" aria-label="Телеметрия">
|
||||
<div class="telemetry-head">
|
||||
<div class="telemetry-head__main">
|
||||
<h3 class="section-title">
|
||||
События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }})
|
||||
</h3>
|
||||
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState.toMs) }}</p>
|
||||
</div>
|
||||
<div class="telemetry-actions">
|
||||
<span class="muted small">{{ telemetryRangeLabel(telemetryState.toMs) }}</span>
|
||||
<div class="telemetry-presets">
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(2)">+2с</button>
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(10)">+10с</button>
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(60)">+1м</button>
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(300)">+5м</button>
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(900)">+15м</button>
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="loadUntilEndTelemetry()">До конца</button>
|
||||
<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">
|
||||
<span class="muted small">До:</span>
|
||||
<input
|
||||
#toInput
|
||||
type="datetime-local"
|
||||
class="sg-native-input"
|
||||
[value]="customToLocal()"
|
||||
(change)="applyCustomTo($any($event.target).value)"
|
||||
aria-label="Верхняя граница диапазона телеметрии"
|
||||
/>
|
||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="applyCustomTo(toInput.value)">
|
||||
Применить
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-content">
|
||||
@if (telemetryState.status === 'loading') {
|
||||
<div class="loading-wrap loading-wrap_small">
|
||||
<tui-loader [loading]="true" size="l" />
|
||||
@@ -126,7 +180,7 @@
|
||||
[class.stream-active]="telemetryEventTypeFilter() === t"
|
||||
(click)="pickTelemetryEventTypeFilter(t)"
|
||||
>
|
||||
{{ telemetryTypeTabLabel(t) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
|
||||
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -139,13 +193,13 @@
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Тип</th>
|
||||
<th>Payload</th>
|
||||
<th>Сводка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
row of filteredTelemetryEvents(telemetryState.telemetry);
|
||||
track row.timestamp + '-' + row.event_type + '-' + $index;
|
||||
track $index;
|
||||
let i = $index
|
||||
) {
|
||||
<tr
|
||||
@@ -154,11 +208,13 @@
|
||||
(click)="toggleTelemetryRow(row, i)"
|
||||
>
|
||||
<td>{{ formatUnixMs(row.timestamp) }}</td>
|
||||
<td><span tuiChip size="xs">{{ row.event_type || '—' }}</span></td>
|
||||
<td class="payload">{{ eventDataPreview(row) }}</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">
|
||||
<tr class="telemetry-row-detail" @telemetryEventDetail>
|
||||
<td colspan="3" (click)="$event.stopPropagation()">
|
||||
<app-telemetry-event-detail [event]="row" />
|
||||
</td>
|
||||
@@ -171,6 +227,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -206,21 +263,19 @@
|
||||
<table class="meta-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тип потока</th>
|
||||
<th>Чанков</th>
|
||||
<th>Длительность (мс)</th>
|
||||
<th>URL плейлиста (как в API)</th>
|
||||
<th>URL плейлиста (абсолютный)</th>
|
||||
<th>Тип</th>
|
||||
<th>Чанки</th>
|
||||
<th>Длительность</th>
|
||||
<th>URL видеозаписи</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (stream of state.detail.streams; track stream.stream_type) {
|
||||
@for (s of state.detail.streams; track s.stream_type) {
|
||||
<tr>
|
||||
<td><code class="mono">{{ stream.stream_type }}</code></td>
|
||||
<td>{{ stream.chunk_count ?? '—' }}</td>
|
||||
<td>{{ formatDurationMs(stream.duration_ms) }}</td>
|
||||
<td class="payload">{{ stream.playlist_url }}</td>
|
||||
<td class="payload">{{ streamResolvedPlaylistUrl(stream) }}</td>
|
||||
<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>
|
||||
@@ -260,8 +315,18 @@
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="Исходный JSON">
|
||||
<div class="json-copy-row">
|
||||
<h3 class="section-title">Исходный JSON</h3>
|
||||
<pre class="json-block">{{ detailPayloadJson(state.detail) }}</pre>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
size="s"
|
||||
appearance="secondary"
|
||||
(click)="copyDetailPayloadJson(state.detail)"
|
||||
>
|
||||
Скопировать в буфер
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
box-sizing: border-box;
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@@ -109,11 +107,3 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat
|
||||
.total {
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
/* Активная страница пагинации (Taiga 5: активная кнопка — appearance primary) */
|
||||
tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] {
|
||||
--t-bg: var(--sg-color-accent);
|
||||
background: var(--t-bg);
|
||||
border-color: var(--sg-color-accent);
|
||||
color: var(--sg-color-text);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="page">
|
||||
<h2 tuiTitle class="heading">Сессии прокторинга</h2>
|
||||
<div class="page sg-content-column">
|
||||
<h2 tuiTitle="m" class="heading">Сессии прокторинга</h2>
|
||||
|
||||
<section class="create card" aria-label="Создать сессию">
|
||||
<h3 class="section-title">Новая сессия</h3>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { SafeHtml } from '@angular/platform-browser';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiAccordion } from '@taiga-ui/kit/components/accordion';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
|
||||
@@ -14,17 +16,20 @@ import {
|
||||
} from '../../../core/keyboard/keyboard-payload.util';
|
||||
import { KeyboardSvgHighlightService } from '../../../core/keyboard/keyboard-svg-highlight.service';
|
||||
import type { ParsedEvent } from '../../../core/models/api.types';
|
||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||
import { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe';
|
||||
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-telemetry-event-detail',
|
||||
imports: [AsyncPipe, TuiLoader],
|
||||
imports: [AsyncPipe, TuiButton, TuiLoader, TelemetryEventTypePipe, ...TuiAccordion],
|
||||
templateUrl: './telemetry-event-detail.html',
|
||||
styleUrl: './telemetry-event-detail.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TelemetryEventDetailComponent {
|
||||
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
|
||||
readonly event = input.required<ParsedEvent>();
|
||||
|
||||
@@ -50,7 +55,16 @@ export class TelemetryEventDetailComponent {
|
||||
return eventPayloadJson(e.data);
|
||||
}
|
||||
|
||||
/** Удобно в шаблоне, где нет сужения типа для `keyboardModel()`. */
|
||||
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 : [];
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
padding: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font: var(--tui-font-text-m);
|
||||
@@ -13,10 +17,14 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 12rem) 1fr;
|
||||
gap: 0.35rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
margin: 0;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.detail-kv_main {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.detail-kv dt {
|
||||
margin: 0;
|
||||
color: var(--tui-text-tertiary);
|
||||
@@ -27,17 +35,36 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.payload-json {
|
||||
/* Подраздел «Служебные данные» внутри «Подробности» */
|
||||
.detail-subsection {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border-radius: var(--tui-radius-m);
|
||||
background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary));
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding-left: 0.65rem;
|
||||
border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary));
|
||||
}
|
||||
|
||||
.telemetry-service-accordion {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.telemetry-service-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.telemetry-service-kv {
|
||||
margin: 0.65rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.json-copy-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.telemetry-json-label {
|
||||
font: var(--tui-font-text-s);
|
||||
color: var(--tui-text-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
@@ -46,9 +73,9 @@
|
||||
}
|
||||
|
||||
.keyboard-block {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--tui-border-normal);
|
||||
margin: 0 0 1rem;
|
||||
padding: 0 0 0.75rem;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.keyboard-title {
|
||||
|
||||
@@ -1,31 +1,7 @@
|
||||
<div class="detail">
|
||||
<h4 class="detail-title">Подробности</h4>
|
||||
<dl class="kv detail-kv">
|
||||
<dt>Тип</dt>
|
||||
<dd>{{ event().event_type || '—' }}</dd>
|
||||
<dt>Время</dt>
|
||||
<dd>{{ formatTime(event().timestamp) }}</dd>
|
||||
@if (keyboardModel().kind === 'keyboard') {
|
||||
<dt>Виртуальный код (VK)</dt>
|
||||
<dd>
|
||||
@if (keyboardModel().vk != null) {
|
||||
{{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }})
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</dd>
|
||||
@if (keyboardKeyIds().length > 0) {
|
||||
<dt>Клавиши на схеме</dt>
|
||||
<dd><code class="mono">{{ keyboardKeyIds().join(', ') }}</code></dd>
|
||||
}
|
||||
}
|
||||
<dt>Данные</dt>
|
||||
<dd><pre class="payload-json">{{ payloadText(event()) }}</pre></dd>
|
||||
</dl>
|
||||
|
||||
@if (keyboardModel().kind === 'keyboard') {
|
||||
<div class="keyboard-block">
|
||||
<h4 class="keyboard-title">Клавиатура (US)</h4>
|
||||
<h4 class="keyboard-title">Предпросмотр</h4>
|
||||
@if (keyboardSvg$ | async; as svg) {
|
||||
<div class="keyboard-svg-host" [innerHTML]="svg"></div>
|
||||
} @else {
|
||||
@@ -41,4 +17,52 @@
|
||||
}
|
||||
</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 }} (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>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Преобразует ISO-время (timestamptz), например `2026-04-06T23:58:27Z`,
|
||||
* в человекочитаемый локальный формат.
|
||||
*/
|
||||
export function formatTimestamp(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
|
||||
46
src/app/shared/utils/duration.util.ts
Normal file
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(' ');
|
||||
}
|
||||
83
src/app/shared/utils/telemetry-summary-human-text.util.ts
Normal file
83
src/app/shared/utils/telemetry-summary-human-text.util.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export function formatTelemetryMouseButtonLabel(button: number): string {
|
||||
switch (button) {
|
||||
case 1:
|
||||
return 'левой';
|
||||
case 2:
|
||||
return 'правой';
|
||||
case 3:
|
||||
return 'средней';
|
||||
default:
|
||||
return `дополнительной (${button})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTelemetryKeyboardKeyDisplay(key: string): string {
|
||||
const t = key.trim();
|
||||
if (t.toUpperCase() === 'META') {
|
||||
return 'Meta';
|
||||
}
|
||||
if (t.length === 1) {
|
||||
return t.toUpperCase();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
export function formatTelemetryModifierTokens(modifiers: string): string {
|
||||
return modifiers
|
||||
.split('+')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
.map((t) => {
|
||||
const u = t.toLowerCase();
|
||||
if (u === 'meta') {
|
||||
return '⌘';
|
||||
}
|
||||
if (u === 'shift') {
|
||||
return 'Shift';
|
||||
}
|
||||
if (u === 'control' || u === 'ctrl') {
|
||||
return 'Ctrl';
|
||||
}
|
||||
if (u === 'alt') {
|
||||
return 'Alt';
|
||||
}
|
||||
return t;
|
||||
})
|
||||
.join(' + ');
|
||||
}
|
||||
|
||||
export function formatTelemetryMouseClickSummary(
|
||||
x: number,
|
||||
y: number,
|
||||
button: number,
|
||||
isDown: boolean,
|
||||
): string {
|
||||
const phase = isDown ? 'Нажатие' : 'Отпускание';
|
||||
const btn = formatTelemetryMouseButtonLabel(button);
|
||||
return `${phase} ${btn} кнопки мыши в точке (${x}, ${y})`;
|
||||
}
|
||||
|
||||
export function formatTelemetryMouseMoveSummary(x: number, y: number, button: number | null): string {
|
||||
if (button !== null) {
|
||||
return `Перемещение указателя — (${x}, ${y}), кнопка ${button}`;
|
||||
}
|
||||
return `Перемещение указателя — (${x}, ${y})`;
|
||||
}
|
||||
|
||||
export function formatTelemetryKeyboardKeySummary(
|
||||
action: 'press' | 'release',
|
||||
keyNameRaw: string,
|
||||
modifiersRaw: unknown,
|
||||
): string {
|
||||
const actionRu = action === 'press' ? 'Нажатие' : 'Отпускание';
|
||||
const keyName = formatTelemetryKeyboardKeyDisplay(keyNameRaw);
|
||||
const modStr = modifiersRaw == null ? '' : String(modifiersRaw).trim();
|
||||
const noMods =
|
||||
modStr === '' || modStr.toLowerCase() === 'none' || modStr.toLowerCase() === 'null';
|
||||
|
||||
let line = `${actionRu} клавиши «${keyName}»`;
|
||||
if (!noMods) {
|
||||
line += ` — ${formatTelemetryModifierTokens(modStr)}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/** Production-сборка (см. fileReplacements в angular.json). При деплое при необходимости меняйте здесь или генерируйте шагом CI. */
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiFallbackOrigin: 'https://sparkguardian.ru',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Sparkguardian</title>
|
||||
<title>GUARD</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@import './styles/session-status-chips.css';
|
||||
@import './styles/sg-input-fields.css';
|
||||
|
||||
/* Базовая вёрстка под Taiga UI: тема и шрифты подключаются в angular.json */
|
||||
@font-face {
|
||||
font-family: 'Tinkoff Sans';
|
||||
src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype');
|
||||
@@ -41,6 +40,14 @@ body {
|
||||
font-family: 'Tinkoff Sans', sans-serif;
|
||||
}
|
||||
|
||||
.sg-content-column {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: var(--sg-content-max-width);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--sg-page-padding-inline);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -66,3 +73,24 @@ textarea::placeholder {
|
||||
tui-notification-alert [tuiTitle] {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Пагинация Taiga: активная страница — primary внутри чужого компонента;
|
||||
* стили из sessions-list не доходят из‑за ViewEncapsulation — только глобально.
|
||||
*/
|
||||
tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] {
|
||||
--t-bg: var(--sg-filter-chip-active-bg) !important;
|
||||
background: var(--t-bg) !important;
|
||||
border-color: var(--sg-filter-chip-active-bg) !important;
|
||||
color: var(--sg-filter-chip-active-fg) !important;
|
||||
}
|
||||
|
||||
tui-pagination
|
||||
button.t-button[tuiButton][tuiAppearance][data-appearance='primary']:hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
) {
|
||||
--t-bg: var(--sg-filter-chip-active-bg-hover) !important;
|
||||
background: var(--t-bg) !important;
|
||||
border-color: var(--sg-filter-chip-active-bg-hover) !important;
|
||||
color: var(--sg-filter-chip-active-fg) !important;
|
||||
}
|
||||
|
||||
@@ -24,16 +24,28 @@
|
||||
--sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
|
||||
--sg-color-danger: #d92d20;
|
||||
|
||||
/* Taiga accent palette override (used by primary/secondary appearances, including pagination active item) */
|
||||
/* Ширина и отступы основного контента (шапка, страницы с классом .page) */
|
||||
/* От 1000px: боковые поля по умолчанию; уже 999px — 48px (media ниже). */
|
||||
--sg-content-max-width: 1104px;
|
||||
--sg-page-padding-inline: 1rem;
|
||||
|
||||
/*
|
||||
* Чипы-категории (фильтры телеметрии, пресеты диапазона, вкладки потоков на просмотре).
|
||||
* Совпадают с полями там, где цвета те же: фон неактивного = --sg-color-textfield-bg.
|
||||
*/
|
||||
--sg-filter-chip-bg: #f3f4f7;
|
||||
--sg-filter-chip-bg-hover: #eaeff3;
|
||||
--sg-filter-chip-fg: #313131;
|
||||
--sg-filter-chip-active-bg: #158eff;
|
||||
--sg-filter-chip-active-bg-hover: #0070ff;
|
||||
--sg-filter-chip-active-fg: #ffffff;
|
||||
|
||||
/* Taiga accent palette override (primary-кнопки и т.п.; активная страница пагинации переопределена в sessions-list) */
|
||||
--tui-background-accent-1: var(--sg-color-accent);
|
||||
--tui-background-accent-1-hover: color-mix(in srgb, var(--sg-color-accent) 92%, black);
|
||||
--tui-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black);
|
||||
--tui-text-primary-on-accent-1: var(--sg-color-text);
|
||||
|
||||
/*
|
||||
* Bridge to Taiga tokens used in the app.
|
||||
* This keeps styling centralized and avoids hardcoding colors in components.
|
||||
*/
|
||||
--tui-background-base: var(--sg-color-bg);
|
||||
--tui-background-elevation-1: var(--sg-color-card-bg);
|
||||
--tui-text-primary: var(--sg-color-text);
|
||||
@@ -48,15 +60,15 @@
|
||||
--sg-session-status-active-fg: #166534;
|
||||
--sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent);
|
||||
|
||||
--sg-session-status-pending-bg: color-mix(in srgb, #d97706 18%, var(--sg-color-card-bg));
|
||||
--sg-session-status-pending-fg: #92400e;
|
||||
--sg-session-status-pending-border: color-mix(in srgb, #d97706 34%, transparent);
|
||||
--sg-session-status-pending-bg: color-mix(in srgb, #eab308 18%, var(--sg-color-card-bg));
|
||||
--sg-session-status-pending-fg: #713f12;
|
||||
--sg-session-status-pending-border: color-mix(in srgb, #eab308 34%, transparent);
|
||||
|
||||
--sg-session-status-unknown-bg: color-mix(in srgb, var(--sg-color-text) 10%, var(--sg-color-card-bg));
|
||||
--sg-session-status-unknown-fg: var(--sg-color-text);
|
||||
--sg-session-status-unknown-border: var(--sg-color-border);
|
||||
|
||||
/* Встроенная SVG-клавиатура (public/KB_USA-standard.svg) */
|
||||
/* Встроенная SVG-клавиатура (public/svg/visual/keyboard.svg) */
|
||||
--sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif;
|
||||
--sg-keyboard-font-weight: 400;
|
||||
--sg-keyboard-letter-spacing: 0.03em;
|
||||
@@ -70,7 +82,7 @@
|
||||
--sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle);
|
||||
--sg-keyboard-key-other: var(--sg-keyboard-key-surface-idle);
|
||||
--sg-keyboard-key-stroke: color-mix(in srgb, var(--sg-color-border) 65%, transparent);
|
||||
/* Символы на клавишах — основной тёмный текст (тот же оттенок, что раньше был заливкой нажатой) */
|
||||
/* Базовые глифы — ink-soft; контрастный «чёрный» — --sg-keyboard-ink (см. подсветку нажатий) */
|
||||
--sg-keyboard-ink: var(--sg-color-text);
|
||||
--sg-keyboard-ink-soft: var(--tui-text-tertiary);
|
||||
/* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */
|
||||
@@ -78,3 +90,9 @@
|
||||
--sg-keyboard-key-pressed-ink: var(--sg-color-text);
|
||||
--sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill);
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
:root {
|
||||
--sg-page-padding-inline: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/*
|
||||
* Единый вид полей ввода SparkGuardian.
|
||||
* Taiga: добавьте class="sg-tui-textfield" на <tui-textfield>.
|
||||
* Нативные input/textarea/select: class="sg-native-input".
|
||||
*/
|
||||
|
||||
/* --- tui-textfield (Taiga Input) --- */
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] {
|
||||
--tui-focus: var(--sg-color-textfield-focus-border);
|
||||
|
||||
Reference in New Issue
Block a user