add new functional
Some checks failed
CI / checks (push) Has been cancelled

This commit is contained in:
Микаэл Оганесян
2026-04-09 00:26:47 +03:00
parent 07c17877ac
commit d96c152ae3
40 changed files with 3243 additions and 945 deletions

44
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,44 @@
# Gitea Actions: линт, unit-тесты (Vitest/jsdom), production build.
# Нужны: включённые Actions в репозитории и зарегистрированный act_runner.
# Если шаги с `uses:` не резолвятся, в настройках Gitea укажите прокси GitHub Actions
# или замените на полные URL, например: https://github.com/actions/checkout@v4
name: CI
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit tests
run: npm run test -- --watch=false
- name: Production build
run: npm run build

6
.gitignore vendored
View File

@@ -45,3 +45,9 @@ __screenshots__/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Claude Code / Cursor — правила исключения из контекста
.claudeignore
.cursorignore
CLAUDE.md

View File

@@ -83,6 +83,12 @@
}, },
"test": { "test": {
"builder": "@angular/build:unit-test" "builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
} }
} }
} }

82
eslint.config.js Normal file
View File

@@ -0,0 +1,82 @@
// @ts-check
const eslint = require('@eslint/js');
const { defineConfig } = require('eslint/config');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = defineConfig([
{
ignores: [
'**/dist/**',
'**/.angular/**',
'**/coverage/**',
'**/node_modules/**',
'**/*.min.js',
],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.stylistic,
angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
// Production safety rules: strict on bugs, neutral on visual style.
'no-alert': 'error',
'no-debugger': 'error',
'no-unsafe-finally': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
'@angular-eslint/contextual-lifecycle': 'error',
'@angular-eslint/no-empty-lifecycle-method': 'error',
'@angular-eslint/no-input-rename': 'error',
'@angular-eslint/no-output-native': 'error',
'@angular-eslint/use-lifecycle-interface': 'error',
},
},
{
files: ['**/*.html'],
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
rules: {
'@angular-eslint/template/no-call-expression': 'off',
},
},
{
files: ['**/*.spec.ts'],
rules: {
'no-console': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
},
},
]);

1165
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,8 @@
"build": "ng build", "build": "ng build",
"prewatch": "npm run env:sync", "prewatch": "npm run env:sync",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"lint": "ng lint"
}, },
"private": true, "private": true,
"packageManager": "npm@11.6.2", "packageManager": "npm@11.6.2",
@@ -35,11 +36,15 @@
"@angular/build": "^21.2.6", "@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6", "@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0", "@angular/compiler-cli": "^21.2.0",
"@eslint/js": "^10.0.1",
"angular-eslint": "21.3.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"eslint": "^10.2.0",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"less": "^4.6.4", "less": "^4.6.4",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.58.1",
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
} }

View File

@@ -366,10 +366,10 @@
</g> </g>
<g id="T_others"> <g id="T_others">
<text id="T_kb6a" x="10" y="230" >Ctrl</text> <text id="T_kb6a" x="10" y="230" >ctrl</text>
<text id="T_kb6c" x="134" y="230" >Alt</text> <text id="T_kb6c" x="134" y="230" >alt</text>
<text id="T_kb6k" x="564" y="230" >Alt</text> <text id="T_kb6k" x="564" y="230" >alt</text>
<text id="T_kb6n" x="750" y="230" >Ctrl</text> <text id="T_kb6n" x="750" y="230" >ctrl</text>
</g> </g>
<!-- ======== alternates inscriptions ======== --> <!-- ======== alternates inscriptions ======== -->
<g inkscape:groupmode="layer" id="sublayer4a" inkscape:label="alternative inscriptions" style="display:inline"> <g inkscape:groupmode="layer" id="sublayer4a" inkscape:label="alternative inscriptions" style="display:inline">

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,19 @@
<svg width="707" height="1042" viewBox="0 0 707 1042" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<style type="text/css"><![CDATA[
text {
font-family: var(--sg-keyboard-font-family);
font-weight: var(--sg-keyboard-font-weight);
letter-spacing: 0.04em;
fill: var(--sg-keyboard-ink-soft);
stroke: none;
text-anchor: middle;
font-size: 44px;
}
]]></style>
</defs>
<path d="M346 80.0889C319.97 83.9545 300 106.395 300 133.5V295.5C300 322.605 319.97 345.045 346 348.91V1042C154.909 1042 4.48005e-06 887.091 0 696V346C0 154.909 154.909 0 346 0V80.0889ZM361 0C552.091 0 707 154.909 707 346V696C707 887.091 552.091 1042 361 1042V349.05C387.516 345.618 408 322.951 408 295.5V133.5C408 106.049 387.516 83.3814 361 79.9492V0Z" fill="#D9D9D9"/>
<rect x="312" y="94" width="84" height="242" rx="42" fill="#D9D9D9"/>
<text x="210" y="210">ЛКМ</text>
<text x="500" y="210">ПКМ</text>
</svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@@ -28,12 +28,12 @@
.brand { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
font-family: inherit; font-family: inherit;
font-size: clamp(1.35rem, 2.4vw, 1.65rem); font-size: clamp(1.15rem, 2vw, 1.4rem);
font-weight: 500; font-weight: 500;
line-height: 1.15; line-height: 1.15;
letter-spacing: 0.035em; letter-spacing: 0.015em;
color: var(--tui-text-primary); color: var(--tui-text-primary);
text-decoration: none; text-decoration: none;
} }

View File

@@ -10,10 +10,10 @@ describe('App', () => {
matches: false, matches: false,
media: query, media: query,
onchange: null, onchange: null,
addListener: () => {}, addListener: () => void 0,
removeListener: () => {}, removeListener: () => void 0,
addEventListener: () => {}, addEventListener: () => void 0,
removeEventListener: () => {}, removeEventListener: () => void 0,
dispatchEvent: () => false, dispatchEvent: () => false,
}), }),
}); });

View File

@@ -1,4 +1,5 @@
import type { ParsedEvent } from '../models/api.types'; import type { ParsedEvent } from '../models/api.types';
import { unwrapJsonPayload } from '../../shared/utils/json.util';
import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map'; import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map';
import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id'; import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
@@ -51,15 +52,21 @@ export function eventPayloadJson(data: unknown): string {
} }
} }
function unwrapJsonPayload(data: unknown): unknown { export function parseKeyboardAction(data: unknown): 'press' | 'release' | null {
if (typeof data === 'string') { const raw = unwrapJsonPayload(data);
try { if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
return JSON.parse(data) as unknown; const action = (raw as Record<string, unknown>)['action'];
} catch { if (typeof action === 'string') {
return data; const normalized = action.toLowerCase();
if (normalized === 'press') {
return 'press';
}
if (normalized === 'release') {
return 'release';
} }
} }
return data; }
return null;
} }
export function parseKeyboardHighlightKeyIds(data: unknown): string[] { export function parseKeyboardHighlightKeyIds(data: unknown): string[] {

View File

@@ -24,29 +24,49 @@ export class KeyboardSvgHighlightService {
), ),
).pipe(shareReplay({ bufferSize: 1, refCount: false })); ).pipe(shareReplay({ bufferSize: 1, refCount: false }));
svgWithHighlight(keyIds: string[] | null): Observable<SafeHtml> { svgWithHighlight(keyIds: string[] | null, animated = true): Observable<SafeHtml> {
return this.baseSvg$.pipe( return this.baseSvg$.pipe(
map((svg) => this.injectHighlight(svg, keyIds ?? [])), map((svg) => this.injectHighlight(svg, keyIds ?? [], animated)),
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
); );
} }
private injectHighlight(svgText: string, keyIds: string[]): string { private injectHighlight(svgText: string, keyIds: string[], animated: boolean): string {
const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)); const valid = [...new Set(keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))];
if (valid.length === 0) { if (valid.length === 0) {
return svgText; return svgText;
} }
const rules: string[] = []; const rules: string[] = [];
for (const id of valid) { for (const id of valid) {
const suffix = id.slice(2); const suffix = id.slice(2);
if (animated) {
rules.push(
`#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;transform-box:fill-box;transform-origin:center;animation:sgInputHighlightPop var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`,
);
rules.push(
`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;animation:sgInputHighlightFade var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`,
);
rules.push(
`#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;transform-box:fill-box;transform-origin:center;animation:sgInputHighlightFade var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`,
);
} else {
rules.push( rules.push(
`#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`, `#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`,
); );
rules.push(`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`); rules.push(
`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`,
);
rules.push( rules.push(
`#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`, `#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`,
); );
} }
}
if (animated) {
rules.push(
`@keyframes sgInputHighlightPop{0%{opacity:.45;transform:scale(var(--sg-input-highlight-from-scale));}100%{opacity:1;transform:scale(1);}}`,
);
rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`);
}
const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`; const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
return svgText.replace(/<svg\b[^>]*>/i, (open) => `${open}${styleBlock}`); return svgText.replace(/<svg\b[^>]*>/i, (open) => `${open}${styleBlock}`);
} }

View File

@@ -0,0 +1,53 @@
import type { ParsedEvent } from '../models/api.types';
import { unwrapJsonPayload } from '../../shared/utils/json.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');
}
export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[] {
const raw = unwrapJsonPayload(data);
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
return [];
}
const o = raw as Record<string, unknown>;
const action = typeof o['action'] === 'string' ? o['action'].toLowerCase() : '';
if (action.includes('wheel') || action.includes('scroll')) {
return ['wheel'];
}
const button = readNumber(o, 'button');
const isDown = o['is_down'];
if (button == null) {
return [];
}
if (isDown === false) {
return [];
}
if (button === 0) {
return ['left'];
}
if (button === 1) {
return ['middle'];
}
if (button === 2) {
return ['right'];
}
return [];
}

View File

