Add landing
Some checks failed
CI / checks (push) Failing after 4m56s

This commit is contained in:
Микаэл Оганесян
2026-04-12 06:17:02 +03:00
parent 84586b5ce2
commit abcd49e117
35 changed files with 2342 additions and 75 deletions

View File

@@ -12,3 +12,6 @@ SG_INTERACTIVE_PREROLL_MS=4000
# Только dev-сервер (`ng serve`): куда проксировать `/api/**`
SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080
# Количество элементов на одной странице по умолчанию (например, для сессий)
SG_DEFAULT_PAGE_LIMIT=10

845
docs/doc_v2.json Normal file
View File

@@ -0,0 +1,845 @@
{
"schemes": [],
"swagger": "2.0",
"info": {
"description": "REST API for SparkProctoring — proctoring session management, telemetry, and video streaming.",
"title": "SparkProctoring API",
"contact": {},
"version": "1.0"
},
"host": "sparkguardian.ru",
"basePath": "/api/v1",
"paths": {
"/me": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Returns the authenticated user's profile.",
"produces": [
"application/json"
],
"tags": [
"auth"
],
"summary": "Get current user info",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.MeResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Paginated list of all sessions with summary stats.",
"produces": [
"application/json"
],
"tags": [
"sessions"
],
"summary": "List proctoring sessions",
"parameters": [
{
"type": "integer",
"description": "Max results (default 50, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset (default 0)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.SessionListResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Only teachers and admins may create sessions. Returns session_key for agent auth.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sessions"
],
"summary": "Create a proctoring session",
"parameters": [
{
"description": "Session title",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handler.CreateSessionRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/handler.CreateSessionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions/{id}": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Returns session info, stream metadata, and event counts.",
"produces": [
"application/json"
],
"tags": [
"sessions"
],
"summary": "Get session details",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.SessionDetailResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions/{id}/events": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Downloads encrypted .bin chunks from S3, decrypts, and returns parsed events with optional time range filtering.",
"produces": [
"application/json"
],
"tags": [
"telemetry"
],
"summary": "Get parsed telemetry events",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Start timestamp (Unix ms)",
"name": "from",
"in": "query"
},
{
"type": "integer",
"description": "End timestamp (Unix ms)",
"name": "to",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.ParsedEventsResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions/{id}/fingerprint/full": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"fingerprint"
],
"summary": "Get fingerprint full snapshot",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions/{id}/fingerprint/heartbeats": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"fingerprint"
],
"summary": "Get fingerprint heartbeats",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Start timestamp (Unix ms)",
"name": "from",
"in": "query"
},
{
"type": "integer",
"description": "End timestamp (Unix ms)",
"name": "to",
"in": "query"
},
{
"type": "integer",
"description": "Max entries to return",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/sessions/{id}/fingerprint/summary": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"fingerprint"
],
"summary": "Get fingerprint summary",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/sessions/{id}/playlist/{stream_type}.m3u8": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Returns M3U8 playlist for a session's video stream.",
"produces": [
"application/vnd.apple.mpegurl"
],
"tags": [
"streaming"
],
"summary": "Get HLS playlist",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
},
{
"enum": [
"screen",
"webcam"
],
"type": "string",
"description": "Stream type",
"name": "stream_type",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions/{id}/segment/{stream_type}/{chunk_idx}.ts": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Proxies a .ts video segment from S3.",
"produces": [
"video/mp2t"
],
"tags": [
"streaming"
],
"summary": "Get video segment",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
},
{
"enum": [
"screen",
"webcam"
],
"type": "string",
"description": "Stream type",
"name": "stream_type",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Chunk index",
"name": "chunk_idx",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/sessions/{id}/telemetry": {
"get": {
"security": [
{
"ForwardAuth": []
}
],
"description": "Returns all telemetry events for a session.",
"produces": [
"application/json"
],
"tags": [
"telemetry"
],
"summary": "List telemetry events",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.TelemetryEvent"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
},
"/upload": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Upload .ts (video) or .bin (event) file for a session. Max 64 MB.",
"consumes": [
"multipart/form-data"
],
"tags": [
"agent"
],
"summary": "Upload a video or event chunk",
"parameters": [
{
"type": "integer",
"description": "Session ID",
"name": "session_id",
"in": "formData",
"required": true
},
{
"type": "integer",
"description": "Chunk index",
"name": "chunk_idx",
"in": "formData",
"required": true
},
{
"enum": [
"screen",
"webcam"
],
"type": "string",
"description": "Stream type (default: screen)",
"name": "stream_type",
"in": "formData"
},
{
"type": "file",
"description": "Chunk file (.ts or .bin)",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handler.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handler.CreateSessionRequest": {
"type": "object",
"properties": {
"title": {
"type": "string",
"example": "Экзамен по ОС"
}
}
},
"handler.CreateSessionResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"example": "2026-04-05T12:00:00Z"
},
"id": {
"type": "integer",
"example": 1
},
"session_key": {
"type": "string",
"example": "1"
},
"status": {
"type": "string",
"example": "pending"
},
"title": {
"type": "string",
"example": "Экзамен по ОС"
}
}
},
"handler.ErrorResponse": {
"type": "object",
"properties": {
"code": {
"type": "string",
"example": "NOT_FOUND"
},
"error": {
"type": "string",
"example": "session not found"
}
}
},
"handler.MeResponse": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "teacher@example.com"
},
"id": {
"type": "integer",
"example": 1
},
"role": {
"type": "string",
"example": "teacher"
}
}
},
"handler.ParsedEvent": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "integer"
}
},
"event_type": {
"type": "string",
"example": "keyboard"
},
"timestamp": {
"type": "integer",
"example": 1711360200000
}
}
},
"handler.ParsedEventsResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"example": 42
},
"events": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.ParsedEvent"
}
},
"session_id": {
"type": "integer",
"example": 1
}
}
},
"handler.SessionDetailResponse": {
"type": "object",
"properties": {
"session": {
"$ref": "#/definitions/handler.SessionSummary"
},
"streams": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.StreamInfo"
}
}
}
},
"handler.SessionListResponse": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"example": 50
},
"offset": {
"type": "integer",
"example": 0
},
"sessions": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.SessionSummary"
}
},
"total": {
"type": "integer",
"example": 5
}
}
},
"handler.SessionSummary": {
"type": "object",
"properties": {
"chunks_total": {
"type": "integer",
"example": 42
},
"ended_at": {
"type": "string"
},
"events_total": {
"type": "integer",
"example": 128
},
"id": {
"type": "string",
"example": "1"
},
"started_at": {
"type": "string",
"example": "2026-04-05T12:00:00Z"
},
"status": {
"type": "string",
"example": "pending"
},
"user_id": {
"type": "string",
"example": "1"
}
}
},
"handler.StreamInfo": {
"type": "object",
"properties": {
"chunk_count": {
"type": "integer",
"example": 10
},
"duration_ms": {
"type": "integer",
"example": 60000
},
"playlist_url": {
"type": "string",
"example": "/api/v1/sessions/1/playlist/screen.m3u8"
},
"stream_type": {
"type": "string",
"example": "screen"
}
}
},
"handler.TelemetryEvent": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"example": "2026-04-05T12:00:00Z"
},
"event_type": {
"type": "string",
"example": "keyboard"
},
"id": {
"type": "integer",
"example": 1
},
"payload": {
"type": "array",
"items": {
"type": "integer"
}
},
"session_id": {
"type": "integer",
"example": 1
},
"user_id": {
"type": "integer",
"example": 42
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "JWT token with \"Bearer \" prefix",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"ForwardAuth": {
"description": "Authelia Forward Auth — set automatically by Traefik",
"type": "apiKey",
"name": "Remote-User",
"in": "header"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -11,6 +11,7 @@ const defaults = {
SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru',
SG_API_BASE_PATH: '/api/v1',
SG_INTERACTIVE_PREROLL_MS: '4000',
SG_DEFAULT_PAGE_LIMIT: '10',
};
function val(key) {
@@ -35,6 +36,7 @@ export const environment = {
apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))},
apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))},
interactivePrerollMs: ${JSON.stringify(intVal('SG_INTERACTIVE_PREROLL_MS'))},
defaultPageLimit: ${JSON.stringify(intVal('SG_DEFAULT_PAGE_LIMIT'))},
} as const;
`;

View File

@@ -57,3 +57,22 @@
.shell-main {
flex: 1;
}
.shell-nav {
display: flex;
gap: 1.5rem;
margin-left: 1.5rem;
align-items: center;
}
.shell-nav-link {
text-decoration: none;
font-weight: 400;
transition: opacity 0.2s;
cursor: pointer;
color: var(--sg-color-subtitle);
}
.shell-nav-link:hover {
opacity: 0.7;
}

View File

@@ -6,7 +6,10 @@
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
GUARD
</a>
<span class="shell-sub">Прокторинг</span>
<div class="shell-nav">
<a routerLink="/" class="shell-sub shell-nav-link">Главная</a>
<a routerLink="/sessions" class="shell-sub shell-nav-link">Прокторинг</a>
</div>
</div>
</header>
<main class="shell-main">

View File

@@ -3,6 +3,11 @@ import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./features/landing/landing.component').then((m) => m.LandingComponent),
},
{
path: 'sessions',
loadComponent: () =>
import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent),
},

View File

@@ -1,11 +1,11 @@
import { TuiRoot } from '@taiga-ui/core';
import { Component, isDevMode } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { RouterLink, RouterOutlet, RouterLinkActive } from '@angular/router';
import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
imports: [RouterLink, RouterLinkActive, RouterOutlet, TuiRoot, DevConsoleComponent],
templateUrl: './app.html',
styleUrl: './app.css',
})

View File

@@ -0,0 +1,11 @@
import { InjectionToken } from '@angular/core';
import { environment } from '../../../environments/environment';
/**
* Количество элементов на одной странице по умолчанию (например, при пагинации списков).
* Берется из переменной окружения SG_DEFAULT_PAGE_LIMIT (fallback: 10).
*/
export const DEFAULT_PAGE_LIMIT = new InjectionToken<number>('DEFAULT_PAGE_LIMIT', {
factory: () => environment.defaultPageLimit,
});

View File

@@ -0,0 +1,115 @@
import { unwrapJsonPayload } from '../../shared/utils/json.util';
import {
parseKeyboardHighlightKeyIds,
parseKeyboardVirtualKey,
parseKeyboardVkScheme,
} from './keyboard-payload.util';
import { svgKeyboardKeyIdToUsUnshiftedChar, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
const MODIFIER_SVG_IDS = new Set<string>([
'K_kb5a',
'K_kb5m',
'K_kb6a',
'K_kb6n',
'K_kb6c',
'K_kb6k',
'K_kb6b',
'K_kb6l',
'K_kb4a',
'K_kb6m',
]);
function sliceLastCodepoint(s: string): string {
if (!s) {
return s;
}
const chars = [...s];
chars.pop();
return chars.join('');
}
/**
* Явный символ из payload агента (если есть).
*/
function parseExplicitTranscriptChar(data: unknown): string | null {
const raw = unwrapJsonPayload(data);
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
return null;
}
const o = raw as Record<string, unknown>;
const keys = ['char', 'character', 'unicode_char', 'UnicodeChar', 'key_char'] as const;
for (const k of keys) {
const v = o[k];
if (typeof v === 'string' && v.length === 1) {
return v;
}
}
const text = o['text'];
if (typeof text === 'string' && text.length === 1) {
return text;
}
return null;
}
function isNonPrintingKeyId(id: string): boolean {
return MODIFIER_SVG_IDS.has(id) || id.startsWith('K_kb7');
}
function applyKeyIdToBuffer(buffer: string, id: string, data: unknown): string {
if (isNonPrintingKeyId(id)) {
return buffer;
}
if (id === 'K_kb2n') {
return sliceLastCodepoint(buffer);
}
const fromId = svgKeyboardKeyIdToUsUnshiftedChar(id);
if (fromId) {
return buffer + fromId;
}
const raw = unwrapJsonPayload(data);
const vk = parseKeyboardVirtualKey(raw);
const scheme = parseKeyboardVkScheme(raw);
if (vk != null) {
const idVk = vkToKeyboardSvgKeyId(vk, scheme);
if (idVk === 'K_kb2n') {
return sliceLastCodepoint(buffer);
}
if (idVk && !isNonPrintingKeyId(idVk)) {
const ch = svgKeyboardKeyIdToUsUnshiftedChar(idVk);
if (ch) {
return buffer + ch;
}
}
}
return buffer;
}
/**
* Одно событие нажатия → обновление буфера «набранного текста».
* Учитывает Tab (`\t`), Enter (`\n`), пробел, Backspace; буквы/цифры — US QWERTY без Shift
* или явный символ из payload.
*/
export function applyKeyboardPressToTranscriptBuffer(buffer: string, data: unknown): string {
const explicit = parseExplicitTranscriptChar(data);
if (explicit !== null) {
return buffer + explicit;
}
const ids = parseKeyboardHighlightKeyIds(data);
const nonMod = ids.filter((id) => !isNonPrintingKeyId(id));
if (nonMod.length === 0) {
return buffer;
}
if (nonMod.length === 1) {
return applyKeyIdToBuffer(buffer, nonMod[0]!, data);
}
const raw = unwrapJsonPayload(data);
const vk = parseKeyboardVirtualKey(raw);
const scheme = parseKeyboardVkScheme(raw);
if (vk != null) {
const idVk = vkToKeyboardSvgKeyId(vk, scheme);
if (idVk && !isNonPrintingKeyId(idVk)) {
return applyKeyIdToBuffer(buffer, idVk, data);
}
}
return buffer;
}

View File

@@ -194,3 +194,35 @@ export function charKeyNameToSvgKeyId(name: string): string | null {
}
return null;
}
/** US QWERTY без Shift по физическому id клавиши (эвристика для восстановления текста). */
const SVG_ID_TO_US_UNSHIFTED_CHAR: Record<string, string> = (() => {
const m: Record<string, string> = {
K_kb3a: '\t',
K_kb4n: '\n',
K_kb6d: ' ',
K_kb2a: '`',
K_kb2l: '-',
K_kb2m: '=',
K_kb3l: '[',
K_kb3m: ']',
K_kb3n: '\\',
K_kb4l: ';',
K_kb4m: "'",
K_kb5j: ',',
K_kb5k: '.',
K_kb5l: '/',
};
for (const [letter, id] of Object.entries(LETTER_TO_ID)) {
m[id] = letter.toLowerCase();
}
DIGIT_TO_ID.forEach((id, code) => {
m[id] = String.fromCharCode(code);
});
return m;
})();
export function svgKeyboardKeyIdToUsUnshiftedChar(id: string): string | null {
const c = SVG_ID_TO_US_UNSHIFTED_CHAR[id];
return c ?? null;
}

