visual and functional upgrade

This commit is contained in:
Микаэл Оганесян
2026-04-08 05:56:47 +03:00
parent 68c3029835
commit 07c17877ac
48 changed files with 923 additions and 283 deletions

View File

@@ -1,5 +1,6 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter } from '@angular/router';
import { provideTaiga } from '@taiga-ui/core';
import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification';
@@ -11,6 +12,7 @@ import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAnimations(),
provideRouter(routes),
provideTaiga(),
tuiNotificationOptionsProvider(() => ({

View File

@@ -11,21 +11,40 @@
}
.shell-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--tui-border-normal);
background: var(--tui-background-elevation-1);
}
.shell-header__inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 1.75rem;
padding-block: 1rem;
text-align: left;
}
.brand {
font: var(--tui-font-heading-5);
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-family: inherit;
font-size: clamp(1.35rem, 2.4vw, 1.65rem);
font-weight: 500;
line-height: 1.15;
letter-spacing: 0.035em;
color: var(--tui-text-primary);
text-decoration: none;
}
.brand-logo {
display: block;
height: 2rem;
width: auto;
flex-shrink: 0;
}
.brand:hover {
color: var(--tui-text-action);
}

View File

@@ -1,8 +1,13 @@
<tui-root>
<div class="shell">
<header class="shell-header">
<a routerLink="/" class="brand">SparkGuardian</a>
<span class="shell-sub">Прокторинг</span>
<div class="shell-header__inner sg-content-column">
<a routerLink="/" class="brand">
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
GUARD
</a>
<span class="shell-sub">Прокторинг</span>
</div>
</header>
<main class="shell-main">
<router-outlet />

View File

@@ -34,6 +34,6 @@ describe('App', () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.brand')?.textContent).toContain('SparkGuardian');
expect(compiled.querySelector('.brand')?.textContent).toContain('GUARD');
});
});

View File