@@ -0,0 +1,78 @@
import { Injectable, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Observable, defer, from, shareReplay } from 'rxjs';
import { map } from 'rxjs/operators';
import type { MouseHighlightTarget } from './mouse-payload.util';
const MOUSE_SVG_PATH = '/svg/visual/mouse.svg';
@Injectable({ providedIn: 'root' })
export class MouseSvgHighlightService {
private readonly sanitizer = inject(DomSanitizer);
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 }));
svgWithHighlight(targets: MouseHighlightTarget[] | null, animated = true): Observable<SafeHtml> {
return this.baseSvg$.pipe(
map((svg) => this.injectHighlight(svg, targets ?? [], animated)),
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
);
}
private injectHighlight(svgText: string, targets: MouseHighlightTarget[], animated: boolean): string {
let normalized = this.ensureMouseIds(svgText);
const uniq = [...new Set(targets)];
const hasLeft = uniq.includes('left');
const hasRight = uniq.includes('right');
const hasMiddle = uniq.includes('middle') || uniq.includes('wheel');
const hasAny = hasLeft || hasRight || hasMiddle;
let surfaceFill = 'var(--sg-keyboard-key-surface-idle)';
if (hasLeft && hasRight) {
surfaceFill = 'var(--sg-keyboard-key-pressed-fill)';
} else if (hasLeft) {
surfaceFill = 'url(#sg-mouse-left-fill)';
} else if (hasRight) {
surfaceFill = 'url(#sg-mouse-right-fill)';
}
const wheelFill = hasMiddle
? 'var(--sg-keyboard-key-pressed-fill)'
: 'var(--sg-keyboard-key-surface-idle)';
const wheelOpacity = hasAny || hasMiddle ? '1' : '0.98';
const surfaceAnim = animated && hasAny
? 'sgInputHighlightFill var(--sg-input-highlight-duration) var(--sg-input-highlight-easing)'
: 'none';
const wheelAnim = animated && hasMiddle
? 'sgInputHighlightPop var(--sg-input-highlight-duration) var(--sg-input-highlight-easing)'
: 'none';
const keyframes = animated
? `@keyframes sgInputHighlightPop{0%{opacity:.45;transform:scale(var(--sg-input-highlight-from-scale));}100%{opacity:1;transform:scale(1);}}@keyframes sgInputHighlightFill{0%{opacity:.72;}100%{opacity:1;}}`
: '';
const defsAndStyles = `<defs><linearGradient id="sg-mouse-left-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-surface-idle)"/></linearGradient><linearGradient id="sg-mouse-right-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-pressed-fill)"/></linearGradient></defs><style type="text/css"><![CDATA[#SG_MOUSE_SURFACE{fill:${surfaceFill}!important;animation:${surfaceAnim};}#SG_MOUSE_WHEEL{fill:${wheelFill}!important;opacity:${wheelOpacity};transform-box:fill-box;transform-origin:center;animation:${wheelAnim};}${keyframes}]]></style>`;
normalized = normalized.replace(/<svg\b[^>]*>/i, (open) => `${open}${defsAndStyles}`);
return normalized;
}
private ensureMouseIds(svgText: string): string {
let next = svgText;
if (!next.includes('id="SG_MOUSE_SURFACE"')) {
next = next.replace(/<path\b(?![^>]*\bid=)/i, '<path id="SG_MOUSE_SURFACE"');
}
if (!next.includes('id="SG_MOUSE_WHEEL"')) {
next = next.replace(/<rect\b(?![^>]*\bid=)/i, '<rect id="SG_MOUSE_WHEEL"');
}
return next;
}
}

View File

@@ -42,7 +42,7 @@ export class UserErrorNotifyService {
.open(escapeHtml(userSubtitle), { .open(escapeHtml(userSubtitle), {
label: ERROR_TOAST_TITLE, label: ERROR_TOAST_TITLE,
appearance: 'negative', appearance: 'negative',
autoClose: 0, autoClose: 10000,
}) })
.subscribe(); .subscribe();
} }

View File

@@ -4,6 +4,7 @@ import {
effect, effect,
ElementRef, ElementRef,
input, input,
output,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import Hls from 'hls.js'; import Hls from 'hls.js';
@@ -16,8 +17,14 @@ import Hls from 'hls.js';
}) })
export class HlsPlayerComponent { export class HlsPlayerComponent {
readonly src = input.required<string>(); readonly src = input.required<string>();
readonly seekToSec = input<number | null>(null);
readonly isPlaying = input<boolean | null>(null);
readonly currentTimeSecChange = output<number>();
readonly durationSecChange = output<number>();
readonly playingChange = output<boolean>();
private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl'); private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl');
private pendingSeekSec: number | null = null;
constructor() { constructor() {
effect((onCleanup) => { effect((onCleanup) => {
@@ -28,20 +35,103 @@ export class HlsPlayerComponent {
} }
const video = ref.nativeElement; const video = ref.nativeElement;
let hls: Hls | null = null; let hls: Hls | null = null;
const emitCurrentTime = () => {
this.currentTimeSecChange.emit(video.currentTime || 0);
};
const emitPlaying = () => {
this.playingChange.emit(!video.paused);
};
const emitDuration = () => {
if (Number.isFinite(video.duration) && video.duration > 0) {
this.durationSecChange.emit(video.duration);
}
};
const applyPendingSeek = () => {
if (this.pendingSeekSec == null) {
return;
}
const duration = Number.isFinite(video.duration) ? video.duration : null;
const clamped =
duration && duration > 0
? Math.max(0, Math.min(this.pendingSeekSec, duration))
: Math.max(0, this.pendingSeekSec);
video.currentTime = clamped;
this.pendingSeekSec = null;
this.currentTimeSecChange.emit(video.currentTime || 0);
};
if (Hls.isSupported()) { if (Hls.isSupported()) {
hls = new Hls({ enableWorker: true, lowLatencyMode: true }); hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
});
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(video); hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url; video.src = url;
} }
video.addEventListener('timeupdate', emitCurrentTime);
video.addEventListener('seeking', emitCurrentTime);
video.addEventListener('loadedmetadata', emitDuration);
video.addEventListener('durationchange', emitDuration);
video.addEventListener('play', emitPlaying);
video.addEventListener('pause', emitPlaying);
video.addEventListener('loadedmetadata', applyPendingSeek);
video.addEventListener('canplay', applyPendingSeek);
video.addEventListener('seeked', emitCurrentTime);
onCleanup(() => { onCleanup(() => {
video.removeEventListener('timeupdate', emitCurrentTime);
video.removeEventListener('seeking', emitCurrentTime);
video.removeEventListener('loadedmetadata', emitDuration);
video.removeEventListener('durationchange', emitDuration);
video.removeEventListener('play', emitPlaying);
video.removeEventListener('pause', emitPlaying);
video.removeEventListener('loadedmetadata', applyPendingSeek);
video.removeEventListener('canplay', applyPendingSeek);
video.removeEventListener('seeked', emitCurrentTime);
hls?.destroy(); hls?.destroy();
video.removeAttribute('src'); video.removeAttribute('src');
video.load(); video.load();
this.pendingSeekSec = null;
}); });
}); });
effect(() => {
const seekTo = this.seekToSec();
const ref = this.videoRef();
if (seekTo == null || !ref) {
return;
}
const video = ref.nativeElement;
this.pendingSeekSec = seekTo;
if (video.readyState < 1) {
return;
}
const duration = Number.isFinite(video.duration) ? video.duration : null;
const clamped = duration && duration > 0 ? Math.max(0, Math.min(seekTo, duration)) : Math.max(0, seekTo);
if (Math.abs(video.currentTime - clamped) > 0.01) {
video.currentTime = clamped;
this.currentTimeSecChange.emit(video.currentTime || 0);
}
this.pendingSeekSec = null;
});
effect(() => {
const shouldPlay = this.isPlaying();
const ref = this.videoRef();
if (shouldPlay == null || !ref) {
return;
}
const video = ref.nativeElement;
if (shouldPlay) {
void video.play().catch(() => {
this.playingChange.emit(false);
});
} else {
video.pause();
}
});
} }
} }

View File

