@@ -57,3 +57,22 @@
|
||||
.shell-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shell-nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-left: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shell-nav-link {
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--sg-color-subtitle);
|
||||
}
|
||||
|
||||
.shell-nav-link:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
|
||||
GUARD
|
||||
</a>
|
||||
<span class="shell-sub">Прокторинг</span>
|
||||
<div class="shell-nav">
|
||||
<a routerLink="/" class="shell-sub shell-nav-link">Главная</a>
|
||||
<a routerLink="/sessions" class="shell-sub shell-nav-link">Прокторинг</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="shell-main">
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Routes } from '@angular/router';
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./features/landing/landing.component').then((m) => m.LandingComponent),
|
||||
},
|
||||
{
|
||||
path: 'sessions',
|
||||
loadComponent: () =>
|
||||
import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent),
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TuiRoot } from '@taiga-ui/core';
|
||||
import { Component, isDevMode } from '@angular/core';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { RouterLink, RouterOutlet, RouterLinkActive } from '@angular/router';
|
||||
import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||
imports: [RouterLink, RouterLinkActive, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 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 { API_ORIGIN } from '../config/api.tokens';
|
||||
import { DEFAULT_PAGE_LIMIT } from '../config/app.tokens';
|
||||
import type {
|
||||
CreateSessionRequest,
|
||||
CreateSessionResponse,
|
||||
FingerprintHeartbeatsResponse,
|
||||
ParsedEventsResponse,
|
||||
SessionDetailResponse,
|
||||
SessionListResponse,
|
||||
@@ -17,8 +19,9 @@ import type {
|
||||
export class SessionsApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiOrigin = inject(API_ORIGIN);
|
||||
private readonly defaultLimit = inject(DEFAULT_PAGE_LIMIT);
|
||||
|
||||
listSessions(limit = 50, offset = 0): Observable<SessionListResponse> {
|
||||
listSessions(limit = this.defaultLimit, offset = 0): Observable<SessionListResponse> {
|
||||
const params = new HttpParams()
|
||||
.set('limit', String(limit))
|
||||
.set('offset', String(offset));
|
||||
@@ -48,6 +51,23 @@ export class SessionsApiService {
|
||||
return this.http.get<ParsedEventsResponse>(`/sessions/${sessionId}/events`, { params });
|
||||
}
|
||||
|
||||
getFingerprintFull(sessionId: number): Observable<unknown> {
|
||||
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/full`);
|
||||
}
|
||||
|
||||
getFingerprintSummary(sessionId: number): Observable<unknown> {
|
||||
return this.http.get<unknown>(`/sessions/${sessionId}/fingerprint/summary`);
|
||||
}
|
||||
|
||||
getFingerprintHeartbeats(sessionId: number, from?: number, to?: number, limit?: number): Observable<FingerprintHeartbeatsResponse> {
|
||||
let params = new HttpParams();
|
||||
if (typeof from === 'number') params = params.set('from', String(from));
|
||||
if (typeof to === 'number') params = params.set('to', String(to));
|
||||
if (typeof limit === 'number') params = params.set('limit', String(limit));
|
||||
|
||||
return this.http.get<FingerprintHeartbeatsResponse>(`/sessions/${sessionId}/fingerprint/heartbeats`, { params });
|
||||
}
|
||||
|
||||
resolvePlaylistUrl(playlistUrl: string): string {
|
||||
if (/^https?:\/\//i.test(playlistUrl)) {
|
||||
return playlistUrl;
|
||||
|
||||
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;
|
||||
user-select: none;
|
||||
transition: background 60ms ease;
|
||||
transition:
|
||||
background 0.24s cubic-bezier(0.33, 1, 0.68, 1),
|
||||
box-shadow 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
/* Space bar: fills leftover width in modifier row */
|
||||
@@ -58,7 +60,7 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Pressed state */
|
||||
/* Pressed state — плавный переход с «серого» idle-surface на акцент */
|
||||
.key--active {
|
||||
background: var(--sg-keyboard-key-pressed-fill);
|
||||
box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--sg-keyboard-key-pressed-fill) 55%, black);
|
||||
@@ -81,6 +83,7 @@
|
||||
font-family: var(--sg-keyboard-font-family);
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
min-width: 0;
|
||||
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
/* Russian glyph — top-right */
|
||||
@@ -92,6 +95,7 @@
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
/* Main English label — bottom-left */
|
||||
@@ -102,6 +106,7 @@
|
||||
font-family: var(--sg-keyboard-font-family);
|
||||
font-weight: 500;
|
||||
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
/* Label for special keys (Tab, Caps, Shift, ⌘ …) — centered */
|
||||
@@ -115,6 +120,7 @@
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
transition: color 0.24s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
/* Active key — invert text colors */
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
:host {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mouse {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 120px;
|
||||
min-height: 0;
|
||||
border-radius: 26px 26px 18px 18px;
|
||||
background: var(--sg-keyboard-key-surface-idle);
|
||||
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
||||
@@ -14,6 +22,7 @@
|
||||
/* ── Buttons area ──────────────────────────────────────────────────────── */
|
||||
|
||||
.mouse__buttons {
|
||||
flex-shrink: 0;
|
||||
height: 88px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 10px 1fr;
|
||||
|
||||
@@ -3,8 +3,6 @@ import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TuiCheckbox } from '@taiga-ui/core/components/checkbox';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
@@ -29,14 +27,13 @@ import { SessionsApiService } from '../../../core/services/sessions-api.service'
|
||||
import { SessionViewTabComponent } from './session-view-tab/session-view-tab.component';
|
||||
import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component';
|
||||
import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component';
|
||||
import { SessionFingerprintTabComponent } from './session-fingerprint-tab/session-fingerprint-tab.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-session-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiCheckbox,
|
||||
TuiLink,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
@@ -44,6 +41,7 @@ import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.com
|
||||
SessionViewTabComponent,
|
||||
SessionInteractiveTabComponent,
|
||||
SessionInfoTabComponent,
|
||||
SessionFingerprintTabComponent,
|
||||
],
|
||||
templateUrl: './session-detail.html',
|
||||
styleUrl: './session-detail.css',
|
||||
@@ -58,7 +56,7 @@ export class SessionDetailComponent {
|
||||
protected readonly telemetryToMs = signal<number | null>(null);
|
||||
protected readonly recordingStartMs = signal<number | null>(null);
|
||||
protected readonly recordingEndMs = signal<number | null>(null);
|
||||
protected readonly excludeMouseMoves = model(false);
|
||||
protected readonly excludeMouseMoves = model(true);
|
||||
protected readonly activeTabIndex = model(0);
|
||||
|
||||
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||
|
||||
@@ -6,26 +6,6 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.session-telemetry-filter-bar {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.session-telemetry-filter-bar__label {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.session-telemetry-filter-bar__text {
|
||||
font: var(--tui-font-text-s);
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
|
||||
.session-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back">
|
||||
<a tuiLink routerLink="/">← К списку сессий</a>
|
||||
<a tuiLink routerLink="/sessions">← К списку сессий</a>
|
||||
</nav>
|
||||
|
||||
@if (vm$ | async; as state) {
|
||||
@@ -19,24 +19,11 @@
|
||||
<tui-tabs class="session-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Просмотр</button>
|
||||
<button tuiTab type="button">Интерактивный режим</button>
|
||||
<button tuiTab type="button">Отпечаток системы</button>
|
||||
<button tuiTab type="button">Служебная информация</button>
|
||||
</tui-tabs>
|
||||
|
||||
@if (telemetry$ | async; as telemetryState) {
|
||||
@if (activeTabIndex() === 0 || activeTabIndex() === 1) {
|
||||
<div class="session-telemetry-filter-bar">
|
||||
<label class="session-telemetry-filter-bar__label">
|
||||
<input
|
||||
type="checkbox"
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
[ngModel]="excludeMouseMoves()"
|
||||
(ngModelChange)="excludeMouseMoves.set($event)"
|
||||
/>
|
||||
<span class="session-telemetry-filter-bar__text">Исключить перемещения мыши</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<app-session-view-tab
|
||||
@@ -44,7 +31,7 @@
|
||||
[telemetryState]="telemetryState"
|
||||
[recordingStartMs]="recordingStartMs()"
|
||||
[recordingEndMs]="recordingEndMs()"
|
||||
[excludeMouseMoves]="excludeMouseMoves()"
|
||||
[(excludeMouseMoves)]="excludeMouseMoves"
|
||||
(telemetryToMsChange)="telemetryToMs.set($event)"
|
||||
/>
|
||||
}
|
||||
@@ -58,6 +45,9 @@
|
||||
/>
|
||||
}
|
||||
@case (2) {
|
||||
<app-session-fingerprint-tab [sessionId]="state.id" />
|
||||
}
|
||||
@case (3) {
|
||||
<app-session-info-tab
|
||||
[detail]="state.detail"
|
||||
[telemetryState]="telemetryState"
|
||||
|
||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiTextarea } from '@taiga-ui/kit/components/textarea';
|
||||
|
||||
import { applyKeyboardPressToTranscriptBuffer } from '../../../../core/keyboard/keyboard-transcript.util';
|
||||
import {
|
||||
isKeyboardTelemetryEvent,
|
||||
parseKeyboardAction,
|
||||
@@ -21,19 +24,19 @@ import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.compone
|
||||
import { MouseViewComponent } from '../../mouse-view/mouse-view.component';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Окно видимости нажатия клавиши (мс). Должно быть > интервала timeupdate (~250ms),
|
||||
* чтобы каждое нажатие попало хотя бы в один кадр курсора.
|
||||
*/
|
||||
const KEY_DISPLAY_MS = 400;
|
||||
const INTERACTIVE_PREROLL_MS = Number.isFinite(environment.interactivePrerollMs)
|
||||
? Math.max(0, environment.interactivePrerollMs)
|
||||
: 4000;
|
||||
|
||||
/** Минимальная длительность подсветки нажатия (мс); при перекрытии побеждает более позднее нажатие. */
|
||||
const KEY_HIGHLIGHT_MIN_MS = 500;
|
||||
|
||||
@Component({
|
||||
selector: 'app-session-interactive-tab',
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiButton,
|
||||
...TuiTextarea,
|
||||
HlsPlayerComponent,
|
||||
StreamSelectorComponent,
|
||||
KeyboardViewComponent,
|
||||
@@ -50,13 +53,12 @@ export class SessionInteractiveTabComponent {
|
||||
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
||||
readonly recordingStartMs = input.required<number | null>();
|
||||
readonly recordingEndMs = input.required<number | null>();
|
||||
readonly excludeMouseMoves = input<boolean>(false);
|
||||
readonly excludeMouseMoves = input<boolean>(true);
|
||||
|
||||
protected readonly selectedStreamType = signal<string | null>(null);
|
||||
protected readonly timelineSec = signal(0);
|
||||
protected readonly durationSec = signal<number | null>(null);
|
||||
protected readonly isPlaying = signal(false);
|
||||
|
||||
protected readonly timelineMaxSec = computed(() => {
|
||||
const videoDuration = this.durationSec();
|
||||
if (videoDuration != null && Number.isFinite(videoDuration) && videoDuration > 0) {
|
||||
@@ -111,6 +113,68 @@ export class SessionInteractiveTabComponent {
|
||||
.sort((a, b) => a.timestamp - b.timestamp),
|
||||
);
|
||||
|
||||
/**
|
||||
* Только `press`. Группировка по целой секунде unix-времени: floor(ts/1000)*1000.
|
||||
* Внутри секунды n нажатий в порядке телеметрии получают непересекающиеся интервалы
|
||||
* [T + i/n·1000, T + (i+1)/n·1000); фактическая подсветка держится минимум KEY_HIGHLIGHT_MIN_MS.
|
||||
*/
|
||||
private readonly keyboardPressDisplaySlots = computed(
|
||||
(): { startMs: number; endMs: number; keyIds: string[]; data: unknown }[] => {
|
||||
const events = this.sortedKeyboardEvents();
|
||||
type RawPress = { ts: number; keyIds: string[]; data: unknown };
|
||||
const raw: RawPress[] = [];
|
||||
for (const event of events) {
|
||||
if (parseKeyboardAction(event.data) !== 'press') {
|
||||
continue;
|
||||
}
|
||||
const keyIds = parseKeyboardHighlightKeyIds(event.data);
|
||||
if (keyIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const ts = event.timestamp;
|
||||
if (!Number.isFinite(ts)) {
|
||||
continue;
|
||||
}
|
||||
raw.push({ ts, keyIds, data: event.data });
|
||||
}
|
||||
const out: { startMs: number; endMs: number; keyIds: string[]; data: unknown }[] = [];
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
const secStartMs = Math.floor(raw[i]!.ts / 1000) * 1000;
|
||||
let j = i + 1;
|
||||
while (j < raw.length && Math.floor(raw[j]!.ts / 1000) * 1000 === secStartMs) {
|
||||
j++;
|
||||
}
|
||||
const n = j - i;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const startMs = secStartMs + (k / n) * 1000;
|
||||
const endMs = secStartMs + ((k + 1) / n) * 1000;
|
||||
out.push({ startMs, endMs, keyIds: raw[i + k]!.keyIds, data: raw[i + k]!.data });
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Текст, «набранный» к моменту позиции на timeline (порядок с тем же распределением по секунде).
|
||||
*/
|
||||
protected readonly interactiveTypedText = computed((): string => {
|
||||
const cursorMs = this.cursorMs();
|
||||
if (cursorMs == null) {
|
||||
return '';
|
||||
}
|
||||
let buffer = '';
|
||||
for (const slot of this.keyboardPressDisplaySlots()) {
|
||||
if (slot.startMs > cursorMs) {
|
||||
break;
|
||||
}
|
||||
buffer = applyKeyboardPressToTranscriptBuffer(buffer, slot.data);
|
||||
}
|
||||
return buffer;
|
||||
});
|
||||
|
||||
/**
|
||||
* Pre-sorted mouse events — recomputed only when telemetryEvents changes.
|
||||
*/
|
||||
@@ -126,32 +190,26 @@ export class SessionInteractiveTabComponent {
|
||||
);
|
||||
|
||||
/**
|
||||
* Set of key IDs that were pressed in the window [cursorMs - KEY_DISPLAY_MS, cursorMs].
|
||||
* Iterates backwards (newest first), deduplicates by keyId.
|
||||
* Клавиши слота с наибольшим `startMs`, для которого cursorMs попадает в
|
||||
* [startMs, max(endMs, startMs + KEY_HIGHLIGHT_MIN_MS)).
|
||||
*/
|
||||
protected readonly pressedKeyIds = computed((): ReadonlySet<string> => {
|
||||
const cursorMs = this.cursorMs();
|
||||
if (cursorMs == null) {
|
||||
return new Set();
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const events = this.sortedKeyboardEvents();
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const event = events[i]!;
|
||||
if (event.timestamp > cursorMs) {
|
||||
const slots = this.keyboardPressDisplaySlots();
|
||||
for (let idx = slots.length - 1; idx >= 0; idx--) {
|
||||
const s = slots[idx]!;
|
||||
if (s.startMs > cursorMs) {
|
||||
continue;
|
||||
}
|
||||
if (event.timestamp < cursorMs - KEY_DISPLAY_MS) {
|
||||
break;
|
||||
}
|
||||
if (parseKeyboardAction(event.data) !== 'press') {
|
||||
continue;
|
||||
}
|
||||
for (const keyId of parseKeyboardHighlightKeyIds(event.data)) {
|
||||
seen.add(keyId);
|
||||
const visibleUntilMs = Math.max(s.endMs, s.startMs + KEY_HIGHLIGHT_MIN_MS);
|
||||
if (cursorMs < visibleUntilMs) {
|
||||
return new Set(s.keyIds);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
return new Set();
|
||||
});
|
||||
|
||||
protected readonly mouseTargets = computed(() => {
|
||||
@@ -171,6 +229,53 @@ export class SessionInteractiveTabComponent {
|
||||
return [] as MouseHighlightTarget[];
|
||||
});
|
||||
|
||||
protected readonly heatmapBuckets = computed(() => {
|
||||
const BUCKETS_COUNT = 150; // Более точная разбивка (150 баров)
|
||||
const events = this.telemetryEvents();
|
||||
if (!events?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const startMs = this.telemetryAnchorStartMs();
|
||||
const durationSec = this.timelineMaxSec();
|
||||
|
||||
if (startMs == null || durationSec <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const buckets = new Array(BUCKETS_COUNT).fill(0);
|
||||
const bucketDurationMs = (durationSec * 1000) / BUCKETS_COUNT;
|
||||
|
||||
let maxCount = 0;
|
||||
for (const e of events) {
|
||||
if (!Number.isFinite(e.timestamp)) {
|
||||
continue;
|
||||
}
|
||||
// Относительное время от старта
|
||||
const offsetMs = e.timestamp - startMs;
|
||||
if (offsetMs < 0 || offsetMs > durationSec * 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bucketIdx = Math.min(Math.floor(offsetMs / bucketDurationMs), BUCKETS_COUNT - 1);
|
||||
buckets[bucketIdx]++;
|
||||
if (buckets[bucketIdx] > maxCount) {
|
||||
maxCount = buckets[bucketIdx];
|
||||
}
|
||||
}
|
||||
|
||||
return buckets.map((count, idx) => {
|
||||
// Нормализуем opacity: фоновый цвет почти прозрачен, активные ярче.
|
||||
const rawOpacity = count > 0 ? Math.max(0.15, count / maxCount) : 0.03;
|
||||
return {
|
||||
index: idx,
|
||||
count,
|
||||
opacity: rawOpacity,
|
||||
startSec: (idx * bucketDurationMs) / 1000,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
protected activeStreamType(): string | null {
|
||||
const streams = this.detail().streams;
|
||||
if (!streams?.length) {
|
||||
|
||||
@@ -166,10 +166,20 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.input-preview app-keyboard-view {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.input-preview app-mouse-view {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mouse-sidebar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -178,3 +188,70 @@
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.interactive-transcript-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.interactive-transcript-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heatmap-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
display: flex;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: var(--tui-radius-s);
|
||||
overflow: hidden;
|
||||
background: var(--tui-background-base-alt, #f5f5f6);
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-bucket {
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.heatmap-bucket:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.heatmap-cursor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--tui-primary, #3872c2);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 4px var(--tui-primary, rgba(56, 114, 194, 0.4));
|
||||
}
|
||||
|
||||
.interactive-transcript-hint {
|
||||
margin: 0;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="Интерактивный режим: клавиатура">
|
||||
<section class="card" aria-label="Интерактивный режим: ввод">
|
||||
<div class="input-preview">
|
||||
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
|
||||
<div class="mouse-sidebar">
|
||||
@@ -139,3 +139,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card heatmap-card" aria-label="Интерактивный режим: тепловая карта">
|
||||
<h3 class="heatmap-title">Тепловая карта событий телеметрии</h3>
|
||||
<div class="heatmap-container">
|
||||
@for (bucket of heatmapBuckets(); track bucket.index) {
|
||||
<div
|
||||
class="heatmap-bucket"
|
||||
[style.background-color]="bucket.count > 0 ? 'color-mix(in srgb, var(--sg-color-accent, #ffdb00) ' + (bucket.opacity * 100) + '%, transparent)' : 'transparent'"
|
||||
[attr.title]="'Событий: ' + bucket.count"
|
||||
(click)="setTimelineSec(bucket.startSec.toString())"
|
||||
></div>
|
||||
}
|
||||
@if (timelineMaxSec() > 0) {
|
||||
<div
|
||||
class="heatmap-cursor"
|
||||
[style.left.%]="(timelineSec() / timelineMaxSec()) * 100"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="Интерактивный режим: текст">
|
||||
<div class="interactive-transcript-wrap">
|
||||
<tui-textfield class="interactive-transcript-field sg-tui-textfield">
|
||||
<label tuiLabel for="interactive-transcript">Набранный текст по телеметрии</label>
|
||||
<textarea
|
||||
id="interactive-transcript"
|
||||
tuiTextarea
|
||||
[min]="4"
|
||||
[max]="16"
|
||||
[ngModel]="interactiveTypedText()"
|
||||
[readOnly]="true"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</tui-textfield>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { NgClass } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, input, model, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TuiCheckbox } from '@taiga-ui/core/components/checkbox';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
@@ -22,7 +24,9 @@ import type { TelemetryLoadState, TelemetryRangeSelection } from '../session-det
|
||||
selector: 'app-session-view-tab',
|
||||
imports: [
|
||||
NgClass,
|
||||
FormsModule,
|
||||
TuiButton,
|
||||
TuiCheckbox,
|
||||
TuiChip,
|
||||
TuiLoader,
|
||||
SessionStatusChipClassesPipe,
|
||||
@@ -60,7 +64,7 @@ export class SessionViewTabComponent {
|
||||
readonly telemetryState = input.required<TelemetryLoadState>();
|
||||
readonly recordingStartMs = input.required<number | null>();
|
||||
readonly recordingEndMs = input.required<number | null>();
|
||||
readonly excludeMouseMoves = input<boolean>(false);
|
||||
readonly excludeMouseMoves = model(true);
|
||||
readonly telemetryToMsChange = output<number>();
|
||||
|
||||
protected readonly selectedStreamType = signal<string | null>(null);
|
||||
|
||||
@@ -21,6 +21,30 @@
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.telemetry-head__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.telemetry-exclude-moves {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.telemetry-exclude-moves__text {
|
||||
font: var(--tui-font-text-s);
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
.telemetry-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
</h3>
|
||||
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
||||
</div>
|
||||
<div class="telemetry-head__side">
|
||||
<div class="telemetry-actions">
|
||||
<div class="telemetry-presets">
|
||||
<button
|
||||
@@ -113,6 +114,17 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="telemetry-exclude-moves">
|
||||
<input
|
||||
type="checkbox"
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
[ngModel]="excludeMouseMoves()"
|
||||
(ngModelChange)="excludeMouseMoves.set($event)"
|
||||
/>
|
||||
<span class="telemetry-exclude-moves__text">Исключить перемещения мыши</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-content">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { UserErrorNotifyService } from '../../../core/notifications/user-error-n
|
||||
import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe';
|
||||
import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe';
|
||||
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||
import { DEFAULT_PAGE_LIMIT } from '../../../core/config/app.tokens';
|
||||
import { formatTimestamp } from '../../../shared/utils/date-time.util';
|
||||
|
||||
@Component({
|
||||
@@ -50,7 +51,7 @@ export class SessionsListComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
|
||||
protected readonly limit = 10;
|
||||
protected readonly limit = inject(DEFAULT_PAGE_LIMIT);
|
||||
protected readonly pageIndex = signal(0);
|
||||
|
||||
protected readonly titleControl = new FormControl('', {
|
||||
|
||||
Reference in New Issue
Block a user