diff --git a/.env.example b/.env.example
index f1eee80..5560b8b 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/docs/doc_v2.json b/docs/doc_v2.json
new file mode 100644
index 0000000..2b814a4
--- /dev/null
+++ b/docs/doc_v2.json
@@ -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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/images/t-bank-hero-final.png b/public/images/t-bank-hero-final.png
new file mode 100644
index 0000000..356a454
Binary files /dev/null and b/public/images/t-bank-hero-final.png differ
diff --git a/public/images/t-bank-hero-v2.png b/public/images/t-bank-hero-v2.png
new file mode 100644
index 0000000..659d79f
Binary files /dev/null and b/public/images/t-bank-hero-v2.png differ
diff --git a/public/images/t-bank-hero.png b/public/images/t-bank-hero.png
new file mode 100644
index 0000000..abbb5d1
Binary files /dev/null and b/public/images/t-bank-hero.png differ
diff --git a/scripts/sync-env.cjs b/scripts/sync-env.cjs
index 658ae25..02db00f 100644
--- a/scripts/sync-env.cjs
+++ b/scripts/sync-env.cjs
@@ -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;
`;
diff --git a/src/app/app.css b/src/app/app.css
index 5bc648b..2dd480a 100644
--- a/src/app/app.css
+++ b/src/app/app.css
@@ -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;
+}
diff --git a/src/app/app.html b/src/app/app.html
index d894a60..289f821 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -6,7 +6,10 @@
GUARD
- Прокторинг
+
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 2c3d309..067787a 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -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),
},
diff --git a/src/app/app.ts b/src/app/app.ts
index 91a460a..d9e97cd 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -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',
})
diff --git a/src/app/core/config/app.tokens.ts b/src/app/core/config/app.tokens.ts
new file mode 100644
index 0000000..9caceaa
--- /dev/null
+++ b/src/app/core/config/app.tokens.ts
@@ -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('DEFAULT_PAGE_LIMIT', {
+ factory: () => environment.defaultPageLimit,
+});
diff --git a/src/app/core/keyboard/keyboard-transcript.util.ts b/src/app/core/keyboard/keyboard-transcript.util.ts
new file mode 100644
index 0000000..ac9d880
--- /dev/null
+++ b/src/app/core/keyboard/keyboard-transcript.util.ts
@@ -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([
+ '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;
+ 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;
+}
diff --git a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts
index b76d130..66501d4 100644
--- a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts
+++ b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts
@@ -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 = (() => {
+ const m: Record = {
+ 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;
+}
diff --git a/src/app/core/models/api.types.ts b/src/app/core/models/api.types.ts
index cfb98b5..65862d1 100644
--- a/src/app/core/models/api.types.ts
+++ b/src/app/core/models/api.types.ts
@@ -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[];
+}
diff --git a/src/app/core/services/sessions-api.service.ts b/src/app/core/services/sessions-api.service.ts
index bf92273..2ffbf82 100644
--- a/src/app/core/services/sessions-api.service.ts
+++ b/src/app/core/services/sessions-api.service.ts
@@ -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 {
+ listSessions(limit = this.defaultLimit, offset = 0): Observable {
const params = new HttpParams()
.set('limit', String(limit))
.set('offset', String(offset));
@@ -48,6 +51,23 @@ export class SessionsApiService {
return this.http.get(`/sessions/${sessionId}/events`, { params });
}
+ getFingerprintFull(sessionId: number): Observable {
+ return this.http.get(`/sessions/${sessionId}/fingerprint/full`);
+ }
+
+ getFingerprintSummary(sessionId: number): Observable {
+ return this.http.get(`/sessions/${sessionId}/fingerprint/summary`);
+ }
+
+ getFingerprintHeartbeats(sessionId: number, from?: number, to?: number, limit?: number): Observable {
+ 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(`/sessions/${sessionId}/fingerprint/heartbeats`, { params });
+ }
+
resolvePlaylistUrl(playlistUrl: string): string {
if (/^https?:\/\//i.test(playlistUrl)) {
return playlistUrl;
diff --git a/src/app/features/landing/landing.component.ts b/src/app/features/landing/landing.component.ts
new file mode 100644
index 0000000..0763114
--- /dev/null
+++ b/src/app/features/landing/landing.component.ts
@@ -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 {}
diff --git a/src/app/features/landing/landing.css b/src/app/features/landing/landing.css
new file mode 100644
index 0000000..75998ae
--- /dev/null
+++ b/src/app/features/landing/landing.css
@@ -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);
+ }
+}
diff --git a/src/app/features/landing/landing.html b/src/app/features/landing/landing.html
new file mode 100644
index 0000000..e80984f
--- /dev/null
+++ b/src/app/features/landing/landing.html
@@ -0,0 +1,217 @@
+
+
+
+
+
+
Умный прокторинг от SparkGuardian
+
+ Панель ревьюера для поведенческого анализа экзаменационных сессий. Синхронный просмотр видеозаписи, телеметрии клавиатуры и мыши, аппаратный фингерпринт — в одном окне.
+
+
+
+ Перейти к сессиям
+
+
+
+
+
+
+
+
+
+
+
+ Ключевые инструменты
+
+
+
+
+
+
Интерактивный режим
+
+ Синхронный просмотр HLS-видеопотока одновременно с визуализацией нажатий клавиатуры, позиции мыши и восстановленного набранного текста. Единый таймлайн с перемоткой ±5/±10 секунд.
+
+
+
+
+
+
+
Аппаратный фингерпринт
+
+ Периодические Heartbeat-снимки оборудования: хеш CPU, материнской платы, дисков, MAC-адресов. Автоматическое выявление смены экранов, сетевого адаптера или появления гипервизора.
+
+
+
+
+
+
+
Тепловая карта активности
+
+ Визуализация плотности событий телеметрии вдоль таймлайна сессии. Мгновенный переход к подозрительным моментам одним кликом по тепловой карте.
+
+
+
+
+
+
+
+ 4 режима анализа в каждой сессии
+
+
+
+
01
+
Просмотр
+
HLS-плеер с переключением потоков (screen / webcam), полная лента телеметрии с фильтрацией по типам событий.
+
+
+
02
+
Интерактивный режим
+
Синхронное воспроизведение видео, клавиатуры, мыши и набранного текста. Тепловая карта событий с курсором позиции.
+
+
+
03
+
Отпечаток системы
+
Текущая конфигурация устройства и журнал подозрительных изменений: смена экрана, сети, юзера или обнаружение ВМ.
+
+
+
04
+
Служебная информация
+
Сырые данные сессии, метаинформация о потоках и подробная статистика по телеметрии для разработчиков.
+
+
+
+
+
+
+
+
+
17
+
параметров оборудования в каждом Heartbeat-снимке
+
+
+
5
+
полей автоматического мониторинга аномалий
+
+
+
4
+
режима анализа в каждой сессии
+
+
+
2
+
видеопотока — экран и камера одновременно
+
+
+
+
+
+
+ Как это работает
+
+
+
+
1
+
+
Создание сессии
+
Ревьюер создаёт сессию в панели. Агент на устройстве студента получает session_key и начинает сбор телеметрии и запись видеопотоков.
+
+
+
+
+
2
+
+
Потоковая загрузка данных
+
Видеочанки загружаются через API с bearer-авторизацией. Параллельно поступают события клавиатуры, мыши и периодические Heartbeat-фингерпринты.
+
+
+
+
+
3
+
+
Анализ ревьюером
+
Ревьюер открывает сессию в интерактивном режиме, видит видеозапись синхронно с клавиатурой и мышью, и проверяет журнал аномалий фингерпринта.
+
+
+
+
+
+
+
+ Отвечаем на вопросы
+
+
+ Какие данные собирает агент?
+
+
+ Агент записывает видеопотоки (screen и webcam), события клавиатуры (нажатия/отпускания), координаты мыши, и периодически отправляет Heartbeat — снимок аппаратной конфигурации (хеши CPU, дисков, MAC, конфигурация экранов, наличие гипервизора).
+
+
+
+ Что такое журнал аномалий?
+
+
+ Система автоматически сравнивает последовательные Heartbeat-снимки и фиксирует все изменения: смену конфигурации экранов, имени пользователя, имени хоста, сетевого адаптера или появление гипервизора. Все изменения отображаются в хронологическом журнале.
+
+
+
+ Обнаруживает ли система виртуальные машины?
+
+
+ Да. Каждый Heartbeat содержит поле hypervisor_present. Если агент обнаруживает среду виртуализации, в панели ревьюера это отображается как критический флаг с предупреждением.
+
+
+
+ Как работает интерактивный режим?
+
+
+ HLS-видео воспроизводится параллельно с визуализацией: виртуальная клавиатура подсвечивает нажатые клавиши, блок мыши показывает позицию курсора, текстовое поле восстанавливает набранный текст из потока событий. Всё синхронизировано через единый таймлайн.
+
+
+
+ Какие типы видеопотоков поддерживаются?
+
+
+ На данный момент поддерживаются два типа HLS-потоков: screen (запись экрана) и webcam (запись с камеры). Переключение между ними происходит через селектор потоков в интерфейсе сессии.
+
+
+
+ Можно ли фильтровать события телеметрии?
+
+
+ Да. В режиме «Просмотр» доступна фильтрация по типу событий (клавиатура, мышь и др.) с отображением количества событий каждого типа. Также можно скрыть события mouse_move для удобства чтения ленты.
+
+
+
+
+
+
+
diff --git a/src/app/features/sessions/keyboard-view/keyboard-view.css b/src/app/features/sessions/keyboard-view/keyboard-view.css
index bfc2fb3..a4160e5 100644
--- a/src/app/features/sessions/keyboard-view/keyboard-view.css
+++ b/src/app/features/sessions/keyboard-view/keyboard-view.css
@@ -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 */
diff --git a/src/app/features/sessions/mouse-view/mouse-view.css b/src/app/features/sessions/mouse-view/mouse-view.css
index c493224..3c26118 100644
--- a/src/app/features/sessions/mouse-view/mouse-view.css
+++ b/src/app/features/sessions/mouse-view/mouse-view.css
@@ -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;
diff --git a/src/app/features/sessions/session-detail/session-detail.component.ts b/src/app/features/sessions/session-detail/session-detail.component.ts
index 62dddd8..9d26654 100644
--- a/src/app/features/sessions/session-detail/session-detail.component.ts
+++ b/src/app/features/sessions/session-detail/session-detail.component.ts
@@ -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(null);
protected readonly recordingStartMs = signal(null);
protected readonly recordingEndMs = signal(null);
- protected readonly excludeMouseMoves = model(false);
+ protected readonly excludeMouseMoves = model(true);
protected readonly activeTabIndex = model(0);
private readonly sessionId$ = this.route.paramMap.pipe(
diff --git a/src/app/features/sessions/session-detail/session-detail.css b/src/app/features/sessions/session-detail/session-detail.css
index 7c9da51..b92ad04 100644
--- a/src/app/features/sessions/session-detail/session-detail.css
+++ b/src/app/features/sessions/session-detail/session-detail.css
@@ -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;
diff --git a/src/app/features/sessions/session-detail/session-detail.html b/src/app/features/sessions/session-detail/session-detail.html
index fc80e08..85049f6 100644
--- a/src/app/features/sessions/session-detail/session-detail.html
+++ b/src/app/features/sessions/session-detail/session-detail.html
@@ -1,6 +1,6 @@
- ← К списку сессий
+ ← К списку сессий
@if (vm$ | async; as state) {
@@ -19,24 +19,11 @@
Просмотр
Интерактивный режим
+ Отпечаток системы
Служебная информация
@if (telemetry$ | async; as telemetryState) {
- @if (activeTabIndex() === 0 || activeTabIndex() === 1) {
-
-
-
- Исключить перемещения мыши
-
-
- }
@switch (activeTabIndex()) {
@case (0) {
}
@@ -58,6 +45,9 @@
/>
}
@case (2) {
+
+ }
+ @case (3) {
();
+
+ 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);
+ }
+}
diff --git a/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css
new file mode 100644
index 0000000..9240547
--- /dev/null
+++ b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.css
@@ -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;
+}
diff --git a/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.html b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.html
new file mode 100644
index 0000000..2c014f3
--- /dev/null
+++ b/src/app/features/sessions/session-detail/session-fingerprint-tab/session-fingerprint-tab.html
@@ -0,0 +1,81 @@
+@if (data$ | async; as state) {
+ @switch (state.status) {
+ @case ('loading') {
+
+
+
+ }
+ @case ('error') {
+ Не удалось загрузить данные системы.
+ }
+ @case ('ok') {
+
+
+
+
+ Текущая конфигурация устройства
+ @if (state.currentConfig; as config) {
+
+
+
+
+ Экраны
+ {{ config.screen_layout }}
+
+
+
+
+
+ Пользователь
+ {{ config.username }} @{{ config.hostname }}
+
+
+
+
+
+ Сеть
+ {{ config.active_iface }}
+
+
+
+
+
+ Виртуализация
+ {{ config.hypervisor_present ? 'ОБНАРУЖЕНА' : 'Нет' }}
+
+
+
+ } @else {
+ Данные телеметрии еще не поступили.
+ }
+
+
+
+
+ Журнал подозрительных действий
+ @if (state.anomalies.length === 0) {
+
+
+ Никаких подозрительных изменений среды не зафиксировано.
+
+ } @else {
+
+ @for (anomaly of state.anomalies; track anomaly.timestamp_ms) {
+
+
{{ anomaly.timestamp_ms | date:'HH:mm:ss' }}
+
+ {{ anomaly.fieldLabel }}:
+ {{ anomaly.oldValue }}
+
+ {{ anomaly.newValue }}
+
+
+ }
+
+ }
+
+
+
+ }
+ }
+}
diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts
index f620666..56ebd56 100644
--- a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts
+++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts
@@ -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();
readonly recordingStartMs = input.required();
readonly recordingEndMs = input.required();
- readonly excludeMouseMoves = input(false);
+ readonly excludeMouseMoves = input(true);
protected readonly selectedStreamType = signal(null);
protected readonly timelineSec = signal(0);
protected readonly durationSec = signal(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 => {
const cursorMs = this.cursorMs();
if (cursorMs == null) {
return new Set();
}
- const seen = new Set();
- 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) {
diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css
index dff317f..2beac55 100644
--- a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css
+++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css
@@ -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;
+}
diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html
index 586217c..e48f123 100644
--- a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html
+++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html
@@ -131,7 +131,7 @@
-
+
+
+
+ Тепловая карта событий телеметрии
+
+ @for (bucket of heatmapBuckets(); track bucket.index) {
+
0 ? 'color-mix(in srgb, var(--sg-color-accent, #ffdb00) ' + (bucket.opacity * 100) + '%, transparent)' : 'transparent'"
+ [attr.title]="'Событий: ' + bucket.count"
+ (click)="setTimelineSec(bucket.startSec.toString())"
+ >
+ }
+ @if (timelineMaxSec() > 0) {
+
+ }
+
+
+
+
+
+
+ Набранный текст по телеметрии
+
+
+
+
diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts
index dcf8b1f..dddcc28 100644
--- a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts
+++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts
@@ -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();
readonly recordingStartMs = input.required();
readonly recordingEndMs = input.required();
- readonly excludeMouseMoves = input(false);
+ readonly excludeMouseMoves = model(true);
readonly telemetryToMsChange = output();
protected readonly selectedStreamType = signal(null);
diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css
index 50be279..df48b99 100644
--- a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css
+++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css
@@ -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;
diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html
index 02963e9..8a35041 100644
--- a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html
+++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html
@@ -40,6 +40,7 @@
{{ telemetryRangeLabel(telemetryState().toMs) }}
+
+
+
+ Исключить перемещения мыши
+
+
diff --git a/src/app/features/sessions/sessions-list/sessions-list.component.ts b/src/app/features/sessions/sessions-list/sessions-list.component.ts
index 274073d..a6829bf 100644
--- a/src/app/features/sessions/sessions-list/sessions-list.component.ts
+++ b/src/app/features/sessions/sessions-list/sessions-list.component.ts
@@ -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('', {
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 5474064..4a28a5d 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -4,4 +4,5 @@ export const environment = {
apiFallbackOrigin: "https://sparkguardian.ru",
apiBasePath: "/api/v1",
interactivePrerollMs: 4000,
+ defaultPageLimit: 10,
} as const;
diff --git a/src/styles/sg-input-fields.css b/src/styles/sg-input-fields.css
index 964bc8e..c7a977e 100644
--- a/src/styles/sg-input-fields.css
+++ b/src/styles/sg-input-fields.css
@@ -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;