@@ -1,93 +1,48 @@
import { animate, style, transition, trigger } from '@angular/animations'; import { AsyncPipe } from '@angular/common';
import { AsyncPipe, NgClass } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core';
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 { TuiButton } from '@taiga-ui/core/components/button';
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';
import { TuiChip } from '@taiga-ui/kit/components/chip';
import { TuiTabs } from '@taiga-ui/kit/components/tabs'; import { TuiTabs } from '@taiga-ui/kit/components/tabs';
import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, switchMap, tap, timeout } from 'rxjs'; import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, switchMap, tap, timeout } from 'rxjs';
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service'; import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe'; import type { ParsedEvent } from '../../../core/models/api.types';
import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe';
import { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe';
import type { ParsedEvent, SessionDetailResponse, StreamInfo } from '../../../core/models/api.types';
import { summarizeTelemetryData } from '../../../core/sessions/telemetry-event-summary.engine';
import { SessionsApiService } from '../../../core/services/sessions-api.service'; import { SessionsApiService } from '../../../core/services/sessions-api.service';
import { formatTimestamp } from '../../../shared/utils/date-time.util'; import { SessionViewTabComponent } from './session-view-tab/session-view-tab.component';
import { formatDurationMsHuman } from '../../../shared/utils/duration.util'; import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component';
import { HlsPlayerComponent } from '../hls-player/hls-player.component'; import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component';
import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component';
type TelemetryRangeSelection =
| { type: 'preset'; seconds: number }
| { type: 'end' }
| { type: 'custom' };
@Component({ @Component({
selector: 'app-session-detail', selector: 'app-session-detail',
imports: [ imports: [
AsyncPipe, AsyncPipe,
NgClass,
RouterLink, RouterLink,
HlsPlayerComponent,
TuiButton,
TuiChip,
...TuiTabs,
TuiLink, TuiLink,
TuiLoader, TuiLoader,
TuiTitle, TuiTitle,
SessionStatusChipClassesPipe, ...TuiTabs,
SessionStatusPipe, SessionViewTabComponent,
TelemetryEventTypePipe, SessionInteractiveTabComponent,
TelemetryEventDetailComponent, SessionInfoTabComponent,
], ],
templateUrl: './session-detail.html', templateUrl: './session-detail.html',
styleUrl: './session-detail.css', styleUrl: './session-detail.css',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('telemetryEventDetail', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(-0.4rem)' }),
animate(
'220ms cubic-bezier(0.33, 1, 0.68, 1)',
style({ opacity: 1, transform: 'translateY(0)' }),
),
]),
transition(':leave', [
animate(
'170ms cubic-bezier(0.4, 0, 1, 1)',
style({ opacity: 0, transform: 'translateY(-0.3rem)' }),
),
]),
]),
],
}) })
export class SessionDetailComponent { export class SessionDetailComponent {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly api = inject(SessionsApiService); private readonly api = inject(SessionsApiService);
private readonly userErrors = inject(UserErrorNotifyService); private readonly userErrors = inject(UserErrorNotifyService);
private readonly telemetryToMs = signal<number | null>(null); protected readonly telemetryToMs = signal<number | null>(null);
private readonly recordingStartMs = signal<number | null>(null); protected readonly recordingStartMs = signal<number | null>(null);
private readonly recordingEndMs = signal<number | null>(null); protected readonly recordingEndMs = signal<number | null>(null);
protected readonly customToLocal = signal('');
private readonly telemetryRangeSelection = signal<TelemetryRangeSelection>({ type: 'end' });
protected readonly selectedStreamType = signal<string | null>(null);
protected readonly activeTabIndex = model(0); protected readonly activeTabIndex = model(0);
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
private readonly sessionId$ = this.route.paramMap.pipe( private readonly sessionId$ = this.route.paramMap.pipe(
map((p) => Number(p.get('id'))), map((p) => Number(p.get('id'))),
distinctUntilChanged(), distinctUntilChanged(),
@@ -99,10 +54,7 @@ export class SessionDetailComponent {
this.userErrors.notifyError(new Error('Некорректный идентификатор сессии'), 'Сессия'); this.userErrors.notifyError(new Error('Некорректный идентификатор сессии'), 'Сессия');
return of({ status: 'error' as const }); return of({ status: 'error' as const });
} }
this.selectedStreamType.set(null);
this.activeTabIndex.set(0); this.activeTabIndex.set(0);
this.telemetryEventTypeFilter.set(null);
this.expandedTelemetryRowKey.set(null);
return this.api.getSession(id).pipe( return this.api.getSession(id).pipe(
map((detail) => ({ status: 'ok' as const, id, detail })), map((detail) => ({ status: 'ok' as const, id, detail })),
tap((state) => { tap((state) => {
@@ -113,12 +65,7 @@ export class SessionDetailComponent {
const end = this.toUnixMs(state.detail.session.ended_at); const end = this.toUnixMs(state.detail.session.ended_at);
this.recordingStartMs.set(start); this.recordingStartMs.set(start);
this.recordingEndMs.set(end); this.recordingEndMs.set(end);
this.telemetryRangeSelection.set({ type: 'end' });
this.customToLocal.set('');
// Дефолт для телеметрии: до текущего момента (или конца записи, если завершена).
if (this.telemetryToMs() === null) {
this.telemetryToMs.set(end ?? Date.now()); this.telemetryToMs.set(end ?? Date.now());
}
}), }),
catchError((e: HttpErrorResponse) => { catchError((e: HttpErrorResponse) => {
this.userErrors.notifyError(e, 'Сессия'); this.userErrors.notifyError(e, 'Сессия');
@@ -139,8 +86,8 @@ export class SessionDetailComponent {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
return of({ return of({
status: 'error' as const, status: 'error' as const,
toMs, toMs: 0,
fromMs: recordingStartMs ?? 0, fromMs: 0,
telemetry: [] as ParsedEvent[], telemetry: [] as ParsedEvent[],
parsedMeta: undefined, parsedMeta: undefined,
}); });
@@ -150,11 +97,7 @@ export class SessionDetailComponent {
const upperBound = recordingEndMs ?? now; const upperBound = recordingEndMs ?? now;
const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound); const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound);
return this.api return this.api
.getParsedEvents( .getParsedEvents(id, lowerBound, normalizedTo)
id,
lowerBound,
normalizedTo,
)
.pipe( .pipe(
timeout(12000), timeout(12000),
map((resp) => ({ map((resp) => ({
@@ -188,160 +131,6 @@ export class SessionDetailComponent {
}), }),
); );
protected pickStream(type: string): void {
this.selectedStreamType.set(type);
}
protected activeStreamType(detail: SessionDetailResponse): string | null {
const streams = 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;
}
protected playlistUrl(detail: SessionDetailResponse): string | null {
const t = this.activeStreamType(detail);
if (!t) {
return null;
}
const stream = detail.streams.find((s) => s.stream_type === t);
return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null;
}
protected formatDate(value: string | null | undefined): string {
return formatTimestamp(value);
}
protected formatUnixMs(value: number | null | undefined): string {
if (!value) {
return '—';
}
return formatTimestamp(new Date(value).toISOString());
}
protected pickTelemetryEventTypeFilter(type: string | null): void {
this.telemetryEventTypeFilter.set(type);
this.expandedTelemetryRowKey.set(null);
}
protected telemetryEventTypeKey(event: ParsedEvent): string {
const t = event.event_type;
if (t == null || t === '') {
return '';
}
return String(t).trim().toLowerCase();
}
protected telemetryRowKey(row: ParsedEvent, index: number): string {
return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\u0000${index}`;
}
protected toggleTelemetryRow(row: ParsedEvent, index: number): void {
const key = this.telemetryRowKey(row, index);
this.expandedTelemetryRowKey.update((cur) => (cur === key ? null : key));
}
protected isTelemetryRowExpanded(row: ParsedEvent, index: number): boolean {
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
}
protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
const set = new Set<string>();
for (const e of events) {
set.add(this.telemetryEventTypeKey(e));
}
return [...set].sort((a, b) => a.localeCompare(b, 'ru'));
}
protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number {
return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length;
}
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
const filter = this.telemetryEventTypeFilter();
if (filter === null) {
return events;
}
return events.filter((e) => this.telemetryEventTypeKey(e) === filter);
}
protected telemetryEventSummary(event: ParsedEvent): string {
return summarizeTelemetryData(event.data);
}
protected selectRecentWindow(seconds: number): void {
this.customToLocal.set('');
this.telemetryRangeSelection.set({ type: 'preset', seconds });
const start = this.recordingStartMs() ?? Date.now();
const end = this.recordingEndMs() ?? Date.now();
this.telemetryToMs.set(this.clamp(start + seconds * 1000, start, end));
}
protected loadUntilEndTelemetry(): void {
this.customToLocal.set('');
this.telemetryRangeSelection.set({ type: 'end' });
const end = this.recordingEndMs() ?? Date.now();
this.telemetryToMs.set(end);
}
protected applyCustomTo(value: string): void {
this.customToLocal.set(value);
if (!value) {
return;
}
const ms = new Date(value).getTime();
if (Number.isFinite(ms)) {
this.telemetryRangeSelection.set({ type: 'custom' });
const start = this.recordingStartMs() ?? Date.now();
const end = this.recordingEndMs() ?? Date.now();
this.telemetryToMs.set(this.clamp(ms, start, end));
}
}
protected telemetryRangePresetIs(seconds: number): boolean {
const s = this.telemetryRangeSelection();
return s.type === 'preset' && s.seconds === seconds;
}
protected telemetryRangeIsEnd(): boolean {
return this.telemetryRangeSelection().type === 'end';
}
protected telemetryRangeLabel(toMs: number | null): string {
return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`;
}
private detailPayloadJson(detail: SessionDetailResponse): string {
try {
return JSON.stringify(detail, null, 2);
} catch {
return '{}';
}
}
protected async copyDetailPayloadJson(detail: SessionDetailResponse): Promise<void> {
const text = this.detailPayloadJson(detail);
try {
await navigator.clipboard.writeText(text);
this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово');
} catch {
// clipboard
}
}
protected formatDurationMs(ms: number | null | undefined): string {
return formatDurationMsHuman(ms);
}
protected streamResolvedPlaylistUrl(stream: StreamInfo): string {
return this.api.resolvePlaylistUrl(stream.playlist_url);
}
private toUnixMs(value: string | null | undefined): number | null { private toUnixMs(value: string | null | undefined): number | null {
if (!value) { if (!value) {
return null; return null;
@@ -356,5 +145,4 @@ export class SessionDetailComponent {
} }
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
} }

View File

@@ -1,8 +1,3 @@
.page {
padding-top: 1.5rem;
padding-bottom: 3rem;
}
.back { .back {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -11,7 +6,7 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
/* Не задавать display: block — ломает горизонтальный flex у tui-tabs и переносит вкладки. */ /* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
.session-tabs { .session-tabs {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -22,351 +17,14 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Воздух между подписью и нижней линией (у Taiga у [tuiTab] padding: 0). */ /* Padding between label and underline (Taiga [tuiTab] has padding: 0 by default). */
.session-tabs [tuiTab] { .session-tabs [tuiTab] {
padding-block-end: 0.5rem; padding-block-end: 0.5rem;
} }
/* /*
* У Taiga для неактивной вкладки: box-shadow inset 0 -.125rem — линия hover оказывается * Taiga's inactive tab hover shadow is above the tab bar bottom border — remove it.
* выше общей нижней границы полосы (inset 0 -1px на tui-tabs). Убираем дублирующую линию.
*/ */
.session-tabs [tuiTab]:hover:not(._active) { .session-tabs [tuiTab]:hover:not(._active) {
box-shadow: none; box-shadow: none;
} }
.session-title {
margin: 0 0 0.75rem;
font: var(--tui-font-heading-6);
color: var(--tui-text-primary);
}
.kv {
display: grid;
grid-template-columns: minmax(10rem, 14rem) 1fr;
gap: 0.35rem 1rem;
margin: 0;
font: var(--tui-font-text-s);
}
.kv dt {
margin: 0;
color: var(--tui-text-tertiary);
}
.kv dd {
margin: 0;
word-break: break-word;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 0.92em;
}
.json-copy-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
}
.json-copy-row .section-title {
margin: 0;
}
.meta-table {
width: 100%;
font: var(--tui-font-text-s);
}
.meta-table tbody td {
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}
.meta-table th {
text-align: left;
font-weight: 600;
color: var(--tui-text-primary);
}
.table-wrap_flat {
max-height: none;
}
.loading-wrap {
display: flex;
justify-content: center;
padding: 3rem;
}
.loading-wrap_small {
padding: 1rem 0;
}
.error {
color: var(--tui-status-negative);
}
.card {
padding: 1.25rem 1.5rem;
border-radius: var(--tui-radius-l);
background: var(--tui-background-elevation-1);
margin-bottom: 1.5rem;
}
.section-title {
margin: 0 0 1rem;
font: var(--tui-font-heading-6);
color: var(--sg-color-subtitle);
}
.telemetry-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.telemetry-content {
display: flex;
flex-direction: column;
min-height: 520px;
}
.telemetry-content > .loading-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.telemetry-head__main {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
flex: 1;
}
.telemetry-head .section-title {
margin: 0;
}
.telemetry-head .telemetry-range {
margin: 0;
}
.telemetry-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.telemetry-presets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.from-picker {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.small {
font: var(--tui-font-text-s);
margin-top: 0.35rem;
}
.muted {
color: var(--tui-text-tertiary);
}
.stream-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.stream-tabs button,
.telemetry-presets button {
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
/* Неактивный чип */
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active),
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active) {
border-radius: 624.9375rem;
font-weight: 400;
background: var(--sg-filter-chip-bg);
color: var(--sg-filter-chip-fg);
border: 1px solid transparent;
outline: none;
box-shadow: none;
}
.stream-tabs
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
[data-state='disabled']
),
.telemetry-presets
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
[data-state='disabled']
) {
background: var(--sg-filter-chip-bg-hover);
color: var(--sg-filter-chip-fg);
border-color: transparent;
outline: none !important;
box-shadow: none !important;
}
/* Активный чип */
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'],
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] {
border-radius: 624.9375rem;
font-weight: 400;
background: var(--sg-filter-chip-active-bg);
color: var(--sg-filter-chip-active-fg);
border: 1px solid transparent;
outline: none;
box-shadow: none;
}
.stream-tabs
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
[data-state='disabled']
),
.telemetry-presets
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
[data-state='disabled']
) {
background: var(--sg-filter-chip-active-bg-hover);
color: var(--sg-filter-chip-active-fg);
border-color: transparent;
outline: none !important;
box-shadow: none !important;
}
/*
* Taiga оставляет :focus после клика и может показать обводку/тень с задержкой — убираем для мыши.
* Клавиатура: лёгкое кольцо только при :focus-visible.
*/
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible),
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible) {
outline: none !important;
box-shadow: none !important;
}
.stream-tabs
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']),
.telemetry-presets
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']) {
outline: none !important;
box-shadow: none !important;
}
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
outline: 2px solid var(--sg-filter-chip-active-bg);
outline-offset: 2px;
box-shadow: none;
}
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
outline-color: var(--sg-filter-chip-active-bg-hover);
}
.table-wrap {
overflow: auto;
max-height: min(520px, 70vh);
}
.table-wrap:not(.table-wrap_flat) {
min-height: 520px;
}
.telemetry {
width: 100%;
font: var(--tui-font-text-s);
}
.telemetry th {
text-align: left;
color: var(--tui-text-primary);
}
.telemetry tbody td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--tui-border-normal);
vertical-align: top;
}
/* Длинные неизвестные event_type — перенос, без разъезда таблицы */
.telemetry-col-type {
min-width: 0;
max-width: 14rem;
overflow-wrap: anywhere;
word-break: break-word;
vertical-align: top;
}
.telemetry-type-tabs {
overflow-wrap: anywhere;
}
.telemetry-type-tabs button {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
text-align: start;
}
.telemetry-row {
cursor: pointer;
transition: background-color 0.12s ease;
}
.telemetry-row:hover {
background: color-mix(in srgb, var(--tui-background-accent-1) 12%, transparent);
}
.telemetry-row.telemetry-row_expanded {
background: color-mix(in srgb, var(--tui-background-accent-1) 18%, transparent);
}
.telemetry-row-detail td {
padding: 0 0.75rem 1rem;
border-bottom: 1px solid var(--tui-border-normal);
vertical-align: top;
}
.telemetry-col-summary {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
vertical-align: top;
}

View File

@@ -18,316 +18,35 @@
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m"> <tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
<button tuiTab type="button">Просмотр</button> <button tuiTab type="button">Просмотр</button>
<button tuiTab type="button">Интерактивный режим</button>
<button tuiTab type="button">Служебная информация</button> <button tuiTab type="button">Служебная информация</button>
</tui-tabs> </tui-tabs>
@if (telemetry$ | async; as telemetryState) { @if (telemetry$ | async; as telemetryState) {
@if (activeTabIndex() === 0) { @switch (activeTabIndex()) {
<section class="card summary" aria-label="Сводка"> @case (0) {
@if (state.detail.session.title) { <app-session-view-tab
<p class="session-title">{{ state.detail.session.title }}</p> [detail]="state.detail"
} [telemetryState]="telemetryState"
<div class="summary-row"> [recordingStartMs]="recordingStartMs()"
<span [recordingEndMs]="recordingEndMs()"
tuiChip (telemetryToMsChange)="telemetryToMs.set($event)"
size="s"
class="status-chip"
[ngClass]="state.detail.session.status | sessionStatusChipClasses"
>{{ state.detail.session.status | sessionStatus }}</span>
</div>
<div class="muted small">
Начало: {{ formatDate(state.detail.session.started_at) }} · Окончание:
{{ formatDate(state.detail.session.ended_at) }}
</div>
</section>
<section class="card" aria-label="Видео">
<h3 class="section-title">Трансляция</h3>
@if (state.detail.streams.length === 0) {
<p class="muted">Потоки ещё не готовы.</p>
} @else {
<div class="stream-tabs">
@for (s of state.detail.streams; track s.stream_type) {
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="activeStreamType(state.detail) === s.stream_type"
(click)="pickStream(s.stream_type)"
>
{{ s.stream_type }}
</button>
}
</div>
@if (playlistUrl(state.detail); as src) {
<app-hls-player [src]="src" />
}
}
</section>
<section class="card" aria-label="Телеметрия">
<div class="telemetry-head">
<div class="telemetry-head__main">
<h3 class="section-title">
События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }})
</h3>
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState.toMs) }}</p>
</div>
<div class="telemetry-actions">
<div class="telemetry-presets">
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(2)"
(click)="selectRecentWindow(2)"
>
+2с
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(10)"
(click)="selectRecentWindow(10)"
>
+10с
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(60)"
(click)="selectRecentWindow(60)"
>
+1м
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(300)"
(click)="selectRecentWindow(300)"
>
+5м
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(900)"
(click)="selectRecentWindow(900)"
>
+15м
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangeIsEnd()"
(click)="loadUntilEndTelemetry()"
>
До конца
</button>
</div>
<label class="from-picker">
<input
type="datetime-local"
class="sg-native-input"
[value]="customToLocal()"
(change)="applyCustomTo($any($event.target).value)"
aria-label="Верхняя граница диапазона телеметрии"
/> />
</label>
</div>
</div>
<div class="telemetry-content">
@if (telemetryState.status === 'loading') {
<div class="loading-wrap loading-wrap_small">
<tui-loader [loading]="true" size="l" />
</div>
} @else if (telemetryState.status === 'error') {
<p class="muted">События телеметрии временно недоступны.</p>
} @else {
@if (telemetryState.telemetry.length === 0) {
<p class="muted">Событий пока нет.</p>
} @else {
<div class="telemetry-type-tabs stream-tabs" aria-label="Фильтр по типу события">
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryEventTypeFilter() === null"
(click)="pickTelemetryEventTypeFilter(null)"
>
Все ({{ telemetryState.telemetry.length }})
</button>
@for (t of uniqueTelemetryEventTypes(telemetryState.telemetry); track t) {
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryEventTypeFilter() === t"
(click)="pickTelemetryEventTypeFilter(t)"
>
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
</button>
} }
</div> @case (1) {
@if (filteredTelemetryEvents(telemetryState.telemetry).length === 0) { <app-session-interactive-tab
<p class="muted">Нет событий выбранного типа.</p> [detail]="state.detail"
} @else { [telemetryEvents]="telemetryState.telemetry"
<div class="table-wrap"> [recordingStartMs]="recordingStartMs()"
<table class="telemetry"> [recordingEndMs]="recordingEndMs()"
<thead> />
<tr>
<th>Время</th>
<th>Тип</th>
<th>Сводка</th>
</tr>
</thead>
<tbody>
@for (
row of filteredTelemetryEvents(telemetryState.telemetry);
track $index;
let i = $index
) {
<tr
class="telemetry-row"
[class.telemetry-row_expanded]="isTelemetryRowExpanded(row, i)"
(click)="toggleTelemetryRow(row, i)"
>
<td>{{ formatUnixMs(row.timestamp) }}</td>
<td class="telemetry-col-type">
<span tuiChip size="xs">{{ row.event_type | telemetryEventType }}</span>
</td>
<td class="telemetry-col-summary">{{ telemetryEventSummary(row) }}</td>
</tr>
@if (isTelemetryRowExpanded(row, i)) {
<tr class="telemetry-row-detail" @telemetryEventDetail>
<td colspan="3" (click)="$event.stopPropagation()">
<app-telemetry-event-detail [event]="row" />
</td>
</tr>
} }
@case (2) {
<app-session-info-tab
[detail]="state.detail"
[telemetryState]="telemetryState"
/>
} }
</tbody>
</table>
</div>
}
}
}
</div>
</section>
}
@if (activeTabIndex() === 1) {
<section class="card" aria-label="Сессия">
<h3 class="section-title">Сессия</h3>
<dl class="kv">
<dt>Идентификатор</dt>
<dd>{{ state.detail.session.id }}</dd>
<dt>ID пользователя</dt>
<dd>{{ state.detail.session.user_id ?? '—' }}</dd>
<dt>Статус</dt>
<dd><code class="mono">{{ state.detail.session.status }}</code></dd>
<dt>Начало</dt>
<dd><code class="mono">{{ state.detail.session.started_at ?? '—' }}</code></dd>
<dt>Окончание</dt>
<dd><code class="mono">{{ state.detail.session.ended_at ?? '—' }}</code></dd>
<dt>Всего чанков</dt>
<dd>{{ state.detail.session.chunks_total ?? '—' }}</dd>
<dt>Всего событий</dt>
<dd>{{ state.detail.session.events_total ?? '—' }}</dd>
<dt>Название</dt>
<dd>{{ state.detail.session.title ?? '—' }}</dd>
</dl>
</section>
<section class="card" aria-label="Потоки">
<h3 class="section-title">Потоки</h3>
@if (state.detail.streams.length === 0) {
<p class="muted">Нет записей о потоках.</p>
} @else {
<div class="table-wrap table-wrap_flat">
<table class="meta-table">
<thead>
<tr>
<th>Тип</th>
<th>Чанки</th>
<th>Длительность</th>
<th>URL видеозаписи</th>
</tr>
</thead>
<tbody>
@for (s of state.detail.streams; track s.stream_type) {
<tr>
<td><code class="mono">{{ s.stream_type }}</code></td>
<td>{{ s.chunk_count ?? '—' }}</td>
<td>{{ formatDurationMs(s.duration_ms) }}</td>
<td class="payload">{{ streamResolvedPlaylistUrl(s) }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="card" aria-label="Телеметрия, ответ API">
<h3 class="section-title">Запрос событий</h3>
<dl class="kv">
<dt>От (мс)</dt>
<dd><code class="mono">{{ telemetryState.fromMs }}</code></dd>
<dt>До (мс)</dt>
<dd><code class="mono">{{ telemetryState.toMs }}</code></dd>
<dt>ID сессии</dt>
<dd>
@if (telemetryState.parsedMeta) {
{{ telemetryState.parsedMeta.session_id }}
} @else {
}
</dd>
<dt>Количество</dt>
<dd>
@if (telemetryState.parsedMeta) {
{{ telemetryState.parsedMeta.count }}
} @else {
}
</dd>
<dt>Строк в выборке</dt>
<dd>{{ telemetryState.telemetry.length }}</dd>
<dt>Статус загрузки</dt>
<dd><code class="mono">{{ telemetryState.status }}</code></dd>
</dl>
</section>
<section class="card" aria-label="Исходный JSON">
<div class="json-copy-row">
<h3 class="section-title">Исходный JSON</h3>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
(click)="copyDetailPayloadJson(state.detail)"
>
Скопировать в буфер
</button>
</div>
</section>
} }
} }
} }

