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

+

+ Панель ревьюера для поведенческого анализа экзаменационных сессий. Синхронный просмотр видеозаписи, телеметрии клавиатуры и мыши, аппаратный фингерпринт — в одном окне. +

+
+ +
+
+ +
+ SparkGuardian Security +
+
+ + +
+

Ключевые инструменты

+ +
+ +
+
+ + + + + + +
+

Интерактивный режим

+

+ Синхронный просмотр 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 @@
-
+
@@ -139,3 +139,42 @@
+ +
+

Тепловая карта событий телеметрии

+
+ @for (bucket of heatmapBuckets(); track bucket.index) { +
+ } + @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;