View File

@@ -69,3 +69,34 @@ export interface ParsedEventsResponse {
}
export type StreamType = 'screen' | 'webcam';
export interface FingerprintHeartbeatPayload {
machine_id_hash: string;
cpu_model_hash?: string;
board_serial_hash?: string;
board_uuid_hash?: string;
primary_mac_hash?: string;
disk_serial_hash?: string;
boot_time_ms?: number;
uptime_ms?: number;
agent_pid?: number;
agent_uptime_ms?: number;
hostname: string;
username: string;
tz_offset_min?: number;
locale?: string;
screen_layout: string;
active_iface: string;
hypervisor_present: boolean;
}
export interface FingerprintHeartbeat {
timestamp_ms: number;
payload: FingerprintHeartbeatPayload;
}
export interface FingerprintHeartbeatsResponse {
count: number;
session_id: number;
heartbeats: FingerprintHeartbeat[];
}

View File

@@ -3,9 +3,11 @@ import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { API_ORIGIN } from '../config/api.tokens';
import { DEFAULT_PAGE_LIMIT } from '../config/app.tokens';
import type {
CreateSessionRequest,
CreateSessionResponse,
FingerprintHeartbeatsResponse,
ParsedEventsResponse,
SessionDetailResponse,
SessionListResponse,
@@ -17,8 +19,9 @@ import type {
export class SessionsApiService {
private readonly http = inject(HttpClient);
private readonly apiOrigin = inject(API_ORIGIN);
private readonly defaultLimit = inject(DEFAULT_PAGE_LIMIT);
listSessions(limit = 50, offset = 0): Observable<SessionListResponse> {
listSessions(limit = this.defaultLimit, offset = 0): Observable<SessionListResponse> {
const params = new HttpParams()
.set('limit', String(limit))
.set('offset', String(offset));
@@ -48,6 +51,23 @@ export class SessionsApiService {
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
}
getFingerprintFull(sessionId: number): Observable<unknown> {
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/full`);
}
getFingerprintSummary(sessionId: number): Observable<unknown> {
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/summary`);
}
getFingerprintHeartbeats(sessionId: number, from?: number, to?: number, limit?: number): Observable<FingerprintHeartbeatsResponse> {
let params = new HttpParams();
if (typeof from === 'number') params = params.set('from', String(from));
if (typeof to === 'number') params = params.set('to', String(to));
if (typeof limit === 'number') params = params.set('limit', String(limit));
return this.http.get<FingerprintHeartbeatsResponse>(`/sessions/${sessionId}/fingerprint/heartbeats`, { params });
}
resolvePlaylistUrl(playlistUrl: string): string {
if (/^https?:\/\//i.test(playlistUrl)) {
return playlistUrl;

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TuiButton, TuiIcon } from '@taiga-ui/core';
import { TuiAccordion } from '@taiga-ui/kit';
@Component({
selector: 'app-landing',
standalone: true,
imports: [RouterLink, TuiButton, TuiIcon, TuiAccordion],
templateUrl: './landing.html',
styleUrl: './landing.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LandingComponent {}

View File

@@ -0,0 +1,372 @@
.landing-page {
padding-block: 4rem;
display: flex;
flex-direction: column;
gap: 8rem;
}
.landing-page__title {
font: var(--tui-typography-heading-h2);
margin: 0 0 3.5rem;
text-align: center;
color: var(--sg-color-text);
}
/* ================= HERO SECTION ================= */
.hero--vertical {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3rem;
animation: fadeUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
padding-top: 2rem;
}
.hero__content {
flex: 1;
max-width: 560px;
display: flex;
flex-direction: column;
}
.hero__title {
font: var(--tui-typography-heading-h1);
color: var(--sg-color-text);
margin: 0 0 1.5rem;
letter-spacing: -0.02em;
}
.hero__description {
font: var(--tui-typography-body-l);
color: var(--tui-text-tertiary);
margin: 0 0 2.5rem;
}
.hero__btn {
background: var(--sg-color-accent) !important;
color: var(--sg-color-text) !important;
border-color: var(--sg-color-accent) !important;
font-weight: 400;
padding: 0 2.5rem;
align-self: flex-start;
}
.hero__visual {
flex: 1;
max-width: 500px;
display: flex;
justify-content: center;
align-items: center;
}
.hero__image {
width: 100%;
height: auto;
object-fit: contain;
}
/* ================= FEATURES SECTION ================= */
.features {
display: flex;
flex-direction: column;
}
.features__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.feature-card {
background: var(--sg-color-form-bg, #e8edf1);
border-radius: 2rem;
padding: 3rem 2.5rem;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.4s ease;
border: none;
cursor: default;
}
.feature-card:hover {
transform: translateY(-4px);
background: var(--sg-filter-chip-bg-hover, #eaeff3);
}
.feature-card__icon {
width: 3.5rem;
height: 3.5rem;
color: var(--sg-color-placeholder);
margin-bottom: 2rem;
display: block;
}
.feature-card__icon svg {
width: 100%;
height: 100%;
}
.feature-card__title {
font: var(--tui-typography-heading-h4);
margin: 0 0 1rem;
color: var(--sg-color-text);
line-height: 1.2;
}
.feature-card__text {
font: var(--tui-typography-body-m);
font-weight: 400;
color: var(--tui-text-tertiary);
margin: 0;
}
/* ================= TABS SHOWCASE SECTION ================= */
.tabs-showcase {
display: flex;
flex-direction: column;
}
.tabs-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.tab-card {
background: var(--tui-background-base);
border-radius: 2rem;
padding: 2.5rem 2rem;
border: 1px solid var(--tui-border-normal);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease;
}
.tab-card:hover {
transform: translateY(-3px);
}
.tab-card__number {
font-size: 2.5rem;
font-weight: 700;
color: var(--sg-color-text);
line-height: 1;
margin-bottom: 1rem;
letter-spacing: -0.02em;
}
.tab-card__title {
font: var(--tui-typography-heading-h4);
margin: 0 0 0.75rem;
color: var(--sg-color-text);
}
.tab-card__text {
font: var(--tui-typography-body-m);
font-weight: 400;
color: var(--tui-text-tertiary);
margin: 0;
}
/* ================= STATS CARDS SECTION ================= */
.stats-cards {
display: flex;
flex-direction: column;
}
.stats-cards__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
.stats-card-item {
background: var(--sg-color-form-bg, #e8edf1);
border-radius: 2rem;
padding: 2.5rem 2rem;
text-align: center;
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.stats-card-item:hover {
transform: translateY(-3px);
}
.stats-card-item__number {
font-size: 3.5rem;
font-weight: 700;
color: var(--sg-color-text);
line-height: 1;
margin-bottom: 1rem;
letter-spacing: -0.02em;
}
.stats-card-item__label {
font: var(--tui-typography-body-m);
font-weight: 400;
color: var(--tui-text-tertiary);
}
/* ================= HOW IT WORKS SECTION ================= */
.how-it-works {
display: flex;
flex-direction: column;
align-items: center;
}
.how-it-works__steps {
display: flex;
flex-direction: column;
gap: 3rem;
max-width: 800px;
width: 100%;
}
.step {
display: flex;
gap: 2rem;
align-items: flex-start;
padding: 2rem;
border-radius: 2rem;
transition: background-color 0.3s ease;
}
.step:hover {
background: var(--sg-color-form-bg);
}
.step__number {
font: var(--tui-typography-heading-h3);
background: var(--sg-color-accent);
color: var(--sg-color-text);
width: 4rem;
height: 4rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: none;
}
.step__content {
padding-top: 0.5rem;
}
.step__title {
font: var(--tui-typography-heading-h4);
margin: 0 0 0.5rem;
color: var(--sg-color-text);
}
.step__text {
font: var(--tui-typography-body-m);
color: var(--tui-text-tertiary);
margin: 0;
}
/* ================= FAQ SECTION ================= */
.faq {
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
}
.faq__list {
width: 100%;
max-width: 800px;
background: var(--tui-background-base);
border-radius: 1.5rem;
padding: 1rem 2rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* Remove all Taiga UI internal borders from accordion */
:host ::ng-deep .faq__list [tuiAccordion] {
box-shadow: none !important;
border: none !important;
}
:host ::ng-deep .faq__list tui-expand {
box-shadow: none !important;
border: none !important;
}
.faq__button {
font: var(--tui-typography-body-l);
font-weight: 500;
color: var(--sg-color-text);
border: none;
padding: 1.5rem 0;
background: transparent;
width: 100%;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
}
.faq__button + .faq__button {
border-top: 1px solid var(--tui-border-normal);
}
.faq__content {
font: var(--tui-typography-body-m);
color: var(--tui-text-tertiary);
padding-top: 0.5rem;
padding-bottom: 1rem;
margin: 0;
}
/* ================= FOOTER ================= */
.footer {
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--tui-border-normal);
}
.footer__text {
font: var(--tui-typography-body-s);
color: var(--sg-color-placeholder);
}
/* ================= ANIMATIONS ================= */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ================= RESPONSIVE ================= */
@media (max-width: 900px) {
.hero--vertical {
flex-direction: column;
text-align: center;
padding-top: 2rem;
}
.hero__content {
align-items: center;
}
.hero__btn {
align-self: center;
}
.hero__title {
margin-top: 0;
}
.tabs-grid {
grid-template-columns: 1fr;
}
.stats-cards__grid {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -0,0 +1,217 @@
<div class="landing-page sg-content-column">
<!-- Hero Section -->
<section class="hero hero--vertical">
<div class="hero__content">
<h1 class="hero__title">Умный прокторинг от SparkGuardian</h1>
<p class="hero__description">
Панель ревьюера для поведенческого анализа экзаменационных сессий. Синхронный просмотр видеозаписи, телеметрии клавиатуры и мыши, аппаратный фингерпринт — в одном окне.
</p>
<div class="hero__actions">
<button
tuiButton
type="button"
size="l"
appearance="accent"
routerLink="/sessions"
class="hero__btn"
>
Перейти к сессиям
</button>
</div>
</div>
<div class="hero__visual">
<img src="/images/t-bank-hero-final.png" alt="SparkGuardian Security" class="hero__image" />
</div>
</section>
<!-- Features Section -->
<section class="features">
<h2 class="landing-page__title">Ключевые инструменты</h2>
<div class="features__grid">
<!-- Card 1: Interactive Mode -->
<div class="feature-card">
<div class="feature-card__icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M8 21H16M12 17V21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M7 8L9 10L7 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<h3 class="feature-card__title">Интерактивный режим</h3>
<p class="feature-card__text">
Синхронный просмотр HLS-видеопотока одновременно с визуализацией нажатий клавиатуры, позиции мыши и восстановленного набранного текста. Единый таймлайн с перемоткой ±5/±10 секунд.
</p>
</div>
<!-- Card 2: Hardware Fingerprint -->
<div class="feature-card">
<div class="feature-card__icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M9 1V4M15 1V4M9 20V23M15 20V23M1 9H4M1 15H4M20 9H23M20 15H23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<rect x="9" y="9" width="6" height="6" rx="1" stroke="currentColor" stroke-width="2"/>
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" fill-opacity="0.1"/>
</svg>
</div>
<h3 class="feature-card__title">Аппаратный фингерпринт</h3>
<p class="feature-card__text">
Периодические Heartbeat-снимки оборудования: хеш CPU, материнской платы, дисков, MAC-адресов. Автоматическое выявление смены экранов, сетевого адаптера или появления гипервизора.
</p>
</div>
<!-- Card 3: Heatmaps -->
<div class="feature-card">
<div class="feature-card__icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3V21H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 9L14 14L10 10L3 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 9H19V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 9L14 14L10 10L3 17V21H21V9H19Z" fill="currentColor" fill-opacity="0.1"/>
</svg>
</div>
<h3 class="feature-card__title">Тепловая карта активности</h3>
<p class="feature-card__text">
Визуализация плотности событий телеметрии вдоль таймлайна сессии. Мгновенный переход к подозрительным моментам одним кликом по тепловой карте.
</p>
</div>
</div>
</section>
<!-- Session Tabs Section (NEW) -->
<section class="tabs-showcase">
<h2 class="landing-page__title">4 режима анализа в каждой сессии</h2>
<div class="tabs-grid">
<div class="tab-card">
<div class="tab-card__number">01</div>
<h4 class="tab-card__title">Просмотр</h4>
<p class="tab-card__text">HLS-плеер с переключением потоков (screen / webcam), полная лента телеметрии с фильтрацией по типам событий.</p>
</div>
<div class="tab-card">
<div class="tab-card__number">02</div>
<h4 class="tab-card__title">Интерактивный режим</h4>
<p class="tab-card__text">Синхронное воспроизведение видео, клавиатуры, мыши и набранного текста. Тепловая карта событий с курсором позиции.</p>
</div>
<div class="tab-card">
<div class="tab-card__number">03</div>
<h4 class="tab-card__title">Отпечаток системы</h4>
<p class="tab-card__text">Текущая конфигурация устройства и журнал подозрительных изменений: смена экрана, сети, юзера или обнаружение ВМ.</p>
</div>
<div class="tab-card">
<div class="tab-card__number">04</div>
<h4 class="tab-card__title">Служебная информация</h4>
<p class="tab-card__text">Сырые данные сессии, метаинформация о потоках и подробная статистика по телеметрии для разработчиков.</p>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="stats-cards">
<div class="stats-cards__grid">
<div class="stats-card-item">
<div class="stats-card-item__number">17</div>
<div class="stats-card-item__label">параметров оборудования в каждом Heartbeat-снимке</div>
</div>
<div class="stats-card-item">
<div class="stats-card-item__number">5</div>
<div class="stats-card-item__label">полей автоматического мониторинга аномалий</div>
</div>
<div class="stats-card-item">
<div class="stats-card-item__number">4</div>
<div class="stats-card-item__label">режима анализа в каждой сессии</div>
</div>
<div class="stats-card-item">
<div class="stats-card-item__number">2</div>
<div class="stats-card-item__label">видеопотока — экран и камера одновременно</div>
</div>
</div>
</section>
<!-- How It Works Section -->
<section class="how-it-works">
<h2 class="landing-page__title">Как это работает</h2>
<div class="how-it-works__steps">
<div class="step">
<div class="step__number">1</div>
<div class="step__content">
<h4 class="step__title">Создание сессии</h4>
<p class="step__text">Ревьюер создаёт сессию в панели. Агент на устройстве студента получает session_key и начинает сбор телеметрии и запись видеопотоков.</p>
</div>
</div>
<div class="step">
<div class="step__number">2</div>
<div class="step__content">
<h4 class="step__title">Потоковая загрузка данных</h4>
<p class="step__text">Видеочанки загружаются через API с bearer-авторизацией. Параллельно поступают события клавиатуры, мыши и периодические Heartbeat-фингерпринты.</p>
</div>
</div>
<div class="step">
<div class="step__number">3</div>
<div class="step__content">
<h4 class="step__title">Анализ ревьюером</h4>
<p class="step__text">Ревьюер открывает сессию в интерактивном режиме, видит видеозапись синхронно с клавиатурой и мышью, и проверяет журнал аномалий фингерпринта.</p>
</div>
</div>
</div>
</section>
<!-- FAQ Section -->
<section class="faq">
<h2 class="landing-page__title">Отвечаем на вопросы</h2>
<tui-accordion class="faq__list">
<button tuiAccordion class="faq__button">Какие данные собирает агент?</button>
<tui-expand>
<p class="faq__content">
Агент записывает видеопотоки (screen и webcam), события клавиатуры (нажатия/отпускания), координаты мыши, и периодически отправляет Heartbeat — снимок аппаратной конфигурации (хеши CPU, дисков, MAC, конфигурация экранов, наличие гипервизора).
</p>
</tui-expand>
<button tuiAccordion class="faq__button">Что такое журнал аномалий?</button>
<tui-expand>
<p class="faq__content">
Система автоматически сравнивает последовательные Heartbeat-снимки и фиксирует все изменения: смену конфигурации экранов, имени пользователя, имени хоста, сетевого адаптера или появление гипервизора. Все изменения отображаются в хронологическом журнале.
</p>
</tui-expand>
<button tuiAccordion class="faq__button">Обнаруживает ли система виртуальные машины?</button>
<tui-expand>
<p class="faq__content">
Да. Каждый Heartbeat содержит поле hypervisor_present. Если агент обнаруживает среду виртуализации, в панели ревьюера это отображается как критический флаг с предупреждением.
</p>
</tui-expand>
<button tuiAccordion class="faq__button">Как работает интерактивный режим?</button>
<tui-expand>
<p class="faq__content">
HLS-видео воспроизводится параллельно с визуализацией: виртуальная клавиатура подсвечивает нажатые клавиши, блок мыши показывает позицию курсора, текстовое поле восстанавливает набранный текст из потока событий. Всё синхронизировано через единый таймлайн.
</p>
</tui-expand>
<button tuiAccordion class="faq__button">Какие типы видеопотоков поддерживаются?</button>
<tui-expand>
<p class="faq__content">
На данный момент поддерживаются два типа HLS-потоков: screen (запись экрана) и webcam (запись с камеры). Переключение между ними происходит через селектор потоков в интерфейсе сессии.
</p>
</tui-expand>
<button tuiAccordion class="faq__button">Можно ли фильтровать события телеметрии?</button>
<tui-expand>
<p class="faq__content">
Да. В режиме «Просмотр» доступна фильтрация по типу событий (клавиатура, мышь и др.) с отображением количества событий каждого типа. Также можно скрыть события mouse_move для удобства чтения ленты.
</p>
</tui-expand>
</tui-accordion>
</section>
<footer class="footer">
<p class="footer__text">© 2026 SparkGuardian. Панель ревьюера для прокторинга экзаменационных сессий.</p>
</footer>
</div>

View File

@@ -49,7 +49,9 @@
cursor: default;
user-select: none;
transition: background 60ms ease;
transition:
background 0.24s cubic-bezier(0.33, 1, 0.68, 1),
box-shadow 0.24s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Space bar: fills leftover width in modifier row */
@@ -58,7 +60,7 @@
width: auto;
}
/* Pressed state */
/* Pressed state — плавный переход с «серого» idle-surface на акцент */
.key--active {
background: var(--sg-keyboard-key-pressed-fill);
box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--sg-keyboard-key-pressed-fill) 55%, black);
@@ -81,6 +83,7 @@
font-family: var(--sg-keyboard-font-family);
font-weight: var(--sg-keyboard-font-weight);
min-width: 0;
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Russian glyph — top-right */
@@ -92,6 +95,7 @@
font-weight: var(--sg-keyboard-font-weight);
text-align: right;
min-width: 0;
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Main English label — bottom-left */
@@ -102,6 +106,7 @@
font-family: var(--sg-keyboard-font-family);
font-weight: 500;
letter-spacing: var(--sg-keyboard-letter-spacing);
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Label for special keys (Tab, Caps, Shift, ⌘ …) — centered */
@@ -115,6 +120,7 @@
margin: auto;
text-align: center;
width: 100%;
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Active key — invert text colors */

View File

@@ -1,9 +1,17 @@
:host {
display: inline-block;
display: flex;
flex: 0 0 auto;
align-self: stretch;
min-height: 0;
min-width: 0;
}
.mouse {
display: flex;
flex-direction: column;
flex: 1;
width: 120px;
min-height: 0;
border-radius: 26px 26px 18px 18px;
background: var(--sg-keyboard-key-surface-idle);
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
@@ -14,6 +22,7 @@
/* ── Buttons area ──────────────────────────────────────────────────────── */
.mouse__buttons {
flex-shrink: 0;
height: 88px;
display: grid;
grid-template-columns: 1fr 10px 1fr;

View File

@@ -3,8 +3,6 @@ import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { TuiCheckbox } from '@taiga-ui/core/components/checkbox';
import { TuiLink } from '@taiga-ui/core/components/link';
import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiTitle } from '@taiga-ui/core/components/title';
@@ -29,14 +27,13 @@ import { SessionsApiService } from '../../../core/services/sessions-api.service'
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';
import { SessionFingerprintTabComponent } from './session-fingerprint-tab/session-fingerprint-tab.component';
@Component({
selector: 'app-session-detail',
imports: [
AsyncPipe,
FormsModule,
RouterLink,
TuiCheckbox,
TuiLink,
TuiLoader,
TuiTitle,
@@ -44,6 +41,7 @@ import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.com
SessionViewTabComponent,
SessionInteractiveTabComponent,
SessionInfoTabComponent,
SessionFingerprintTabComponent,
],
templateUrl: './session-detail.html',
styleUrl: './session-detail.css',
@@ -58,7 +56,7 @@ export class SessionDetailComponent {
protected readonly telemetryToMs = signal<number | null>(null);
protected readonly recordingStartMs = signal<number | null>(null);
protected readonly recordingEndMs = signal<number | null>(null);
protected readonly excludeMouseMoves = model(false);
protected readonly excludeMouseMoves = model(true);
protected readonly activeTabIndex = model(0);
private readonly sessionId$ = this.route.paramMap.pipe(

View File

@@ -6,26 +6,6 @@
margin-bottom: 1.25rem;
}
.session-telemetry-filter-bar {
margin: 0 0 1rem;
}
.session-telemetry-filter-bar__label {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0;
max-width: 100%;
cursor: pointer;
user-select: none;
}
.session-telemetry-filter-bar__text {
font: var(--tui-font-text-s);
color: var(--tui-text-primary);
}
/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
.session-tabs {
display: flex;

View File

@@ -1,6 +1,6 @@
<div class="page sg-content-column">
<nav class="back">
<a tuiLink routerLink="/">К списку сессий</a>
<a tuiLink routerLink="/sessions">К списку сессий</a>
</nav>
@if (vm$ | async; as state) {
@@ -19,24 +19,11 @@
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
<button tuiTab type="button">Просмотр</button>
<button tuiTab type="button">Интерактивный режим</button>
<button tuiTab type="button">Отпечаток системы</button>
<button tuiTab type="button">Служебная информация</button>
</tui-tabs>
@if (telemetry$ | async; as telemetryState) {
@if (activeTabIndex() === 0 || activeTabIndex() === 1) {
<div class="session-telemetry-filter-bar">
<label class="session-telemetry-filter-bar__label">
<input
type="checkbox"
tuiCheckbox
size="s"
[ngModel]="excludeMouseMoves()"
(ngModelChange)="excludeMouseMoves.set($event)"
/>
<span class="session-telemetry-filter-bar__text">Исключить перемещения мыши</span>
</label>
</div>
}
@switch (activeTabIndex()) {
@case (0) {
<app-session-view-tab
@@ -44,7 +31,7 @@
[telemetryState]="telemetryState"
[recordingStartMs]="recordingStartMs()"
[recordingEndMs]="recordingEndMs()"
[excludeMouseMoves]="excludeMouseMoves()"
[(excludeMouseMoves)]="excludeMouseMoves"
(telemetryToMsChange)="telemetryToMs.set($event)"
/>
}
@@ -58,6 +45,9 @@
/>
}
@case (2) {
<app-session-fingerprint-tab [sessionId]="state.id" />
}
@case (3) {
<app-session-info-tab
[detail]="state.detail"
[telemetryState]="telemetryState"

View File

@@ -0,0 +1,107 @@
import { DatePipe } from '@angular/common';
import { AsyncPipe, JsonPipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiScrollbar } from '@taiga-ui/core/components/scrollbar';
import { TuiIcon } from '@taiga-ui/core/components/icon';
import { catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs';
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
import type { FingerprintHeartbeat } from '../../../../core/models/api.types';
export interface Anomaly {
timestamp_ms: number;
field: string;
fieldLabel: string;
oldValue: string | boolean;
newValue: string | boolean;
}
@Component({
selector: 'app-session-fingerprint-tab',
imports: [AsyncPipe, DatePipe, TuiLoader, TuiIcon],
templateUrl: './session-fingerprint-tab.html',
styleUrl: './session-fingerprint-tab.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionFingerprintTabComponent {
private readonly api = inject(SessionsApiService);
private readonly userErrors = inject(UserErrorNotifyService);
readonly sessionId = input.required<number>();
protected readonly data$ = toObservable(this.sessionId).pipe(
switchMap((id) => {
// Параллельно запрашиваем все данные фингерпринта
return combineLatest([
this.api.getFingerprintSummary(id).pipe(catchError(() => of(null))),
this.api.getFingerprintFull(id).pipe(catchError(() => of(null))),
this.api.getFingerprintHeartbeats(id, undefined, undefined, 50).pipe(catchError(() => of(null)))
]).pipe(
map(([summary, full, heartbeats]) => {
const hbs = heartbeats?.heartbeats || [];
const anomalies = this.detectAnomalies(hbs);
const currentConfig = hbs.length > 0 ? hbs[hbs.length - 1].payload : null;
return {
status: 'ok' as const,
summary,
full,
heartbeats: hbs,
anomalies,
currentConfig,
};
}),
catchError((e: HttpErrorResponse) => {
this.userErrors.notifyError(e, 'Фингерпринт');
return of({ status: 'error' as const });
}),
startWith({ status: 'loading' as const })
);
})
);
private detectAnomalies(heartbeats: FingerprintHeartbeat[]): Anomaly[] {
if (!heartbeats || heartbeats.length < 2) {
return [];
}
const anomalies: Anomaly[] = [];
const fieldsToCheck = [
{ key: 'screen_layout', label: 'Конфигурация экранов' },
{ key: 'username', label: 'Пользователь системы' },
{ key: 'hostname', label: 'Имя компьютера' },
{ key: 'active_iface', label: 'Сетевой адаптер' },
{ key: 'hypervisor_present', label: 'Виртуализация / Hypervisor' },
] as const;
for (let i = 1; i < heartbeats.length; i++) {
const prev = heartbeats[i - 1].payload;
const curr = heartbeats[i].payload;
const timestamp = heartbeats[i].timestamp_ms;
for (const field of fieldsToCheck) {
// @ts-ignore (dynamic key access)
const oldValue = prev[field.key];
// @ts-ignore
const newValue = curr[field.key];
if (oldValue !== newValue) {
anomalies.push({
timestamp_ms: timestamp,
field: field.key,
fieldLabel: field.label,
oldValue,
newValue,
});
}
}
}
// Сортируем от новых к старым
return anomalies.sort((a, b) => b.timestamp_ms - a.timestamp_ms);
}
}

View File

@@ -0,0 +1,131 @@
.fingerprint-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-top: 1rem;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-top: 0;
margin-bottom: 1rem;
}
.loading-wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 0;
}
/* Безопасность / Сейф баннер */
.safe-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--tui-positive-bg, rgba(74, 201, 155, 0.1));
color: var(--tui-positive, #259b6f);
border-radius: var(--tui-radius-m);
font-weight: 500;
}
.success-icon {
font-size: 1.2rem;
}
/* Журнал аномалий */
.anomalies-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.anomaly-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--tui-negative-bg, rgba(239, 68, 68, 0.1));
border-left: 4px solid var(--tui-negative, #ef4444);
border-radius: var(--tui-radius-m);
}
.anomaly-time {
font-family: monospace;
font-size: 0.85rem;
color: var(--tui-text-secondary);
padding-top: 0.1rem;
}
.anomaly-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.anomaly-label {
font-weight: 600;
color: var(--tui-text-primary);
}
.cross-out {
text-decoration: line-through;
color: var(--tui-text-secondary);
}
.arrow-icon {
font-size: 1rem;
color: var(--tui-text-secondary);
}
.highlight {
font-weight: bold;
color: var(--tui-negative, #ef4444);
}
/* Текущая конфигурация */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.config-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
border-radius: var(--tui-radius-m);
border: 1px solid var(--tui-border-normal);
background: var(--tui-background-base);
}
.danger-container.danger {
border-color: var(--tui-negative, #ef4444);
color: var(--tui-negative, #ef4444);
}
.danger-container.danger .cfg-label {
color: var(--tui-negative, #ef4444);
}
.cfg-icon {
font-size: 2rem;
color: var(--tui-text-secondary);
flex-shrink: 0;
margin-top: 0.1rem;
}
.danger-container.danger .cfg-icon {
color: var(--tui-negative, #ef4444);
}
.cfg-text {
display: flex;
flex-direction: column;
}
.cfg-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--tui-text-secondary);
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.cfg-value {
font-weight: 500;
color: var(--tui-text-primary);
line-height: 1.4;
}

View File

@@ -0,0 +1,81 @@
@if (data$ | async; as state) {
@switch (state.status) {
@case ('loading') {
<div class="loading-wrap">
<tui-loader [loading]="true" size="xl" />
</div>
}
@case ('error') {
<p class="muted">Не удалось загрузить данные системы.</p>
}
@case ('ok') {
<div class="fingerprint-container">
<!-- Текущая конфигурация -->
<section class="card config-section">
<h3 class="section-title">Текущая конфигурация устройства</h3>
@if (state.currentConfig; as config) {
<div class="config-grid">
<div class="config-item">
<tui-icon icon="@tui.monitor" class="cfg-icon" />
<div class="cfg-text">
<span class="cfg-label">Экраны</span>
<span class="cfg-value">{{ config.screen_layout }}</span>
</div>
</div>
<div class="config-item">
<tui-icon icon="@tui.user" class="cfg-icon" />
<div class="cfg-text">
<span class="cfg-label">Пользователь</span>
<span class="cfg-value">{{ config.username }} <span class="muted">@{{ config.hostname }}</span></span>
</div>
</div>
<div class="config-item">
<tui-icon icon="@tui.globe" class="cfg-icon" />
<div class="cfg-text">
<span class="cfg-label">Сеть</span>
<span class="cfg-value">{{ config.active_iface }}</span>
</div>
</div>
<div class="config-item danger-container" [class.danger]="config.hypervisor_present">
<tui-icon [icon]="config.hypervisor_present ? '@tui.triangle-alert' : '@tui.cpu'" class="cfg-icon" />
<div class="cfg-text">
<span class="cfg-label">Виртуализация</span>
<span class="cfg-value">{{ config.hypervisor_present ? 'ОБНАРУЖЕНА' : 'Нет' }}</span>
</div>
</div>
</div>
} @else {
<p class="muted">Данные телеметрии еще не поступили.</p>
}
</section>
<!-- ВАЖНО: Аномалии -->
<section class="card anomalies-section">
<h3 class="section-title">Журнал подозрительных действий</h3>
@if (state.anomalies.length === 0) {
<div class="safe-banner">
<tui-icon icon="@tui.check" class="success-icon" />
<span>Никаких подозрительных изменений среды не зафиксировано.</span>
</div>
} @else {
<div class="anomalies-list">
@for (anomaly of state.anomalies; track anomaly.timestamp_ms) {
<div class="anomaly-item">
<div class="anomaly-time">{{ anomaly.timestamp_ms | date:'HH:mm:ss' }}</div>
<div class="anomaly-content">
<span class="anomaly-label">{{ anomaly.fieldLabel }}:</span>
<span class="cross-out">{{ anomaly.oldValue }}</span>
<tui-icon icon="@tui.arrow-right" class="arrow-icon"/>
<span class="highlight">{{ anomaly.newValue }}</span>
</div>
</div>
}
</div>
}
</section>
</div>
}
}
}

View File

@@ -1,6 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TuiButton } from '@taiga-ui/core/components/button';
import { TuiTextarea } from '@taiga-ui/kit/components/textarea';
import { applyKeyboardPressToTranscriptBuffer } from '../../../../core/keyboard/keyboard-transcript.util';
import {
isKeyboardTelemetryEvent,
parseKeyboardAction,
@@ -21,19 +24,19 @@ import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.compone
import { MouseViewComponent } from '../../mouse-view/mouse-view.component';
import { environment } from '../../../../../environments/environment';
/**
* Окно видимости нажатия клавиши (мс). Должно быть > интервала timeupdate (~250ms),
* чтобы каждое нажатие попало хотя бы в один кадр курсора.
*/
const KEY_DISPLAY_MS = 400;
const INTERACTIVE_PREROLL_MS = Number.isFinite(environment.interactivePrerollMs)
? Math.max(0, environment.interactivePrerollMs)
: 4000;
/** Минимальная длительность подсветки нажатия (мс); при перекрытии побеждает более позднее нажатие. */
const KEY_HIGHLIGHT_MIN_MS = 500;
@Component({
selector: 'app-session-interactive-tab',
imports: [
FormsModule,
TuiButton,
...TuiTextarea,
HlsPlayerComponent,
StreamSelectorComponent,
KeyboardViewComponent,
@@ -50,13 +53,12 @@ export class SessionInteractiveTabComponent {
readonly telemetryEvents = input.required<ParsedEvent[]>();
readonly recordingStartMs = input.required<number | null>();
readonly recordingEndMs = input.required<number | null>();
readonly excludeMouseMoves = input<boolean>(false);
readonly excludeMouseMoves = input<boolean>(true);
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) {
@@ -111,6 +113,68 @@ export class SessionInteractiveTabComponent {
.sort((a, b) => a.timestamp - b.timestamp),
);
/**
* Только `press`. Группировка по целой секунде unix-времени: floor(ts/1000)*1000.
* Внутри секунды n нажатий в порядке телеметрии получают непересекающиеся интервалы
* [T + i/n·1000, T + (i+1)/n·1000); фактическая подсветка держится минимум KEY_HIGHLIGHT_MIN_MS.
*/
private readonly keyboardPressDisplaySlots = computed(
(): { startMs: number; endMs: number; keyIds: string[]; data: unknown }[] => {
const events = this.sortedKeyboardEvents();
type RawPress = { ts: number; keyIds: string[]; data: unknown };
const raw: RawPress[] = [];
for (const event of events) {
if (parseKeyboardAction(event.data) !== 'press') {
continue;
}
const keyIds = parseKeyboardHighlightKeyIds(event.data);
if (keyIds.length === 0) {
continue;
}
const ts = event.timestamp;
if (!Number.isFinite(ts)) {
continue;
}
raw.push({ ts, keyIds, data: event.data });
}
const out: { startMs: number; endMs: number; keyIds: string[]; data: unknown }[] = [];
let i = 0;
while (i < raw.length) {
const secStartMs = Math.floor(raw[i]!.ts / 1000) * 1000;
let j = i + 1;
while (j < raw.length && Math.floor(raw[j]!.ts / 1000) * 1000 === secStartMs) {
j++;
}
const n = j - i;
for (let k = 0; k < n; k++) {
const startMs = secStartMs + (k / n) * 1000;
const endMs = secStartMs + ((k + 1) / n) * 1000;
out.push({ startMs, endMs, keyIds: raw[i + k]!.keyIds, data: raw[i + k]!.data });
}
i = j;
}
return out;
},
);
/**
* Текст, «набранный» к моменту позиции на timeline (порядок с тем же распределением по секунде).
*/
protected readonly interactiveTypedText = computed((): string => {
const cursorMs = this.cursorMs();
if (cursorMs == null) {
return '';
}
let buffer = '';
for (const slot of this.keyboardPressDisplaySlots()) {
if (slot.startMs > cursorMs) {
break;
}
buffer = applyKeyboardPressToTranscriptBuffer(buffer, slot.data);
}
return buffer;
});
/**
* Pre-sorted mouse events — recomputed only when telemetryEvents changes.
*/
@@ -126,32 +190,26 @@ export class SessionInteractiveTabComponent {
);
/**
* Set of key IDs that were pressed in the window [cursorMs - KEY_DISPLAY_MS, cursorMs].
* Iterates backwards (newest first), deduplicates by keyId.
* Клавиши слота с наибольшим `startMs`, для которого cursorMs попадает в
* [startMs, max(endMs, startMs + KEY_HIGHLIGHT_MIN_MS)).
*/
protected readonly pressedKeyIds = computed((): ReadonlySet<string> => {
const cursorMs = this.cursorMs();
if (cursorMs == null) {
return new Set();
}
const seen = new Set<string>();
const events = this.sortedKeyboardEvents();
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]!;
if (event.timestamp > cursorMs) {
const slots = this.keyboardPressDisplaySlots();
for (let idx = slots.length - 1; idx >= 0; idx--) {
const s = slots[idx]!;
if (s.startMs > cursorMs) {
continue;
}
if (event.timestamp < cursorMs - KEY_DISPLAY_MS) {
break;
}
if (parseKeyboardAction(event.data) !== 'press') {
continue;
}
for (const keyId of parseKeyboardHighlightKeyIds(event.data)) {
seen.add(keyId);
const visibleUntilMs = Math.max(s.endMs, s.startMs + KEY_HIGHLIGHT_MIN_MS);
if (cursorMs < visibleUntilMs) {
return new Set(s.keyIds);
}
}
return seen;
return new Set();
});
protected readonly mouseTargets = computed(() => {
@@ -171,6 +229,53 @@ export class SessionInteractiveTabComponent {
return [] as MouseHighlightTarget[];
});
protected readonly heatmapBuckets = computed(() => {
const BUCKETS_COUNT = 150; // Более точная разбивка (150 баров)
const events = this.telemetryEvents();
if (!events?.length) {
return [];
}
const startMs = this.telemetryAnchorStartMs();
const durationSec = this.timelineMaxSec();
if (startMs == null || durationSec <= 0) {
return [];
}
const buckets = new Array(BUCKETS_COUNT).fill(0);
const bucketDurationMs = (durationSec * 1000) / BUCKETS_COUNT;
let maxCount = 0;
for (const e of events) {
if (!Number.isFinite(e.timestamp)) {
continue;
}
// Относительное время от старта
const offsetMs = e.timestamp - startMs;
if (offsetMs < 0 || offsetMs > durationSec * 1000) {
continue;
}
const bucketIdx = Math.min(Math.floor(offsetMs / bucketDurationMs), BUCKETS_COUNT - 1);
buckets[bucketIdx]++;
if (buckets[bucketIdx] > maxCount) {
maxCount = buckets[bucketIdx];
}
}
return buckets.map((count, idx) => {
// Нормализуем opacity: фоновый цвет почти прозрачен, активные ярче.
const rawOpacity = count > 0 ? Math.max(0.15, count / maxCount) : 0.03;
return {
index: idx,
count,
opacity: rawOpacity,
startSec: (idx * bucketDurationMs) / 1000,
};
});
});
protected activeStreamType(): string | null {
const streams = this.detail().streams;
if (!streams?.length) {

View File

@@ -166,10 +166,20 @@
overflow-x: auto;
}
.input-preview app-keyboard-view {
flex: 0 0 auto;
}
.input-preview app-mouse-view {
flex: 0 0 auto;
}
.mouse-sidebar {
flex: 0 0 auto;
display: flex;
align-items: stretch;
align-self: stretch;
min-height: 0;
}
@media (max-width: 980px) {
@@ -178,3 +188,70 @@
align-items: center;
}
}
.interactive-transcript-wrap {
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 100%;
min-width: 0;
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--tui-border-normal);
}
.interactive-transcript-field {
width: 100%;
}
.heatmap-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.heatmap-title {
font-size: 1rem;
font-weight: 500;
margin: 0;
color: var(--tui-text-primary);
}
.heatmap-container {
display: flex;
height: 48px;
width: 100%;
position: relative;
border-radius: var(--tui-radius-s);
overflow: hidden;
background: var(--tui-background-base-alt, #f5f5f6);
border: 1px solid var(--tui-border-normal);
cursor: pointer;
}
.heatmap-bucket {
flex-grow: 1;
height: 100%;
transition: opacity 0.1s;
}
.heatmap-bucket:hover {
opacity: 0.7;
}
.heatmap-cursor {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--tui-primary, #3872c2);
transform: translateX(-50%);
pointer-events: none;
z-index: 2;
box-shadow: 0 0 4px var(--tui-primary, rgba(56, 114, 194, 0.4));
}
.interactive-transcript-hint {
margin: 0;
max-width: 52rem;
}

View File

@@ -131,7 +131,7 @@
</div>
</section>
<section class="card" aria-label="Интерактивный режим: клавиатура">
<section class="card" aria-label="Интерактивный режим: ввод">
<div class="input-preview">
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
<div class="mouse-sidebar">
@@ -139,3 +139,42 @@
</div>
</div>
</section>
<section class="card heatmap-card" aria-label="Интерактивный режим: тепловая карта">
<h3 class="heatmap-title">Тепловая карта событий телеметрии</h3>
<div class="heatmap-container">
@for (bucket of heatmapBuckets(); track bucket.index) {
<div
class="heatmap-bucket"
[style.background-color]="bucket.count > 0 ? 'color-mix(in srgb, var(--sg-color-accent, #ffdb00) ' + (bucket.opacity * 100) + '%, transparent)' : 'transparent'"
[attr.title]="'Событий: ' + bucket.count"
(click)="setTimelineSec(bucket.startSec.toString())"
></div>
}
@if (timelineMaxSec() > 0) {
<div
class="heatmap-cursor"
[style.left.%]="(timelineSec() / timelineMaxSec()) * 100"
></div>
}
</div>
</section>
<section class="card" aria-label="Интерактивный режим: текст">
<div class="interactive-transcript-wrap">
<tui-textfield class="interactive-transcript-field sg-tui-textfield">
<label tuiLabel for="interactive-transcript">Набранный текст по телеметрии</label>
<textarea
id="interactive-transcript"
tuiTextarea
[min]="4"
[max]="16"
[ngModel]="interactiveTypedText()"
[readOnly]="true"
tabindex="-1"
autocomplete="off"
spellcheck="false"
></textarea>
</tui-textfield>
</div>
</section>

View File

@@ -1,6 +1,8 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, input, model, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TuiCheckbox } from '@taiga-ui/core/components/checkbox';
import { TuiButton } from '@taiga-ui/core/components/button';
import { TuiLoader } from '@taiga-ui/core/components/loader';
import { TuiChip } from '@taiga-ui/kit/components/chip';
@@ -22,7 +24,9 @@ import type { TelemetryLoadState, TelemetryRangeSelection } from '../session-det
selector: 'app-session-view-tab',
imports: [
NgClass,
FormsModule,
TuiButton,
TuiCheckbox,
TuiChip,
TuiLoader,
SessionStatusChipClassesPipe,
@@ -60,7 +64,7 @@ export class SessionViewTabComponent {
readonly telemetryState = input.required<TelemetryLoadState>();
readonly recordingStartMs = input.required<number | null>();
readonly recordingEndMs = input.required<number | null>();
readonly excludeMouseMoves = input<boolean>(false);
readonly excludeMouseMoves = model(true);
readonly telemetryToMsChange = output<number>();
protected readonly selectedStreamType = signal<string | null>(null);

View File

@@ -21,6 +21,30 @@
margin-bottom: 0.75rem;
}
.telemetry-head__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
min-width: 0;
}
.telemetry-exclude-moves {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0;
max-width: 100%;
cursor: pointer;
user-select: none;
}
.telemetry-exclude-moves__text {
font: var(--tui-font-text-s);
color: var(--tui-text-primary);
}
.telemetry-content {
display: flex;
flex-direction: column;

View File

@@ -40,6 +40,7 @@
</h3>
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
</div>
<div class="telemetry-head__side">
<div class="telemetry-actions">
<div class="telemetry-presets">
<button
@@ -113,6 +114,17 @@
/>
</label>
</div>
<label class="telemetry-exclude-moves">
<input
type="checkbox"
tuiCheckbox
size="s"
[ngModel]="excludeMouseMoves()"
(ngModelChange)="excludeMouseMoves.set($event)"
/>
<span class="telemetry-exclude-moves__text">Исключить перемещения мыши</span>
</label>
</div>
</div>
<div class="telemetry-content">

View File

@@ -22,6 +22,7 @@ import { UserErrorNotifyService } from '../../../core/notifications/user-error-n
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe';
import { SessionsApiService } from '../../../core/services/sessions-api.service';
import { DEFAULT_PAGE_LIMIT } from '../../../core/config/app.tokens';
import { formatTimestamp } from '../../../shared/utils/date-time.util';
@Component({
@@ -50,7 +51,7 @@ export class SessionsListComponent {
private readonly router = inject(Router);
private readonly userErrors = inject(UserErrorNotifyService);
protected readonly limit = 10;
protected readonly limit = inject(DEFAULT_PAGE_LIMIT);
protected readonly pageIndex = signal(0);
protected readonly titleControl = new FormControl('', {

View File

@@ -4,4 +4,5 @@ export const environment = {
apiFallbackOrigin: "https://sparkguardian.ru",
apiBasePath: "/api/v1",
interactivePrerollMs: 4000,
defaultPageLimit: 10,
} as const;

View File

@@ -63,6 +63,19 @@ tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-
color: var(--sg-color-textfield-focus-label);
}
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] textarea {
background: transparent;
outline: none;
box-shadow: none;
color: var(--sg-color-text);
}
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within textarea,
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] textarea {
outline: none;
box-shadow: none;
}
/* --- нативные поля (datetime-local, text, …) --- */
.sg-native-input {
box-sizing: border-box;