some refactor in code
Some checks failed
CI / checks (push) Failing after 4m53s

This commit is contained in:
Микаэл Оганесян
2026-04-12 06:28:47 +03:00
parent abcd49e117
commit 1381a99ca1
23 changed files with 209 additions and 197 deletions

View File

@@ -1,11 +1,11 @@
import { TuiRoot } from '@taiga-ui/core'; import { TuiRoot } from '@taiga-ui/core';
import { Component, isDevMode } from '@angular/core'; import { Component, isDevMode } from '@angular/core';
import { RouterLink, RouterOutlet, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterOutlet } from '@angular/router';
import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component'; import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterLink, RouterLinkActive, RouterOutlet, TuiRoot, DevConsoleComponent], imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
}) })

View File

@@ -3,11 +3,17 @@ import { inject } from '@angular/core';
import { API_BASE_URL } from '../config/api.tokens'; import { API_BASE_URL } from '../config/api.tokens';
/** Префиксы путей, которые НЕ проксируются через API base URL (статические ассеты). */
const STATIC_ASSET_PREFIXES = ['/svg/', '/fonts/', '/images/'];
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)) {
return next(req); return next(req);
} }
const path = req.url.startsWith('/') ? req.url : `/${req.url}`; const path = req.url.startsWith('/') ? req.url : `/${req.url}`;
if (STATIC_ASSET_PREFIXES.some((p) => path.startsWith(p))) {
return next(req);
}
return next(req.clone({ url: `${base}${path}` })); return next(req.clone({ url: `${base}${path}` }));
}; };

View File

