This commit is contained in:
44
.gitea/workflows/ci.yml
Normal file
44
.gitea/workflows/ci.yml
Normal 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
6
.gitignore
vendored
@@ -45,3 +45,9 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code / Cursor — правила исключения из контекста
|
||||||
|
.claudeignore
|
||||||
|
.cursorignore
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
@@ -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
82
eslint.config.js
Normal 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
1165
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
19
public/svg/visual/mouse.svg
Normal file
19
public/svg/visual/mouse.svg
Normal 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 |
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/app/core/mouse/mouse-payload.util.ts
Normal file
53
src/app/core/mouse/mouse-payload.util.ts
Normal 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 [];
|
||||||
|
}
|
||||||
78
src/app/core/mouse/mouse-svg-highlight.service.ts
Normal file
78
src/app/core/mouse/mouse-svg-highlight.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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="Например, экзамен по ОС"
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
10
src/app/shared/utils/json.util.ts
Normal file
10
src/app/shared/utils/json.util.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
99
src/styles/filter-chips.css
Normal file
99
src/styles/filter-chips.css
Normal 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);
|
||||||
|
}
|
||||||
41
src/styles/page-common.css
Normal file
41
src/styles/page-common.css
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user