View File

@@ -0,0 +1,14 @@
import type { ParsedEvent } from '../../../core/models/api.types';
export type TelemetryRangeSelection =
| { type: 'preset'; seconds: number }
| { type: 'end' }
| { type: 'custom' };
export interface TelemetryLoadState {
status: 'loading' | 'ok' | 'error';
toMs: number;
fromMs: number;
telemetry: ParsedEvent[];
parsedMeta?: { session_id: number; count: number };
}

View File

@@ -0,0 +1,49 @@
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { TuiButton } from '@taiga-ui/core/components/button';
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
import type { SessionDetailResponse, StreamInfo } from '../../../../core/models/api.types';
import { formatDurationMsHuman } from '../../../../shared/utils/duration.util';
import type { TelemetryLoadState } from '../session-detail.types';
@Component({
selector: 'app-session-info-tab',
imports: [TuiButton],
templateUrl: './session-info-tab.html',
styleUrl: './session-info-tab.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionInfoTabComponent {
private readonly api = inject(SessionsApiService);
private readonly userErrors = inject(UserErrorNotifyService);
readonly detail = input.required<SessionDetailResponse>();
readonly telemetryState = input.required<TelemetryLoadState>();
protected formatDurationMs(ms: number | null | undefined): string {
return formatDurationMsHuman(ms);
}
protected streamResolvedPlaylistUrl(stream: StreamInfo): string {
return this.api.resolvePlaylistUrl(stream.playlist_url);
}
protected async copyDetailPayloadJson(): Promise<void> {
const text = this.detailPayloadJson();
try {
await navigator.clipboard.writeText(text);
this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово');
} catch {
// clipboard
}
}
private detailPayloadJson(): string {
try {
return JSON.stringify(this.detail(), null, 2);
} catch {
return '{}';
}
}
}

View File

@@ -0,0 +1,54 @@
.kv {
display: grid;
grid-template-columns: minmax(10rem, 14rem) 1fr;
gap: 0.35rem 1rem;
margin: 0;
font: var(--tui-font-text-s);
}
.kv dt {
margin: 0;
color: var(--tui-text-tertiary);
}
.kv dd {
margin: 0;
word-break: break-word;
}
.json-copy-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
}
.json-copy-row .section-title {
margin: 0;
}
.meta-table {
width: 100%;
font: var(--tui-font-text-s);
}
.meta-table tbody td {
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}
.meta-table th {
text-align: left;
font-weight: 600;
color: var(--tui-text-primary);
}
.table-wrap {
overflow: auto;
}
.table-wrap_flat {
max-height: none;
}

