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
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code / Cursor — правила исключения из контекста
|
||||
.claudeignore
|
||||
.cursorignore
|
||||
|
||||
CLAUDE.md
|
||||
@@ -83,6 +83,12 @@
|
||||
},
|
||||
"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",
|
||||
"prewatch": "npm run env:sync",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
"test": "ng test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.6.2",
|
||||
@@ -35,11 +36,15 @@
|
||||
"@angular/build": "^21.2.6",
|
||||
"@angular/cli": "^21.2.6",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"angular-eslint": "21.3.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"less": "^4.6.4",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,10 +366,10 @@
|
||||
</g>
|
||||
|
||||
<g id="T_others">
|
||||
<text id="T_kb6a" x="10" y="230" >Ctrl</text>
|
||||
<text id="T_kb6c" x="134" 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_kb6a" x="10" y="230" >ctrl</text>
|
||||
<text id="T_kb6c" x="134" y="230" >alt</text>
|
||||
<text id="T_kb6k" x="564" y="230" >alt</text>
|
||||
<text id="T_kb6n" x="750" y="230" >ctrl</text>
|
||||
</g>
|
||||
<!-- ======== alternates inscriptions ======== -->
|
||||
<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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
font-family: inherit;
|
||||
font-size: clamp(1.35rem, 2.4vw, 1.65rem);
|
||||
font-size: clamp(1.15rem, 2vw, 1.4rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.035em;
|
||||
letter-spacing: 0.015em;
|
||||
color: var(--tui-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ describe('App', () => {
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => void 0,
|
||||
removeListener: () => void 0,
|
||||
addEventListener: () => void 0,
|
||||
removeEventListener: () => void 0,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParsedEvent } from '../models/api.types';
|
||||
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||
|
||||
import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map';
|
||||
import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||
@@ -51,15 +52,21 @@ export function eventPayloadJson(data: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapJsonPayload(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return data;
|
||||
export function parseKeyboardAction(data: unknown): 'press' | 'release' | null {
|
||||
const raw = unwrapJsonPayload(data);
|
||||
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const action = (raw as Record<string, unknown>)['action'];
|
||||
if (typeof action === 'string') {
|
||||
const normalized = action.toLowerCase();
|
||||
if (normalized === 'press') {
|
||||
return 'press';
|
||||
}
|
||||
if (normalized === 'release') {
|
||||
return 'release';
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
||||
|
||||
@@ -24,28 +24,48 @@ export class KeyboardSvgHighlightService {
|
||||
),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
|
||||
svgWithHighlight(keyIds: string[] | null): Observable<SafeHtml> {
|
||||
svgWithHighlight(keyIds: string[] | null, animated = true): Observable<SafeHtml> {
|
||||
return this.baseSvg$.pipe(
|
||||
map((svg) => this.injectHighlight(svg, keyIds ?? [])),
|
||||
map((svg) => this.injectHighlight(svg, keyIds ?? [], animated)),
|
||||
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
|
||||
);
|
||||
}
|
||||
|
||||
private injectHighlight(svgText: string, keyIds: string[]): string {
|
||||
const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id));
|
||||
private injectHighlight(svgText: string, keyIds: string[], animated: boolean): string {
|
||||
const valid = [...new Set(keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))];
|
||||
if (valid.length === 0) {
|
||||
return svgText;
|
||||
}
|
||||
const rules: string[] = [];
|
||||
for (const id of valid) {
|
||||
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(
|
||||
`#${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(
|
||||
`#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(
|
||||
`#${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(
|
||||
`#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`,
|
||||
`@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>`;
|
||||
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), {
|
||||
label: ERROR_TOAST_TITLE,
|
||||
appearance: 'negative',
|
||||
autoClose: 0,
|
||||
autoClose: 10000,
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
effect,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import Hls from 'hls.js';
|
||||
@@ -16,8 +17,14 @@ import Hls from 'hls.js';
|
||||
})
|
||||
export class HlsPlayerComponent {
|
||||
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 pendingSeekSec: number | null = null;
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
@@ -28,20 +35,103 @@ export class HlsPlayerComponent {
|
||||
}
|
||||
const video = ref.nativeElement;
|
||||
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()) {
|
||||
hls = new Hls({ enableWorker: true, lowLatencyMode: true });
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
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(() => {
|
||||
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();
|
||||
video.removeAttribute('src');
|
||||
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, NgClass } from '@angular/common';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
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 { catchError, combineLatest, distinctUntilChanged, map, of, startWith, switchMap, tap, timeout } from 'rxjs';
|
||||
|
||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||
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 type { ParsedEvent, SessionDetailResponse, StreamInfo } from '../../../core/models/api.types';
|
||||
import { summarizeTelemetryData } from '../../../core/sessions/telemetry-event-summary.engine';
|
||||
import type { ParsedEvent } from '../../../core/models/api.types';
|
||||
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||
import { formatDurationMsHuman } from '../../../shared/utils/duration.util';
|
||||
import { HlsPlayerComponent } from '../hls-player/hls-player.component';
|
||||
import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component';
|
||||
|
||||
type TelemetryRangeSelection =
|
||||
| { type: 'preset'; seconds: number }
|
||||
| { type: 'end' }
|
||||
| { type: 'custom' };
|
||||
import { SessionViewTabComponent } from './session-view-tab/session-view-tab.component';
|
||||
import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component';
|
||||
import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-session-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
NgClass,
|
||||
RouterLink,
|
||||
HlsPlayerComponent,
|
||||
TuiButton,
|
||||
TuiChip,
|
||||
...TuiTabs,
|
||||
TuiLink,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
SessionStatusChipClassesPipe,
|
||||
SessionStatusPipe,
|
||||
TelemetryEventTypePipe,
|
||||
TelemetryEventDetailComponent,
|
||||
...TuiTabs,
|
||||
SessionViewTabComponent,
|
||||
SessionInteractiveTabComponent,
|
||||
SessionInfoTabComponent,
|
||||
],
|
||||
templateUrl: './session-detail.html',
|
||||
styleUrl: './session-detail.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 SessionDetailComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(SessionsApiService);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
|
||||
private readonly telemetryToMs = signal<number | null>(null);
|
||||
private readonly recordingStartMs = signal<number | null>(null);
|
||||
private 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 telemetryToMs = signal<number | null>(null);
|
||||
protected readonly recordingStartMs = signal<number | null>(null);
|
||||
protected readonly recordingEndMs = signal<number | null>(null);
|
||||
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(
|
||||
map((p) => Number(p.get('id'))),
|
||||
distinctUntilChanged(),
|
||||
@@ -99,10 +54,7 @@ export class SessionDetailComponent {
|
||||
this.userErrors.notifyError(new Error('Некорректный идентификатор сессии'), 'Сессия');
|
||||
return of({ status: 'error' as const });
|
||||
}
|
||||
this.selectedStreamType.set(null);
|
||||
this.activeTabIndex.set(0);
|
||||
this.telemetryEventTypeFilter.set(null);
|
||||
this.expandedTelemetryRowKey.set(null);
|
||||
return this.api.getSession(id).pipe(
|
||||
map((detail) => ({ status: 'ok' as const, id, detail })),
|
||||
tap((state) => {
|
||||
@@ -113,12 +65,7 @@ export class SessionDetailComponent {
|
||||
const end = this.toUnixMs(state.detail.session.ended_at);
|
||||
this.recordingStartMs.set(start);
|
||||
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) => {
|
||||
this.userErrors.notifyError(e, 'Сессия');
|
||||
@@ -139,8 +86,8 @@ export class SessionDetailComponent {
|
||||
if (!Number.isFinite(id)) {
|
||||
return of({
|
||||
status: 'error' as const,
|
||||
toMs,
|
||||
fromMs: recordingStartMs ?? 0,
|
||||
toMs: 0,
|
||||
fromMs: 0,
|
||||
telemetry: [] as ParsedEvent[],
|
||||
parsedMeta: undefined,
|
||||
});
|
||||
@@ -150,11 +97,7 @@ export class SessionDetailComponent {
|
||||
const upperBound = recordingEndMs ?? now;
|
||||
const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound);
|
||||
return this.api
|
||||
.getParsedEvents(
|
||||
id,
|
||||
lowerBound,
|
||||
normalizedTo,
|
||||
)
|
||||
.getParsedEvents(id, lowerBound, normalizedTo)
|
||||
.pipe(
|
||||
timeout(12000),
|
||||
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 {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -356,5 +145,4 @@ export class SessionDetailComponent {
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.page {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -11,7 +6,7 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -22,351 +17,14 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Воздух между подписью и нижней линией (у Taiga у [tuiTab] padding: 0). */
|
||||
/* Padding between label and underline (Taiga [tuiTab] has padding: 0 by default). */
|
||||
.session-tabs [tuiTab] {
|
||||
padding-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* У Taiga для неактивной вкладки: box-shadow inset 0 -.125rem — линия hover оказывается
|
||||
* выше общей нижней границы полосы (inset 0 -1px на tui-tabs). Убираем дублирующую линию.
|
||||
* Taiga's inactive tab hover shadow is above the tab bar bottom border — remove it.
|
||||
*/
|
||||
.session-tabs [tuiTab]:hover:not(._active) {
|
||||
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">
|
||||
<button tuiTab type="button">Просмотр</button>
|
||||
<button tuiTab type="button">Интерактивный режим</button>
|
||||
<button tuiTab type="button">Служебная информация</button>
|
||||
</tui-tabs>
|
||||
|
||||
@if (telemetry$ | async; as telemetryState) {
|
||||
@if (activeTabIndex() === 0) {
|
||||
<section class="card summary" aria-label="Сводка">
|
||||
@if (state.detail.session.title) {
|
||||
<p class="session-title">{{ state.detail.session.title }}</p>
|
||||
}
|
||||
<div class="summary-row">
|
||||
<span
|
||||
tuiChip
|
||||
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>
|
||||
@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>
|
||||
}
|
||||
|
||||
@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>
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<app-session-view-tab
|
||||
[detail]="state.detail"
|
||||
[telemetryState]="telemetryState"
|
||||
[recordingStartMs]="recordingStartMs()"
|
||||
[recordingEndMs]="recordingEndMs()"
|
||||
(telemetryToMsChange)="telemetryToMs.set($event)"
|
||||
/>
|
||||
}
|
||||
@case (1) {
|
||||
<app-session-interactive-tab
|
||||
[detail]="state.detail"
|
||||
[telemetryEvents]="telemetryState.telemetry"
|
||||
[recordingStartMs]="recordingStartMs()"
|
||||
[recordingEndMs]="recordingEndMs()"
|
||||
/>
|
||||
}
|
||||
@case (2) {
|
||||
<app-session-info-tab
|
||||
[detail]="state.detail"
|
||||
[telemetryState]="telemetryState"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -46,21 +28,11 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--tui-status-negative);
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--tui-text-tertiary);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
<h3 class="section-title">Новая сессия</h3>
|
||||
<div class="create-row">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<label tuiLabel>Название</label>
|
||||
<label tuiLabel for="session-title-input">Название</label>
|
||||
<input
|
||||
id="session-title-input"
|
||||
tuiInput
|
||||
type="text"
|
||||
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 {
|
||||
margin: 0;
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 0.65rem;
|
||||
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">
|
||||
<dt>Виртуальный код (VK)</dt>
|
||||
<dd>
|
||||
@if (keyboardModel().vk != null) {
|
||||
@if (keyboardModel().vk !== null && keyboardModel().vk !== undefined) {
|
||||
{{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }})
|
||||
} @else {
|
||||
—
|
||||
@@ -49,7 +49,7 @@
|
||||
</dl>
|
||||
}
|
||||
<div class="json-copy-row">
|
||||
<span class="telemetry-json-label">Данные события (JSON)</span>
|
||||
<span class="telemetry-json-label">Исходный JSON события</span>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
|
||||
@@ -17,3 +17,21 @@ export function formatTimestamp(value: string | null | undefined): string {
|
||||
second: '2-digit',
|
||||
}).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/page-common.css';
|
||||
@import './styles/filter-chips.css';
|
||||
@import './styles/session-status-chips.css';
|
||||
@import './styles/sg-input-fields.css';
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
--sg-keyboard-key-pressed-fill: var(--sg-color-accent);
|
||||
--sg-keyboard-key-pressed-ink: var(--sg-color-text);
|
||||
--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) {
|
||||
|
||||
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