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",
|
"name": "sparkguardian",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"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",
|
||||||
@@ -333,6 +334,22 @@
|
|||||||
"yarn": ">= 1.13.0"
|
"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": {
|
"node_modules/@angular/build": {
|
||||||
"version": "21.2.6",
|
"version": "21.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-5.1.0.tgz",
|
||||||
"integrity": "sha512-3k1asuOgEjDLAtkK/snO8anctfo4vmMsyr16NHcNVDRqvUeRDrWf7sdRD1uXloO0hTrJvVbKR3WnDWxnuTKm8Q==",
|
"integrity": "sha512-3k1asuOgEjDLAtkK/snO8anctfo4vmMsyr16NHcNVDRqvUeRDrWf7sdRD1uXloO0hTrJvVbKR3WnDWxnuTKm8Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": ">=2.8.1"
|
"tslib": ">=2.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"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",
|
||||||
@@ -31,10 +32,10 @@
|
|||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"@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",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"less": "^4.6.4",
|
"less": "^4.6.4",
|
||||||
"prettier": "^3.8.1",
|
"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);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
text-align: start;
|
text-align: start;
|
||||||
text-anchor: start;
|
text-anchor: start;
|
||||||
fill: var(--sg-keyboard-ink);
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
}
|
}
|
||||||
#T_alterninsc text {
|
#T_alterninsc text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
fill: var(--sg-keyboard-ink);
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
stroke: none;
|
stroke: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
fill: var(--sg-keyboard-ink);
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
stroke: none;
|
stroke: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
fill: var(--sg-keyboard-ink);
|
fill: var(--sg-keyboard-ink-soft);
|
||||||
stroke: none;
|
stroke: none;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
text-anchor: start;
|
text-anchor: start;
|
||||||
@@ -159,23 +159,17 @@
|
|||||||
stroke-width: 0.85;
|
stroke-width: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Встроенные значки Lucide (обводка); Meta — иконка command (⌘) */
|
/* Lucide: в покое как подписи tab/caps; нажатие — через injectHighlight */
|
||||||
.sg-lucide path,
|
.sg-lucide path,
|
||||||
.sg-lucide line,
|
.sg-lucide line,
|
||||||
.sg-lucide polyline,
|
.sg-lucide polyline,
|
||||||
.sg-lucide rect {
|
.sg-lucide rect {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--sg-keyboard-ink);
|
stroke: var(--sg-keyboard-ink-soft);
|
||||||
stroke-width: 1.55;
|
stroke-width: 1.55;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: 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 { 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 { provideTaiga } from '@taiga-ui/core';
|
||||||
import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification';
|
import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification';
|
||||||
@@ -11,6 +12,7 @@ import { routes } from './app.routes';
|
|||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideAnimations(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideTaiga(),
|
provideTaiga(),
|
||||||
tuiNotificationOptionsProvider(() => ({
|
tuiNotificationOptionsProvider(() => ({
|
||||||
|
|||||||
@@ -11,21 +11,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shell-header {
|
.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);
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
background: var(--tui-background-elevation-1);
|
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 {
|
.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);
|
color: var(--tui-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
height: 2rem;
|
||||||
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand:hover {
|
.brand:hover {
|
||||||
color: var(--tui-text-action);
|
color: var(--tui-text-action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<tui-root>
|
<tui-root>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="shell-header">
|
<header class="shell-header">
|
||||||
<a routerLink="/" class="brand">SparkGuardian</a>
|
<div class="shell-header__inner sg-content-column">
|
||||||
<span class="shell-sub">Прокторинг</span>
|
<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>
|
</header>
|
||||||
<main class="shell-main">
|
<main class="shell-main">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ describe('App', () => {
|
|||||||
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('.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';
|
import { API_BASE_URL } from '../config/api.tokens';
|
||||||
|
|
||||||
/**
|
|
||||||
* Подставляет базовый URL ко всем относительным запросам.
|
|
||||||
* Абсолютные URL (`http…`) не трогаем — как в Axios с `baseURL`, только через перехватчик.
|
|
||||||
*/
|
|
||||||
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const base = inject(API_BASE_URL);
|
const base = inject(API_BASE_URL);
|
||||||
if (/^https?:\/\//i.test(req.url)) {
|
if (/^https?:\/\//i.test(req.url)) {
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ function isParseFailureMessage(message: string | undefined): boolean {
|
|||||||
return !!message?.includes('Http failure during parsing');
|
return !!message?.includes('Http failure during parsing');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Определяет категорию ошибки для пользовательского сообщения (без утечки технических деталей).
|
|
||||||
*/
|
|
||||||
export function classifyUserError(err: unknown): UserErrorKind {
|
export function classifyUserError(err: unknown): UserErrorKind {
|
||||||
if (err instanceof TimeoutError) {
|
if (err instanceof TimeoutError) {
|
||||||
return 'timeout';
|
return 'timeout';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { HttpErrorResponse } from '@angular/common/http';
|
|||||||
|
|
||||||
import type { ApiErrorBody } from '../models/api.types';
|
import type { ApiErrorBody } from '../models/api.types';
|
||||||
|
|
||||||
/** Безопасная подстановка текста в разметку с innerHTML (например, Taiga notification). */
|
|
||||||
export function escapeHtml(text: string): string {
|
export function escapeHtml(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -1,24 +1,56 @@
|
|||||||
import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id';
|
import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
/**
|
function normalizeKeyToken(token: string): string {
|
||||||
* Токены из `key_name` и из `modifiers` (через "+"), в нижнем регистре.
|
return token.trim().toLowerCase().replace(/-/g, '_');
|
||||||
* Соответствие имён бэкенда → id прямоугольников в KB_USA-standard.svg.
|
}
|
||||||
*/
|
|
||||||
function tokenToSvgIds(token: string): string[] {
|
function tokenToSvgIds(token: string): string[] {
|
||||||
const t = token.trim().toLowerCase();
|
const t = normalizeKeyToken(token);
|
||||||
if (!t) {
|
if (!t) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const named: Record<string, string[]> = {
|
const named: Record<string, string[]> = {
|
||||||
shift: ['K_kb5a'],
|
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'],
|
meta: ['K_kb6b'],
|
||||||
super: ['K_kb6b'],
|
super: ['K_kb6b'],
|
||||||
|
l_super: ['K_kb6b'],
|
||||||
|
r_super: ['K_kb6l'],
|
||||||
win: ['K_kb6b'],
|
win: ['K_kb6b'],
|
||||||
windows: ['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'],
|
control: ['K_kb6a'],
|
||||||
ctrl: ['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'],
|
alt: ['K_kb6c'],
|
||||||
|
l_alt: ['K_kb6c'],
|
||||||
|
r_alt: ['K_kb6k'],
|
||||||
|
left_alt: ['K_kb6c'],
|
||||||
|
right_alt: ['K_kb6k'],
|
||||||
|
|
||||||
tab: ['K_kb3a'],
|
tab: ['K_kb3a'],
|
||||||
enter: ['K_kb4n'],
|
enter: ['K_kb4n'],
|
||||||
return: ['K_kb4n'],
|
return: ['K_kb4n'],
|
||||||
@@ -40,10 +72,6 @@ function tokenToSvgIds(token: string): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Формат бэкенда: `{ key_name, modifiers: "Shift+Meta", action }` и т.п.
|
|
||||||
* Собирает уникальные id клавиш для подсветки (модификаторы + основная клавиша).
|
|
||||||
*/
|
|
||||||
export function keyNameModifiersPayloadToSvgIds(o: Record<string, unknown>): string[] {
|
export function keyNameModifiersPayloadToSvgIds(o: Record<string, unknown>): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
|
|||||||
return t.includes('keyboard') || t.includes('key');
|
return t.includes('keyboard') || t.includes('key');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлекает виртуальный код клавиши из payload (массив чисел, объект с полями vk/keyCode и т.д.).
|
|
||||||
*/
|
|
||||||
export function parseKeyboardVirtualKey(data: unknown): number | null {
|
export function parseKeyboardVirtualKey(data: unknown): number | null {
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -65,9 +62,6 @@ function unwrapJsonPayload(data: unknown): unknown {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Id элементов `K_kb*` для подсветки: сначала объект `key_name` / `modifiers`, иначе VK из массива/полей.
|
|
||||||
*/
|
|
||||||
export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
||||||
const raw = unwrapJsonPayload(data);
|
const raw = unwrapJsonPayload(data);
|
||||||
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
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';
|
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/...`.
|
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
|
||||||
*/
|
*/
|
||||||
const KEYBOARD_SVG_PATH = '/KB_USA-standard.svg';
|
const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class KeyboardSvgHighlightService {
|
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 {
|
private injectHighlight(svgText: string, keyIds: string[]): string {
|
||||||
const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id));
|
const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id));
|
||||||
if (valid.length === 0) {
|
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 {
|
export function normalizeVirtualKey(vk: number): number {
|
||||||
if (vk >= 0x61 && vk <= 0x7a) {
|
if (vk >= 0x61 && vk <= 0x7a) {
|
||||||
return vk - 0x20;
|
return vk - 0x20;
|
||||||
@@ -49,7 +45,6 @@ const LETTER_TO_ID: Record<string, string> = {
|
|||||||
Z: 'K_kb5c',
|
Z: 'K_kb5c',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** OEM и прочие VK (Windows). */
|
|
||||||
const EXTRA_VK: Record<number, string> = {
|
const EXTRA_VK: Record<number, string> = {
|
||||||
0x08: 'K_kb2n',
|
0x08: 'K_kb2n',
|
||||||
0x09: 'K_kb3a',
|
0x09: 'K_kb3a',
|
||||||
@@ -98,7 +93,6 @@ export function vkToKeyboardSvgKeyId(vk: number): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Одна буква A–Z или цифра 0–9 как в подписи клавиши (`key_name`). */
|
|
||||||
export function charKeyNameToSvgKeyId(name: string): string | null {
|
export function charKeyNameToSvgKeyId(name: string): string | null {
|
||||||
const c = name.trim();
|
const c = name.trim();
|
||||||
if (c.length !== 1) {
|
if (c.length !== 1) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Соответствует `handler.ErrorResponse` из OpenAPI. */
|
/** OpenAPI: handler.ErrorResponse */
|
||||||
export interface ApiErrorBody {
|
export interface ApiErrorBody {
|
||||||
code?: string;
|
code?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -24,7 +24,7 @@ export interface SessionSummary {
|
|||||||
ended_at?: string;
|
ended_at?: string;
|
||||||
chunks_total?: number;
|
chunks_total?: number;
|
||||||
events_total?: number;
|
events_total?: number;
|
||||||
/** Не в swagger, но бэкенд может отдавать для списка. */
|
/** в списке сессий с бэка, не всегда в swagger */
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,14 @@
|
|||||||
/**
|
|
||||||
* Типы ошибок для подбора дружелюбного текста во всплывающих уведомлениях.
|
|
||||||
* Соответствие «тип → фраза» задаётся здесь; технические детали — только в Dev Console.
|
|
||||||
*/
|
|
||||||
export const USER_ERROR_FRIENDLY_MESSAGES = {
|
export const USER_ERROR_FRIENDLY_MESSAGES = {
|
||||||
/** Нет сети / соединение сброшено (часто HTTP 0). */
|
|
||||||
network: 'Проверьте интернет-соединение.',
|
network: 'Проверьте интернет-соединение.',
|
||||||
/** Таймаут запроса (в т.ч. RxJS timeout, HTTP 408). */
|
|
||||||
timeout: 'Запрос занял слишком много времени. Попробуйте позже.',
|
timeout: 'Запрос занял слишком много времени. Попробуйте позже.',
|
||||||
/** Ответ сервера 5xx. */
|
|
||||||
server_error: 'Уже работаем над этим.',
|
server_error: 'Уже работаем над этим.',
|
||||||
/** 404. */
|
|
||||||
not_found: 'Не удалось найти запрашиваемые данные.',
|
not_found: 'Не удалось найти запрашиваемые данные.',
|
||||||
/** 401. */
|
|
||||||
unauthorized: 'Требуется вход в систему.',
|
unauthorized: 'Требуется вход в систему.',
|
||||||
/** 403. */
|
|
||||||
forbidden: 'Недостаточно прав для этого действия.',
|
forbidden: 'Недостаточно прав для этого действия.',
|
||||||
/** 400. */
|
|
||||||
bad_request: 'Некорректный запрос. Попробуйте позже.',
|
bad_request: 'Некорректный запрос. Попробуйте позже.',
|
||||||
/** Прочие 4xx (кроме перечисленных выше). */
|
|
||||||
client_error: 'Не удалось выполнить запрос. Попробуйте позже.',
|
client_error: 'Не удалось выполнить запрос. Попробуйте позже.',
|
||||||
/** Не JSON / ошибка разбора ответа. */
|
|
||||||
parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.',
|
parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.',
|
||||||
/** Локальная валидация (неверный id и т.п.). */
|
|
||||||
invalid_input: 'Проверьте введённые данные.',
|
invalid_input: 'Проверьте введённые данные.',
|
||||||
/** Не удалось отнести к категории. */
|
|
||||||
unknown: 'Попробуйте позже.',
|
unknown: 'Попробуйте позже.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ import { friendlyMessageForUserError } from './user-error-messages.config';
|
|||||||
|
|
||||||
const ERROR_TOAST_TITLE = 'Что-то пошло не так...';
|
const ERROR_TOAST_TITLE = 'Что-то пошло не так...';
|
||||||
|
|
||||||
/**
|
|
||||||
* Показывает пользователю обобщённое уведомление; точный текст ошибки — только в DevLog (в dev).
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class UserErrorNotifyService {
|
export class UserErrorNotifyService {
|
||||||
private readonly notifications = inject(TuiNotificationService);
|
private readonly notifications = inject(TuiNotificationService);
|
||||||
private readonly devLog = inject(DevLogService);
|
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 {
|
notifyError(err: unknown, source: string): void {
|
||||||
const kind = classifyUserError(err);
|
const kind = classifyUserError(err);
|
||||||
const userSubtitle = friendlyMessageForUserError(kind);
|
const userSubtitle = friendlyMessageForUserError(kind);
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ export class SessionsApiService {
|
|||||||
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Относительный `playlist_url` из API нужно разрешить относительно хоста (`API_ORIGIN`).
|
|
||||||
*/
|
|
||||||
resolvePlaylistUrl(playlistUrl: string): string {
|
resolvePlaylistUrl(playlistUrl: string): string {
|
||||||
if (/^https?:\/\//i.test(playlistUrl)) {
|
if (/^https?:\/\//i.test(playlistUrl)) {
|
||||||
return playlistUrl;
|
return playlistUrl;
|
||||||
@@ -58,9 +55,6 @@ export class SessionsApiService {
|
|||||||
return new URL(playlistUrl, `${this.apiOrigin}/`).href;
|
return new URL(playlistUrl, `${this.apiOrigin}/`).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ручка для агента (Bearer). В веб-интерфейсе обычно не нужна — оставлена для отладки/скриптов.
|
|
||||||
*/
|
|
||||||
uploadChunk(
|
uploadChunk(
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
chunkIdx: number,
|
chunkIdx: number,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/** finished — без классов (дефолт Taiga). */
|
||||||
* Классы-модификаторы для чипа статуса. `finished` — без модификатора (дефолтный вид Taiga).
|
|
||||||
*/
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'sessionStatusChipClasses',
|
name: 'sessionStatusChipClasses',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* Отображаемые подписи для статусов сессий (значения с API — обычно в нижнем регистре).
|
|
||||||
* Неизвестный ключ в UI показывается как есть; в dev — предупреждение в консоли разработчика.
|
|
||||||
*/
|
|
||||||
export const SESSION_STATUS_LABELS: Readonly<Record<string, string>> = {
|
export const SESSION_STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||||
active: 'Активная',
|
active: 'Активная',
|
||||||
pending: 'Ожидается',
|
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);
|
private readonly logs = inject(DevLogService);
|
||||||
protected readonly isDev = isDevMode();
|
protected readonly isDev = isDevMode();
|
||||||
protected readonly collapsed = signal(false);
|
protected readonly collapsed = signal(false);
|
||||||
protected readonly minimized = signal(false);
|
protected readonly minimized = signal(true);
|
||||||
protected readonly entries = this.logs.entries;
|
protected readonly entries = this.logs.entries;
|
||||||
protected readonly count = computed(() => this.entries().length);
|
protected readonly count = computed(() => this.entries().length);
|
||||||
protected readonly expandedIds = signal<Record<number, boolean>>({});
|
protected readonly expandedIds = signal<Record<number, boolean>>({});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import Hls from 'hls.js';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class HlsPlayerComponent {
|
export class HlsPlayerComponent {
|
||||||
/** Полный URL плейлиста `.m3u8`. */
|
|
||||||
readonly src = input.required<string>();
|
readonly src = input.required<string>();
|
||||||
|
|
||||||
private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl');
|
private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 960px;
|
max-width: var(--sg-content-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player {
|
.player {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { animate, style, transition, trigger } from '@angular/animations';
|
||||||
import { AsyncPipe, NgClass } from '@angular/common';
|
import { AsyncPipe, NgClass } from '@angular/common';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core';
|
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 { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||||
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
|
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
|
||||||
import { SessionStatusPipe } from '../../../core/sessions/session-status.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 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 { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||||
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||||
|
import { formatDurationMsHuman } from '../../../shared/utils/duration.util';
|
||||||
import { HlsPlayerComponent } from '../hls-player/hls-player.component';
|
import { HlsPlayerComponent } from '../hls-player/hls-player.component';
|
||||||
import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component';
|
import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component';
|
||||||
|
|
||||||
|
type TelemetryRangeSelection =
|
||||||
|
| { type: 'preset'; seconds: number }
|
||||||
|
| { type: 'end' }
|
||||||
|
| { type: 'custom' };
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-session-detail',
|
selector: 'app-session-detail',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -35,11 +44,29 @@ import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemet
|
|||||||
TuiTitle,
|
TuiTitle,
|
||||||
SessionStatusChipClassesPipe,
|
SessionStatusChipClassesPipe,
|
||||||
SessionStatusPipe,
|
SessionStatusPipe,
|
||||||
|
TelemetryEventTypePipe,
|
||||||
TelemetryEventDetailComponent,
|
TelemetryEventDetailComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './session-detail.html',
|
templateUrl: './session-detail.html',
|
||||||
styleUrl: './session-detail.css',
|
styleUrl: './session-detail.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
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 {
|
export class SessionDetailComponent {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
@@ -51,16 +78,14 @@ export class SessionDetailComponent {
|
|||||||
private readonly recordingEndMs = signal<number | null>(null);
|
private readonly recordingEndMs = signal<number | null>(null);
|
||||||
protected readonly customToLocal = signal('');
|
protected readonly customToLocal = signal('');
|
||||||
|
|
||||||
/** Выбранный тип потока (или первая вкладка по умолчанию в шаблоне). */
|
private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' });
|
||||||
|
|
||||||
protected readonly selectedStreamType = signal<string | null>(null);
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
|
|
||||||
/** 0 — просмотр, 1 — служебная информация. */
|
|
||||||
protected readonly activeTabIndex = model(0);
|
protected readonly activeTabIndex = model(0);
|
||||||
|
|
||||||
/** null — все типы событий в таблице телеметрии. */
|
|
||||||
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
|
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
|
||||||
|
|
||||||
/** Раскрытая строка телеметрии: ключ или null. */
|
|
||||||
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
|
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
|
||||||
|
|
||||||
private readonly sessionId$ = this.route.paramMap.pipe(
|
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||||
@@ -88,6 +113,8 @@ export class SessionDetailComponent {
|
|||||||
const end = this.toUnixMs(state.detail.session.ended_at);
|
const end = this.toUnixMs(state.detail.session.ended_at);
|
||||||
this.recordingStartMs.set(start);
|
this.recordingStartMs.set(start);
|
||||||
this.recordingEndMs.set(end);
|
this.recordingEndMs.set(end);
|
||||||
|
this.telemetryRangeSelection.set({ type: 'end' });
|
||||||
|
this.customToLocal.set('');
|
||||||
// Дефолт для телеметрии: до текущего момента (или конца записи, если завершена).
|
// Дефолт для телеметрии: до текущего момента (или конца записи, если завершена).
|
||||||
if (this.telemetryToMs() === null) {
|
if (this.telemetryToMs() === null) {
|
||||||
this.telemetryToMs.set(end ?? Date.now());
|
this.telemetryToMs.set(end ?? Date.now());
|
||||||
@@ -202,8 +229,16 @@ export class SessionDetailComponent {
|
|||||||
this.expandedTelemetryRowKey.set(null);
|
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 {
|
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 {
|
protected toggleTelemetryRow(row: ParsedEvent, index: number): void {
|
||||||
@@ -215,17 +250,16 @@ export class SessionDetailComponent {
|
|||||||
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
|
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Уникальные значения `event_type` (пустая строка — отдельная вкладка). */
|
|
||||||
protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
|
protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
set.add(e.event_type ?? '');
|
set.add(this.telemetryEventTypeKey(e));
|
||||||
}
|
}
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, 'ru'));
|
return [...set].sort((a, b) => a.localeCompare(b, 'ru'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number {
|
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[] {
|
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
|
||||||
@@ -233,33 +267,16 @@ export class SessionDetailComponent {
|
|||||||
if (filter === null) {
|
if (filter === null) {
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
return events.filter((e) => (e.event_type ?? '') === filter);
|
return events.filter((e) => this.telemetryEventTypeKey(e) === filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected telemetryTypeTabLabel(typeKey: string): string {
|
protected telemetryEventSummary(event: ParsedEvent): string {
|
||||||
return typeKey === '' ? 'Без типа' : typeKey;
|
return summarizeTelemetryData(event.data);
|
||||||
}
|
|
||||||
|
|
||||||
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 selectRecentWindow(seconds: number): void {
|
protected selectRecentWindow(seconds: number): void {
|
||||||
this.customToLocal.set('');
|
this.customToLocal.set('');
|
||||||
|
this.telemetryRangeSelection.set({ type: 'preset', seconds });
|
||||||
const start = this.recordingStartMs() ?? Date.now();
|
const start = this.recordingStartMs() ?? Date.now();
|
||||||
const end = this.recordingEndMs() ?? Date.now();
|
const end = this.recordingEndMs() ?? Date.now();
|
||||||
this.telemetryToMs.set(this.clamp(start + seconds * 1000, start, end));
|
this.telemetryToMs.set(this.clamp(start + seconds * 1000, start, end));
|
||||||
@@ -267,6 +284,7 @@ export class SessionDetailComponent {
|
|||||||
|
|
||||||
protected loadUntilEndTelemetry(): void {
|
protected loadUntilEndTelemetry(): void {
|
||||||
this.customToLocal.set('');
|
this.customToLocal.set('');
|
||||||
|
this.telemetryRangeSelection.set({ type: 'end' });
|
||||||
const end = this.recordingEndMs() ?? Date.now();
|
const end = this.recordingEndMs() ?? Date.now();
|
||||||
this.telemetryToMs.set(end);
|
this.telemetryToMs.set(end);
|
||||||
}
|
}
|
||||||
@@ -278,17 +296,27 @@ export class SessionDetailComponent {
|
|||||||
}
|
}
|
||||||
const ms = new Date(value).getTime();
|
const ms = new Date(value).getTime();
|
||||||
if (Number.isFinite(ms)) {
|
if (Number.isFinite(ms)) {
|
||||||
|
this.telemetryRangeSelection.set({ type: 'custom' });
|
||||||
const start = this.recordingStartMs() ?? Date.now();
|
const start = this.recordingStartMs() ?? Date.now();
|
||||||
const end = this.recordingEndMs() ?? Date.now();
|
const end = this.recordingEndMs() ?? Date.now();
|
||||||
this.telemetryToMs.set(this.clamp(ms, start, end));
|
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 {
|
protected telemetryRangeLabel(toMs: number | null): string {
|
||||||
return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`;
|
return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected detailPayloadJson(detail: SessionDetailResponse): string {
|
private detailPayloadJson(detail: SessionDetailResponse): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(detail, null, 2);
|
return JSON.stringify(detail, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -296,11 +324,18 @@ export class SessionDetailComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected formatDurationMs(ms: number | null | undefined): string {
|
protected async copyDetailPayloadJson(detail: SessionDetailResponse): Promise<void> {
|
||||||
if (ms === null || ms === undefined || !Number.isFinite(ms)) {
|
const text = this.detailPayloadJson(detail);
|
||||||
return '—';
|
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 {
|
protected streamResolvedPlaylistUrl(stream: StreamInfo): string {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
.page {
|
.page {
|
||||||
max-width: 960px;
|
padding-top: 1.5rem;
|
||||||
margin: 0 auto;
|
padding-bottom: 3rem;
|
||||||
padding: 1.5rem 1rem 3rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back {
|
.back {
|
||||||
@@ -66,36 +64,33 @@
|
|||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-block {
|
.json-copy-row {
|
||||||
margin: 0.75rem 0 0;
|
display: flex;
|
||||||
padding: 1rem;
|
flex-wrap: wrap;
|
||||||
max-height: min(360px, 50vh);
|
align-items: center;
|
||||||
overflow: auto;
|
justify-content: space-between;
|
||||||
border-radius: var(--tui-radius-m);
|
gap: 0.75rem 1rem;
|
||||||
background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary));
|
}
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.45;
|
.json-copy-row .section-title {
|
||||||
white-space: pre-wrap;
|
margin: 0;
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-table {
|
.meta-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font: var(--tui-font-text-s);
|
font: var(--tui-font-text-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-table th,
|
.meta-table tbody td {
|
||||||
.meta-table td {
|
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
border-bottom: 1px solid var(--tui-border-normal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-table th {
|
.meta-table th {
|
||||||
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tui-text-secondary);
|
color: var(--tui-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap_flat {
|
.table-wrap_flat {
|
||||||
@@ -133,14 +128,41 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 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 {
|
.telemetry-head .section-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.telemetry-head .telemetry-range {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.telemetry-actions {
|
.telemetry-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -184,53 +206,143 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-tabs button {
|
.stream-tabs button,
|
||||||
|
.telemetry-presets button {
|
||||||
transition:
|
transition:
|
||||||
background-color 0.15s ease,
|
background-color 0.15s ease,
|
||||||
color 0.15s ease,
|
color 0.15s ease,
|
||||||
border-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
|
.stream-tabs
|
||||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not([data-state='disabled']) {
|
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
|
||||||
background: var(--sg-color-accent);
|
[data-state='disabled']
|
||||||
color: var(--sg-color-text);
|
),
|
||||||
border-color: color-mix(in srgb, var(--sg-color-accent) 78%, black);
|
.telemetry-presets
|
||||||
outline: 2px solid transparent;
|
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);
|
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'],
|
||||||
background: color-mix(in srgb, var(--sg-color-accent) 22%, white);
|
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] {
|
||||||
border-color: var(--sg-color-accent);
|
border-radius: 624.9375rem;
|
||||||
color: var(--sg-color-text);
|
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 {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: min(480px, 70vh);
|
max-height: min(520px, 70vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap:not(.table-wrap_flat) {
|
||||||
|
min-height: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry {
|
.telemetry {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font: var(--tui-font-text-s);
|
font: var(--tui-font-text-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry th,
|
.telemetry th {
|
||||||
.telemetry td {
|
text-align: left;
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry tbody td {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--tui-border-normal);
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry th {
|
/* Длинные неизвестные event_type — перенос, без разъезда таблицы */
|
||||||
position: sticky;
|
.telemetry-col-type {
|
||||||
top: 0;
|
min-width: 0;
|
||||||
background: var(--tui-background-elevation-1);
|
max-width: 14rem;
|
||||||
z-index: 1;
|
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 {
|
.telemetry-row {
|
||||||
@@ -252,7 +364,9 @@
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payload {
|
.telemetry-col-summary {
|
||||||
font-family: ui-monospace, monospace;
|
min-width: 0;
|
||||||
word-break: break-all;
|
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">
|
<nav class="back">
|
||||||
<a tuiLink routerLink="/">← К списку сессий</a>
|
<a tuiLink routerLink="/">← К списку сессий</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<p class="muted">Не удалось загрузить сессию.</p>
|
<p class="muted">Не удалось загрузить сессию.</p>
|
||||||
}
|
}
|
||||||
@case ('ok') {
|
@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">
|
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||||
<button tuiTab type="button">Просмотр</button>
|
<button tuiTab type="button">Просмотр</button>
|
||||||
@@ -68,34 +68,88 @@
|
|||||||
|
|
||||||
<section class="card" aria-label="Телеметрия">
|
<section class="card" aria-label="Телеметрия">
|
||||||
<div class="telemetry-head">
|
<div class="telemetry-head">
|
||||||
<h3 class="section-title">
|
<div class="telemetry-head__main">
|
||||||
События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }})
|
<h3 class="section-title">
|
||||||
</h3>
|
События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }})
|
||||||
|
</h3>
|
||||||
|
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState.toMs) }}</p>
|
||||||
|
</div>
|
||||||
<div class="telemetry-actions">
|
<div class="telemetry-actions">
|
||||||
<span class="muted small">{{ telemetryRangeLabel(telemetryState.toMs) }}</span>
|
|
||||||
<div class="telemetry-presets">
|
<div class="telemetry-presets">
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(2)">+2с</button>
|
<button
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(10)">+10с</button>
|
tuiButton
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(60)">+1м</button>
|
type="button"
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(300)">+5м</button>
|
size="s"
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(900)">+15м</button>
|
appearance="secondary"
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="loadUntilEndTelemetry()">До конца</button>
|
[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>
|
</div>
|
||||||
<label class="from-picker">
|
<label class="from-picker">
|
||||||
<span class="muted small">До:</span>
|
|
||||||
<input
|
<input
|
||||||
#toInput
|
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="sg-native-input"
|
class="sg-native-input"
|
||||||
[value]="customToLocal()"
|
[value]="customToLocal()"
|
||||||
|
(change)="applyCustomTo($any($event.target).value)"
|
||||||
|
aria-label="Верхняя граница диапазона телеметрии"
|
||||||
/>
|
/>
|
||||||
<button tuiButton type="button" size="s" appearance="secondary" (click)="applyCustomTo(toInput.value)">
|
|
||||||
Применить
|
|
||||||
</button>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="telemetry-content">
|
||||||
@if (telemetryState.status === 'loading') {
|
@if (telemetryState.status === 'loading') {
|
||||||
<div class="loading-wrap loading-wrap_small">
|
<div class="loading-wrap loading-wrap_small">
|
||||||
<tui-loader [loading]="true" size="l" />
|
<tui-loader [loading]="true" size="l" />
|
||||||
@@ -126,7 +180,7 @@
|
|||||||
[class.stream-active]="telemetryEventTypeFilter() === t"
|
[class.stream-active]="telemetryEventTypeFilter() === t"
|
||||||
(click)="pickTelemetryEventTypeFilter(t)"
|
(click)="pickTelemetryEventTypeFilter(t)"
|
||||||
>
|
>
|
||||||
{{ telemetryTypeTabLabel(t) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
|
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,13 +193,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Время</th>
|
<th>Время</th>
|
||||||
<th>Тип</th>
|
<th>Тип</th>
|
||||||
<th>Payload</th>
|
<th>Сводка</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (
|
@for (
|
||||||
row of filteredTelemetryEvents(telemetryState.telemetry);
|
row of filteredTelemetryEvents(telemetryState.telemetry);
|
||||||
track row.timestamp + '-' + row.event_type + '-' + $index;
|
track $index;
|
||||||
let i = $index
|
let i = $index
|
||||||
) {
|
) {
|
||||||
<tr
|
<tr
|
||||||
@@ -154,11 +208,13 @@
|
|||||||
(click)="toggleTelemetryRow(row, i)"
|
(click)="toggleTelemetryRow(row, i)"
|
||||||
>
|
>
|
||||||
<td>{{ formatUnixMs(row.timestamp) }}</td>
|
<td>{{ formatUnixMs(row.timestamp) }}</td>
|
||||||
<td><span tuiChip size="xs">{{ row.event_type || '—' }}</span></td>
|
<td class="telemetry-col-type">
|
||||||
<td class="payload">{{ eventDataPreview(row) }}</td>
|
<span tuiChip size="xs">{{ row.event_type | telemetryEventType }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="telemetry-col-summary">{{ telemetryEventSummary(row) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@if (isTelemetryRowExpanded(row, i)) {
|
@if (isTelemetryRowExpanded(row, i)) {
|
||||||
<tr class="telemetry-row-detail">
|
<tr class="telemetry-row-detail" @telemetryEventDetail>
|
||||||
<td colspan="3" (click)="$event.stopPropagation()">
|
<td colspan="3" (click)="$event.stopPropagation()">
|
||||||
<app-telemetry-event-detail [event]="row" />
|
<app-telemetry-event-detail [event]="row" />
|
||||||
</td>
|
</td>
|
||||||
@@ -171,6 +227,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,21 +263,19 @@
|
|||||||
<table class="meta-table">
|
<table class="meta-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Тип потока</th>
|
<th>Тип</th>
|
||||||
<th>Чанков</th>
|
<th>Чанки</th>
|
||||||
<th>Длительность (мс)</th>
|
<th>Длительность</th>
|
||||||
<th>URL плейлиста (как в API)</th>
|
<th>URL видеозаписи</th>
|
||||||
<th>URL плейлиста (абсолютный)</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (stream of state.detail.streams; track stream.stream_type) {
|
@for (s of state.detail.streams; track s.stream_type) {
|
||||||
<tr>
|
<tr>
|
||||||
<td><code class="mono">{{ stream.stream_type }}</code></td>
|
<td><code class="mono">{{ s.stream_type }}</code></td>
|
||||||
<td>{{ stream.chunk_count ?? '—' }}</td>
|
<td>{{ s.chunk_count ?? '—' }}</td>
|
||||||
<td>{{ formatDurationMs(stream.duration_ms) }}</td>
|
<td>{{ formatDurationMs(s.duration_ms) }}</td>
|
||||||
<td class="payload">{{ stream.playlist_url }}</td>
|
<td class="payload">{{ streamResolvedPlaylistUrl(s) }}</td>
|
||||||
<td class="payload">{{ streamResolvedPlaylistUrl(stream) }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -260,8 +315,18 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" aria-label="Исходный JSON">
|
<section class="card" aria-label="Исходный JSON">
|
||||||
<h3 class="section-title">Исходный JSON</h3>
|
<div class="json-copy-row">
|
||||||
<pre class="json-block">{{ detailPayloadJson(state.detail) }}</pre>
|
<h3 class="section-title">Исходный JSON</h3>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="s"
|
||||||
|
appearance="secondary"
|
||||||
|
(click)="copyDetailPayloadJson(state.detail)"
|
||||||
|
>
|
||||||
|
Скопировать в буфер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
.page {
|
.page {
|
||||||
max-width: 960px;
|
padding-top: 1.5rem;
|
||||||
margin: 0 auto;
|
padding-bottom: 3rem;
|
||||||
padding: 1.5rem 1rem 3rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
@@ -109,11 +107,3 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat
|
|||||||
.total {
|
.total {
|
||||||
font: var(--tui-font-text-s);
|
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">
|
<div class="page sg-content-column">
|
||||||
<h2 tuiTitle class="heading">Сессии прокторинга</h2>
|
<h2 tuiTitle="m" class="heading">Сессии прокторинга</h2>
|
||||||
|
|
||||||
<section class="create card" aria-label="Создать сессию">
|
<section class="create card" aria-label="Создать сессию">
|
||||||
<h3 class="section-title">Новая сессия</h3>
|
<h3 class="section-title">Новая сессия</h3>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { AsyncPipe } from '@angular/common';
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||||
import { toObservable } from '@angular/core/rxjs-interop';
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { SafeHtml } from '@angular/platform-browser';
|
import { SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
|
import { TuiAccordion } from '@taiga-ui/kit/components/accordion';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
@@ -14,17 +16,20 @@ import {
|
|||||||
} from '../../../core/keyboard/keyboard-payload.util';
|
} from '../../../core/keyboard/keyboard-payload.util';
|
||||||
import { KeyboardSvgHighlightService } from '../../../core/keyboard/keyboard-svg-highlight.service';
|
import { KeyboardSvgHighlightService } from '../../../core/keyboard/keyboard-svg-highlight.service';
|
||||||
import type { ParsedEvent } from '../../../core/models/api.types';
|
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';
|
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-telemetry-event-detail',
|
selector: 'app-telemetry-event-detail',
|
||||||
imports: [AsyncPipe, TuiLoader],
|
imports: [AsyncPipe, TuiButton, TuiLoader, TelemetryEventTypePipe, ...TuiAccordion],
|
||||||
templateUrl: './telemetry-event-detail.html',
|
templateUrl: './telemetry-event-detail.html',
|
||||||
styleUrl: './telemetry-event-detail.css',
|
styleUrl: './telemetry-event-detail.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class TelemetryEventDetailComponent {
|
export class TelemetryEventDetailComponent {
|
||||||
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
|
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
|
||||||
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
|
||||||
readonly event = input.required<ParsedEvent>();
|
readonly event = input.required<ParsedEvent>();
|
||||||
|
|
||||||
@@ -50,7 +55,16 @@ export class TelemetryEventDetailComponent {
|
|||||||
return eventPayloadJson(e.data);
|
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[] {
|
protected keyboardKeyIds(): string[] {
|
||||||
const m = this.keyboardModel();
|
const m = this.keyboardModel();
|
||||||
return m.kind === 'keyboard' ? m.keyIds : [];
|
return m.kind === 'keyboard' ? m.keyIds : [];
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
padding: 0.5rem 0 0;
|
padding: 0.5rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
font: var(--tui-font-text-m);
|
font: var(--tui-font-text-m);
|
||||||
@@ -13,10 +17,14 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(8rem, 12rem) 1fr;
|
grid-template-columns: minmax(8rem, 12rem) 1fr;
|
||||||
gap: 0.35rem 1rem;
|
gap: 0.35rem 1rem;
|
||||||
margin: 0 0 1rem;
|
margin: 0;
|
||||||
font: var(--tui-font-text-s);
|
font: var(--tui-font-text-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-kv_main {
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-kv dt {
|
.detail-kv dt {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--tui-text-tertiary);
|
color: var(--tui-text-tertiary);
|
||||||
@@ -27,17 +35,36 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payload-json {
|
/* Подраздел «Служебные данные» внутри «Подробности» */
|
||||||
|
.detail-subsection {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.75rem 1rem;
|
padding-left: 0.65rem;
|
||||||
max-height: 200px;
|
border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary));
|
||||||
overflow: auto;
|
}
|
||||||
border-radius: var(--tui-radius-m);
|
|
||||||
background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary));
|
.telemetry-service-accordion {
|
||||||
font-size: 0.8rem;
|
inline-size: 100%;
|
||||||
line-height: 1.45;
|
}
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
.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 {
|
.mono {
|
||||||
@@ -46,9 +73,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-block {
|
.keyboard-block {
|
||||||
margin-top: 0.75rem;
|
margin: 0 0 1rem;
|
||||||
padding-top: 0.75rem;
|
padding: 0 0 0.75rem;
|
||||||
border-top: 1px solid var(--tui-border-normal);
|
border-bottom: 1px solid var(--tui-border-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-title {
|
.keyboard-title {
|
||||||
|
|||||||
@@ -1,31 +1,7 @@
|
|||||||
<div class="detail">
|
<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') {
|
@if (keyboardModel().kind === 'keyboard') {
|
||||||
<div class="keyboard-block">
|
<div class="keyboard-block">
|
||||||
<h4 class="keyboard-title">Клавиатура (US)</h4>
|
<h4 class="keyboard-title">Предпросмотр</h4>
|
||||||
@if (keyboardSvg$ | async; as svg) {
|
@if (keyboardSvg$ | async; as svg) {
|
||||||
<div class="keyboard-svg-host" [innerHTML]="svg"></div>
|
<div class="keyboard-svg-host" [innerHTML]="svg"></div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -41,4 +17,52 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* Преобразует ISO-время (timestamptz), например `2026-04-06T23:58:27Z`,
|
|
||||||
* в человекочитаемый локальный формат.
|
|
||||||
*/
|
|
||||||
export function formatTimestamp(value: string | null | undefined): string {
|
export function formatTimestamp(value: string | null | undefined): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '—';
|
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 = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiFallbackOrigin: 'https://sparkguardian.ru',
|
apiFallbackOrigin: 'https://sparkguardian.ru',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Sparkguardian</title>
|
<title>GUARD</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
@import './styles/session-status-chips.css';
|
@import './styles/session-status-chips.css';
|
||||||
@import './styles/sg-input-fields.css';
|
@import './styles/sg-input-fields.css';
|
||||||
|
|
||||||
/* Базовая вёрстка под Taiga UI: тема и шрифты подключаются в angular.json */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Tinkoff Sans';
|
font-family: 'Tinkoff Sans';
|
||||||
src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype');
|
src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype');
|
||||||
@@ -41,6 +40,14 @@ body {
|
|||||||
font-family: 'Tinkoff Sans', sans-serif;
|
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,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -66,3 +73,24 @@ textarea::placeholder {
|
|||||||
tui-notification-alert [tuiTitle] {
|
tui-notification-alert [tuiTitle] {
|
||||||
font-weight: 500;
|
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-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
|
||||||
--sg-color-danger: #d92d20;
|
--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: var(--sg-color-accent);
|
||||||
--tui-background-accent-1-hover: color-mix(in srgb, var(--sg-color-accent) 92%, black);
|
--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-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black);
|
||||||
--tui-text-primary-on-accent-1: var(--sg-color-text);
|
--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-base: var(--sg-color-bg);
|
||||||
--tui-background-elevation-1: var(--sg-color-card-bg);
|
--tui-background-elevation-1: var(--sg-color-card-bg);
|
||||||
--tui-text-primary: var(--sg-color-text);
|
--tui-text-primary: var(--sg-color-text);
|
||||||
@@ -48,15 +60,15 @@
|
|||||||
--sg-session-status-active-fg: #166534;
|
--sg-session-status-active-fg: #166534;
|
||||||
--sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent);
|
--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-bg: color-mix(in srgb, #eab308 18%, var(--sg-color-card-bg));
|
||||||
--sg-session-status-pending-fg: #92400e;
|
--sg-session-status-pending-fg: #713f12;
|
||||||
--sg-session-status-pending-border: color-mix(in srgb, #d97706 34%, transparent);
|
--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-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-fg: var(--sg-color-text);
|
||||||
--sg-session-status-unknown-border: var(--sg-color-border);
|
--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-family: 'Tinkoff Sans', system-ui, sans-serif;
|
||||||
--sg-keyboard-font-weight: 400;
|
--sg-keyboard-font-weight: 400;
|
||||||
--sg-keyboard-letter-spacing: 0.03em;
|
--sg-keyboard-letter-spacing: 0.03em;
|
||||||
@@ -70,7 +82,7 @@
|
|||||||
--sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle);
|
--sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle);
|
||||||
--sg-keyboard-key-other: 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);
|
--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: var(--sg-color-text);
|
||||||
--sg-keyboard-ink-soft: var(--tui-text-tertiary);
|
--sg-keyboard-ink-soft: var(--tui-text-tertiary);
|
||||||
/* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */
|
/* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */
|
||||||
@@ -78,3 +90,9 @@
|
|||||||
--sg-keyboard-key-pressed-ink: var(--sg-color-text);
|
--sg-keyboard-key-pressed-ink: var(--sg-color-text);
|
||||||
--sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill);
|
--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 (Taiga Input) --- */
|
||||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] {
|
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] {
|
||||||
--tui-focus: var(--sg-color-textfield-focus-border);
|
--tui-focus: var(--sg-color-textfield-focus-border);
|
||||||
|
|||||||
Reference in New Issue
Block a user