View File

@@ -0,0 +1,96 @@
<section class="card" aria-label="Сессия">
<h3 class="section-title">Сессия</h3>
<dl class="kv">
<dt>Идентификатор</dt>
<dd>{{ detail().session.id }}</dd>
<dt>ID пользователя</dt>
<dd>{{ detail().session.user_id ?? '—' }}</dd>
<dt>Статус</dt>
<dd><code class="mono">{{ detail().session.status }}</code></dd>
<dt>Начало</dt>
<dd><code class="mono">{{ detail().session.started_at ?? '—' }}</code></dd>
<dt>Окончание</dt>
<dd><code class="mono">{{ detail().session.ended_at ?? '—' }}</code></dd>
<dt>Всего чанков</dt>
<dd>{{ detail().session.chunks_total ?? '—' }}</dd>
<dt>Всего событий</dt>
<dd>{{ detail().session.events_total ?? '—' }}</dd>
<dt>Название</dt>
<dd>{{ detail().session.title ?? '—' }}</dd>
</dl>
</section>
<section class="card" aria-label="Потоки">
<h3 class="section-title">Потоки</h3>
@if (detail().streams.length === 0) {
<p class="muted">Нет записей о потоках.</p>
} @else {
<div class="table-wrap table-wrap_flat">
<table class="meta-table">
<thead>
<tr>
<th>Тип</th>
<th>Чанки</th>
<th>Длительность</th>
<th>URL видеозаписи</th>
</tr>
</thead>
<tbody>
@for (s of detail().streams; track s.stream_type) {
<tr>
<td><code class="mono">{{ s.stream_type }}</code></td>
<td>{{ s.chunk_count ?? '—' }}</td>
<td>{{ formatDurationMs(s.duration_ms) }}</td>
<td class="payload">{{ streamResolvedPlaylistUrl(s) }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="card" aria-label="Телеметрия, ответ API">
<h3 class="section-title">Запрос событий</h3>
<dl class="kv">
<dt>От (мс)</dt>
<dd><code class="mono">{{ telemetryState().fromMs }}</code></dd>
<dt>До (мс)</dt>
<dd><code class="mono">{{ telemetryState().toMs }}</code></dd>
<dt>ID сессии</dt>
<dd>
@if (telemetryState().parsedMeta) {
{{ telemetryState().parsedMeta!.session_id }}
} @else {
}
</dd>
<dt>Количество</dt>
<dd>
@if (telemetryState().parsedMeta) {
{{ telemetryState().parsedMeta!.count }}
} @else {
}
</dd>
<dt>Строк в выборке</dt>
<dd>{{ telemetryState().telemetry.length }}</dd>
<dt>Статус загрузки</dt>
<dd><code class="mono">{{ telemetryState().status }}</code></dd>
</dl>
</section>
<section class="card" aria-label="Исходный JSON">
<div class="json-copy-row">
<h3 class="section-title">Исходный JSON</h3>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
(click)="copyDetailPayloadJson()"
>
Скопировать в буфер
</button>
</div>
</section>

View File

@@ -0,0 +1,265 @@
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { TuiButton } from '@taiga-ui/core/components/button';
import { TuiLoader } from '@taiga-ui/core/components/loader';
import { distinctUntilChanged, switchMap } from 'rxjs';
import {
isKeyboardTelemetryEvent,
parseKeyboardAction,
parseKeyboardHighlightKeyIds,
} from '../../../../core/keyboard/keyboard-payload.util';
import { KeyboardSvgHighlightService } from '../../../../core/keyboard/keyboard-svg-highlight.service';
import { isMouseTelemetryEvent, parseMouseHighlightTargets } from '../../../../core/mouse/mouse-payload.util';
import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.util';
import { MouseSvgHighlightService } from '../../../../core/mouse/mouse-svg-highlight.service';
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 { HlsPlayerComponent } from '../../hls-player/hls-player.component';
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
@Component({
selector: 'app-session-interactive-tab',
imports: [
AsyncPipe,
TuiButton,
TuiLoader,
HlsPlayerComponent,
StreamSelectorComponent,
],
templateUrl: './session-interactive-tab.html',
styleUrl: './session-interactive-tab.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionInteractiveTabComponent {
private readonly api = inject(SessionsApiService);
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
private readonly mouseSvg = inject(MouseSvgHighlightService);
readonly detail = input.required<SessionDetailResponse>();
readonly telemetryEvents = input.required<ParsedEvent[]>();
readonly recordingStartMs = input.required<number | null>();
readonly recordingEndMs = input.required<number | null>();
protected readonly selectedStreamType = signal<string | null>(null);
protected readonly timelineSec = signal(0);
protected readonly durationSec = signal<number | null>(null);
protected readonly isPlaying = signal(false);
protected readonly timelineMaxSec = computed(() => {
const videoDuration = this.durationSec();
if (videoDuration != null && Number.isFinite(videoDuration) && videoDuration > 0) {
return videoDuration;
}
const start = this.recordingStartMs();
const end = this.recordingEndMs();
if (start == null || end == null || end <= start) {
return 0;
}
return (end - start) / 1000;
});
private readonly cursorMs = computed(() => {
const start = this.recordingStartMs();
if (start == null) {
return null;
}
return start + this.timelineSec() * 1000;
});
/**
* Pre-sorted keyboard events — recomputed only when telemetryEvents changes,
* not on every slider tick.
*/
private readonly sortedKeyboardEvents = computed(() =>
this.telemetryEvents()
.filter(isKeyboardTelemetryEvent)
.sort((a, b) => a.timestamp - b.timestamp),
);
/**
* Pre-sorted mouse events — recomputed only when telemetryEvents changes.
*/
private readonly sortedMouseEvents = computed(() =>
this.telemetryEvents()
.filter(isMouseTelemetryEvent)
.sort((a, b) => a.timestamp - b.timestamp),
);
private readonly keyboardKeyIds = computed(() => {
const cursorMs = this.cursorMs();
if (cursorMs == null) {
return [];
}
const active = new Set<string>();
// Events are sorted; break early once we exceed cursorMs.
for (const event of this.sortedKeyboardEvents()) {
if (event.timestamp > cursorMs) {
break;
}
const keyIds = parseKeyboardHighlightKeyIds(event.data);
if (keyIds.length === 0) {
continue;
}
const action = parseKeyboardAction(event.data);
if (action === 'release') {
for (const keyId of keyIds) {
active.delete(keyId);
}
} else {
for (const keyId of keyIds) {
active.add(keyId);
}
}
}
return [...active];
});
private readonly mouseTargets = computed(() => {
const cursorMs = this.cursorMs();
if (cursorMs == null) {
return [];
}
// Find the last mouse event at or before cursorMs.
// Events are sorted ascending; iterate backwards for efficiency.
const events = this.sortedMouseEvents();
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]!;
if (event.timestamp <= cursorMs) {
return parseMouseHighlightTargets(event.data);
}
}
return [] as MouseHighlightTarget[];
});
/**
* Keyboard SVG without animations — interactive mode shows state, not events.
* distinctUntilChanged prevents SVG regeneration when the key set hasn't changed,
* which eliminates animation restarts during slider movement.
*/
protected readonly keyboardSvg$ = toObservable(this.keyboardKeyIds).pipe(
distinctUntilChanged(keySetEqual),
switchMap((keyIds) => this.keyboardSvg.svgWithHighlight(keyIds, false)),
);
protected readonly mouseSvg$ = toObservable(this.mouseTargets).pipe(
distinctUntilChanged(targetSetEqual),
switchMap((targets) => this.mouseSvg.svgWithHighlight(targets, false)),
);
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;
}
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;
}
protected pickStream(type: string): void {
this.selectedStreamType.set(type);
}
protected setTimelineSec(value: string): void {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return;
}
this.timelineSec.set(this.clamp(parsed, 0, this.timelineMaxSec()));
}
protected onPlayerCurrentTimeChange(seconds: number): void {
if (!Number.isFinite(seconds)) {
return;
}
this.timelineSec.set(this.clamp(seconds, 0, this.timelineMaxSec()));
}
protected onPlayerDurationChange(seconds: number): void {
if (!Number.isFinite(seconds) || seconds <= 0) {
return;
}
this.durationSec.set(seconds);
this.timelineSec.update((current) => this.clamp(current, 0, this.timelineMaxSec()));
}
protected shiftTimeline(deltaSec: number): void {
this.timelineSec.update((current) => this.clamp(current + deltaSec, 0, this.timelineMaxSec()));
}
protected togglePlayback(): void {
this.isPlaying.update((v) => !v);
}
protected onPlayerPlayingChange(playing: boolean): void {
this.isPlaying.set(playing);
}
protected currentPositionLabel(): string {
return `${Math.max(0, Math.round(this.timelineSec()))}с`;
}
protected endPositionLabel(): string {
return `${Math.max(0, Math.round(this.timelineMaxSec()))}с`;
}
protected startTimeLabel(): string {
return formatClockTime(this.recordingStartMs());
}
protected endTimeLabel(): string {
const end = this.recordingEndMs();
const start = this.recordingStartMs();
if (end != null) {
return formatClockTime(end);
}
const max = this.timelineMaxSec();
if (start != null && Number.isFinite(max)) {
return formatClockTime(start + max * 1000);
}
return '—';
}
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));
}
}
/** Compares two key-id arrays regardless of order. */
function keySetEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false;
}
const setB = new Set(b);
return a.every((v) => setB.has(v));
}
/** Compares two mouse-target arrays regardless of order. */
function targetSetEqual(a: MouseHighlightTarget[], b: MouseHighlightTarget[]): boolean {
if (a.length !== b.length) {
return false;
}
const setB = new Set<string>(b);
return a.every((v) => setB.has(v));
}