@@ -3,10 +3,6 @@ import { inject } from '@angular/core';
import { API_BASE_URL } from '../config/api.tokens';
/**
* Подставляет базовый URL ко всем относительным запросам.
* Абсолютные URL (`http…`) не трогаем — как в Axios с `baseURL`, только через перехватчик.
*/
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
const base = inject(API_BASE_URL);
if (/^https?:\/\//i.test(req.url)) {

View File

@@ -7,9 +7,6 @@ function isParseFailureMessage(message: string | undefined): boolean {
return !!message?.includes('Http failure during parsing');
}
/**
* Определяет категорию ошибки для пользовательского сообщения (без утечки технических деталей).
*/
export function classifyUserError(err: unknown): UserErrorKind {
if (err instanceof TimeoutError) {
return 'timeout';

View File

@@ -2,7 +2,6 @@ import { HttpErrorResponse } from '@angular/common/http';
import type { ApiErrorBody } from '../models/api.types';
/** Безопасная подстановка текста в разметку с innerHTML (например, Taiga notification). */
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')

View File

@@ -1,24 +1,56 @@
import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id';
/**
* Токены из `key_name` и из `modifiers` (через "+"), в нижнем регистре.
* Соответствие имён бэкенда → id прямоугольников в KB_USA-standard.svg.
*/
function normalizeKeyToken(token: string): string {
return token.trim().toLowerCase().replace(/-/g, '_');
}
function tokenToSvgIds(token: string): string[] {
const t = token.trim().toLowerCase();
const t = normalizeKeyToken(token);
if (!t) {
return [];
}
const named: Record<string, string[]> = {
shift: ['K_kb5a'],
l_shift: ['K_kb5a'],
r_shift: ['K_kb5m'],
left_shift: ['K_kb5a'],
right_shift: ['K_kb5m'],
lshift: ['K_kb5a'],
rshift: ['K_kb5m'],
meta: ['K_kb6b'],
super: ['K_kb6b'],
l_super: ['K_kb6b'],
r_super: ['K_kb6l'],
win: ['K_kb6b'],
windows: ['K_kb6b'],
l_meta: ['K_kb6b'],
r_meta: ['K_kb6l'],
left_meta: ['K_kb6b'],
right_meta: ['K_kb6l'],
l_win: ['K_kb6b'],
r_win: ['K_kb6l'],
left_win: ['K_kb6b'],
right_win: ['K_kb6l'],
control: ['K_kb6a'],
ctrl: ['K_kb6a'],
l_control: ['K_kb6a'],
r_control: ['K_kb6n'],
left_control: ['K_kb6a'],
right_control: ['K_kb6n'],
l_ctrl: ['K_kb6a'],
r_ctrl: ['K_kb6n'],
left_ctrl: ['K_kb6a'],
right_ctrl: ['K_kb6n'],
alt: ['K_kb6c'],
l_alt: ['K_kb6c'],
r_alt: ['K_kb6k'],
left_alt: ['K_kb6c'],
right_alt: ['K_kb6k'],
tab: ['K_kb3a'],
enter: ['K_kb4n'],
return: ['K_kb4n'],
@@ -40,10 +72,6 @@ function tokenToSvgIds(token: string): string[] {
return [];
}
/**
* Формат бэкенда: `{ key_name, modifiers: "Shift+Meta", action }` и т.п.
* Собирает уникальные id клавиш для подсветки (модификаторы + основная клавиша).
*/
export function keyNameModifiersPayloadToSvgIds(o: Record<string, unknown>): string[] {
const out: string[] = [];
const seen = new Set<string>();

View File

@@ -8,9 +8,6 @@ export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
return t.includes('keyboard') || t.includes('key');
}
/**
* Извлекает виртуальный код клавиши из payload (массив чисел, объект с полями vk/keyCode и т.д.).
*/
export function parseKeyboardVirtualKey(data: unknown): number | null {
if (data == null) {
return null;
@@ -65,9 +62,6 @@ function unwrapJsonPayload(data: unknown): unknown {
return data;
}
/**
* Id элементов `K_kb*` для подсветки: сначала объект `key_name` / `modifiers`, иначе VK из массива/полей.
*/
export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
const raw = unwrapJsonPayload(data);
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {

View File

@@ -4,10 +4,10 @@ import { Observable, defer, from, shareReplay } from 'rxjs';
import { map } from 'rxjs/operators';
/**
* Файл из `public/KB_USA-standard.svg` — отдаётся с корня приложения (`/KB_USA-standard.svg`),
* Файл из `public/svg/visual/keyboard.svg` — URL в приложении `/svg/visual/keyboard.svg`,
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
*/
const KEYBOARD_SVG_PATH = '/KB_USA-standard.svg';
const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg';
@Injectable({ providedIn: 'root' })
export class KeyboardSvgHighlightService {
@@ -31,7 +31,6 @@ export class KeyboardSvgHighlightService {
);
}
/** Подсветка: заливка `--sg-keyboard-key-pressed-fill` (акцент), подписи/иконки `--sg-keyboard-key-pressed-ink`. */
private injectHighlight(svgText: string, keyIds: string[]): string {
const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id));
if (valid.length === 0) {

View File

@@ -1,7 +1,3 @@
/**
* Соответствие Windows Virtual Key → id элемента в `public/KB_USA-standard.svg` (K_kb*).
* Раскладка US QWERTY, без блока F1F12 (на схеме их нет).
*/
export function normalizeVirtualKey(vk: number): number {
if (vk >= 0x61 && vk <= 0x7a) {
return vk - 0x20;
@@ -49,7 +45,6 @@ const LETTER_TO_ID: Record<string, string> = {
Z: 'K_kb5c',
};
/** OEM и прочие VK (Windows). */
const EXTRA_VK: Record<number, string> = {
0x08: 'K_kb2n',
0x09: 'K_kb3a',
@@ -98,7 +93,6 @@ export function vkToKeyboardSvgKeyId(vk: number): string | null {
return null;
}
/** Одна буква AZ или цифра 09 как в подписи клавиши (`key_name`). */
export function charKeyNameToSvgKeyId(name: string): string | null {
const c = name.trim();
if (c.length !== 1) {

View File

@@ -1,4 +1,4 @@
/** Соответствует `handler.ErrorResponse` из OpenAPI. */
/** OpenAPI: handler.ErrorResponse */
export interface ApiErrorBody {
code?: string;
error?: string;
@@ -24,7 +24,7 @@ export interface SessionSummary {
ended_at?: string;
chunks_total?: number;
events_total?: number;
/** Не в swagger, но бэкенд может отдавать для списка. */
/** в списке сессий с бэка, не всегда в swagger */
title?: string;
}

View File

@@ -1,29 +1,14 @@
/**
* Типы ошибок для подбора дружелюбного текста во всплывающих уведомлениях.
* Соответствие «тип → фраза» задаётся здесь; технические детали — только в Dev Console.
*/
export const USER_ERROR_FRIENDLY_MESSAGES = {
/** Нет сети / соединение сброшено (часто HTTP 0). */
network: 'Проверьте интернет-соединение.',
/** Таймаут запроса (в т.ч. RxJS timeout, HTTP 408). */
timeout: 'Запрос занял слишком много времени. Попробуйте позже.',
/** Ответ сервера 5xx. */
server_error: 'Уже работаем над этим.',
/** 404. */
not_found: 'Не удалось найти запрашиваемые данные.',
/** 401. */
unauthorized: 'Требуется вход в систему.',
/** 403. */
forbidden: 'Недостаточно прав для этого действия.',
/** 400. */
bad_request: 'Некорректный запрос. Попробуйте позже.',
/** Прочие 4xx (кроме перечисленных выше). */
client_error: 'Не удалось выполнить запрос. Попробуйте позже.',
/** Не JSON / ошибка разбора ответа. */
parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.',
/** Локальная валидация (неверный id и т.п.). */
invalid_input: 'Проверьте введённые данные.',
/** Не удалось отнести к категории. */
unknown: 'Попробуйте позже.',
} as const;

View File

@@ -8,14 +8,23 @@ import { friendlyMessageForUserError } from './user-error-messages.config';
const ERROR_TOAST_TITLE = 'Что-то пошло не так...';
/**
* Показывает пользователю обобщённое уведомление; точный текст ошибки — только в DevLog (в dev).
*/
@Injectable({ providedIn: 'root' })
export class UserErrorNotifyService {
private readonly notifications = inject(TuiNotificationService);
private readonly devLog = inject(DevLogService);
notifySuccess(message: string, label: string): void {
this.notifications
.open(escapeHtml(message), {
label,
appearance: 'positive',
autoClose: 4000,
closable: true,
size: 'm',
})
.subscribe();
}
notifyError(err: unknown, source: string): void {
const kind = classifyUserError(err);
const userSubtitle = friendlyMessageForUserError(kind);

View File

@@ -48,9 +48,6 @@ export class SessionsApiService {
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
}
/**
* Относительный `playlist_url` из API нужно разрешить относительно хоста (`API_ORIGIN`).
*/
resolvePlaylistUrl(playlistUrl: string): string {
if (/^https?:\/\//i.test(playlistUrl)) {
return playlistUrl;
@@ -58,9 +55,6 @@ export class SessionsApiService {
return new URL(playlistUrl, `${this.apiOrigin}/`).href;
}
/**
* Ручка для агента (Bearer). В веб-интерфейсе обычно не нужна — оставлена для отладки/скриптов.
*/
uploadChunk(
sessionId: number,
chunkIdx: number,

View File

@@ -1,8 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
/**
* Классы-модификаторы для чипа статуса. `finished` — без модификатора (дефолтный вид Taiga).
*/
/** finished — без классов (дефолт Taiga). */
@Pipe({
name: 'sessionStatusChipClasses',
standalone: true,

View File

@@ -1,7 +1,3 @@
/**
* Отображаемые подписи для статусов сессий (значения с API — обычно в нижнем регистре).
* Неизвестный ключ в UI показывается как есть; в dev — предупреждение в консоли разработчика.
*/
export const SESSION_STATUS_LABELS: Readonly<Record<string, string>> = {
active: 'Активная',
pending: 'Ожидается',

View File

@@ -0,0 +1,8 @@
import { keyboardKeyRule, mouseClickRule, mouseMoveRule } from './telemetry-event-summary.handlers';
import type { TelemetrySummaryRule } from './telemetry-event-summary.types';
export const TELEMETRY_SUMMARY_RULES: readonly TelemetrySummaryRule[] = [
mouseClickRule,
mouseMoveRule,
keyboardKeyRule,
];

View File

@@ -0,0 +1,27 @@
import { TELEMETRY_SUMMARY_RULES } from './telemetry-event-summary.config';
import { fallbackCompactJson, unwrapTelemetryPayload } from './telemetry-event-summary.payload';
export function summarizeTelemetryData(data: unknown): string {
const raw = unwrapTelemetryPayload(data);
if (raw === null || raw === undefined) {
return '—';
}
if (Array.isArray(raw)) {
return raw.map((v) => String(v)).join(', ');
}
if (typeof raw !== 'object') {
return String(raw);
}
const o = raw as Record<string, unknown>;
for (const rule of TELEMETRY_SUMMARY_RULES) {
if (rule.match(o)) {
try {
return rule.summarize(o);
} catch {
// ignore
}
}
}
return fallbackCompactJson(o);
}

View File

@@ -0,0 +1,52 @@
import {
formatTelemetryKeyboardKeySummary,
formatTelemetryMouseClickSummary,
formatTelemetryMouseMoveSummary,
} from '../../shared/utils/telemetry-summary-human-text.util';
import { readNumericField } from './telemetry-event-summary.payload';
import type { TelemetrySummaryRule } from './telemetry-event-summary.types';
export const mouseClickRule: TelemetrySummaryRule = {
id: 'mouse-click',
match: (o) =>
o['action'] === 'click' &&
readNumericField(o, 'x') !== null &&
readNumericField(o, 'y') !== null &&
readNumericField(o, 'button') !== null,
summarize: (o) =>
formatTelemetryMouseClickSummary(
readNumericField(o, 'x')!,
readNumericField(o, 'y')!,
readNumericField(o, 'button')!,
o['is_down'] === true,
),
};
export const mouseMoveRule: TelemetrySummaryRule = {
id: 'mouse-move',
match: (o) =>
o['action'] === 'move' && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null,
summarize: (o) =>
formatTelemetryMouseMoveSummary(
readNumericField(o, 'x')!,
readNumericField(o, 'y')!,
readNumericField(o, 'button'),
),
};
export const keyboardKeyRule: TelemetrySummaryRule = {
id: 'keyboard-key',
match: (o) => {
const a = o['action'];
if (a !== 'press' && a !== 'release') {
return false;
}
return typeof o['key_name'] === 'string';
},
summarize: (o) =>
formatTelemetryKeyboardKeySummary(
o['action'] === 'press' ? 'press' : 'release',
String(o['key_name']),
o['modifiers'],
),
};

View File

@@ -0,0 +1,30 @@
export function unwrapTelemetryPayload(data: unknown): unknown {
if (typeof data === 'string') {
try {
return JSON.parse(data) as unknown;
} catch {
return data;
}
}
return data;
}
export function readNumericField(o: Record<string, unknown>, key: string): number | null {
const v = o[key];
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
export function fallbackCompactJson(o: Record<string, unknown>): string {
try {
return JSON.stringify(o);
} catch {
return '[объект]';
}
}

View File

@@ -0,0 +1,5 @@
export interface TelemetrySummaryRule {
readonly id: string;
readonly match: (o: Record<string, unknown>) => boolean;
readonly summarize: (o: Record<string, unknown>) => string;
}

View File

@@ -0,0 +1,4 @@
export const TELEMETRY_EVENT_TYPE_LABELS: Readonly<Record<string, string>> = {
keyboard: 'Клавиатура',
mouse: 'Мышь',
};

View File

@@ -0,0 +1,38 @@
import { isDevMode, Pipe, PipeTransform, inject } from '@angular/core';
import { DevLogService } from '../devtools/dev-log.service';
import { TELEMETRY_EVENT_TYPE_LABELS } from './telemetry-event-type-labels.config';
@Pipe({
name: 'telemetryEventType',
standalone: true,
})
export class TelemetryEventTypePipe implements PipeTransform {
private readonly devLog = inject(DevLogService);
private readonly warnedUnknown = new Set<string>();
transform(value: unknown): string {
if (value == null || value === '') {
return '—';
}
const raw = typeof value === 'string' ? value : String(value);
const trimmed = raw.trim();
if (trimmed === '') {
return '—';
}
const lookup = trimmed.toLowerCase();
const mapped = TELEMETRY_EVENT_TYPE_LABELS[lookup];
if (mapped !== undefined && String(mapped).trim() !== '') {
return String(mapped).trim();
}
if (isDevMode() && !this.warnedUnknown.has(lookup)) {
this.warnedUnknown.add(lookup);
this.devLog.add({
level: 'warn',
source: 'system',
message: `Неизвестный тип события телеметрии (нет подписи в telemetry-event-type-labels.config): ${trimmed}`,
});
}
return trimmed;
}
}

View File

@@ -14,7 +14,7 @@ export class DevConsoleComponent {
private readonly logs = inject(DevLogService);
protected readonly isDev = isDevMode();
protected readonly collapsed = signal(false);
protected readonly minimized = signal(false);
protected readonly minimized = signal(true);
protected readonly entries = this.logs.entries;
protected readonly count = computed(() => this.entries().length);
protected readonly expandedIds = signal<Record<number, boolean>>({});

View File

@@ -15,7 +15,6 @@ import Hls from 'hls.js';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HlsPlayerComponent {
/** Полный URL плейлиста `.m3u8`. */
readonly src = input.required<string>();
private readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('videoEl');

View File

@@ -1,7 +1,7 @@
:host {
display: block;
width: 100%;
max-width: 960px;
max-width: var(--sg-content-max-width);
}
.player {

View File

@@ -1,3 +1,4 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { AsyncPipe, NgClass } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core';
@@ -14,12 +15,20 @@ import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, sw
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 { 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' };
@Component({
selector: 'app-session-detail',
imports: [
@@ -35,11 +44,29 @@ import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemet
TuiTitle,
SessionStatusChipClassesPipe,
SessionStatusPipe,
TelemetryEventTypePipe,
TelemetryEventDetailComponent,
],
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);
@@ -51,16 +78,14 @@ export class SessionDetailComponent {
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);
/** 0 — просмотр, 1 — служебная информация. */
protected readonly activeTabIndex = model(0);
/** null — все типы событий в таблице телеметрии. */
protected readonly telemetryEventTypeFilter = signal<string | null>(null);
/** Раскрытая строка телеметрии: ключ или null. */
protected readonly expandedTelemetryRowKey = signal<string | null>(null);
private readonly sessionId$ = this.route.paramMap.pipe(
@@ -88,6 +113,8 @@ 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());
@@ -202,8 +229,16 @@ export class SessionDetailComponent {
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${row.event_type}\u0000${index}`;
return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\u0000${index}`;
}
protected toggleTelemetryRow(row: ParsedEvent, index: number): void {
@@ -215,17 +250,16 @@ export class SessionDetailComponent {
return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index);
}
/** Уникальные значения `event_type` (пустая строка — отдельная вкладка). */
protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] {
const set = new Set<string>();
for (const e of events) {
set.add(e.event_type ?? '');
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) => (e.event_type ?? '') === typeKey).length;
return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length;
}
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
@@ -233,33 +267,16 @@ export class SessionDetailComponent {
if (filter === null) {
return events;
}
return events.filter((e) => (e.event_type ?? '') === filter);
return events.filter((e) => this.telemetryEventTypeKey(e) === filter);
}
protected telemetryTypeTabLabel(typeKey: string): string {
return typeKey === '' ? 'Без типа' : typeKey;
}
protected eventDataPreview(event: ParsedEvent): string {
const data = event.data;
if (data === null || data === undefined) {
return '—';
}
if (Array.isArray(data)) {
return data.map((v) => String(v)).join(', ');
}
if (typeof data === 'object') {
try {
return JSON.stringify(data);
} catch {
return '[object]';
}
}
return String(data);
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));
@@ -267,6 +284,7 @@ export class SessionDetailComponent {
protected loadUntilEndTelemetry(): void {
this.customToLocal.set('');
this.telemetryRangeSelection.set({ type: 'end' });
const end = this.recordingEndMs() ?? Date.now();
this.telemetryToMs.set(end);
}
@@ -278,17 +296,27 @@ export class SessionDetailComponent {
}
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)}`;
}
protected detailPayloadJson(detail: SessionDetailResponse): string {
private detailPayloadJson(detail: SessionDetailResponse): string {
try {
return JSON.stringify(detail, null, 2);
} catch {
@@ -296,11 +324,18 @@ export class SessionDetailComponent {
}
}
protected formatDurationMs(ms: number | null | undefined): string {
if (ms === null || ms === undefined || !Number.isFinite(ms)) {
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
}
return `${ms} ms`;
}
protected formatDurationMs(ms: number | null | undefined): string {
return formatDurationMsHuman(ms);
}
protected streamResolvedPlaylistUrl(stream: StreamInfo): string {

View File

@@ -1,8 +1,6 @@
.page {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
box-sizing: border-box;
padding-top: 1.5rem;
padding-bottom: 3rem;
}
.back {
@@ -66,36 +64,33 @@
font-size: 0.92em;
}
.json-block {
margin: 0.75rem 0 0;
padding: 1rem;
max-height: min(360px, 50vh);
overflow: auto;
border-radius: var(--tui-radius-m);
background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary));
font-size: 0.8rem;
line-height: 1.45;
white-space: pre-wrap;
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%;
border-collapse: collapse;
font: var(--tui-font-text-s);
}
.meta-table th,
.meta-table td {
.meta-table tbody td {
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--tui-border-normal);
}
.meta-table th {
text-align: left;
font-weight: 600;
color: var(--tui-text-secondary);
color: var(--tui-text-primary);
}
.table-wrap_flat {
@@ -133,14 +128,41 @@
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;
@@ -184,53 +206,143 @@
margin-bottom: 1rem;
}
.stream-tabs button {
.stream-tabs button,
.telemetry-presets button {
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
outline-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;
}
/* Контраст к карточке — жёлтый акцент при наведении (специфичнее secondary appearance Taiga) */
.stream-tabs
button[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not([data-state='disabled']) {
background: var(--sg-color-accent);
color: var(--sg-color-text);
border-color: color-mix(in srgb, var(--sg-color-accent) 78%, black);
outline: 2px solid transparent;
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'] {
outline: 2px solid var(--sg-color-accent);
background: color-mix(in srgb, var(--sg-color-accent) 22%, white);
border-color: var(--sg-color-accent);
color: var(--sg-color-text);
/* Активный чип */
.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(480px, 70vh);
max-height: min(520px, 70vh);
}
.table-wrap:not(.table-wrap_flat) {
min-height: 520px;
}
.telemetry {
width: 100%;
border-collapse: collapse;
font: var(--tui-font-text-s);
}
.telemetry th,
.telemetry td {
.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 th {
position: sticky;
top: 0;
background: var(--tui-background-elevation-1);
z-index: 1;
/* Длинные неизвестные 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 {
@@ -252,7 +364,9 @@
vertical-align: top;
}
.payload {
font-family: ui-monospace, monospace;
word-break: break-all;
.telemetry-col-summary {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
vertical-align: top;
}

View File

@@ -1,4 +1,4 @@
<div class="page">
<div class="page sg-content-column">
<nav class="back">
<a tuiLink routerLink="/">К списку сессий</a>
</nav>
@@ -14,7 +14,7 @@
<p class="muted">Не удалось загрузить сессию.</p>
}
@case ('ok') {
<h2 tuiTitle class="heading">Сессия {{ state.id }}</h2>
<h2 tuiTitle="m" class="heading">Сессия {{ state.id }}</h2>
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
<button tuiTab type="button">Просмотр</button>
@@ -68,34 +68,88 @@
<section class="card" aria-label="Телеметрия">
<div class="telemetry-head">
<h3 class="section-title">
События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }})
</h3>
<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">
<span class="muted small">{{ telemetryRangeLabel(telemetryState.toMs) }}</span>
<div class="telemetry-presets">
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(2)">+2с</button>
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(10)">+10с</button>
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(60)">+1м</button>
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(300)">+5м</button>
<button tuiButton type="button" size="s" appearance="secondary" (click)="selectRecentWindow(900)">+15м</button>
<button tuiButton type="button" size="s" appearance="secondary" (click)="loadUntilEndTelemetry()">До конца</button>
<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">
<span class="muted small">До:</span>
<input
#toInput
type="datetime-local"
class="sg-native-input"
[value]="customToLocal()"
(change)="applyCustomTo($any($event.target).value)"
aria-label="Верхняя граница диапазона телеметрии"
/>
<button tuiButton type="button" size="s" appearance="secondary" (click)="applyCustomTo(toInput.value)">
Применить
</button>
</label>
</div>
</div>
<div class="telemetry-content">
@if (telemetryState.status === 'loading') {
<div class="loading-wrap loading-wrap_small">
<tui-loader [loading]="true" size="l" />
@@ -126,7 +180,7 @@
[class.stream-active]="telemetryEventTypeFilter() === t"
(click)="pickTelemetryEventTypeFilter(t)"
>
{{ telemetryTypeTabLabel(t) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState.telemetry, t) }})
</button>
}
</div>
@@ -139,13 +193,13 @@
<tr>
<th>Время</th>
<th>Тип</th>
<th>Payload</th>
<th>Сводка</th>
</tr>
</thead>
<tbody>
@for (
row of filteredTelemetryEvents(telemetryState.telemetry);
track row.timestamp + '-' + row.event_type + '-' + $index;
track $index;
let i = $index
) {
<tr
@@ -154,11 +208,13 @@
(click)="toggleTelemetryRow(row, i)"
>
<td>{{ formatUnixMs(row.timestamp) }}</td>
<td><span tuiChip size="xs">{{ row.event_type || '—' }}</span></td>
<td class="payload">{{ eventDataPreview(row) }}</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">
<tr class="telemetry-row-detail" @telemetryEventDetail>
<td colspan="3" (click)="$event.stopPropagation()">
<app-telemetry-event-detail [event]="row" />
</td>
@@ -171,6 +227,7 @@
}
}
}
</div>
</section>
}
@@ -206,21 +263,19 @@
<table class="meta-table">
<thead>
<tr>
<th>Тип потока</th>
<th>Чанков</th>
<th>Длительность (мс)</th>
<th>URL плейлиста (как в API)</th>
<th>URL плейлиста (абсолютный)</th>
<th>Тип</th>
<th>Чанки</th>
<th>Длительность</th>
<th>URL видеозаписи</th>
</tr>
</thead>
<tbody>
@for (stream of state.detail.streams; track stream.stream_type) {
@for (s of state.detail.streams; track s.stream_type) {
<tr>
<td><code class="mono">{{ stream.stream_type }}</code></td>
<td>{{ stream.chunk_count ?? '—' }}</td>
<td>{{ formatDurationMs(stream.duration_ms) }}</td>
<td class="payload">{{ stream.playlist_url }}</td>
<td class="payload">{{ streamResolvedPlaylistUrl(stream) }}</td>
<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>
@@ -260,8 +315,18 @@
</section>
<section class="card" aria-label="Исходный JSON">
<h3 class="section-title">Исходный JSON</h3>
<pre class="json-block">{{ detailPayloadJson(state.detail) }}</pre>
<div class="json-copy-row">
<h3 class="section-title">Исходный JSON</h3>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
(click)="copyDetailPayloadJson(state.detail)"
>
Скопировать в буфер
</button>
</div>
</section>
}
}

View File

@@ -1,8 +1,6 @@
.page {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
box-sizing: border-box;
padding-top: 1.5rem;
padding-bottom: 3rem;
}
.heading {
@@ -109,11 +107,3 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat
.total {
font: var(--tui-font-text-s);
}
/* Активная страница пагинации (Taiga 5: активная кнопка — appearance primary) */
tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] {
--t-bg: var(--sg-color-accent);
background: var(--t-bg);
border-color: var(--sg-color-accent);
color: var(--sg-color-text);
}

View File

@@ -1,5 +1,5 @@
<div class="page">
<h2 tuiTitle class="heading">Сессии прокторинга</h2>
<div class="page sg-content-column">
<h2 tuiTitle="m" class="heading">Сессии прокторинга</h2>
<section class="create card" aria-label="Создать сессию">
<h3 class="section-title">Новая сессия</h3>

View File

@@ -2,7 +2,9 @@ import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { SafeHtml } from '@angular/platform-browser';
import { TuiButton } from '@taiga-ui/core/components/button';
import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiAccordion } from '@taiga-ui/kit/components/accordion';
import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@@ -14,17 +16,20 @@ import {
} from '../../../core/keyboard/keyboard-payload.util';
import { KeyboardSvgHighlightService } from '../../../core/keyboard/keyboard-svg-highlight.service';
import type { ParsedEvent } from '../../../core/models/api.types';
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
import { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe';
import { formatTimestamp } from '../../../shared/utils/date-time.util';
@Component({
selector: 'app-telemetry-event-detail',
imports: [AsyncPipe, TuiLoader],
imports: [AsyncPipe, TuiButton, TuiLoader, TelemetryEventTypePipe, ...TuiAccordion],
templateUrl: './telemetry-event-detail.html',
styleUrl: './telemetry-event-detail.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TelemetryEventDetailComponent {
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
private readonly userErrors = inject(UserErrorNotifyService);
readonly event = input.required<ParsedEvent>();
@@ -50,7 +55,16 @@ export class TelemetryEventDetailComponent {
return eventPayloadJson(e.data);
}
/** Удобно в шаблоне, где нет сужения типа для `keyboardModel()`. */
protected async copyEventPayload(): Promise<void> {
const text = this.payloadText(this.event());
try {
await navigator.clipboard.writeText(text);
this.userErrors.notifySuccess('JSON события скопирован в буфер обмена.', 'Готово');
} catch {
// clipboard
}
}
protected keyboardKeyIds(): string[] {
const m = this.keyboardModel();
return m.kind === 'keyboard' ? m.keyIds : [];

View File

@@ -2,6 +2,10 @@
padding: 0.5rem 0 0;
}
.detail-section {
margin: 0;
}
.detail-title {
margin: 0 0 0.75rem;
font: var(--tui-font-text-m);
@@ -13,10 +17,14 @@
display: grid;
grid-template-columns: minmax(8rem, 12rem) 1fr;
gap: 0.35rem 1rem;
margin: 0 0 1rem;
margin: 0;
font: var(--tui-font-text-s);
}
.detail-kv_main {
margin-bottom: 0.65rem;
}
.detail-kv dt {
margin: 0;
color: var(--tui-text-tertiary);
@@ -27,17 +35,36 @@
word-break: break-word;
}
.payload-json {
/* Подраздел «Служебные данные» внутри «Подробности» */
.detail-subsection {
margin: 0;
padding: 0.75rem 1rem;
max-height: 200px;
overflow: auto;
border-radius: var(--tui-radius-m);
background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary));
font-size: 0.8rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
padding-left: 0.65rem;
border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary));
}
.telemetry-service-accordion {
inline-size: 100%;
}
.telemetry-service-body {
padding: 0;
}
.telemetry-service-kv {
margin: 0.65rem 0 0.75rem;
}
.json-copy-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
}
.telemetry-json-label {
font: var(--tui-font-text-s);
color: var(--tui-text-secondary);
}
.mono {
@@ -46,9 +73,9 @@
}
.keyboard-block {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--tui-border-normal);
margin: 0 0 1rem;
padding: 0 0 0.75rem;
border-bottom: 1px solid var(--tui-border-normal);
}
.keyboard-title {

View File

@@ -1,31 +1,7 @@
<div class="detail">
<h4 class="detail-title">Подробности</h4>
<dl class="kv detail-kv">
<dt>Тип</dt>
<dd>{{ event().event_type || '—' }}</dd>
<dt>Время</dt>
<dd>{{ formatTime(event().timestamp) }}</dd>
@if (keyboardModel().kind === 'keyboard') {
<dt>Виртуальный код (VK)</dt>
<dd>
@if (keyboardModel().vk != null) {
{{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }})
} @else {
}
</dd>
@if (keyboardKeyIds().length > 0) {
<dt>Клавиши на схеме</dt>
<dd><code class="mono">{{ keyboardKeyIds().join(', ') }}</code></dd>
}
}
<dt>Данные</dt>
<dd><pre class="payload-json">{{ payloadText(event()) }}</pre></dd>
</dl>
@if (keyboardModel().kind === 'keyboard') {
<div class="keyboard-block">
<h4 class="keyboard-title">Клавиатура (US)</h4>
<h4 class="keyboard-title">Предпросмотр</h4>
@if (keyboardSvg$ | async; as svg) {
<div class="keyboard-svg-host" [innerHTML]="svg"></div>
} @else {
@@ -41,4 +17,52 @@
}
</div>
}
<section class="detail-section">
<h4 class="detail-title">Подробности</h4>
<dl class="kv detail-kv detail-kv_main">
<dt>Тип</dt>
<dd>{{ event().event_type | telemetryEventType }}</dd>
<dt>Время</dt>
<dd>{{ formatTime(event().timestamp) }}</dd>
</dl>
<div class="detail-subsection">
<tui-accordion class="telemetry-service-accordion" [closeOthers]="true" size="s">
<button tuiAccordion type="button">Служебные данные</button>
<tui-expand>
<div class="telemetry-service-body">
@if (keyboardModel().kind === 'keyboard') {
<dl class="kv detail-kv telemetry-service-kv">
<dt>Виртуальный код (VK)</dt>
<dd>
@if (keyboardModel().vk != null) {
{{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }})
} @else {
}
</dd>
@if (keyboardKeyIds().length > 0) {
<dt>Клавиши на схеме</dt>
<dd><code class="mono">{{ keyboardKeyIds().join(', ') }}</code></dd>
}
</dl>
}
<div class="json-copy-row">
<span class="telemetry-json-label">Данные события (JSON)</span>
<button
tuiButton
type="button"
size="s"
appearance="secondary"
(click)="copyEventPayload()"
>
Скопировать в буфер
</button>
</div>
</div>
</tui-expand>
</tui-accordion>
</div>
</section>
</div>

View File

@@ -1,7 +1,3 @@
/**
* Преобразует ISO-время (timestamptz), например `2026-04-06T23:58:27Z`,
* в человекочитаемый локальный формат.
*/
export function formatTimestamp(value: string | null | undefined): string {
if (!value) {
return '—';

View File

@@ -0,0 +1,46 @@
const NBSP = '\u00a0';
export function formatDurationMsHuman(ms: number | null | undefined): string {
if (ms === null || ms === undefined || !Number.isFinite(ms)) {
return '—';
}
const rounded = Math.round(ms);
if (rounded < 0) {
return '—';
}
if (rounded === 0) {
return `0${NBSP}с`;
}
if (rounded < 1000) {
return `${rounded}${NBSP}мс`;
}
const secFloat = rounded / 1000;
if (secFloat < 60) {
const hasFraction = rounded % 1000 !== 0;
const text = hasFraction
? secFloat.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 1 })
: String(Math.round(secFloat));
return `${text}${NBSP}с`;
}
const totalSec = Math.floor(rounded / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const parts: string[] = [];
if (h > 0) {
parts.push(`${h}${NBSP}ч`);
}
if (m > 0) {
parts.push(`${m}${NBSP}мин`);
}
if (s > 0 || parts.length === 0) {
parts.push(`${s}${NBSP}с`);
}
return parts.join(' ');
}

View File

@@ -0,0 +1,83 @@
export function formatTelemetryMouseButtonLabel(button: number): string {
switch (button) {
case 1:
return 'левой';
case 2:
return 'правой';
case 3:
return 'средней';
default:
return `дополнительной (${button})`;
}
}
export function formatTelemetryKeyboardKeyDisplay(key: string): string {
const t = key.trim();
if (t.toUpperCase() === 'META') {
return 'Meta';
}
if (t.length === 1) {
return t.toUpperCase();
}
return t;
}
export function formatTelemetryModifierTokens(modifiers: string): string {
return modifiers
.split('+')
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => {
const u = t.toLowerCase();
if (u === 'meta') {
return '⌘';
}
if (u === 'shift') {
return 'Shift';
}
if (u === 'control' || u === 'ctrl') {
return 'Ctrl';
}
if (u === 'alt') {
return 'Alt';
}
return t;
})
.join(' + ');
}
export function formatTelemetryMouseClickSummary(
x: number,
y: number,
button: number,
isDown: boolean,
): string {
const phase = isDown ? 'Нажатие' : 'Отпускание';
const btn = formatTelemetryMouseButtonLabel(button);
return `${phase} ${btn} кнопки мыши в точке (${x}, ${y})`;
}
export function formatTelemetryMouseMoveSummary(x: number, y: number, button: number | null): string {
if (button !== null) {
return `Перемещение указателя — (${x}, ${y}), кнопка ${button}`;
}
return `Перемещение указателя — (${x}, ${y})`;
}
export function formatTelemetryKeyboardKeySummary(
action: 'press' | 'release',
keyNameRaw: string,
modifiersRaw: unknown,
): string {
const actionRu = action === 'press' ? 'Нажатие' : 'Отпускание';
const keyName = formatTelemetryKeyboardKeyDisplay(keyNameRaw);
const modStr = modifiersRaw == null ? '' : String(modifiersRaw).trim();
const noMods =
modStr === '' || modStr.toLowerCase() === 'none' || modStr.toLowerCase() === 'null';
let line = `${actionRu} клавиши «${keyName}»`;
if (!noMods) {
line += `${formatTelemetryModifierTokens(modStr)}`;
}
return line;
}

View File

@@ -1,4 +1,3 @@
/** Production-сборка (см. fileReplacements в angular.json). При деплое при необходимости меняйте здесь или генерируйте шагом CI. */
export const environment = {
production: true,
apiFallbackOrigin: 'https://sparkguardian.ru',

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sparkguardian</title>
<title>GUARD</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">

View File

@@ -2,7 +2,6 @@
@import './styles/session-status-chips.css';
@import './styles/sg-input-fields.css';
/* Базовая вёрстка под Taiga UI: тема и шрифты подключаются в angular.json */
@font-face {
font-family: 'Tinkoff Sans';
src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype');
@@ -41,6 +40,14 @@ body {
font-family: 'Tinkoff Sans', sans-serif;
}
.sg-content-column {
box-sizing: border-box;
width: 100%;
max-width: var(--sg-content-max-width);
margin-inline: auto;
padding-inline: var(--sg-page-padding-inline);
}
*,
*::before,
*::after {
@@ -66,3 +73,24 @@ textarea::placeholder {
tui-notification-alert [tuiTitle] {
font-weight: 500;
}
/*
* Пагинация Taiga: активная страница — primary внутри чужого компонента;
* стили из sessions-list не доходят из‑за ViewEncapsulation — только глобально.
*/
tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] {
--t-bg: var(--sg-filter-chip-active-bg) !important;
background: var(--t-bg) !important;
border-color: var(--sg-filter-chip-active-bg) !important;
color: var(--sg-filter-chip-active-fg) !important;
}
tui-pagination
button.t-button[tuiButton][tuiAppearance][data-appearance='primary']:hover:not(:disabled):not(
[data-state='disabled']
) {
--t-bg: var(--sg-filter-chip-active-bg-hover) !important;
background: var(--t-bg) !important;
border-color: var(--sg-filter-chip-active-bg-hover) !important;
color: var(--sg-filter-chip-active-fg) !important;
}

View File

@@ -24,16 +24,28 @@
--sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
--sg-color-danger: #d92d20;
/* Taiga accent palette override (used by primary/secondary appearances, including pagination active item) */
/* Ширина и отступы основного контента (шапка, страницы с классом .page) */
/* От 1000px: боковые поля по умолчанию; уже 999px — 48px (media ниже). */
--sg-content-max-width: 1104px;
--sg-page-padding-inline: 1rem;
/*
* Чипы-категории (фильтры телеметрии, пресеты диапазона, вкладки потоков на просмотре).
* Совпадают с полями там, где цвета те же: фон неактивного = --sg-color-textfield-bg.
*/
--sg-filter-chip-bg: #f3f4f7;
--sg-filter-chip-bg-hover: #eaeff3;
--sg-filter-chip-fg: #313131;
--sg-filter-chip-active-bg: #158eff;
--sg-filter-chip-active-bg-hover: #0070ff;
--sg-filter-chip-active-fg: #ffffff;
/* Taiga accent palette override (primary-кнопки и т.п.; активная страница пагинации переопределена в sessions-list) */
--tui-background-accent-1: var(--sg-color-accent);
--tui-background-accent-1-hover: color-mix(in srgb, var(--sg-color-accent) 92%, black);
--tui-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black);
--tui-text-primary-on-accent-1: var(--sg-color-text);
/*
* Bridge to Taiga tokens used in the app.
* This keeps styling centralized and avoids hardcoding colors in components.
*/
--tui-background-base: var(--sg-color-bg);
--tui-background-elevation-1: var(--sg-color-card-bg);
--tui-text-primary: var(--sg-color-text);
@@ -48,15 +60,15 @@
--sg-session-status-active-fg: #166534;
--sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent);
--sg-session-status-pending-bg: color-mix(in srgb, #d97706 18%, var(--sg-color-card-bg));
--sg-session-status-pending-fg: #92400e;
--sg-session-status-pending-border: color-mix(in srgb, #d97706 34%, transparent);
--sg-session-status-pending-bg: color-mix(in srgb, #eab308 18%, var(--sg-color-card-bg));
--sg-session-status-pending-fg: #713f12;
--sg-session-status-pending-border: color-mix(in srgb, #eab308 34%, transparent);
--sg-session-status-unknown-bg: color-mix(in srgb, var(--sg-color-text) 10%, var(--sg-color-card-bg));
--sg-session-status-unknown-fg: var(--sg-color-text);
--sg-session-status-unknown-border: var(--sg-color-border);
/* Встроенная SVG-клавиатура (public/KB_USA-standard.svg) */
/* Встроенная SVG-клавиатура (public/svg/visual/keyboard.svg) */
--sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif;
--sg-keyboard-font-weight: 400;
--sg-keyboard-letter-spacing: 0.03em;
@@ -70,7 +82,7 @@
--sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle);
--sg-keyboard-key-other: var(--sg-keyboard-key-surface-idle);
--sg-keyboard-key-stroke: color-mix(in srgb, var(--sg-color-border) 65%, transparent);
/* Символы на клавишах — основной тёмный текст (тот же оттенок, что раньше был заливкой нажатой) */
/* Базовые глифы — ink-soft; контрастный «чёрный» — --sg-keyboard-ink (см. подсветку нажатий) */
--sg-keyboard-ink: var(--sg-color-text);
--sg-keyboard-ink-soft: var(--tui-text-tertiary);
/* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */
@@ -78,3 +90,9 @@
--sg-keyboard-key-pressed-ink: var(--sg-color-text);
--sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill);
}
@media (max-width: 999px) {
:root {
--sg-page-padding-inline: 48px;
}
}

View File

@@ -1,9 +1,3 @@
/*
* Единый вид полей ввода SparkGuardian.
* Taiga: добавьте class="sg-tui-textfield" на <tui-textfield>.
* Нативные input/textarea/select: class="sg-native-input".
*/
/* --- tui-textfield (Taiga Input) --- */
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] {
--tui-focus: var(--sg-color-textfield-focus-border);