@@ -12,3 +12,6 @@ SG_INTERACTIVE_PREROLL_MS=4000
|
|||||||
|
|
||||||
# Только dev-сервер (`ng serve`): куда проксировать `/api/**`
|
# Только dev-сервер (`ng serve`): куда проксировать `/api/**`
|
||||||
SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080
|
SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080
|
||||||
|
|
||||||
|
# Количество элементов на одной странице по умолчанию (например, для сессий)
|
||||||
|
SG_DEFAULT_PAGE_LIMIT=10
|
||||||
|
|||||||
845
docs/doc_v2.json
Normal file
845
docs/doc_v2.json
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
{
|
||||||
|
"schemes": [],
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"description": "REST API for SparkProctoring — proctoring session management, telemetry, and video streaming.",
|
||||||
|
"title": "SparkProctoring API",
|
||||||
|
"contact": {},
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"host": "sparkguardian.ru",
|
||||||
|
"basePath": "/api/v1",
|
||||||
|
"paths": {
|
||||||
|
"/me": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns the authenticated user's profile.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Get current user info",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.MeResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Paginated list of all sessions with summary stats.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"summary": "List proctoring sessions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max results (default 50, max 100)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Offset (default 0)",
|
||||||
|
"name": "offset",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.SessionListResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Only teachers and admins may create sessions. Returns session_key for agent auth.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"summary": "Create a proctoring session",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Session title",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.CreateSessionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.CreateSessionResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns session info, stream metadata, and event counts.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"summary": "Get session details",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.SessionDetailResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/events": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Downloads encrypted .bin chunks from S3, decrypts, and returns parsed events with optional time range filtering.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"telemetry"
|
||||||
|
],
|
||||||
|
"summary": "Get parsed telemetry events",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Start timestamp (Unix ms)",
|
||||||
|
"name": "from",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "End timestamp (Unix ms)",
|
||||||
|
"name": "to",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ParsedEventsResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/fingerprint/full": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"fingerprint"
|
||||||
|
],
|
||||||
|
"summary": "Get fingerprint full snapshot",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/fingerprint/heartbeats": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"fingerprint"
|
||||||
|
],
|
||||||
|
"summary": "Get fingerprint heartbeats",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Start timestamp (Unix ms)",
|
||||||
|
"name": "from",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "End timestamp (Unix ms)",
|
||||||
|
"name": "to",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max entries to return",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/fingerprint/summary": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"fingerprint"
|
||||||
|
],
|
||||||
|
"summary": "Get fingerprint summary",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/playlist/{stream_type}.m3u8": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns M3U8 playlist for a session's video stream.",
|
||||||
|
"produces": [
|
||||||
|
"application/vnd.apple.mpegurl"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"summary": "Get HLS playlist",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"screen",
|
||||||
|
"webcam"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stream type",
|
||||||
|
"name": "stream_type",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/segment/{stream_type}/{chunk_idx}.ts": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Proxies a .ts video segment from S3.",
|
||||||
|
"produces": [
|
||||||
|
"video/mp2t"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"summary": "Get video segment",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"screen",
|
||||||
|
"webcam"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stream type",
|
||||||
|
"name": "stream_type",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Chunk index",
|
||||||
|
"name": "chunk_idx",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/telemetry": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ForwardAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Returns all telemetry events for a session.",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"telemetry"
|
||||||
|
],
|
||||||
|
"summary": "List telemetry events",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.TelemetryEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/upload": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Upload .ts (video) or .bin (event) file for a session. Max 64 MB.",
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"summary": "Upload a video or event chunk",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Session ID",
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Chunk index",
|
||||||
|
"name": "chunk_idx",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"screen",
|
||||||
|
"webcam"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Stream type (default: screen)",
|
||||||
|
"name": "stream_type",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"description": "Chunk file (.ts or .bin)",
|
||||||
|
"name": "file",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handler.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"handler.CreateSessionRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Экзамен по ОС"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.CreateSessionResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-04-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"session_key": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "pending"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Экзамен по ОС"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ErrorResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "NOT_FOUND"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "session not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.MeResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "teacher@example.com"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "teacher"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ParsedEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "keyboard"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1711360200000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.ParsedEventsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.ParsedEvent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.SessionDetailResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session": {
|
||||||
|
"$ref": "#/definitions/handler.SessionSummary"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.StreamInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.SessionListResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 50
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 0
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handler.SessionSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.SessionSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chunks_total": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
},
|
||||||
|
"ended_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"events_total": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 128
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-04-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "pending"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.StreamInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chunk_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 10
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 60000
|
||||||
|
},
|
||||||
|
"playlist_url": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/api/v1/sessions/1/playlist/screen.m3u8"
|
||||||
|
},
|
||||||
|
"stream_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handler.TelemetryEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-04-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "keyboard"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityDefinitions": {
|
||||||
|
"BearerAuth": {
|
||||||
|
"description": "JWT token with \"Bearer \" prefix",
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header"
|
||||||
|
},
|
||||||
|
"ForwardAuth": {
|
||||||
|
"description": "Authelia Forward Auth — set automatically by Traefik",
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Remote-User",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/images/t-bank-hero-final.png
Normal file
BIN
public/images/t-bank-hero-final.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 MiB |
BIN
public/images/t-bank-hero-v2.png
Normal file
BIN
public/images/t-bank-hero-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
BIN
public/images/t-bank-hero.png
Normal file
BIN
public/images/t-bank-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 524 KiB |
@@ -11,6 +11,7 @@ const defaults = {
|
|||||||
SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru',
|
SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru',
|
||||||
SG_API_BASE_PATH: '/api/v1',
|
SG_API_BASE_PATH: '/api/v1',
|
||||||
SG_INTERACTIVE_PREROLL_MS: '4000',
|
SG_INTERACTIVE_PREROLL_MS: '4000',
|
||||||
|
SG_DEFAULT_PAGE_LIMIT: '10',
|
||||||
};
|
};
|
||||||
|
|
||||||
function val(key) {
|
function val(key) {
|
||||||
@@ -35,6 +36,7 @@ export const environment = {
|
|||||||
apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))},
|
apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))},
|
||||||
apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))},
|
apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))},
|
||||||
interactivePrerollMs: ${JSON.stringify(intVal('SG_INTERACTIVE_PREROLL_MS'))},
|
interactivePrerollMs: ${JSON.stringify(intVal('SG_INTERACTIVE_PREROLL_MS'))},
|
||||||
|
defaultPageLimit: ${JSON.stringify(intVal('SG_DEFAULT_PAGE_LIMIT'))},
|
||||||
} as const;
|
} as const;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -57,3 +57,22 @@
|
|||||||
.shell-main {
|
.shell-main {
|
||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
|
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
|
||||||
GUARD
|
GUARD
|
||||||
</a>
|
</a>
|
||||||
<span class="shell-sub">Прокторинг</span>
|
<div class="shell-nav">
|
||||||
|
<a routerLink="/" class="shell-sub shell-nav-link">Главная</a>
|
||||||
|
<a routerLink="/sessions" class="shell-sub shell-nav-link">Прокторинг</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="shell-main">
|
<main class="shell-main">
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { Routes } from '@angular/router';
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/landing/landing.component').then((m) => m.LandingComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sessions',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent),
|
import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TuiRoot } from '@taiga-ui/core';
|
import { TuiRoot } from '@taiga-ui/core';
|
||||||
import { Component, isDevMode } from '@angular/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';
|
import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
|
imports: [RouterLink, RouterLinkActive, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css',
|
styleUrl: './app.css',
|
||||||
})
|
})
|
||||||
|
|||||||
11
src/app/core/config/app.tokens.ts
Normal file
11
src/app/core/config/app.tokens.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Количество элементов на одной странице по умолчанию (например, при пагинации списков).
|
||||||
|
* Берется из переменной окружения SG_DEFAULT_PAGE_LIMIT (fallback: 10).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PAGE_LIMIT = new InjectionToken<number>('DEFAULT_PAGE_LIMIT', {
|
||||||
|
factory: () => environment.defaultPageLimit,
|
||||||
|
});
|
||||||
115
src/app/core/keyboard/keyboard-transcript.util.ts
Normal file
115
src/app/core/keyboard/keyboard-transcript.util.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||||
|
import {
|
||||||
|
parseKeyboardHighlightKeyIds,
|
||||||
|
parseKeyboardVirtualKey,
|
||||||
|
parseKeyboardVkScheme,
|
||||||
|
} from './keyboard-payload.util';
|
||||||
|
import { svgKeyboardKeyIdToUsUnshiftedChar, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
|
const MODIFIER_SVG_IDS = new Set<string>([
|
||||||
|
'K_kb5a',
|
||||||
|
'K_kb5m',
|
||||||
|
'K_kb6a',
|
||||||
|
'K_kb6n',
|
||||||
|
'K_kb6c',
|
||||||
|
'K_kb6k',
|
||||||
|
'K_kb6b',
|
||||||
|
'K_kb6l',
|
||||||
|
'K_kb4a',
|
||||||
|
'K_kb6m',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sliceLastCodepoint(s: string): string {
|
||||||
|
if (!s) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const chars = [...s];
|
||||||
|
chars.pop();
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Явный символ из payload агента (если есть).
|
||||||
|
*/
|
||||||
|
function parseExplicitTranscriptChar(data: unknown): string | null {
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const keys = ['char', 'character', 'unicode_char', 'UnicodeChar', 'key_char'] as const;
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = o[k];
|
||||||
|
if (typeof v === 'string' && v.length === 1) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = o['text'];
|
||||||
|
if (typeof text === 'string' && text.length === 1) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonPrintingKeyId(id: string): boolean {
|
||||||
|
return MODIFIER_SVG_IDS.has(id) || id.startsWith('K_kb7');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyKeyIdToBuffer(buffer: string, id: string, data: unknown): string {
|
||||||
|
if (isNonPrintingKeyId(id)) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (id === 'K_kb2n') {
|
||||||
|
return sliceLastCodepoint(buffer);
|
||||||
|
}
|
||||||
|
const fromId = svgKeyboardKeyIdToUsUnshiftedChar(id);
|
||||||
|
if (fromId) {
|
||||||
|
return buffer + fromId;
|
||||||
|
}
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
const vk = parseKeyboardVirtualKey(raw);
|
||||||
|
const scheme = parseKeyboardVkScheme(raw);
|
||||||
|
if (vk != null) {
|
||||||
|
const idVk = vkToKeyboardSvgKeyId(vk, scheme);
|
||||||
|
if (idVk === 'K_kb2n') {
|
||||||
|
return sliceLastCodepoint(buffer);
|
||||||
|
}
|
||||||
|
if (idVk && !isNonPrintingKeyId(idVk)) {
|
||||||
|
const ch = svgKeyboardKeyIdToUsUnshiftedChar(idVk);
|
||||||
|
if (ch) {
|
||||||
|
return buffer + ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одно событие нажатия → обновление буфера «набранного текста».
|
||||||
|
* Учитывает Tab (`\t`), Enter (`\n`), пробел, Backspace; буквы/цифры — US QWERTY без Shift
|
||||||
|
* или явный символ из payload.
|
||||||
|
*/
|
||||||
|
export function applyKeyboardPressToTranscriptBuffer(buffer: string, data: unknown): string {
|
||||||
|
const explicit = parseExplicitTranscriptChar(data);
|
||||||
|
if (explicit !== null) {
|
||||||
|
return buffer + explicit;
|
||||||
|
}
|
||||||
|
const ids = parseKeyboardHighlightKeyIds(data);
|
||||||
|
const nonMod = ids.filter((id) => !isNonPrintingKeyId(id));
|
||||||
|
if (nonMod.length === 0) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (nonMod.length === 1) {
|
||||||
|
return applyKeyIdToBuffer(buffer, nonMod[0]!, data);
|
||||||
|
}
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
const vk = parseKeyboardVirtualKey(raw);
|
||||||
|
const scheme = parseKeyboardVkScheme(raw);
|
||||||
|
if (vk != null) {
|
||||||
|
const idVk = vkToKeyboardSvgKeyId(vk, scheme);
|
||||||
|
if (idVk && !isNonPrintingKeyId(idVk)) {
|
||||||
|
return applyKeyIdToBuffer(buffer, idVk, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
@@ -194,3 +194,35 @@ export function charKeyNameToSvgKeyId(name: string): string | null {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** US QWERTY без Shift по физическому id клавиши (эвристика для восстановления текста). */
|
||||||
|
const SVG_ID_TO_US_UNSHIFTED_CHAR: Record<string, string> = (() => {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
K_kb3a: '\t',
|
||||||
|
K_kb4n: '\n',
|
||||||
|
K_kb6d: ' ',
|
||||||
|
K_kb2a: '`',
|
||||||
|
K_kb2l: '-',
|
||||||
|
K_kb2m: '=',
|
||||||
|
K_kb3l: '[',
|
||||||
|
K_kb3m: ']',
|
||||||
|
K_kb3n: '\\',
|
||||||
|
K_kb4l: ';',
|
||||||
|
K_kb4m: "'",
|
||||||
|
K_kb5j: ',',
|
||||||
|
K_kb5k: '.',
|
||||||
|
K_kb5l: '/',
|
||||||
|
};
|
||||||
|
for (const [letter, id] of Object.entries(LETTER_TO_ID)) {
|
||||||
|
m[id] = letter.toLowerCase();
|
||||||
|
}
|
||||||
|
DIGIT_TO_ID.forEach((id, code) => {
|
||||||
|
m[id] = String.fromCharCode(code);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function svgKeyboardKeyIdToUsUnshiftedChar(id: string): string | null {
|
||||||
|
const c = SVG_ID_TO_US_UNSHIFTED_CHAR[id];
|
||||||
|
return c ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,3 +69,34 @@ export interface ParsedEventsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StreamType = 'screen' | 'webcam';
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { API_ORIGIN } from '../config/api.tokens';
|
import { API_ORIGIN } from '../config/api.tokens';
|
||||||
|
import { DEFAULT_PAGE_LIMIT } from '../config/app.tokens';
|
||||||
import type {
|
import type {
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
CreateSessionResponse,
|
CreateSessionResponse,
|
||||||
|
FingerprintHeartbeatsResponse,
|
||||||
ParsedEventsResponse,
|
ParsedEventsResponse,
|
||||||
SessionDetailResponse,
|
SessionDetailResponse,
|
||||||
SessionListResponse,
|
SessionListResponse,
|
||||||
@@ -17,8 +19,9 @@ import type {
|
|||||||
export class SessionsApiService {
|
export class SessionsApiService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly apiOrigin = inject(API_ORIGIN);
|
private readonly apiOrigin = inject(API_ORIGIN);
|
||||||
|
private readonly defaultLimit = inject(DEFAULT_PAGE_LIMIT);
|
||||||
|
|
||||||
listSessions(limit = 50, offset = 0): Observable<SessionListResponse> {
|
listSessions(limit = this.defaultLimit, offset = 0): Observable<SessionListResponse> {
|
||||||
const params = new HttpParams()
|
const params = new HttpParams()
|
||||||
.set('limit', String(limit))
|
.set('limit', String(limit))
|
||||||
.set('offset', String(offset));
|
.set('offset', String(offset));
|
||||||
@@ -48,6 +51,23 @@ export class SessionsApiService {
|
|||||||
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFingerprintFull(sessionId: number): Observable<unknown> {
|
||||||
|
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/full`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFingerprintSummary(sessionId: number): Observable<unknown> {
|
||||||
|
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFingerprintHeartbeats(sessionId: number, from?: number, to?: number, limit?: number): Observable<FingerprintHeartbeatsResponse> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (typeof from === 'number') params = params.set('from', String(from));
|
||||||
|
if (typeof to === 'number') params = params.set('to', String(to));
|
||||||
|
if (typeof limit === 'number') params = params.set('limit', String(limit));
|
||||||
|
|
||||||
|
return this.http.get<FingerprintHeartbeatsResponse>(`/sessions/${sessionId}/fingerprint/heartbeats`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
resolvePlaylistUrl(playlistUrl: string): string {
|
resolvePlaylistUrl(playlistUrl: string): string {
|
||||||
if (/^https?:\/\//i.test(playlistUrl)) {
|
if (/^https?:\/\//i.test(playlistUrl)) {
|
||||||
return playlistUrl;
|
return playlistUrl;
|
||||||
|
|||||||
14
src/app/features/landing/landing.component.ts
Normal file
14
src/app/features/landing/landing.component.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { TuiButton, TuiIcon } from '@taiga-ui/core';
|
||||||
|
import { TuiAccordion } from '@taiga-ui/kit';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-landing',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, TuiButton, TuiIcon, TuiAccordion],
|
||||||
|
templateUrl: './landing.html',
|
||||||
|
styleUrl: './landing.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class LandingComponent {}
|
||||||
372
src/app/features/landing/landing.css
Normal file
372
src/app/features/landing/landing.css
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
.landing-page {
|
||||||
|
padding-block: 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-page__title {
|
||||||
|
font: var(--tui-typography-heading-h2);
|
||||||
|
margin: 0 0 3.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= HERO SECTION ================= */
|
||||||
|
.hero--vertical {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 3rem;
|
||||||
|
animation: fadeUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
font: var(--tui-typography-heading-h1);
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__description {
|
||||||
|
font: var(--tui-typography-body-l);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__btn {
|
||||||
|
background: var(--sg-color-accent) !important;
|
||||||
|
color: var(--sg-color-text) !important;
|
||||||
|
border-color: var(--sg-color-accent) !important;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0 2.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__visual {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= FEATURES SECTION ================= */
|
||||||
|
.features {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: var(--sg-color-form-bg, #e8edf1);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.4s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
background: var(--sg-filter-chip-bg-hover, #eaeff3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card__icon {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
color: var(--sg-color-placeholder);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card__icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.feature-card__title {
|
||||||
|
font: var(--tui-typography-heading-h4);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card__text {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= TABS SHOWCASE SECTION ================= */
|
||||||
|
.tabs-showcase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card {
|
||||||
|
background: var(--tui-background-base);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border: 1px solid var(--tui-border-normal);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card__number {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card__title {
|
||||||
|
font: var(--tui-typography-heading-h4);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-card__text {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= STATS CARDS SECTION ================= */
|
||||||
|
.stats-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-cards__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item {
|
||||||
|
background: var(--sg-color-form-bg, #e8edf1);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item__number {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-item__label {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= HOW IT WORKS SECTION ================= */
|
||||||
|
.how-it-works {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.how-it-works__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3rem;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:hover {
|
||||||
|
background: var(--sg-color-form-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__number {
|
||||||
|
font: var(--tui-typography-heading-h3);
|
||||||
|
background: var(--sg-color-accent);
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__title {
|
||||||
|
font: var(--tui-typography-heading-h4);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step__text {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= FAQ SECTION ================= */
|
||||||
|
.faq {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__list {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
background: var(--tui-background-base);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove all Taiga UI internal borders from accordion */
|
||||||
|
:host ::ng-deep .faq__list [tuiAccordion] {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .faq__list tui-expand {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__button {
|
||||||
|
font: var(--tui-typography-body-l);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--sg-color-text);
|
||||||
|
border: none;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__button + .faq__button {
|
||||||
|
border-top: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq__content {
|
||||||
|
font: var(--tui-typography-body-m);
|
||||||
|
color: var(--tui-text-tertiary);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= FOOTER ================= */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--tui-border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__text {
|
||||||
|
font: var(--tui-typography-body-s);
|
||||||
|
color: var(--sg-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= ANIMATIONS ================= */
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= RESPONSIVE ================= */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero--vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__content {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__btn {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-cards__grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/app/features/landing/landing.html
Normal file
217
src/app/features/landing/landing.html
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<div class="landing-page sg-content-column">
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero hero--vertical">
|
||||||
|
<div class="hero__content">
|
||||||
|
<h1 class="hero__title">Умный прокторинг от SparkGuardian</h1>
|
||||||
|
<p class="hero__description">
|
||||||
|
Панель ревьюера для поведенческого анализа экзаменационных сессий. Синхронный просмотр видеозаписи, телеметрии клавиатуры и мыши, аппаратный фингерпринт — в одном окне.
|
||||||
|
</p>
|
||||||
|
<div class="hero__actions">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
type="button"
|
||||||
|
size="l"
|
||||||
|
appearance="accent"
|
||||||
|
routerLink="/sessions"
|
||||||
|
class="hero__btn"
|
||||||
|
>
|
||||||
|
Перейти к сессиям
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero__visual">
|
||||||
|
<img src="/images/t-bank-hero-final.png" alt="SparkGuardian Security" class="hero__image" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<section class="features">
|
||||||
|
<h2 class="landing-page__title">Ключевые инструменты</h2>
|
||||||
|
|
||||||
|
<div class="features__grid">
|
||||||
|
<!-- Card 1: Interactive Mode -->
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-card__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M8 21H16M12 17V21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M7 8L9 10L7 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feature-card__title">Интерактивный режим</h3>
|
||||||
|
<p class="feature-card__text">
|
||||||
|
Синхронный просмотр HLS-видеопотока одновременно с визуализацией нажатий клавиатуры, позиции мыши и восстановленного набранного текста. Единый таймлайн с перемоткой ±5/±10 секунд.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Hardware Fingerprint -->
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-card__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M9 1V4M15 1V4M9 20V23M15 20V23M1 9H4M1 15H4M20 9H23M20 15H23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" fill-opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feature-card__title">Аппаратный фингерпринт</h3>
|
||||||
|
<p class="feature-card__text">
|
||||||
|
Периодические Heartbeat-снимки оборудования: хеш CPU, материнской платы, дисков, MAC-адресов. Автоматическое выявление смены экранов, сетевого адаптера или появления гипервизора.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 3: Heatmaps -->
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-card__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 3V21H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19 9L14 14L10 10L3 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 9H19V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19 9L14 14L10 10L3 17V21H21V9H19Z" fill="currentColor" fill-opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feature-card__title">Тепловая карта активности</h3>
|
||||||
|
<p class="feature-card__text">
|
||||||
|
Визуализация плотности событий телеметрии вдоль таймлайна сессии. Мгновенный переход к подозрительным моментам одним кликом по тепловой карте.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Session Tabs Section (NEW) -->
|
||||||
|
<section class="tabs-showcase">
|
||||||
|
<h2 class="landing-page__title">4 режима анализа в каждой сессии</h2>
|
||||||
|
|
||||||
|
<div class="tabs-grid">
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">01</div>
|
||||||
|
<h4 class="tab-card__title">Просмотр</h4>
|
||||||
|
<p class="tab-card__text">HLS-плеер с переключением потоков (screen / webcam), полная лента телеметрии с фильтрацией по типам событий.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">02</div>
|
||||||
|
<h4 class="tab-card__title">Интерактивный режим</h4>
|
||||||
|
<p class="tab-card__text">Синхронное воспроизведение видео, клавиатуры, мыши и набранного текста. Тепловая карта событий с курсором позиции.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">03</div>
|
||||||
|
<h4 class="tab-card__title">Отпечаток системы</h4>
|
||||||
|
<p class="tab-card__text">Текущая конфигурация устройства и журнал подозрительных изменений: смена экрана, сети, юзера или обнаружение ВМ.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tab-card">
|
||||||
|
<div class="tab-card__number">04</div>
|
||||||
|
<h4 class="tab-card__title">Служебная информация</h4>
|
||||||
|
<p class="tab-card__text">Сырые данные сессии, метаинформация о потоках и подробная статистика по телеметрии для разработчиков.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<section class="stats-cards">
|
||||||
|
<div class="stats-cards__grid">
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">17</div>
|
||||||
|
<div class="stats-card-item__label">параметров оборудования в каждом Heartbeat-снимке</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">5</div>
|
||||||
|
<div class="stats-card-item__label">полей автоматического мониторинга аномалий</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">4</div>
|
||||||
|
<div class="stats-card-item__label">режима анализа в каждой сессии</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card-item">
|
||||||
|
<div class="stats-card-item__number">2</div>
|
||||||
|
<div class="stats-card-item__label">видеопотока — экран и камера одновременно</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- How It Works Section -->
|
||||||
|
<section class="how-it-works">
|
||||||
|
<h2 class="landing-page__title">Как это работает</h2>
|
||||||
|
|
||||||
|
<div class="how-it-works__steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__number">1</div>
|
||||||
|
<div class="step__content">
|
||||||
|
<h4 class="step__title">Создание сессии</h4>
|
||||||
|
<p class="step__text">Ревьюер создаёт сессию в панели. Агент на устройстве студента получает session_key и начинает сбор телеметрии и запись видеопотоков.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__number">2</div>
|
||||||
|
<div class="step__content">
|
||||||
|
<h4 class="step__title">Потоковая загрузка данных</h4>
|
||||||
|
<p class="step__text">Видеочанки загружаются через API с bearer-авторизацией. Параллельно поступают события клавиатуры, мыши и периодические Heartbeat-фингерпринты.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__number">3</div>
|
||||||
|
<div class="step__content">
|
||||||
|
<h4 class="step__title">Анализ ревьюером</h4>
|
||||||
|
<p class="step__text">Ревьюер открывает сессию в интерактивном режиме, видит видеозапись синхронно с клавиатурой и мышью, и проверяет журнал аномалий фингерпринта.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ Section -->
|
||||||
|
<section class="faq">
|
||||||
|
<h2 class="landing-page__title">Отвечаем на вопросы</h2>
|
||||||
|
|
||||||
|
<tui-accordion class="faq__list">
|
||||||
|
<button tuiAccordion class="faq__button">Какие данные собирает агент?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Агент записывает видеопотоки (screen и webcam), события клавиатуры (нажатия/отпускания), координаты мыши, и периодически отправляет Heartbeat — снимок аппаратной конфигурации (хеши CPU, дисков, MAC, конфигурация экранов, наличие гипервизора).
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Что такое журнал аномалий?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Система автоматически сравнивает последовательные Heartbeat-снимки и фиксирует все изменения: смену конфигурации экранов, имени пользователя, имени хоста, сетевого адаптера или появление гипервизора. Все изменения отображаются в хронологическом журнале.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Обнаруживает ли система виртуальные машины?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Да. Каждый Heartbeat содержит поле hypervisor_present. Если агент обнаруживает среду виртуализации, в панели ревьюера это отображается как критический флаг с предупреждением.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Как работает интерактивный режим?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
HLS-видео воспроизводится параллельно с визуализацией: виртуальная клавиатура подсвечивает нажатые клавиши, блок мыши показывает позицию курсора, текстовое поле восстанавливает набранный текст из потока событий. Всё синхронизировано через единый таймлайн.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Какие типы видеопотоков поддерживаются?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
На данный момент поддерживаются два типа HLS-потоков: screen (запись экрана) и webcam (запись с камеры). Переключение между ними происходит через селектор потоков в интерфейсе сессии.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
|
||||||
|
<button tuiAccordion class="faq__button">Можно ли фильтровать события телеметрии?</button>
|
||||||
|
<tui-expand>
|
||||||
|
<p class="faq__content">
|
||||||
|
Да. В режиме «Просмотр» доступна фильтрация по типу событий (клавиатура, мышь и др.) с отображением количества событий каждого типа. Также можно скрыть события mouse_move для удобства чтения ленты.
|
||||||
|
</p>
|
||||||
|
</tui-expand>
|
||||||
|
</tui-accordion>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p class="footer__text">© 2026 SparkGuardian. Панель ревьюера для прокторинга экзаменационных сессий.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -49,7 +49,9 @@
|
|||||||
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
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 */
|
/* Space bar: fills leftover width in modifier row */
|
||||||
@@ -58,7 +60,7 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pressed state */
|
/* Pressed state — плавный переход с «серого» idle-surface на акцент */
|
||||||
.key--active {
|
.key--active {
|
||||||
background: var(--sg-keyboard-key-pressed-fill);
|
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);
|
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-family: var(--sg-keyboard-font-family);
|
||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Russian glyph — top-right */
|
/* Russian glyph — top-right */
|
||||||
@@ -92,6 +95,7 @@
|
|||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main English label — bottom-left */
|
/* Main English label — bottom-left */
|
||||||
@@ -102,6 +106,7 @@
|
|||||||
font-family: var(--sg-keyboard-font-family);
|
font-family: var(--sg-keyboard-font-family);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: var(--sg-keyboard-letter-spacing);
|
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 */
|
/* Label for special keys (Tab, Caps, Shift, ⌘ …) — centered */
|
||||||
@@ -115,6 +120,7 @@
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active key — invert text colors */
|
/* Active key — invert text colors */
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mouse {
|
.mouse {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
|
min-height: 0;
|
||||||
border-radius: 26px 26px 18px 18px;
|
border-radius: 26px 26px 18px 18px;
|
||||||
background: var(--sg-keyboard-key-surface-idle);
|
background: var(--sg-keyboard-key-surface-idle);
|
||||||
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
||||||
@@ -14,6 +22,7 @@
|
|||||||
/* ── Buttons area ──────────────────────────────────────────────────────── */
|
/* ── Buttons area ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.mouse__buttons {
|
.mouse__buttons {
|
||||||
|
flex-shrink: 0;
|
||||||
height: 88px;
|
height: 88px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 10px 1fr;
|
grid-template-columns: 1fr 10px 1fr;
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { HttpErrorResponse } from '@angular/common/http';
|
|||||||
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
|
||||||
import { toObservable } from '@angular/core/rxjs-interop';
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
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 { TuiLink } from '@taiga-ui/core/components/link';
|
||||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
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 { SessionViewTabComponent } from './session-view-tab/session-view-tab.component';
|
||||||
import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component';
|
import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component';
|
||||||
import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component';
|
import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component';
|
||||||
|
import { SessionFingerprintTabComponent } from './session-fingerprint-tab/session-fingerprint-tab.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-session-detail',
|
selector: 'app-session-detail',
|
||||||
imports: [
|
imports: [
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
FormsModule,
|
|
||||||
RouterLink,
|
RouterLink,
|
||||||
TuiCheckbox,
|
|
||||||
TuiLink,
|
TuiLink,
|
||||||
TuiLoader,
|
TuiLoader,
|
||||||
TuiTitle,
|
TuiTitle,
|
||||||
@@ -44,6 +41,7 @@ import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.com
|
|||||||
SessionViewTabComponent,
|
SessionViewTabComponent,
|
||||||
SessionInteractiveTabComponent,
|
SessionInteractiveTabComponent,
|
||||||
SessionInfoTabComponent,
|
SessionInfoTabComponent,
|
||||||
|
SessionFingerprintTabComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './session-detail.html',
|
templateUrl: './session-detail.html',
|
||||||
styleUrl: './session-detail.css',
|
styleUrl: './session-detail.css',
|
||||||
@@ -58,7 +56,7 @@ export class SessionDetailComponent {
|
|||||||
protected readonly telemetryToMs = signal<number | null>(null);
|
protected readonly telemetryToMs = signal<number | null>(null);
|
||||||
protected readonly recordingStartMs = signal<number | null>(null);
|
protected readonly recordingStartMs = signal<number | null>(null);
|
||||||
protected readonly recordingEndMs = signal<number | null>(null);
|
protected readonly recordingEndMs = signal<number | null>(null);
|
||||||
protected readonly excludeMouseMoves = model(false);
|
protected readonly excludeMouseMoves = model(true);
|
||||||
protected readonly activeTabIndex = model(0);
|
protected readonly activeTabIndex = model(0);
|
||||||
|
|
||||||
private readonly sessionId$ = this.route.paramMap.pipe(
|
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||||
|
|||||||
@@ -6,26 +6,6 @@
|
|||||||
margin-bottom: 1.25rem;
|
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. */
|
/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
|
||||||
.session-tabs {
|
.session-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="page sg-content-column">
|
<div class="page sg-content-column">
|
||||||
<nav class="back">
|
<nav class="back">
|
||||||
<a tuiLink routerLink="/">← К списку сессий</a>
|
<a tuiLink routerLink="/sessions">← К списку сессий</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if (vm$ | async; as state) {
|
@if (vm$ | async; as state) {
|
||||||
@@ -19,24 +19,11 @@
|
|||||||
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||||
<button tuiTab type="button">Просмотр</button>
|
<button tuiTab type="button">Просмотр</button>
|
||||||
<button tuiTab type="button">Интерактивный режим</button>
|
<button tuiTab type="button">Интерактивный режим</button>
|
||||||
|
<button tuiTab type="button">Отпечаток системы</button>
|
||||||
<button tuiTab type="button">Служебная информация</button>
|
<button tuiTab type="button">Служебная информация</button>
|
||||||
</tui-tabs>
|
</tui-tabs>
|
||||||
|
|
||||||
@if (telemetry$ | async; as telemetryState) {
|
@if (telemetry$ | async; as telemetryState) {
|
||||||
@if (activeTabIndex() === 0 || activeTabIndex() === 1) {
|
|
||||||
<div class="session-telemetry-filter-bar">
|
|
||||||
<label class="session-telemetry-filter-bar__label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
tuiCheckbox
|
|
||||||
size="s"
|
|
||||||
[ngModel]="excludeMouseMoves()"
|
|
||||||
(ngModelChange)="excludeMouseMoves.set($event)"
|
|
||||||
/>
|
|
||||||
<span class="session-telemetry-filter-bar__text">Исключить перемещения мыши</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@switch (activeTabIndex()) {
|
@switch (activeTabIndex()) {
|
||||||
@case (0) {
|
@case (0) {
|
||||||
<app-session-view-tab
|
<app-session-view-tab
|
||||||
@@ -44,7 +31,7 @@
|
|||||||
[telemetryState]="telemetryState"
|
[telemetryState]="telemetryState"
|
||||||
[recordingStartMs]="recordingStartMs()"
|
[recordingStartMs]="recordingStartMs()"
|
||||||
[recordingEndMs]="recordingEndMs()"
|
[recordingEndMs]="recordingEndMs()"
|
||||||
[excludeMouseMoves]="excludeMouseMoves()"
|
[(excludeMouseMoves)]="excludeMouseMoves"
|
||||||
(telemetryToMsChange)="telemetryToMs.set($event)"
|
(telemetryToMsChange)="telemetryToMs.set($event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -58,6 +45,9 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case (2) {
|
@case (2) {
|
||||||
|
<app-session-fingerprint-tab [sessionId]="state.id" />
|
||||||
|
}
|
||||||
|
@case (3) {
|
||||||
<app-session-info-tab
|
<app-session-info-tab
|
||||||
[detail]="state.detail"
|
[detail]="state.detail"
|
||||||
[telemetryState]="telemetryState"
|
[telemetryState]="telemetryState"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { AsyncPipe, JsonPipe } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
|
import { TuiScrollbar } from '@taiga-ui/core/components/scrollbar';
|
||||||
|
import { TuiIcon } from '@taiga-ui/core/components/icon';
|
||||||
|
import { catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||||
|
import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service';
|
||||||
|
import type { FingerprintHeartbeat } from '../../../../core/models/api.types';
|
||||||
|
|
||||||
|
export interface Anomaly {
|
||||||
|
timestamp_ms: number;
|
||||||
|
field: string;
|
||||||
|
fieldLabel: string;
|
||||||
|
oldValue: string | boolean;
|
||||||
|
newValue: string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-session-fingerprint-tab',
|
||||||
|
imports: [AsyncPipe, DatePipe, TuiLoader, TuiIcon],
|
||||||
|
templateUrl: './session-fingerprint-tab.html',
|
||||||
|
styleUrl: './session-fingerprint-tab.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SessionFingerprintTabComponent {
|
||||||
|
private readonly api = inject(SessionsApiService);
|
||||||
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
|
||||||
|
readonly sessionId = input.required<number>();
|
||||||
|
|
||||||
|
protected readonly data$ = toObservable(this.sessionId).pipe(
|
||||||
|
switchMap((id) => {
|
||||||
|
// Параллельно запрашиваем все данные фингерпринта
|
||||||
|
return combineLatest([
|
||||||
|
this.api.getFingerprintSummary(id).pipe(catchError(() => of(null))),
|
||||||
|
this.api.getFingerprintFull(id).pipe(catchError(() => of(null))),
|
||||||
|
this.api.getFingerprintHeartbeats(id, undefined, undefined, 50).pipe(catchError(() => of(null)))
|
||||||
|
]).pipe(
|
||||||
|
map(([summary, full, heartbeats]) => {
|
||||||
|
const hbs = heartbeats?.heartbeats || [];
|
||||||
|
const anomalies = this.detectAnomalies(hbs);
|
||||||
|
const currentConfig = hbs.length > 0 ? hbs[hbs.length - 1].payload : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok' as const,
|
||||||
|
summary,
|
||||||
|
full,
|
||||||
|
heartbeats: hbs,
|
||||||
|
anomalies,
|
||||||
|
currentConfig,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
catchError((e: HttpErrorResponse) => {
|
||||||
|
this.userErrors.notifyError(e, 'Фингерпринт');
|
||||||
|
return of({ status: 'error' as const });
|
||||||
|
}),
|
||||||
|
startWith({ status: 'loading' as const })
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
private detectAnomalies(heartbeats: FingerprintHeartbeat[]): Anomaly[] {
|
||||||
|
if (!heartbeats || heartbeats.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies: Anomaly[] = [];
|
||||||
|
const fieldsToCheck = [
|
||||||
|
{ key: 'screen_layout', label: 'Конфигурация экранов' },
|
||||||
|
{ key: 'username', label: 'Пользователь системы' },
|
||||||
|
{ key: 'hostname', label: 'Имя компьютера' },
|
||||||
|
{ key: 'active_iface', label: 'Сетевой адаптер' },
|
||||||
|
{ key: 'hypervisor_present', label: 'Виртуализация / Hypervisor' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (let i = 1; i < heartbeats.length; i++) {
|
||||||
|
const prev = heartbeats[i - 1].payload;
|
||||||
|
const curr = heartbeats[i].payload;
|
||||||
|
const timestamp = heartbeats[i].timestamp_ms;
|
||||||
|
|
||||||
|
for (const field of fieldsToCheck) {
|
||||||
|
// @ts-ignore (dynamic key access)
|
||||||
|
const oldValue = prev[field.key];
|
||||||
|
// @ts-ignore
|
||||||
|
const newValue = curr[field.key];
|
||||||
|
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
anomalies.push({
|
||||||
|
timestamp_ms: timestamp,
|
||||||
|
field: field.key,
|
||||||
|
fieldLabel: field.label,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем от новых к старым
|
||||||
|
return anomalies.sort((a, b) => b.timestamp_ms - a.timestamp_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@if (data$ | async; as state) {
|
||||||
|
@switch (state.status) {
|
||||||
|
@case ('loading') {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<tui-loader [loading]="true" size="xl" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
<p class="muted">Не удалось загрузить данные системы.</p>
|
||||||
|
}
|
||||||
|
@case ('ok') {
|
||||||
|
<div class="fingerprint-container">
|
||||||
|
|
||||||
|
<!-- Текущая конфигурация -->
|
||||||
|
<section class="card config-section">
|
||||||
|
<h3 class="section-title">Текущая конфигурация устройства</h3>
|
||||||
|
@if (state.currentConfig; as config) {
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-item">
|
||||||
|
<tui-icon icon="@tui.monitor" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Экраны</span>
|
||||||
|
<span class="cfg-value">{{ config.screen_layout }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<tui-icon icon="@tui.user" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Пользователь</span>
|
||||||
|
<span class="cfg-value">{{ config.username }} <span class="muted">@{{ config.hostname }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<tui-icon icon="@tui.globe" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Сеть</span>
|
||||||
|
<span class="cfg-value">{{ config.active_iface }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item danger-container" [class.danger]="config.hypervisor_present">
|
||||||
|
<tui-icon [icon]="config.hypervisor_present ? '@tui.triangle-alert' : '@tui.cpu'" class="cfg-icon" />
|
||||||
|
<div class="cfg-text">
|
||||||
|
<span class="cfg-label">Виртуализация</span>
|
||||||
|
<span class="cfg-value">{{ config.hypervisor_present ? 'ОБНАРУЖЕНА' : 'Нет' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="muted">Данные телеметрии еще не поступили.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ВАЖНО: Аномалии -->
|
||||||
|
<section class="card anomalies-section">
|
||||||
|
<h3 class="section-title">Журнал подозрительных действий</h3>
|
||||||
|
@if (state.anomalies.length === 0) {
|
||||||
|
<div class="safe-banner">
|
||||||
|
<tui-icon icon="@tui.check" class="success-icon" />
|
||||||
|
<span>Никаких подозрительных изменений среды не зафиксировано.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="anomalies-list">
|
||||||
|
@for (anomaly of state.anomalies; track anomaly.timestamp_ms) {
|
||||||
|
<div class="anomaly-item">
|
||||||
|
<div class="anomaly-time">{{ anomaly.timestamp_ms | date:'HH:mm:ss' }}</div>
|
||||||
|
<div class="anomaly-content">
|
||||||
|
<span class="anomaly-label">{{ anomaly.fieldLabel }}:</span>
|
||||||
|
<span class="cross-out">{{ anomaly.oldValue }}</span>
|
||||||
|
<tui-icon icon="@tui.arrow-right" class="arrow-icon"/>
|
||||||
|
<span class="highlight">{{ anomaly.newValue }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
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 {
|
import {
|
||||||
isKeyboardTelemetryEvent,
|
isKeyboardTelemetryEvent,
|
||||||
parseKeyboardAction,
|
parseKeyboardAction,
|
||||||
@@ -21,19 +24,19 @@ import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.compone
|
|||||||
import { MouseViewComponent } from '../../mouse-view/mouse-view.component';
|
import { MouseViewComponent } from '../../mouse-view/mouse-view.component';
|
||||||
import { environment } from '../../../../../environments/environment';
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
|
||||||
/**
|
|
||||||
* Окно видимости нажатия клавиши (мс). Должно быть > интервала timeupdate (~250ms),
|
|
||||||
* чтобы каждое нажатие попало хотя бы в один кадр курсора.
|
|
||||||
*/
|
|
||||||
const KEY_DISPLAY_MS = 400;
|
|
||||||
const INTERACTIVE_PREROLL_MS = Number.isFinite(environment.interactivePrerollMs)
|
const INTERACTIVE_PREROLL_MS = Number.isFinite(environment.interactivePrerollMs)
|
||||||
? Math.max(0, environment.interactivePrerollMs)
|
? Math.max(0, environment.interactivePrerollMs)
|
||||||
: 4000;
|
: 4000;
|
||||||
|
|
||||||
|
/** Минимальная длительность подсветки нажатия (мс); при перекрытии побеждает более позднее нажатие. */
|
||||||
|
const KEY_HIGHLIGHT_MIN_MS = 500;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-session-interactive-tab',
|
selector: 'app-session-interactive-tab',
|
||||||
imports: [
|
imports: [
|
||||||
|
FormsModule,
|
||||||
TuiButton,
|
TuiButton,
|
||||||
|
...TuiTextarea,
|
||||||
HlsPlayerComponent,
|
HlsPlayerComponent,
|
||||||
StreamSelectorComponent,
|
StreamSelectorComponent,
|
||||||
KeyboardViewComponent,
|
KeyboardViewComponent,
|
||||||
@@ -50,13 +53,12 @@ export class SessionInteractiveTabComponent {
|
|||||||
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
||||||
readonly recordingStartMs = input.required<number | null>();
|
readonly recordingStartMs = input.required<number | null>();
|
||||||
readonly recordingEndMs = input.required<number | null>();
|
readonly recordingEndMs = input.required<number | null>();
|
||||||
readonly excludeMouseMoves = input<boolean>(false);
|
readonly excludeMouseMoves = input<boolean>(true);
|
||||||
|
|
||||||
protected readonly selectedStreamType = signal<string | null>(null);
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
protected readonly timelineSec = signal(0);
|
protected readonly timelineSec = signal(0);
|
||||||
protected readonly durationSec = signal<number | null>(null);
|
protected readonly durationSec = signal<number | null>(null);
|
||||||
protected readonly isPlaying = signal(false);
|
protected readonly isPlaying = signal(false);
|
||||||
|
|
||||||
protected readonly timelineMaxSec = computed(() => {
|
protected readonly timelineMaxSec = computed(() => {
|
||||||
const videoDuration = this.durationSec();
|
const videoDuration = this.durationSec();
|
||||||
if (videoDuration != null && Number.isFinite(videoDuration) && videoDuration > 0) {
|
if (videoDuration != null && Number.isFinite(videoDuration) && videoDuration > 0) {
|
||||||
@@ -111,6 +113,68 @@ export class SessionInteractiveTabComponent {
|
|||||||
.sort((a, b) => a.timestamp - b.timestamp),
|
.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.
|
* 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].
|
* Клавиши слота с наибольшим `startMs`, для которого cursorMs попадает в
|
||||||
* Iterates backwards (newest first), deduplicates by keyId.
|
* [startMs, max(endMs, startMs + KEY_HIGHLIGHT_MIN_MS)).
|
||||||
*/
|
*/
|
||||||
protected readonly pressedKeyIds = computed((): ReadonlySet<string> => {
|
protected readonly pressedKeyIds = computed((): ReadonlySet<string> => {
|
||||||
const cursorMs = this.cursorMs();
|
const cursorMs = this.cursorMs();
|
||||||
if (cursorMs == null) {
|
if (cursorMs == null) {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
const seen = new Set<string>();
|
const slots = this.keyboardPressDisplaySlots();
|
||||||
const events = this.sortedKeyboardEvents();
|
for (let idx = slots.length - 1; idx >= 0; idx--) {
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
const s = slots[idx]!;
|
||||||
const event = events[i]!;
|
if (s.startMs > cursorMs) {
|
||||||
if (event.timestamp > cursorMs) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (event.timestamp < cursorMs - KEY_DISPLAY_MS) {
|
const visibleUntilMs = Math.max(s.endMs, s.startMs + KEY_HIGHLIGHT_MIN_MS);
|
||||||
break;
|
if (cursorMs < visibleUntilMs) {
|
||||||
}
|
return new Set(s.keyIds);
|
||||||
if (parseKeyboardAction(event.data) !== 'press') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const keyId of parseKeyboardHighlightKeyIds(event.data)) {
|
|
||||||
seen.add(keyId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return seen;
|
return new Set();
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly mouseTargets = computed(() => {
|
protected readonly mouseTargets = computed(() => {
|
||||||
@@ -171,6 +229,53 @@ export class SessionInteractiveTabComponent {
|
|||||||
return [] as MouseHighlightTarget[];
|
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 {
|
protected activeStreamType(): string | null {
|
||||||
const streams = this.detail().streams;
|
const streams = this.detail().streams;
|
||||||
if (!streams?.length) {
|
if (!streams?.length) {
|
||||||
|
|||||||
@@ -166,10 +166,20 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-preview app-keyboard-view {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-preview app-mouse-view {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mouse-sidebar {
|
.mouse-sidebar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
@@ -178,3 +188,70 @@
|
|||||||
align-items: center;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" aria-label="Интерактивный режим: клавиатура">
|
<section class="card" aria-label="Интерактивный режим: ввод">
|
||||||
<div class="input-preview">
|
<div class="input-preview">
|
||||||
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
|
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
|
||||||
<div class="mouse-sidebar">
|
<div class="mouse-sidebar">
|
||||||
@@ -139,3 +139,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card heatmap-card" aria-label="Интерактивный режим: тепловая карта">
|
||||||
|
<h3 class="heatmap-title">Тепловая карта событий телеметрии</h3>
|
||||||
|
<div class="heatmap-container">
|
||||||
|
@for (bucket of heatmapBuckets(); track bucket.index) {
|
||||||
|
<div
|
||||||
|
class="heatmap-bucket"
|
||||||
|
[style.background-color]="bucket.count > 0 ? 'color-mix(in srgb, var(--sg-color-accent, #ffdb00) ' + (bucket.opacity * 100) + '%, transparent)' : 'transparent'"
|
||||||
|
[attr.title]="'Событий: ' + bucket.count"
|
||||||
|
(click)="setTimelineSec(bucket.startSec.toString())"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
@if (timelineMaxSec() > 0) {
|
||||||
|
<div
|
||||||
|
class="heatmap-cursor"
|
||||||
|
[style.left.%]="(timelineSec() / timelineMaxSec()) * 100"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Интерактивный режим: текст">
|
||||||
|
<div class="interactive-transcript-wrap">
|
||||||
|
<tui-textfield class="interactive-transcript-field sg-tui-textfield">
|
||||||
|
<label tuiLabel for="interactive-transcript">Набранный текст по телеметрии</label>
|
||||||
|
<textarea
|
||||||
|
id="interactive-transcript"
|
||||||
|
tuiTextarea
|
||||||
|
[min]="4"
|
||||||
|
[max]="16"
|
||||||
|
[ngModel]="interactiveTypedText()"
|
||||||
|
[readOnly]="true"
|
||||||
|
tabindex="-1"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
</tui-textfield>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { animate, style, transition, trigger } from '@angular/animations';
|
import { animate, style, transition, trigger } from '@angular/animations';
|
||||||
import { NgClass } from '@angular/common';
|
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 { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||||
@@ -22,7 +24,9 @@ import type { TelemetryLoadState, TelemetryRangeSelection } from '../session-det
|
|||||||
selector: 'app-session-view-tab',
|
selector: 'app-session-view-tab',
|
||||||
imports: [
|
imports: [
|
||||||
NgClass,
|
NgClass,
|
||||||
|
FormsModule,
|
||||||
TuiButton,
|
TuiButton,
|
||||||
|
TuiCheckbox,
|
||||||
TuiChip,
|
TuiChip,
|
||||||
TuiLoader,
|
TuiLoader,
|
||||||
SessionStatusChipClassesPipe,
|
SessionStatusChipClassesPipe,
|
||||||
@@ -60,7 +64,7 @@ export class SessionViewTabComponent {
|
|||||||
readonly telemetryState = input.required<TelemetryLoadState>();
|
readonly telemetryState = input.required<TelemetryLoadState>();
|
||||||
readonly recordingStartMs = input.required<number | null>();
|
readonly recordingStartMs = input.required<number | null>();
|
||||||
readonly recordingEndMs = input.required<number | null>();
|
readonly recordingEndMs = input.required<number | null>();
|
||||||
readonly excludeMouseMoves = input<boolean>(false);
|
readonly excludeMouseMoves = model(true);
|
||||||
readonly telemetryToMsChange = output<number>();
|
readonly telemetryToMsChange = output<number>();
|
||||||
|
|
||||||
protected readonly selectedStreamType = signal<string | null>(null);
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
|
|||||||
@@ -21,6 +21,30 @@
|
|||||||
margin-bottom: 0.75rem;
|
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 {
|
.telemetry-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="telemetry-head__side">
|
||||||
<div class="telemetry-actions">
|
<div class="telemetry-actions">
|
||||||
<div class="telemetry-presets">
|
<div class="telemetry-presets">
|
||||||
<button
|
<button
|
||||||
@@ -113,6 +114,17 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="telemetry-exclude-moves">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tuiCheckbox
|
||||||
|
size="s"
|
||||||
|
[ngModel]="excludeMouseMoves()"
|
||||||
|
(ngModelChange)="excludeMouseMoves.set($event)"
|
||||||
|
/>
|
||||||
|
<span class="telemetry-exclude-moves__text">Исключить перемещения мыши</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="telemetry-content">
|
<div class="telemetry-content">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { UserErrorNotifyService } from '../../../core/notifications/user-error-n
|
|||||||
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
|
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
|
||||||
import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe';
|
import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe';
|
||||||
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
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';
|
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -50,7 +51,7 @@ export class SessionsListComponent {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly userErrors = inject(UserErrorNotifyService);
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
|
||||||
protected readonly limit = 10;
|
protected readonly limit = inject(DEFAULT_PAGE_LIMIT);
|
||||||
protected readonly pageIndex = signal(0);
|
protected readonly pageIndex = signal(0);
|
||||||
|
|
||||||
protected readonly titleControl = new FormControl('', {
|
protected readonly titleControl = new FormControl('', {
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export const environment = {
|
|||||||
apiFallbackOrigin: "https://sparkguardian.ru",
|
apiFallbackOrigin: "https://sparkguardian.ru",
|
||||||
apiBasePath: "/api/v1",
|
apiBasePath: "/api/v1",
|
||||||
interactivePrerollMs: 4000,
|
interactivePrerollMs: 4000,
|
||||||
|
defaultPageLimit: 10,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-
|
|||||||
color: var(--sg-color-textfield-focus-label);
|
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, …) --- */
|
/* --- нативные поля (datetime-local, text, …) --- */
|
||||||
.sg-native-input {
|
.sg-native-input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
Reference in New Issue
Block a user