View File

@@ -0,0 +1,220 @@
.controls-row {
margin-top: 0.85rem;
display: grid;
grid-template-columns: minmax(8rem, 1fr) auto minmax(8rem, 1fr);
align-items: center;
gap: 1rem;
}
.timeline-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.timeline-controls_center {
justify-content: center;
}
.timeline-range {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.timeline-slider {
width: 100%;
-webkit-appearance: none;
appearance: none;
background: transparent;
border: 0;
outline: none;
box-shadow: none;
}
.timeline-slider::-webkit-slider-runnable-track {
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
border: 0;
}
.timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -0.33rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid var(--sg-color-card-bg);
background: var(--sg-filter-chip-active-bg);
}
.timeline-slider::-moz-range-track {
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
border: 0;
}
.timeline-slider::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid var(--sg-color-card-bg);
background: var(--sg-filter-chip-active-bg);
}
.timeline-slider:focus-visible::-webkit-slider-thumb,
.timeline-slider:focus-visible::-moz-range-thumb {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-filter-chip-active-bg) 24%, transparent);
}
.control-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 4.25rem;
justify-content: center;
}
.control-btn_primary[tuiButton][tuiAppearance][data-appearance='primary'] {
background: var(--sg-filter-chip-active-bg);
color: var(--sg-filter-chip-active-fg);
}
.control-btn_primary[tuiButton][tuiAppearance][data-appearance='primary']:hover:not(
[data-state='disabled']
) {
background: var(--sg-filter-chip-active-bg-hover);
}
.control-icon {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
.time-meta {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 1rem;
}
.time-meta_start {
width: 100%;
justify-content: flex-start;
justify-self: start;
}
.time-meta_end {
width: 100%;
justify-content: flex-end;
justify-self: end;
}
.time-meta__col {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.time-meta__col_end {
align-items: flex-end;
text-align: right;
}
@media (max-width: 720px) {
.controls-row {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.time-meta_start,
.timeline-controls_center,
.time-meta_end {
justify-content: center;
}
.time-meta__col,
.time-meta__col_end {
align-items: center;
text-align: center;
}
}
.time-meta__position {
font: var(--tui-font-text-m);
font-weight: 600;
color: var(--tui-text-primary);
}
.time-meta__clock {
font: var(--tui-font-text-s);
color: var(--tui-text-tertiary);
}
.keyboard-svg-host {
max-width: 100%;
overflow: auto;
border-radius: var(--tui-radius-m);
background: transparent;
}
.keyboard-svg-host_compact {
max-width: min(700px, 100%);
}
.keyboard-svg-host ::ng-deep svg {
display: block;
width: 100%;
height: auto;
max-width: 800px;
}
.keyboard-loading {
display: flex;
justify-content: center;
padding: 1rem;
}
.input-preview {
display: flex;
align-items: center;
justify-content: center;
gap: 2.75rem;
width: 100%;
}
.mouse-svg-host {
width: 144px;
flex: 0 0 144px;
border-radius: var(--tui-radius-m);
overflow: hidden;
}
.mouse-svg-host ::ng-deep svg {
display: block;
width: 100%;
height: auto;
}
.mouse-loading {
width: 144px;
flex: 0 0 144px;
}
@media (max-width: 980px) {
.input-preview {
flex-direction: column;
}
.mouse-svg-host,
.mouse-loading {
width: min(220px, 100%);
flex-basis: auto;
}
}

View File

@@ -0,0 +1,151 @@
<section class="card" aria-label="Интерактивный режим: плеер">
@if (detail().streams.length === 0) {
<p class="muted">Потоки ещё не готовы.</p>
} @else {
<app-stream-selector
[streams]="detail().streams"
[activeType]="activeStreamType()"
(typeChange)="pickStream($event)"
/>
@if (playlistUrl(); as src) {
<app-hls-player
[src]="src"
[seekToSec]="timelineSec()"
[isPlaying]="isPlaying()"
(currentTimeSecChange)="onPlayerCurrentTimeChange($event)"
(durationSecChange)="onPlayerDurationChange($event)"
(playingChange)="onPlayerPlayingChange($event)"
/>
}
}
</section>
<section class="card" aria-label="Интерактивный режим: timeline">
<div class="timeline-range">
<input
type="range"
class="timeline-slider"
step="0.001"
min="0"
[max]="timelineMaxSec()"
[value]="timelineSec()"
(input)="setTimelineSec($any($event.target).value)"
aria-label="Позиция интерактивного режима по timeline"
/>
</div>
<div class="controls-row">
<div class="time-meta time-meta_start">
<div class="time-meta__col">
<span class="time-meta__position">{{ currentPositionLabel() }}</span>
<span class="time-meta__clock">{{ startTimeLabel() }}</span>
</div>
</div>
<div class="timeline-controls timeline-controls_center">
<button
tuiButton
type="button"
size="s"
appearance="secondary"
class="control-btn"
(click)="shiftTimeline(-10)"
aria-label="Назад на 10 секунд"
>
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m11 17-5-5 5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m18 17-5-5 5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>-10</span>
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
class="control-btn"
(click)="shiftTimeline(-5)"
aria-label="Назад на 5 секунд"
>
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m15 17-5-5 5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>-5</span>
</button>
<button
tuiButton
type="button"
size="s"
appearance="primary"
class="control-btn control-btn_primary"
(click)="togglePlayback()"
[attr.aria-label]="isPlaying() ? 'Пауза' : 'Старт'"
>
@if (isPlaying()) {
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="6" y="4" width="4" height="16" stroke-width="2" />
<rect x="14" y="4" width="4" height="16" stroke-width="2" />
</svg>
<span>Пауза</span>
} @else {
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polygon points="6 4 20 12 6 20 6 4" stroke-width="2" stroke-linejoin="round" />
</svg>
<span>Старт</span>
}
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
class="control-btn"
(click)="shiftTimeline(5)"
aria-label="Вперед на 5 секунд"
>
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m9 17 5-5-5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>+5</span>
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
class="control-btn"
(click)="shiftTimeline(10)"
aria-label="Вперед на 10 секунд"
>
<svg class="control-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m6 17 5-5-5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m13 17 5-5-5-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>+10</span>
</button>
</div>
<div class="time-meta time-meta_end">
<div class="time-meta__col time-meta__col_end">
<span class="time-meta__position">{{ endPositionLabel() }}</span>
<span class="time-meta__clock">{{ endTimeLabel() }}</span>
</div>
</div>
</div>
</section>
<section class="card" aria-label="Интерактивный режим: клавиатура">
<div class="input-preview">
@if (keyboardSvg$ | async; as keyboardSvg) {
<div class="keyboard-svg-host keyboard-svg-host_compact" [innerHTML]="keyboardSvg"></div>
} @else {
<div class="keyboard-loading">
<tui-loader [loading]="true" size="m" />
</div>
}
@if (mouseSvg$ | async; as mouseSvg) {
<div class="mouse-svg-host" [innerHTML]="mouseSvg"></div>
} @else {
<div class="keyboard-loading mouse-loading">
<tui-loader [loading]="true" size="m" />
</div>
}
</div>
</section>

View File

@@ -0,0 +1,200 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core';
import { TuiButton } from '@taiga-ui/core/components/button';
import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiChip } from '@taiga-ui/kit/components/chip';
import { SessionStatusChipClassesPipe } from '../../../../core/sessions/session-status-chip-classes.pipe';
import { SessionStatusPipe } from '../../../../core/sessions/session-status.pipe';
import { TelemetryEventTypePipe } from '../../../../core/sessions/telemetry-event-type.pipe';
import { summarizeTelemetryData } from '../../../../core/sessions/telemetry-event-summary.engine';
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 { 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';
import type { TelemetryLoadState, TelemetryRangeSelection } from '../session-detail.types';
@Component({
selector: 'app-session-view-tab',
imports: [
NgClass,
TuiButton,
TuiChip,
TuiLoader,
SessionStatusChipClassesPipe,
SessionStatusPipe,
TelemetryEventTypePipe,
HlsPlayerComponent,
StreamSelectorComponent,
TelemetryEventDetailComponent,
],
templateUrl: './session-view-tab.html',
styleUrl: './session-view-tab.css',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('telemetryEventDetail', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(-0.4rem)' }),
animate(
'220ms cubic-bezier(0.33, 1, 0.68, 1)',
style({ opacity: 1, transform: 'translateY(0)' }),
),
]),
transition(':leave', [
animate(
'170ms cubic-bezier(0.4, 0, 1, 1)',
style({ opacity: 0, transform: 'translateY(-0.3rem)' }),
),
]),
]),
],
})
export class SessionViewTabComponent {
private readonly api = inject(SessionsApiService);
readonly detail = input.required<SessionDetailResponse>();
readonly telemetryState = input.required<TelemetryLoadState>();
readonly recordingStartMs = input.required<number | null>();
readonly recordingEndMs = input.required<number | null>();
readonly telemetryToMsChange = output<number>();
protected readonly selectedStreamType = signal<string | null>(null);
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
protected readonly customToLocal = signal('');
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;
}
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;
}
protected pickStream(type: string): void {
this.selectedStreamType.set(type);
}
protected pickTelemetryEventTypeFilter(type: string | null): void {
this.telemetryEventTypeFilter.set(type);
this.expandedTelemetryRowKey.set(null);
}
protected telemetryEventTypeKey(event: ParsedEvent): string {
const t = event.event_type;
if (t == null || t === '') {
return '';
}
return String(t).trim().toLowerCase();
}
protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
const set = new Set<string>();
for (const e of events) {
set.add(this.telemetryEventTypeKey(e));
}
return [...set].sort((a, b) => a.localeCompare(b, 'ru'));
}
protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number {
return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length;
}
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
const filter = this.telemetryEventTypeFilter();
if (filter === null) {
return events;
}
return events.filter((e) => this.telemetryEventTypeKey(e) === filter);
}
protected telemetryEventSummary(event: ParsedEvent): string {
return summarizeTelemetryData(event.data);
}
protected telemetryRowKey(row: ParsedEvent, index: number): string {
return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\u0000${index}`;
}
protected toggleTelemetryRow(row: ParsedEvent, index: number): void {
const key = this.telemetryRowKey(row, index);
this.expandedTelemetryRowKey.update((cur) => (cur === key ? null : key));
}
protected isTelemetryRowExpanded(row: ParsedEvent, index: number): boolean {
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
}
protected selectRecentWindow(seconds: number): void {
this.customToLocal.set('');
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));
}
protected loadUntilEndTelemetry(): void {
this.customToLocal.set('');
this.telemetryRangeSelection.set({ type: 'end' });
this.telemetryToMsChange.emit(this.recordingEndMs() ?? Date.now());
}
protected applyCustomTo(value: string): void {
this.customToLocal.set(value);
if (!value) {
return;
}
const ms = new Date(value).getTime();
if (Number.isFinite(ms)) {
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));
}
}
protected telemetryRangePresetIs(seconds: number): boolean {
const s = this.telemetryRangeSelection();
return s.type === 'preset' && s.seconds === seconds;
}
protected telemetryRangeIsEnd(): boolean {
return this.telemetryRangeSelection().type === 'end';
}
protected telemetryRangeLabel(toMs: number): string {
return `С ${formatUnixMsUtil(this.recordingStartMs())} до ${formatUnixMsUtil(toMs)}`;
}
protected formatDate(value: string | null | undefined): string {
return formatTimestamp(value);
}
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

@@ -0,0 +1,142 @@
.session-title {
margin: 0 0 0.75rem;
font: var(--tui-font-heading-6);
color: var(--tui-text-primary);
}
.summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.telemetry-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.telemetry-content {
display: flex;
flex-direction: column;
min-height: 520px;
}
.telemetry-content > .loading-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.telemetry-head__main {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
flex: 1;
}
.telemetry-head .section-title {
margin: 0;
}
.telemetry-head .telemetry-range {
margin: 0;
}
.telemetry-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.telemetry-presets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.from-picker {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.telemetry-type-tabs {
overflow-wrap: anywhere;
}
.telemetry-type-tabs button {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
text-align: start;
}
.table-wrap {
overflow: auto;
max-height: min(520px, 70vh);
}
.table-wrap:not(.table-wrap_flat) {
min-height: 520px;
}
.telemetry {
width: 100%;
font: var(--tui-font-text-s);
}
.telemetry th {
text-align: left;
color: var(--tui-text-primary);
}
.telemetry tbody td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--tui-border-normal);
vertical-align: top;
}
.telemetry-col-type {
min-width: 0;
max-width: 14rem;
overflow-wrap: anywhere;
word-break: break-word;
vertical-align: top;
}
.telemetry-row {
cursor: pointer;
transition: background-color 0.12s ease;
}
.telemetry-row:hover {
background: color-mix(in srgb, var(--tui-background-accent-1) 12%, transparent);
}
.telemetry-row.telemetry-row_expanded {
background: color-mix(in srgb, var(--tui-background-accent-1) 18%, transparent);
}
.telemetry-row-detail td {
padding: 0 0.75rem 1rem;
border-bottom: 1px solid var(--tui-border-normal);
vertical-align: top;
}
.telemetry-col-summary {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
vertical-align: top;
}

View File

@@ -0,0 +1,197 @@
<section class="card summary" aria-label="Сводка">
@if (detail().session.title) {
<p class="session-title">{{ detail().session.title }}</p>
}
<div class="summary-row">
<span
tuiChip
size="s"
class="status-chip"
[ngClass]="detail().session.status | sessionStatusChipClasses"
>{{ detail().session.status | sessionStatus }}</span>
</div>
<div class="muted small">
Начало: {{ formatDate(detail().session.started_at) }} · Окончание:
{{ formatDate(detail().session.ended_at) }}
</div>
</section>
<section class="card" aria-label="Видео">
<h3 class="section-title">Трансляция</h3>
@if (detail().streams.length === 0) {
<p class="muted">Потоки ещё не готовы.</p>
} @else {
<app-stream-selector
[streams]="detail().streams"
[activeType]="activeStreamType()"
(typeChange)="pickStream($event)"
/>
@if (playlistUrl(); as src) {
<app-hls-player [src]="src" />
}
}
</section>
<section class="card" aria-label="Телеметрия">
<div class="telemetry-head">
<div class="telemetry-head__main">
<h3 class="section-title">
События телеметрии ({{ filteredTelemetryEvents(telemetryState().telemetry).length }})
</h3>
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
</div>
<div class="telemetry-actions">
<div class="telemetry-presets">
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(2)"
(click)="selectRecentWindow(2)"
>
+2с
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(10)"
(click)="selectRecentWindow(10)"
>
+10с
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(60)"
(click)="selectRecentWindow(60)"
>
+1м
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(300)"
(click)="selectRecentWindow(300)"
>
+5м
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangePresetIs(900)"
(click)="selectRecentWindow(900)"
>
+15м
</button>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryRangeIsEnd()"
(click)="loadUntilEndTelemetry()"
>
До конца
</button>
</div>
<label class="from-picker">
<input
type="datetime-local"
class="sg-native-input"
[value]="customToLocal()"
(change)="applyCustomTo($any($event.target).value)"
aria-label="Верхняя граница диапазона телеметрии"
/>
</label>
</div>
</div>
<div class="telemetry-content">
@if (telemetryState().status === 'loading') {
<div class="loading-wrap loading-wrap_small">
<tui-loader [loading]="true" size="l" />
</div>
} @else if (telemetryState().status === 'error') {
<p class="muted">События телеметрии временно недоступны.</p>
} @else {
@if (telemetryState().telemetry.length === 0) {
<p class="muted">Событий пока нет.</p>
} @else {
<div class="telemetry-type-tabs stream-tabs" aria-label="Фильтр по типу события">
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryEventTypeFilter() === null"
(click)="pickTelemetryEventTypeFilter(null)"
>
Все ({{ telemetryState().telemetry.length }})
</button>
@for (t of uniqueTelemetryEventTypes(telemetryState().telemetry); track t) {
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="telemetryEventTypeFilter() === t"
(click)="pickTelemetryEventTypeFilter(t)"
>
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState().telemetry, t) }})
</button>
}
</div>
@if (filteredTelemetryEvents(telemetryState().telemetry).length === 0) {
<p class="muted">Нет событий выбранного типа.</p>
} @else {
<div class="table-wrap">
<table class="telemetry">
<thead>
<tr>
<th>Время</th>
<th>Тип</th>
<th>Сводка</th>
</tr>
</thead>
<tbody>
@for (
row of filteredTelemetryEvents(telemetryState().telemetry);
track $index;
let i = $index
) {
<tr
class="telemetry-row"
[class.telemetry-row_expanded]="isTelemetryRowExpanded(row, i)"
(click)="toggleTelemetryRow(row, i)"
>
<td>{{ formatUnixMs(row.timestamp) }}</td>
<td class="telemetry-col-type">
<span tuiChip size="xs">{{ row.event_type | telemetryEventType }}</span>
</td>
<td class="telemetry-col-summary">{{ telemetryEventSummary(row) }}</td>
</tr>
@if (isTelemetryRowExpanded(row, i)) {
<tr class="telemetry-row-detail" @telemetryEventDetail>
<td colspan="3" (click)="$event.stopPropagation()">
<app-telemetry-event-detail [event]="row" />
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
}
}
</div>
</section>

View File

@@ -1,25 +1,7 @@
.page {
padding-top: 1.5rem;
padding-bottom: 3rem;
}
.heading { .heading {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.card {
padding: 1.25rem 1.5rem;
border-radius: var(--tui-radius-l);
background: var(--tui-background-elevation-1);
margin-bottom: 1.5rem;
}
.section-title {
margin: 0 0 1rem;
font: var(--tui-font-heading-6);
color: var(--sg-color-subtitle);
}
.create-row { .create-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -46,21 +28,11 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat
filter: brightness(0.96); filter: brightness(0.96);
} }
.loading-wrap {
display: flex;
justify-content: center;
padding: 3rem;
}
.error { .error {
color: var(--tui-status-negative); color: var(--tui-status-negative);
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
} }
.muted {
color: var(--tui-text-tertiary);
}
.session-list { .session-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -5,8 +5,9 @@
<h3 class="section-title">Новая сессия</h3> <h3 class="section-title">Новая сессия</h3>
<div class="create-row"> <div class="create-row">
<tui-textfield class="create-field sg-tui-textfield"> <tui-textfield class="create-field sg-tui-textfield">
<label tuiLabel>Название</label> <label tuiLabel for="session-title-input">Название</label>
<input <input
id="session-title-input"
tuiInput tuiInput
type="text" type="text"
placeholder="Например, экзамен по ОС" placeholder="Например, экзамен по ОС"

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { TuiButton } from '@taiga-ui/core/components/button';
import type { StreamInfo } from '../../../core/models/api.types';
@Component({
selector: 'app-stream-selector',
imports: [TuiButton],
template: `
<div class="stream-tabs">
@for (s of streams(); track s.stream_type) {
<button
tuiButton
type="button"
size="s"
appearance="secondary"
[class.stream-active]="activeType() === s.stream_type"
(click)="typeChange.emit(s.stream_type)"
>
{{ s.stream_type }}
</button>
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StreamSelectorComponent {
readonly streams = input.required<StreamInfo[]>();
readonly activeType = input.required<string | null>();
readonly typeChange = output<string>();
}

View File

@@ -37,7 +37,7 @@
/* Подраздел «Служебные данные» внутри «Подробности» */ /* Подраздел «Служебные данные» внутри «Подробности» */
.detail-subsection { .detail-subsection {
margin: 0; margin: 0 0 0.5rem;
padding-left: 0.65rem; padding-left: 0.65rem;
border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary)); border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary));
} }