@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Observable, defer, from, shareReplay } from 'rxjs'; import { Observable, shareReplay } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
/** /**
@@ -21,8 +22,8 @@ export interface KeyHighlightDiff {
} }
/** /**
* Файлы из `public/svg/visual/*.svg` — URL в приложении `/svg/visual/...`, * Файлы из `public/svg/visual/*.svg` — URL в приложении `/svg/visual/...`.
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`. * `apiBaseUrlInterceptor` пропускает пути с префиксом `/svg/` (STATIC_ASSET_PREFIXES).
*/ */
export const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg'; export const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg';
@@ -32,22 +33,16 @@ export const ARROW_KEYS_SVG_PATH = '/svg/visual/arrow-keys.svg';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class KeyboardSvgHighlightService { export class KeyboardSvgHighlightService {
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
private readonly http = inject(HttpClient);
private readonly baseSvgCache = new Map<string, Observable<string>>(); private readonly baseSvgCache = new Map<string, Observable<string>>();
private baseSvg$(path: string): Observable<string> { private baseSvg$(path: string): Observable<string> {
let cached = this.baseSvgCache.get(path); let cached = this.baseSvgCache.get(path);
if (!cached) { if (!cached) {
cached = defer(() => cached = this.http.get(path, { responseType: 'text' }).pipe(
from( shareReplay({ bufferSize: 1, refCount: false }),
fetch(path).then((r) => { );
if (!r.ok) {
throw new Error(`Не удалось загрузить SVG: ${path} (${r.status} ${r.statusText})`);
}
return r.text();
}),
),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
this.baseSvgCache.set(path, cached); this.baseSvgCache.set(path, cached);
} }
return cached; return cached;
@@ -56,6 +51,9 @@ export class KeyboardSvgHighlightService {
/** /**
* Interactive timeline mode: animates only the changed keys. * Interactive timeline mode: animates only the changed keys.
* Pressed keys pop in, held keys stay static, released keys fade back to idle. * Pressed keys pop in, held keys stay static, released keys fade back to idle.
*
* SECURITY: bypassSecurityTrustHtml используется для SVG, загруженных из собственного public/.
* НЕ передавать пользовательский контент через этот метод — XSS-риск.
*/ */
svgWithKeyDiff(diff: KeyHighlightDiff, svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> { svgWithKeyDiff(diff: KeyHighlightDiff, svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> {
return this.baseSvg$(svgPath).pipe( return this.baseSvg$(svgPath).pipe(

View File

@@ -1,20 +1,9 @@
import type { ParsedEvent } from '../models/api.types'; import type { ParsedEvent } from '../models/api.types';
import { unwrapJsonPayload } from '../../shared/utils/json.util'; import { unwrapJsonPayload } from '../../shared/utils/json.util';
import { readNumericField } from '../../shared/utils/number.util';
export type MouseHighlightTarget = 'left' | 'middle' | 'right' | 'wheel'; export type MouseHighlightTarget = 'left' | 'middle' | 'right' | 'wheel';
function readNumber(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 parsed = Number(v);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function isMouseTelemetryEvent(event: ParsedEvent): boolean { export function isMouseTelemetryEvent(event: ParsedEvent): boolean {
const t = (event.event_type ?? '').toLowerCase(); const t = (event.event_type ?? '').toLowerCase();
return t.includes('mouse'); return t.includes('mouse');
@@ -31,7 +20,7 @@ export function isMouseMovePayload(o: Record<string, unknown>): boolean {
if (action !== 'move') { if (action !== 'move') {
return false; return false;
} }
return readNumber(o, 'x') !== null && readNumber(o, 'y') !== null; return readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null;
} }
/** Событие перемещения курсора (payload или тип события с «move»). */ /** Событие перемещения курсора (payload или тип события с «move»). */
@@ -45,7 +34,7 @@ export function isMouseMoveTelemetryEvent(event: ParsedEvent): boolean {
return true; return true;
} }
const t = (event.event_type ?? '').toLowerCase(); const t = (event.event_type ?? '').toLowerCase();
if (t.includes('mouse') && t.includes('move') && readNumber(o, 'x') !== null && readNumber(o, 'y') !== null) { if (t.includes('mouse') && t.includes('move') && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null) {
return true; return true;
} }
return false; return false;
@@ -74,7 +63,7 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[
return ['middle']; return ['middle'];
} }
const button = readNumber(o, 'button'); const button = readNumericField(o, 'button');
const isDown = o['is_down']; const isDown = o['is_down'];
if (button == null) { if (button == null) {
return []; return [];

View File

@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Observable, defer, from, shareReplay } from 'rxjs'; import { Observable, shareReplay } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import type { MouseHighlightTarget } from './mouse-payload.util'; import type { MouseHighlightTarget } from './mouse-payload.util';
@@ -10,17 +11,16 @@ const MOUSE_SVG_PATH = '/svg/visual/mouse.svg';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class MouseSvgHighlightService { export class MouseSvgHighlightService {
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
private readonly http = inject(HttpClient);
private readonly baseSvg$ = defer(() => private readonly baseSvg$ = this.http.get(MOUSE_SVG_PATH, { responseType: 'text' }).pipe(
from( shareReplay({ bufferSize: 1, refCount: false }),
fetch(MOUSE_SVG_PATH).then((r) => { );
if (!r.ok) {
throw new Error(`Не удалось загрузить мышь: ${r.status} ${r.statusText}`); /**
} * SECURITY: bypassSecurityTrustHtml используется для SVG, загруженных из собственного public/.
return r.text(); * НЕ передавать пользовательский контент через этот метод — XSS-риск.
}), */
),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
svgWithHighlight(targets: MouseHighlightTarget[] | null, animated = true): Observable<SafeHtml> { svgWithHighlight(targets: MouseHighlightTarget[] | null, animated = true): Observable<SafeHtml> {
return this.baseSvg$.pipe( return this.baseSvg$.pipe(

View File

@@ -1,8 +1,8 @@
import { TELEMETRY_SUMMARY_RULES } from './telemetry-event-summary.config'; import { TELEMETRY_SUMMARY_RULES } from './telemetry-event-summary.config';
import { fallbackCompactJson, unwrapTelemetryPayload } from './telemetry-event-summary.payload'; import { fallbackCompactJson, unwrapJsonPayload } from './telemetry-event-summary.payload';
export function summarizeTelemetryData(data: unknown): string { export function summarizeTelemetryData(data: unknown): string {
const raw = unwrapTelemetryPayload(data); const raw = unwrapJsonPayload(data);
if (raw === null || raw === undefined) { if (raw === null || raw === undefined) {
return '—'; return '—';
} }

View File

@@ -1,25 +1,7 @@
export function unwrapTelemetryPayload(data: unknown): unknown { import { unwrapJsonPayload } from '../../shared/utils/json.util';
if (typeof data === 'string') { import { readNumericField } from '../../shared/utils/number.util';
try {
return JSON.parse(data) as unknown;
} catch {
return data;
}
}
return data;
}
export function readNumericField(o: Record<string, unknown>, key: string): number | null { export { unwrapJsonPayload, readNumericField };
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 { export function fallbackCompactJson(o: Record<string, unknown>): string {
try { try {
@@ -28,3 +10,4 @@ export function fallbackCompactJson(o: Record<string, unknown>): string {
return '[объект]'; return '[объект]';
} }
} }

View File

@@ -1,4 +1,19 @@
/*
* Dev Console: фиксированная панель для отладки (только isDevMode()).
* Цвета определены через CSS-переменные для единства с дизайн-системой.
* Компонент намеренно использует тёмную палитру, отличную от основного UI.
*/
:host { :host {
/* Тёмная палитра dev-console */
--dc-bg: color-mix(in srgb, var(--sg-color-text) 96%, var(--sg-color-bg));
--dc-bg-header: color-mix(in srgb, var(--sg-color-text) 90%, var(--sg-color-bg));
--dc-fg: var(--sg-color-form-bg);
--dc-fg-muted: color-mix(in srgb, var(--dc-fg) 65%, transparent);
--dc-fg-secondary: color-mix(in srgb, var(--dc-fg) 80%, transparent);
--dc-border: rgb(255 255 255 / 8%);
--dc-border-btn: rgb(255 255 255 / 18%);
position: fixed; position: fixed;
right: 1rem; right: 1rem;
bottom: 1rem; bottom: 1rem;
@@ -8,8 +23,8 @@
.dev-console-mini { .dev-console-mini {
border: 1px solid var(--tui-border-normal); border: 1px solid var(--tui-border-normal);
border-radius: 999px; border-radius: 999px;
background: #1b1d22; background: var(--dc-bg-header);
color: #e8edf1; color: var(--dc-fg);
padding: 0.45rem 0.7rem; padding: 0.45rem 0.7rem;
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1; line-height: 1;
@@ -22,8 +37,8 @@
max-height: min(46vh, 380px); max-height: min(46vh, 380px);
border: 1px solid var(--tui-border-normal); border: 1px solid var(--tui-border-normal);
border-radius: 0.75rem; border-radius: 0.75rem;
background: #121317; background: var(--dc-bg);
color: #e8edf1; color: var(--dc-fg);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%); box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
overflow: hidden; overflow: hidden;
} }
@@ -37,18 +52,18 @@
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
padding: 0.4rem 0.55rem; padding: 0.4rem 0.55rem;
background: #1b1d22; background: var(--dc-bg-header);
border-bottom: 1px solid rgb(255 255 255 / 8%); border-bottom: 1px solid var(--dc-border);
font-size: 0.8rem; font-size: 0.8rem;
} }
.dev-console__count { .dev-console__count {
margin-right: auto; margin-right: auto;
color: #9aa4b2; color: var(--dc-fg-muted);
} }
.dev-console__header button { .dev-console__header button {
border: 1px solid rgb(255 255 255 / 18%); border: 1px solid var(--dc-border-btn);
background: transparent; background: transparent;
color: inherit; color: inherit;
border-radius: 0.35rem; border-radius: 0.35rem;
@@ -86,11 +101,11 @@
} }
.dev-console__time { .dev-console__time {
color: #9aa4b2; color: var(--dc-fg-muted);
} }
.dev-console__source { .dev-console__source {
color: #c7cbd1; color: var(--dc-fg-secondary);
text-transform: uppercase; text-transform: uppercase;
} }
@@ -100,9 +115,9 @@
.dev-console__expand { .dev-console__expand {
margin-top: 0.25rem; margin-top: 0.25rem;
border: 1px solid rgb(255 255 255 / 18%); border: 1px solid var(--dc-border-btn);
background: transparent; background: transparent;
color: #d6dde7; color: var(--dc-fg-secondary);
border-radius: 0.3rem; border-radius: 0.3rem;
font-size: 0.72rem; font-size: 0.72rem;
line-height: 1.2; line-height: 1.2;
@@ -131,7 +146,7 @@
.dev-console__details summary { .dev-console__details summary {
cursor: pointer; cursor: pointer;
color: #d4d9e0; color: var(--dc-fg-secondary);
} }
.dev-console__details pre { .dev-console__details pre {

View File

@@ -9,10 +9,10 @@
<strong>Dev Console</strong> <strong>Dev Console</strong>
<span class="dev-console__count">{{ count() }}</span> <span class="dev-console__count">{{ count() }}</span>
<button type="button" (click)="toggleCollapsed()"> <button type="button" (click)="toggleCollapsed()">
{{ collapsed() ? 'Expand' : 'Collapse' }} {{ collapsed() ? 'Развернуть' : 'Свернуть' }}
</button> </button>
<button type="button" (click)="clear()">Clear</button> <button type="button" (click)="clear()">Очистить</button>
<button type="button" (click)="minimize()">Minimize</button> <button type="button" (click)="minimize()">Скрыть</button>
</header> </header>
@if (!collapsed()) { @if (!collapsed()) {
@@ -51,37 +51,37 @@
class="dev-console__expand" class="dev-console__expand"
(click)="toggleExpanded(entry.id)" (click)="toggleExpanded(entry.id)"
> >
{{ isExpanded(entry.id) ? 'Hide details' : 'Show details' }} {{ isExpanded(entry.id) ? 'Скрыть детали' : 'Показать детали' }}
</button> </button>
@if (isExpanded(entry.id)) { @if (isExpanded(entry.id)) {
<div class="dev-console__details"> <div class="dev-console__details">
<div class="dev-console__meta"> <div class="dev-console__meta">
<span><b>Method:</b> {{ entry.details.method }}</span> <span><b>Метод:</b> {{ entry.details.method }}</span>
<span><b>Status:</b> {{ entry.details.statusCode ?? 'pending' }}</span> <span><b>Статус:</b> {{ entry.details.statusCode ?? 'ожидание' }}</span>
<span><b>Duration:</b> {{ entry.details.durationMs ?? '—' }} ms</span> <span><b>Длительность:</b> {{ entry.details.durationMs ?? '—' }} мс</span>
</div> </div>
<div class="dev-console__meta"><b>URL:</b> {{ entry.details.url }}</div> <div class="dev-console__meta"><b>URL:</b> {{ entry.details.url }}</div>
<details open> <details open>
<summary>Request headers</summary> <summary>Заголовки запроса</summary>
<pre>{{ pretty(entry.details.requestHeaders) }}</pre> <pre>{{ pretty(entry.details.requestHeaders) }}</pre>
</details> </details>
<details open> <details open>
<summary>Request body</summary> <summary>Тело запроса</summary>
<pre>{{ pretty(entry.details.requestBody) }}</pre> <pre>{{ pretty(entry.details.requestBody) }}</pre>
</details> </details>
<details open> <details open>
<summary>Response headers</summary> <summary>Заголовки ответа</summary>
<pre>{{ pretty(entry.details.responseHeaders) }}</pre> <pre>{{ pretty(entry.details.responseHeaders) }}</pre>
</details> </details>
<details open> <details open>
<summary>Response body</summary> <summary>Тело ответа</summary>
<pre>{{ pretty(entry.details.responseBody) }}</pre> <pre>{{ pretty(entry.details.responseBody) }}</pre>
</details> </details>
@if (entry.details.error) { @if (entry.details.error) {
<details open> <details open>
<summary>Error</summary> <summary>Ошибка</summary>
<pre>{{ entry.details.error }}</pre> <pre>{{ entry.details.error }}</pre>
</details> </details>
} }

View File

@@ -1,12 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { TuiButton, TuiIcon } from '@taiga-ui/core'; import { TuiButton } from '@taiga-ui/core';
import { TuiAccordion } from '@taiga-ui/kit'; import { TuiAccordion } from '@taiga-ui/kit';
@Component({ @Component({
selector: 'app-landing', selector: 'app-landing',
standalone: true, standalone: true,
imports: [RouterLink, TuiButton, TuiIcon, TuiAccordion], imports: [RouterLink, TuiButton, TuiAccordion],
templateUrl: './landing.html', templateUrl: './landing.html',
styleUrl: './landing.css', styleUrl: './landing.css',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -3,6 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop'; import { toObservable } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { clamp } from '../../../shared/utils/math.util';
import { TuiLink } from '@taiga-ui/core/components/link'; import { TuiLink } from '@taiga-ui/core/components/link';
import { TuiLoader } from '@taiga-ui/core/components/loader'; import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiTitle } from '@taiga-ui/core/components/title'; import { TuiTitle } from '@taiga-ui/core/components/title';
@@ -111,7 +112,7 @@ export class SessionDetailComponent {
const now = Date.now(); const now = Date.now();
const lowerBound = recordingStartMs ?? 0; const lowerBound = recordingStartMs ?? 0;
const upperBound = recordingEndMs ?? now; const upperBound = recordingEndMs ?? now;
const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound); const normalizedTo = clamp(toMs ?? upperBound, lowerBound, upperBound);
return this.api return this.api
.getParsedEvents(id, lowerBound, normalizedTo) .getParsedEvents(id, lowerBound, normalizedTo)
.pipe( .pipe(
@@ -173,11 +174,4 @@ export class SessionDetailComponent {
const ms = new Date(value).getTime(); const ms = new Date(value).getTime();
return Number.isFinite(ms) ? ms : null; return Number.isFinite(ms) ? ms : null;
} }
private clamp(value: number, min: number, max: number): number {
if (max < min) {
return min;
}
return Math.min(max, Math.max(min, value));
}
} }

View File

@@ -36,6 +36,7 @@
/> />
} }
@case (1) { @case (1) {
@defer {
<app-session-interactive-tab <app-session-interactive-tab
[detail]="state.detail" [detail]="state.detail"
[telemetryEvents]="telemetryState.telemetry" [telemetryEvents]="telemetryState.telemetry"
@@ -43,15 +44,32 @@
[recordingEndMs]="recordingEndMs()" [recordingEndMs]="recordingEndMs()"
[excludeMouseMoves]="excludeMouseMoves()" [excludeMouseMoves]="excludeMouseMoves()"
/> />
} @placeholder {
<div class="loading-wrap">
<tui-loader [loading]="true" size="l" />
</div>
}
} }
@case (2) { @case (2) {
@defer {
<app-session-fingerprint-tab [sessionId]="state.id" /> <app-session-fingerprint-tab [sessionId]="state.id" />
} @placeholder {
<div class="loading-wrap">
<tui-loader [loading]="true" size="l" />
</div>
}
} }
@case (3) { @case (3) {
@defer {
<app-session-info-tab <app-session-info-tab
[detail]="state.detail" [detail]="state.detail"
[telemetryState]="telemetryState" [telemetryState]="telemetryState"
/> />
} @placeholder {
<div class="loading-wrap">
<tui-loader [loading]="true" size="l" />
</div>
}
} }
} }
} }

View File

@@ -1,23 +1,21 @@
import { DatePipe } from '@angular/common'; import { AsyncPipe, DatePipe } from '@angular/common';
import { AsyncPipe, JsonPipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop'; import { toObservable } from '@angular/core/rxjs-interop';
import { TuiLoader } from '@taiga-ui/core/components/loader'; import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiScrollbar } from '@taiga-ui/core/components/scrollbar';
import { TuiIcon } from '@taiga-ui/core/components/icon'; import { TuiIcon } from '@taiga-ui/core/components/icon';
import { catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs'; import { catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs';
import { SessionsApiService } from '../../../../core/services/sessions-api.service'; import { SessionsApiService } from '../../../../core/services/sessions-api.service';
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service'; import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
import type { FingerprintHeartbeat } from '../../../../core/models/api.types'; import type { FingerprintHeartbeat, FingerprintHeartbeatPayload } from '../../../../core/models/api.types';
export interface Anomaly { export interface Anomaly {
timestamp_ms: number; timestamp_ms: number;
field: string; field: string;
fieldLabel: string; fieldLabel: string;
oldValue: string | boolean; oldValue: unknown;
newValue: string | boolean; newValue: unknown;
} }
@Component({ @Component({
@@ -64,29 +62,31 @@ export class SessionFingerprintTabComponent {
}) })
); );
private static readonly ANOMALY_FIELDS: ReadonlyArray<{
key: keyof FingerprintHeartbeatPayload;
label: string;
}> = [
{ key: 'screen_layout', label: 'Конфигурация экранов' },
{ key: 'username', label: 'Пользователь системы' },
{ key: 'hostname', label: 'Имя компьютера' },
{ key: 'active_iface', label: 'Сетевой адаптер' },
{ key: 'hypervisor_present', label: 'Виртуализация / Hypervisor' },
];
private detectAnomalies(heartbeats: FingerprintHeartbeat[]): Anomaly[] { private detectAnomalies(heartbeats: FingerprintHeartbeat[]): Anomaly[] {
if (!heartbeats || heartbeats.length < 2) { if (!heartbeats || heartbeats.length < 2) {
return []; return [];
} }
const anomalies: Anomaly[] = []; const anomalies: Anomaly[] = [];
const fieldsToCheck = [
{ key: 'screen_layout', label: 'Конфигурация экранов' },
{ key: 'username', label: 'Пользователь системы' },
{ key: 'hostname', label: 'Имя компьютера' },
{ key: 'active_iface', label: 'Сетевой адаптер' },
{ key: 'hypervisor_present', label: 'Виртуализация / Hypervisor' },
] as const;
for (let i = 1; i < heartbeats.length; i++) { for (let i = 1; i < heartbeats.length; i++) {
const prev = heartbeats[i - 1].payload; const prev = heartbeats[i - 1].payload;
const curr = heartbeats[i].payload; const curr = heartbeats[i].payload;
const timestamp = heartbeats[i].timestamp_ms; const timestamp = heartbeats[i].timestamp_ms;
for (const field of fieldsToCheck) { for (const field of SessionFingerprintTabComponent.ANOMALY_FIELDS) {
// @ts-ignore (dynamic key access)
const oldValue = prev[field.key]; const oldValue = prev[field.key];
// @ts-ignore
const newValue = curr[field.key]; const newValue = curr[field.key];
if (oldValue !== newValue) { if (oldValue !== newValue) {

View File

@@ -5,12 +5,7 @@
padding-top: 1rem; padding-top: 1rem;
} }
.section-title { /* Наследует глобальный .section-title из page-common.css; дополнительных переопределений не требуется */
font-size: 1.25rem;
font-weight: 600;
margin-top: 0;
margin-bottom: 1rem;
}
.loading-wrap { .loading-wrap {
display: flex; display: flex;

View File

@@ -18,6 +18,8 @@ import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.
import { SessionsApiService } from '../../../../core/services/sessions-api.service'; import { SessionsApiService } from '../../../../core/services/sessions-api.service';
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types'; import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util'; import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util';
import { clamp } from '../../../../shared/utils/math.util';
import { resolveActiveStreamType, resolvePlaylistUrlForType } from '../../../../shared/utils/stream.util';
import { HlsPlayerComponent } from '../../hls-player/hls-player.component'; import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component'; import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.component'; import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.component';
@@ -277,24 +279,11 @@ export class SessionInteractiveTabComponent {
}); });
protected activeStreamType(): string | null { protected activeStreamType(): string | null {
const streams = this.detail().streams; return resolveActiveStreamType(this.detail().streams, this.selectedStreamType());
if (!streams?.length) {
return null;
}
const picked = this.selectedStreamType();
if (picked && streams.some((s) => s.stream_type === picked)) {
return picked;
}
return streams[0].stream_type;
} }
protected playlistUrl(): string | null { protected playlistUrl(): string | null {
const t = this.activeStreamType(); return resolvePlaylistUrlForType(this.detail().streams, this.activeStreamType(), this.api);
if (!t) {
return null;
}
const stream = this.detail().streams.find((s) => s.stream_type === t);
return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null;
} }
protected pickStream(type: string): void { protected pickStream(type: string): void {
@@ -306,14 +295,14 @@ export class SessionInteractiveTabComponent {
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
return; return;
} }
this.timelineSec.set(this.clamp(parsed, 0, this.timelineMaxSec())); this.timelineSec.set(clamp(parsed, 0, this.timelineMaxSec()));
} }
protected onPlayerCurrentTimeChange(seconds: number): void { protected onPlayerCurrentTimeChange(seconds: number): void {
if (!Number.isFinite(seconds)) { if (!Number.isFinite(seconds)) {
return; return;
} }
this.timelineSec.set(this.clamp(seconds, 0, this.timelineMaxSec())); this.timelineSec.set(clamp(seconds, 0, this.timelineMaxSec()));
} }
protected onPlayerDurationChange(seconds: number): void { protected onPlayerDurationChange(seconds: number): void {
@@ -321,11 +310,11 @@ export class SessionInteractiveTabComponent {
return; return;
} }
this.durationSec.set(seconds); this.durationSec.set(seconds);
this.timelineSec.update((current) => this.clamp(current, 0, this.timelineMaxSec())); this.timelineSec.update((current) => clamp(current, 0, this.timelineMaxSec()));
} }
protected shiftTimeline(deltaSec: number): void { protected shiftTimeline(deltaSec: number): void {
this.timelineSec.update((current) => this.clamp(current + deltaSec, 0, this.timelineMaxSec())); this.timelineSec.update((current) => clamp(current + deltaSec, 0, this.timelineMaxSec()));
} }
protected togglePlayback(): void { protected togglePlayback(): void {
@@ -364,11 +353,4 @@ export class SessionInteractiveTabComponent {
protected cursorLabel(): string { protected cursorLabel(): string {
return formatUnixMs(this.cursorMs()); return formatUnixMs(this.cursorMs());
} }
private clamp(value: number, min: number, max: number): number {
if (max < min) {
return min;
}
return Math.min(max, Math.max(min, value));
}
} }

View File

@@ -15,6 +15,8 @@ import { summarizeTelemetryData } from '../../../../core/sessions/telemetry-even
import { SessionsApiService } from '../../../../core/services/sessions-api.service'; import { SessionsApiService } from '../../../../core/services/sessions-api.service';
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types'; import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
import { formatTimestamp, formatUnixMs as formatUnixMsUtil } from '../../../../shared/utils/date-time.util'; import { formatTimestamp, formatUnixMs as formatUnixMsUtil } from '../../../../shared/utils/date-time.util';
import { clamp } from '../../../../shared/utils/math.util';
import { resolveActiveStreamType, resolvePlaylistUrlForType } from '../../../../shared/utils/stream.util';
import { HlsPlayerComponent } from '../../hls-player/hls-player.component'; import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component'; import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
import { TelemetryEventDetailComponent } from '../../telemetry-event-detail/telemetry-event-detail.component'; import { TelemetryEventDetailComponent } from '../../telemetry-event-detail/telemetry-event-detail.component';
@@ -74,24 +76,11 @@ export class SessionViewTabComponent {
private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' }); private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' });
protected activeStreamType(): string | null { protected activeStreamType(): string | null {
const streams = this.detail().streams; return resolveActiveStreamType(this.detail().streams, this.selectedStreamType());
if (!streams?.length) {
return null;
}
const picked = this.selectedStreamType();
if (picked && streams.some((s) => s.stream_type === picked)) {
return picked;
}
return streams[0].stream_type;
} }
protected playlistUrl(): string | null { protected playlistUrl(): string | null {
const t = this.activeStreamType(); return resolvePlaylistUrlForType(this.detail().streams, this.activeStreamType(), this.api);
if (!t) {
return null;
}
const stream = this.detail().streams.find((s) => s.stream_type === t);
return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null;
} }
protected pickStream(type: string): void { protected pickStream(type: string): void {
@@ -161,7 +150,7 @@ export class SessionViewTabComponent {
this.telemetryRangeSelection.set({ type: 'preset', seconds }); 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.telemetryToMsChange.emit(this.clamp(start + seconds * 1000, start, end)); this.telemetryToMsChange.emit(clamp(start + seconds * 1000, start, end));
} }
protected loadUntilEndTelemetry(): void { protected loadUntilEndTelemetry(): void {
@@ -180,7 +169,7 @@ export class SessionViewTabComponent {
this.telemetryRangeSelection.set({ type: 'custom' }); 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.telemetryToMsChange.emit(this.clamp(ms, start, end)); this.telemetryToMsChange.emit(clamp(ms, start, end));
} }
} }
@@ -204,11 +193,4 @@ export class SessionViewTabComponent {
protected formatUnixMs(value: number | null | undefined): string { protected formatUnixMs(value: number | null | undefined): string {
return formatUnixMsUtil(value); return formatUnixMsUtil(value);
} }
private clamp(value: number, min: number, max: number): number {
if (max < min) {
return min;
}
return Math.min(max, Math.max(min, value));
}
} }

View File

@@ -179,7 +179,7 @@
<tbody> <tbody>
@for ( @for (
row of filteredTelemetryEvents(visibleTelemetryEvents()); row of filteredTelemetryEvents(visibleTelemetryEvents());
track $index; track row.timestamp + '_' + row.event_type + '_' + $index;
let i = $index let i = $index
) { ) {
<tr <tr

View File

@@ -67,10 +67,7 @@
color: var(--tui-text-secondary); color: var(--tui-text-secondary);
} }
.mono { /* .mono определён глобально в page-common.css */
font-family: ui-monospace, monospace;
font-size: 0.92em;
}
.keyboard-block { .keyboard-block {
margin: 0 0 1rem; margin: 0 0 1rem;
@@ -105,11 +102,4 @@
padding: 1rem; padding: 1rem;
} }
.muted { /* .muted и .small определены глобально в page-common.css */
color: var(--tui-text-tertiary);
}
.small {
font: var(--tui-font-text-s);
margin: 0.5rem 0 0;
}

View File

@@ -19,7 +19,7 @@ export function formatTimestamp(value: string | null | undefined): string {
} }
export function formatUnixMs(value: number | null | undefined): string { export function formatUnixMs(value: number | null | undefined): string {
if (!value) { if (value == null) {
return '—'; return '—';
} }
return formatTimestamp(new Date(value).toISOString()); return formatTimestamp(new Date(value).toISOString());

View File

@@ -0,0 +1,10 @@
/**
* Ограничивает значение в диапазон [min, max].
* Если max < min — возвращает min (защита от инверсии границ).
*/
export function clamp(value: number, min: number, max: number): number {
if (max < min) {
return min;
}
return Math.min(max, Math.max(min, value));
}

View File

@@ -0,0 +1,15 @@
/**
* Безопасно извлекает числовое значение по ключу из объекта.
* Поддерживает как `number`, так и строки-числа.
*/
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 parsed = Number(v);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}

View File

@@ -0,0 +1,34 @@
import type { StreamInfo } from '../../core/models/api.types';
import { SessionsApiService } from '../../core/services/sessions-api.service';
/**
* Определяет активный тип потока из списка с учётом пользовательского выбора.
* Если выбранного потока нет в списке, возвращает первый доступный.
*/
export function resolveActiveStreamType(
streams: StreamInfo[],
selectedType: string | null,
): string | null {
if (!streams?.length) {
return null;
}
if (selectedType && streams.some((s) => s.stream_type === selectedType)) {
return selectedType;
}
return streams[0].stream_type;
}
/**
* Возвращает полный URL плейлиста для активного типа потока.
*/
export function resolvePlaylistUrlForType(
streams: StreamInfo[],
activeType: string | null,
api: SessionsApiService,
): string | null {
if (!activeType) {
return null;
}
const stream = streams.find((s) => s.stream_type === activeType);
return stream ? api.resolvePlaylistUrl(stream.playlist_url) : null;
}

View File

@@ -3,4 +3,5 @@ export const environment = {
apiFallbackOrigin: 'https://sparkguardian.ru', apiFallbackOrigin: 'https://sparkguardian.ru',
apiBasePath: '/api/v1', apiBasePath: '/api/v1',
interactivePrerollMs: 4000, interactivePrerollMs: 4000,
defaultPageLimit: 10,
} as const; } as const;