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 { 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',
})

View File

@@ -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}` }));
};

View File

@@ -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(

View File

@@ -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 [];

View File

@@ -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(

View File

@@ -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 '—';
}

View File

@@ -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 '[объект]';
}
}

View File

@@ -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 {

View File

@@ -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>
}

View File

@@ -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,

View File

@@ -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));
}
}

View File

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

View File

@@ -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;

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

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

View File

@@ -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 */

View File

@@ -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());

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',
apiBasePath: '/api/v1',
interactivePrerollMs: 4000,
defaultPageLimit: 10,
} as const;