View File

@@ -36,7 +36,7 @@
<dl class="kv detail-kv telemetry-service-kv"> <dl class="kv detail-kv telemetry-service-kv">
<dt>Виртуальный код (VK)</dt> <dt>Виртуальный код (VK)</dt>
<dd> <dd>
@if (keyboardModel().vk != null) { @if (keyboardModel().vk !== null && keyboardModel().vk !== undefined) {
{{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }}) {{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }})
} @else { } @else {
@@ -49,7 +49,7 @@
</dl> </dl>
} }
<div class="json-copy-row"> <div class="json-copy-row">
<span class="telemetry-json-label">Данные события (JSON)</span> <span class="telemetry-json-label">Исходный JSON события</span>
<button <button
tuiButton tuiButton
type="button" type="button"

View File

@@ -17,3 +17,21 @@ export function formatTimestamp(value: string | null | undefined): string {
second: '2-digit', second: '2-digit',
}).format(date); }).format(date);
} }
export function formatUnixMs(value: number | null | undefined): string {
if (!value) {
return '—';
}
return formatTimestamp(new Date(value).toISOString());
}
export function formatClockTime(valueMs: number | null | undefined): string {
if (valueMs == null || !Number.isFinite(valueMs)) {
return '—';
}
return new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(valueMs));
}

