diff --git a/src/app/app.ts b/src/app/app.ts index d9e97cd..91a460a 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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', }) diff --git a/src/app/core/http/api-base-url.interceptor.ts b/src/app/core/http/api-base-url.interceptor.ts index 3a60749..14358de 100644 --- a/src/app/core/http/api-base-url.interceptor.ts +++ b/src/app/core/http/api-base-url.interceptor.ts @@ -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}` })); }; diff --git a/src/app/core/keyboard/keyboard-svg-highlight.service.ts b/src/app/core/keyboard/keyboard-svg-highlight.service.ts index fc89a15..3b38b06 100644 --- a/src/app/core/keyboard/keyboard-svg-highlight.service.ts +++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts @@ -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>(); private baseSvg$(path: string): Observable { 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 { return this.baseSvg$(svgPath).pipe( diff --git a/src/app/core/mouse/mouse-payload.util.ts b/src/app/core/mouse/mouse-payload.util.ts index 281f571..e422164 100644 --- a/src/app/core/mouse/mouse-payload.util.ts +++ b/src/app/core/mouse/mouse-payload.util.ts @@ -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, 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): 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 []; diff --git a/src/app/core/mouse/mouse-svg-highlight.service.ts b/src/app/core/mouse/mouse-svg-highlight.service.ts index b20784b..0cfa170 100644 --- a/src/app/core/mouse/mouse-svg-highlight.service.ts +++ b/src/app/core/mouse/mouse-svg-highlight.service.ts @@ -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 { return this.baseSvg$.pipe( diff --git a/src/app/core/sessions/telemetry-event-summary.engine.ts b/src/app/core/sessions/telemetry-event-summary.engine.ts index 3a4eb00..7c2aee8 100644 --- a/src/app/core/sessions/telemetry-event-summary.engine.ts +++ b/src/app/core/sessions/telemetry-event-summary.engine.ts @@ -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 '—'; } diff --git a/src/app/core/sessions/telemetry-event-summary.payload.ts b/src/app/core/sessions/telemetry-event-summary.payload.ts index 73f1f76..e3fa8e6 100644 --- a/src/app/core/sessions/telemetry-event-summary.payload.ts +++ b/src/app/core/sessions/telemetry-event-summary.payload.ts @@ -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, 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 { try { @@ -28,3 +10,4 @@ export function fallbackCompactJson(o: Record): string { return '[объект]'; } } + diff --git a/src/app/features/devtools/dev-console/dev-console.css b/src/app/features/devtools/dev-console/dev-console.css index ab13b83..c3002a9 100644 --- a/src/app/features/devtools/dev-console/dev-console.css +++ b/src/app/features/devtools/dev-console/dev-console.css @@ -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 { diff --git a/src/app/features/devtools/dev-console/dev-console.html b/src/app/features/devtools/dev-console/dev-console.html index 65a8473..7a986d6 100644 --- a/src/app/features/devtools/dev-console/dev-console.html +++ b/src/app/features/devtools/dev-console/dev-console.html @@ -9,10 +9,10 @@ Dev Console {{ count() }} - - + + @if (!collapsed()) { @@ -51,37 +51,37 @@ class="dev-console__expand" (click)="toggleExpanded(entry.id)" > - {{ isExpanded(entry.id) ? 'Hide details' : 'Show details' }} + {{ isExpanded(entry.id) ? 'Скрыть детали' : 'Показать детали' }} @if (isExpanded(entry.id)) {
- Method: {{ entry.details.method }} - Status: {{ entry.details.statusCode ?? 'pending' }} - Duration: {{ entry.details.durationMs ?? '—' }} ms + Метод: {{ entry.details.method }} + Статус: {{ entry.details.statusCode ?? 'ожидание' }} + Длительность: {{ entry.details.durationMs ?? '—' }} мс
URL: {{ entry.details.url }}
- Request headers + Заголовки запроса
{{ pretty(entry.details.requestHeaders) }}
- Request body + Тело запроса
{{ pretty(entry.details.requestBody) }}
- Response headers + Заголовки ответа
{{ pretty(entry.details.responseHeaders) }}
- Response body + Тело ответа
{{ pretty(entry.details.responseBody) }}
@if (entry.details.error) {
- Error + Ошибка
{{ entry.details.error }}
} diff --git a/src/app/features/landing/landing.component.ts b/src/app/features/landing/landing.component.ts index 0763114..09fc80f 100644 --- a/src/app/features/landing/landing.component.ts +++ b/src/app/features/landing/landing.component.ts @@ -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, diff --git a/src/app/features/sessions/session-detail/session-detail.component.ts b/src/app/features/sessions/session-detail/session-detail.component.ts index 9d26654..fd9c867 100644 --- a/src/app/features/sessions/session-detail/session-detail.component.ts +++ b/src/app/features/sessions/session-detail/session-detail.component.ts @@ -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)); - } } diff --git a/src/app/features/sessions/session-detail/session-detail.html b/src/app/features/sessions/session-detail/session-detail.html index 85049f6..995fbd8 100644 --- a/src/app/features/sessions/session-detail/session-detail.html +++ b/src/app/features/sessions/session-detail/session-detail.html @@ -36,22 +36,40 @@ /> } @case (1) { - + @defer { + + } @placeholder { +
+ +
+ } } @case (2) { - + @defer { + + } @placeholder { +
+ +
+ } } @case (3) { - + @defer { + + } @placeholder { +
+ +
+ } } } } diff --git a/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.component.ts b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.component.ts index 41c40aa..dc9f436 100644 --- a/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.component.ts +++ b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.component.ts @@ -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) { diff --git a/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css index 9240547..ec99da4 100644 --- a/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css +++ b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css @@ -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; diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts index 56ebd56..a90edda 100644 --- a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts +++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts @@ -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)); - } } diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts index dddcc28..41891c4 100644 --- a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts +++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts @@ -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({ 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)); - } } diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html index 8a35041..23b9c88 100644 --- a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html +++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html @@ -179,7 +179,7 @@ @for ( row of filteredTelemetryEvents(visibleTelemetryEvents()); - track $index; + track row.timestamp + '_' + row.event_type + '_' + $index; let i = $index ) { , 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; +} diff --git a/src/app/shared/utils/stream.util.ts b/src/app/shared/utils/stream.util.ts new file mode 100644 index 0000000..8f381d4 --- /dev/null +++ b/src/app/shared/utils/stream.util.ts @@ -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; +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 83725a4..b8fed91 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -3,4 +3,5 @@ export const environment = { apiFallbackOrigin: 'https://sparkguardian.ru', apiBasePath: '/api/v1', interactivePrerollMs: 4000, + defaultPageLimit: 10, } as const;