This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { TuiRoot } from '@taiga-ui/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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterLink, RouterLinkActive, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||
imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
|
||||
@@ -3,11 +3,17 @@ import { inject } from '@angular/core';
|
||||
|
||||
import { API_BASE_URL } from '../config/api.tokens';
|
||||
|
||||
/** Префиксы путей, которые НЕ проксируются через API base URL (статические ассеты). */
|
||||
const STATIC_ASSET_PREFIXES = ['/svg/', '/fonts/', '/images/'];
|
||||
|
||||
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const base = inject(API_BASE_URL);
|
||||
if (/^https?:\/\//i.test(req.url)) {
|
||||
return next(req);
|
||||
}
|
||||
const path = req.url.startsWith('/') ? req.url : `/${req.url}`;
|
||||
if (STATIC_ASSET_PREFIXES.some((p) => path.startsWith(p))) {
|
||||
return next(req);
|
||||
}
|
||||
return next(req.clone({ url: `${base}${path}` }));
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Observable, defer, from, shareReplay } from 'rxjs';
|
||||
import { Observable, shareReplay } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
@@ -21,8 +22,8 @@ export interface KeyHighlightDiff {
|
||||
}
|
||||
|
||||
/**
|
||||
* Файлы из `public/svg/visual/*.svg` — URL в приложении `/svg/visual/...`,
|
||||
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
|
||||
* Файлы из `public/svg/visual/*.svg` — URL в приложении `/svg/visual/...`.
|
||||
* `apiBaseUrlInterceptor` пропускает пути с префиксом `/svg/` (STATIC_ASSET_PREFIXES).
|
||||
*/
|
||||
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' })
|
||||
export class KeyboardSvgHighlightService {
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private readonly baseSvgCache = new Map<string, Observable<string>>();
|
||||
|
||||
private baseSvg$(path: string): Observable<string> {
|
||||
let cached = this.baseSvgCache.get(path);
|
||||
if (!cached) {
|
||||
cached = defer(() =>
|
||||
from(
|
||||
fetch(path).then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`Не удалось загрузить SVG: ${path} (${r.status} ${r.statusText})`);
|
||||
}
|
||||
return r.text();
|
||||
}),
|
||||
),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
cached = this.http.get(path, { responseType: 'text' }).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
this.baseSvgCache.set(path, cached);
|
||||
}
|
||||
return cached;
|
||||
@@ -56,6 +51,9 @@ export class KeyboardSvgHighlightService {
|
||||
/**
|
||||
* Interactive timeline mode: animates only the changed keys.
|
||||
* Pressed keys pop in, held keys stay static, released keys fade back to idle.
|
||||
*
|
||||
* SECURITY: bypassSecurityTrustHtml используется для SVG, загруженных из собственного public/.
|
||||
* НЕ передавать пользовательский контент через этот метод — XSS-риск.
|
||||
*/
|
||||
svgWithKeyDiff(diff: KeyHighlightDiff, svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> {
|
||||
return this.baseSvg$(svgPath).pipe(
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { ParsedEvent } from '../models/api.types';
|
||||
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||
import { readNumericField } from '../../shared/utils/number.util';
|
||||
|
||||
export type MouseHighlightTarget = 'left' | 'middle' | 'right' | 'wheel';
|
||||
|
||||
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 {
|
||||
const t = (event.event_type ?? '').toLowerCase();
|
||||
return t.includes('mouse');
|
||||
@@ -31,7 +20,7 @@ export function isMouseMovePayload(o: Record<string, unknown>): boolean {
|
||||
if (action !== 'move') {
|
||||
return false;
|
||||
}
|
||||
return readNumber(o, 'x') !== null && readNumber(o, 'y') !== null;
|
||||
return readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null;
|
||||
}
|
||||
|
||||
/** Событие перемещения курсора (payload или тип события с «move»). */
|
||||
@@ -45,7 +34,7 @@ export function isMouseMoveTelemetryEvent(event: ParsedEvent): boolean {
|
||||
return true;
|
||||
}
|
||||
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 false;
|
||||
@@ -74,7 +63,7 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[
|
||||
return ['middle'];
|
||||
}
|
||||
|
||||
const button = readNumber(o, 'button');
|
||||
const button = readNumericField(o, 'button');
|
||||
const isDown = o['is_down'];
|
||||
if (button == null) {
|
||||
return [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
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 type { MouseHighlightTarget } from './mouse-payload.util';
|
||||
@@ -10,17 +11,16 @@ const MOUSE_SVG_PATH = '/svg/visual/mouse.svg';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MouseSvgHighlightService {
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private readonly baseSvg$ = defer(() =>
|
||||
from(
|
||||
fetch(MOUSE_SVG_PATH).then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`Не удалось загрузить мышь: ${r.status} ${r.statusText}`);
|
||||
}
|
||||
return r.text();
|
||||
}),
|
||||
),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
private readonly baseSvg$ = this.http.get(MOUSE_SVG_PATH, { responseType: 'text' }).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
/**
|
||||
* SECURITY: bypassSecurityTrustHtml используется для SVG, загруженных из собственного public/.
|
||||
* НЕ передавать пользовательский контент через этот метод — XSS-риск.
|
||||
*/
|
||||
|
||||
svgWithHighlight(targets: MouseHighlightTarget[] | null, animated = true): Observable<SafeHtml> {
|
||||
return this.baseSvg$.pipe(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 {
|
||||
const raw = unwrapTelemetryPayload(data);
|
||||
const raw = unwrapJsonPayload(data);
|
||||
if (raw === null || raw === undefined) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
export function unwrapTelemetryPayload(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||
import { readNumericField } from '../../shared/utils/number.util';
|
||||
|
||||
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 { unwrapJsonPayload, readNumericField };
|
||||
|
||||
export function fallbackCompactJson(o: Record<string, unknown>): string {
|
||||
try {
|
||||
@@ -28,3 +10,4 @@ export function fallbackCompactJson(o: Record<string, unknown>): string {
|
||||
return '[объект]';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
/*
|
||||
* Dev Console: фиксированная панель для отладки (только isDevMode()).
|
||||
* Цвета определены через CSS-переменные для единства с дизайн-системой.
|
||||
* Компонент намеренно использует тёмную палитру, отличную от основного UI.
|
||||
*/
|
||||
|
||||
:host {
|
||||
/* Тёмная палитра dev-console */
|
||||
--dc-bg: color-mix(in srgb, var(--sg-color-text) 96%, var(--sg-color-bg));
|
||||
--dc-bg-header: color-mix(in srgb, var(--sg-color-text) 90%, var(--sg-color-bg));
|
||||
--dc-fg: var(--sg-color-form-bg);
|
||||
--dc-fg-muted: color-mix(in srgb, var(--dc-fg) 65%, transparent);
|
||||
--dc-fg-secondary: color-mix(in srgb, var(--dc-fg) 80%, transparent);
|
||||
--dc-border: rgb(255 255 255 / 8%);
|
||||
--dc-border-btn: rgb(255 255 255 / 18%);
|
||||
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
@@ -8,8 +23,8 @@
|
||||
.dev-console-mini {
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
border-radius: 999px;
|
||||
background: #1b1d22;
|
||||
color: #e8edf1;
|
||||
background: var(--dc-bg-header);
|
||||
color: var(--dc-fg);
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
@@ -22,8 +37,8 @@
|
||||
max-height: min(46vh, 380px);
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
border-radius: 0.75rem;
|
||||
background: #121317;
|
||||
color: #e8edf1;
|
||||
background: var(--dc-bg);
|
||||
color: var(--dc-fg);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -37,18 +52,18 @@
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: #1b1d22;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||
background: var(--dc-bg-header);
|
||||
border-bottom: 1px solid var(--dc-border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dev-console__count {
|
||||
margin-right: auto;
|
||||
color: #9aa4b2;
|
||||
color: var(--dc-fg-muted);
|
||||
}
|
||||
|
||||
.dev-console__header button {
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border: 1px solid var(--dc-border-btn);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0.35rem;
|
||||
@@ -86,11 +101,11 @@
|
||||
}
|
||||
|
||||
.dev-console__time {
|
||||
color: #9aa4b2;
|
||||
color: var(--dc-fg-muted);
|
||||
}
|
||||
|
||||
.dev-console__source {
|
||||
color: #c7cbd1;
|
||||
color: var(--dc-fg-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -100,9 +115,9 @@
|
||||
|
||||
.dev-console__expand {
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border: 1px solid var(--dc-border-btn);
|
||||
background: transparent;
|
||||
color: #d6dde7;
|
||||
color: var(--dc-fg-secondary);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
@@ -131,7 +146,7 @@
|
||||
|
||||
.dev-console__details summary {
|
||||
cursor: pointer;
|
||||
color: #d4d9e0;
|
||||
color: var(--dc-fg-secondary);
|
||||
}
|
||||
|
||||
.dev-console__details pre {
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<strong>Dev Console</strong>
|
||||
<span class="dev-console__count">{{ count() }}</span>
|
||||
<button type="button" (click)="toggleCollapsed()">
|
||||
{{ collapsed() ? 'Expand' : 'Collapse' }}
|
||||
{{ collapsed() ? 'Развернуть' : 'Свернуть' }}
|
||||
</button>
|
||||
<button type="button" (click)="clear()">Clear</button>
|
||||
<button type="button" (click)="minimize()">Minimize</button>
|
||||
<button type="button" (click)="clear()">Очистить</button>
|
||||
<button type="button" (click)="minimize()">Скрыть</button>
|
||||
</header>
|
||||
|
||||
@if (!collapsed()) {
|
||||
@@ -51,37 +51,37 @@
|
||||
class="dev-console__expand"
|
||||
(click)="toggleExpanded(entry.id)"
|
||||
>
|
||||
{{ isExpanded(entry.id) ? 'Hide details' : 'Show details' }}
|
||||
{{ isExpanded(entry.id) ? 'Скрыть детали' : 'Показать детали' }}
|
||||
</button>
|
||||
|
||||
@if (isExpanded(entry.id)) {
|
||||
<div class="dev-console__details">
|
||||
<div class="dev-console__meta">
|
||||
<span><b>Method:</b> {{ entry.details.method }}</span>
|
||||
<span><b>Status:</b> {{ entry.details.statusCode ?? 'pending' }}</span>
|
||||
<span><b>Duration:</b> {{ entry.details.durationMs ?? '—' }} ms</span>
|
||||
<span><b>Метод:</b> {{ entry.details.method }}</span>
|
||||
<span><b>Статус:</b> {{ entry.details.statusCode ?? 'ожидание' }}</span>
|
||||
<span><b>Длительность:</b> {{ entry.details.durationMs ?? '—' }} мс</span>
|
||||
</div>
|
||||
<div class="dev-console__meta"><b>URL:</b> {{ entry.details.url }}</div>
|
||||
|
||||
<details open>
|
||||
<summary>Request headers</summary>
|
||||
<summary>Заголовки запроса</summary>
|
||||
<pre>{{ pretty(entry.details.requestHeaders) }}</pre>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Request body</summary>
|
||||
<summary>Тело запроса</summary>
|
||||
<pre>{{ pretty(entry.details.requestBody) }}</pre>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Response headers</summary>
|
||||
<summary>Заголовки ответа</summary>
|
||||
<pre>{{ pretty(entry.details.responseHeaders) }}</pre>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Response body</summary>
|
||||
<summary>Тело ответа</summary>
|
||||
<pre>{{ pretty(entry.details.responseBody) }}</pre>
|
||||
</details>
|
||||
@if (entry.details.error) {
|
||||
<details open>
|
||||
<summary>Error</summary>
|
||||
<summary>Ошибка</summary>
|
||||
<pre>{{ entry.details.error }}</pre>
|
||||
</details>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TuiButton, TuiIcon } from '@taiga-ui/core';
|
||||
import { TuiButton } from '@taiga-ui/core';
|
||||
import { TuiAccordion } from '@taiga-ui/kit';
|
||||
|
||||
@Component({
|
||||
selector: 'app-landing',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TuiButton, TuiIcon, TuiAccordion],
|
||||
imports: [RouterLink, TuiButton, TuiAccordion],
|
||||
templateUrl: './landing.html',
|
||||
styleUrl: './landing.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { clamp } from '../../../shared/utils/math.util';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
@@ -111,7 +112,7 @@ export class SessionDetailComponent {
|
||||
const now = Date.now();
|
||||
const lowerBound = recordingStartMs ?? 0;
|
||||
const upperBound = recordingEndMs ?? now;
|
||||
const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound);
|
||||
const normalizedTo = clamp(toMs ?? upperBound, lowerBound, upperBound);
|
||||
return this.api
|
||||
.getParsedEvents(id, lowerBound, normalizedTo)
|
||||
.pipe(
|
||||
@@ -173,11 +174,4 @@ export class SessionDetailComponent {
|
||||
const ms = new Date(value).getTime();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
/>
|
||||
}
|
||||
@case (1) {
|
||||
@defer {
|
||||
<app-session-interactive-tab
|
||||
[detail]="state.detail"
|
||||
[telemetryEvents]="telemetryState.telemetry"
|
||||
@@ -43,15 +44,32 @@
|
||||
[recordingEndMs]="recordingEndMs()"
|
||||
[excludeMouseMoves]="excludeMouseMoves()"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="loading-wrap">
|
||||
<tui-loader [loading]="true" size="l" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case (2) {
|
||||
@defer {
|
||||
<app-session-fingerprint-tab [sessionId]="state.id" />
|
||||
} @placeholder {
|
||||
<div class="loading-wrap">
|
||||
<tui-loader [loading]="true" size="l" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case (3) {
|
||||
@defer {
|
||||
<app-session-info-tab
|
||||
[detail]="state.detail"
|
||||
[telemetryState]="telemetryState"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="loading-wrap">
|
||||
<tui-loader [loading]="true" size="l" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { AsyncPipe, JsonPipe } from '@angular/common';
|
||||
import { AsyncPipe, DatePipe } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiScrollbar } from '@taiga-ui/core/components/scrollbar';
|
||||
import { TuiIcon } from '@taiga-ui/core/components/icon';
|
||||
import { catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs';
|
||||
|
||||
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
|
||||
import type { FingerprintHeartbeat } from '../../../../core/models/api.types';
|
||||
import type { FingerprintHeartbeat, FingerprintHeartbeatPayload } from '../../../../core/models/api.types';
|
||||
|
||||
export interface Anomaly {
|
||||
timestamp_ms: number;
|
||||
field: string;
|
||||
fieldLabel: string;
|
||||
oldValue: string | boolean;
|
||||
newValue: string | boolean;
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
@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[] {
|
||||
if (!heartbeats || heartbeats.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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++) {
|
||||
const prev = heartbeats[i - 1].payload;
|
||||
const curr = heartbeats[i].payload;
|
||||
const timestamp = heartbeats[i].timestamp_ms;
|
||||
|
||||
for (const field of fieldsToCheck) {
|
||||
// @ts-ignore (dynamic key access)
|
||||
for (const field of SessionFingerprintTabComponent.ANOMALY_FIELDS) {
|
||||
const oldValue = prev[field.key];
|
||||
// @ts-ignore
|
||||
const newValue = curr[field.key];
|
||||
|
||||
if (oldValue !== newValue) {
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Наследует глобальный .section-title из page-common.css; дополнительных переопределений не требуется */
|
||||
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.
|
||||
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
||||
import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util';
|
||||
import { clamp } from '../../../../shared/utils/math.util';
|
||||
import { resolveActiveStreamType, resolvePlaylistUrlForType } from '../../../../shared/utils/stream.util';
|
||||
import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
|
||||
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
|
||||
import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.component';
|
||||
@@ -277,24 +279,11 @@ export class SessionInteractiveTabComponent {
|
||||
});
|
||||
|
||||
protected activeStreamType(): string | null {
|
||||
const streams = this.detail().streams;
|
||||
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;
|
||||
return resolveActiveStreamType(this.detail().streams, this.selectedStreamType());
|
||||
}
|
||||
|
||||
protected playlistUrl(): string | null {
|
||||
const t = this.activeStreamType();
|
||||
if (!t) {
|
||||
return null;
|
||||
}
|
||||
const stream = this.detail().streams.find((s) => s.stream_type === t);
|
||||
return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null;
|
||||
return resolvePlaylistUrlForType(this.detail().streams, this.activeStreamType(), this.api);
|
||||
}
|
||||
|
||||
protected pickStream(type: string): void {
|
||||
@@ -306,14 +295,14 @@ export class SessionInteractiveTabComponent {
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return;
|
||||
}
|
||||
this.timelineSec.set(this.clamp(parsed, 0, this.timelineMaxSec()));
|
||||
this.timelineSec.set(clamp(parsed, 0, this.timelineMaxSec()));
|
||||
}
|
||||
|
||||
protected onPlayerCurrentTimeChange(seconds: number): void {
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return;
|
||||
}
|
||||
this.timelineSec.set(this.clamp(seconds, 0, this.timelineMaxSec()));
|
||||
this.timelineSec.set(clamp(seconds, 0, this.timelineMaxSec()));
|
||||
}
|
||||
|
||||
protected onPlayerDurationChange(seconds: number): void {
|
||||
@@ -321,11 +310,11 @@ export class SessionInteractiveTabComponent {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
this.timelineSec.update((current) => this.clamp(current + deltaSec, 0, this.timelineMaxSec()));
|
||||
this.timelineSec.update((current) => clamp(current + deltaSec, 0, this.timelineMaxSec()));
|
||||
}
|
||||
|
||||
protected togglePlayback(): void {
|
||||
@@ -364,11 +353,4 @@ export class SessionInteractiveTabComponent {
|
||||
protected cursorLabel(): string {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { summarizeTelemetryData } from '../../../../core/sessions/telemetry-even
|
||||
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
||||
import { formatTimestamp, formatUnixMs as formatUnixMsUtil } from '../../../../shared/utils/date-time.util';
|
||||
import { clamp } from '../../../../shared/utils/math.util';
|
||||
import { resolveActiveStreamType, resolvePlaylistUrlForType } from '../../../../shared/utils/stream.util';
|
||||
import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
|
||||
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
|
||||
import { TelemetryEventDetailComponent } from '../../telemetry-event-detail/telemetry-event-detail.component';
|
||||
@@ -74,24 +76,11 @@ export class SessionViewTabComponent {
|
||||
private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' });
|
||||
|
||||
protected activeStreamType(): string | null {
|
||||
const streams = this.detail().streams;
|
||||
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;
|
||||
return resolveActiveStreamType(this.detail().streams, this.selectedStreamType());
|
||||
}
|
||||
|
||||
protected playlistUrl(): string | null {
|
||||
const t = this.activeStreamType();
|
||||
if (!t) {
|
||||
return null;
|
||||
}
|
||||
const stream = this.detail().streams.find((s) => s.stream_type === t);
|
||||
return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null;
|
||||
return resolvePlaylistUrlForType(this.detail().streams, this.activeStreamType(), this.api);
|
||||
}
|
||||
|
||||
protected pickStream(type: string): void {
|
||||
@@ -161,7 +150,7 @@ export class SessionViewTabComponent {
|
||||
this.telemetryRangeSelection.set({ type: 'preset', seconds });
|
||||
const start = this.recordingStartMs() ?? 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 {
|
||||
@@ -180,7 +169,7 @@ export class SessionViewTabComponent {
|
||||
this.telemetryRangeSelection.set({ type: 'custom' });
|
||||
const start = this.recordingStartMs() ?? 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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
<tbody>
|
||||
@for (
|
||||
row of filteredTelemetryEvents(visibleTelemetryEvents());
|
||||
track $index;
|
||||
track row.timestamp + '_' + row.event_type + '_' + $index;
|
||||
let i = $index
|
||||
) {
|
||||
<tr
|
||||
|
||||
@@ -67,10 +67,7 @@
|
||||
color: var(--tui-text-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
/* .mono определён глобально в page-common.css */
|
||||
|
||||
.keyboard-block {
|
||||
margin: 0 0 1rem;
|
||||
@@ -105,11 +102,4 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--tui-text-tertiary);
|
||||
}
|
||||
|
||||
.small {
|
||||
font: var(--tui-font-text-s);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
/* .muted и .small определены глобально в page-common.css */
|
||||
|
||||
@@ -19,7 +19,7 @@ export function formatTimestamp(value: string | null | undefined): string {
|
||||
}
|
||||
|
||||
export function formatUnixMs(value: number | null | undefined): string {
|
||||
if (!value) {
|
||||
if (value == null) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimestamp(new Date(value).toISOString());
|
||||
|
||||
10
src/app/shared/utils/math.util.ts
Normal file
10
src/app/shared/utils/math.util.ts
Normal 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));
|
||||
}
|
||||
15
src/app/shared/utils/number.util.ts
Normal file
15
src/app/shared/utils/number.util.ts
Normal 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;
|
||||
}
|
||||
34
src/app/shared/utils/stream.util.ts
Normal file
34
src/app/shared/utils/stream.util.ts
Normal 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;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export const environment = {
|
||||
apiFallbackOrigin: 'https://sparkguardian.ru',
|
||||
apiBasePath: '/api/v1',
|
||||
interactivePrerollMs: 4000,
|
||||
defaultPageLimit: 10,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user