View File

@@ -0,0 +1,10 @@
export function unwrapJsonPayload(data: unknown): unknown {
if (typeof data === 'string') {
try {
return JSON.parse(data) as unknown;
} catch {
return data;
}
}
return data;
}

View File

@@ -1,4 +1,6 @@
@import './styles/color-tokens.css'; @import './styles/color-tokens.css';
@import './styles/page-common.css';
@import './styles/filter-chips.css';
@import './styles/session-status-chips.css'; @import './styles/session-status-chips.css';
@import './styles/sg-input-fields.css'; @import './styles/sg-input-fields.css';

View File

@@ -89,6 +89,10 @@
--sg-keyboard-key-pressed-fill: var(--sg-color-accent); --sg-keyboard-key-pressed-fill: var(--sg-color-accent);
--sg-keyboard-key-pressed-ink: var(--sg-color-text); --sg-keyboard-key-pressed-ink: var(--sg-color-text);
--sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill); --sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill);
/* Анимация подсветки ввода (клавиатура/мышь): централизованные параметры */
--sg-input-highlight-duration: 140ms;
--sg-input-highlight-easing: cubic-bezier(0.33, 1, 0.68, 1);
--sg-input-highlight-from-scale: 0.94;
} }
@media (max-width: 999px) { @media (max-width: 999px) {

View File

@@ -0,0 +1,99 @@
.stream-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.stream-tabs button,
.telemetry-presets button {
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
/* Inactive chip */
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active),
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active) {
border-radius: 624.9375rem;
font-weight: 400;
background: var(--sg-filter-chip-bg);
color: var(--sg-filter-chip-fg);
border: 1px solid transparent;
outline: none;
box-shadow: none;
}
.stream-tabs
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
[data-state='disabled']
),
.telemetry-presets
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
[data-state='disabled']
) {
background: var(--sg-filter-chip-bg-hover);
color: var(--sg-filter-chip-fg);
border-color: transparent;
outline: none !important;
box-shadow: none !important;
}
/* Active chip */
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'],
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] {
border-radius: 624.9375rem;
font-weight: 400;
background: var(--sg-filter-chip-active-bg);
color: var(--sg-filter-chip-active-fg);
border: 1px solid transparent;
outline: none;
box-shadow: none;
}
.stream-tabs
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
[data-state='disabled']
),
.telemetry-presets
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
[data-state='disabled']
) {
background: var(--sg-filter-chip-active-bg-hover);
color: var(--sg-filter-chip-active-fg);
border-color: transparent;
outline: none !important;
box-shadow: none !important;
}
/*
* Remove Taiga's :focus outline/shadow after mouse click.
* Keyboard: light ring only on :focus-visible.
*/
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible),
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible) {
outline: none !important;
box-shadow: none !important;
}
.stream-tabs
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']),
.telemetry-presets
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']) {
outline: none !important;
box-shadow: none !important;
}
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
outline: 2px solid var(--sg-filter-chip-active-bg);
outline-offset: 2px;
box-shadow: none;
}
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
outline-color: var(--sg-filter-chip-active-bg-hover);
}

View File

@@ -0,0 +1,41 @@
.card {
padding: 1.25rem 1.5rem;
border-radius: var(--tui-radius-l);
background: var(--tui-background-elevation-1);
margin-bottom: 1.5rem;
}
.section-title {
margin: 0 0 1rem;
font: var(--tui-font-heading-6);
color: var(--sg-color-subtitle);
}
.loading-wrap {
display: flex;
justify-content: center;
padding: 3rem;
}
.loading-wrap_small {
padding: 1rem 0;
}
.muted {
color: var(--tui-text-tertiary);
}
.small {
font: var(--tui-font-text-s);
margin-top: 0.35rem;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 0.92em;
}
.page {
padding-top: 1.5rem;
padding-bottom: 3rem;
}