Publish reviewed standalone otche-frontend sources

This commit is contained in:
omar
2026-09-23 17:15:07 +03:00
commit 162ba2d12b
23 changed files with 9383 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
**
!package.json
!package-lock.json
!tsconfig.json
!vite.config.ts
!index.html
!nginx.conf
!src/
!src/**
**/secrets/**
**/.env*
**/.git-credentials
**/.netrc
**/.npmrc
**/*.key
**/*.pem
**/*.pfx
**/*.p12
**/*.crt
**/*.cer
**/artifacts/**
**/*.zip
**/*.log
+25
View File
@@ -0,0 +1,25 @@
node_modules/
dist/
*.tsbuildinfo
artifacts/
.env
.env.*
!.env.example
secrets/
.runtime/
*.log
*.zip
*.pem
*.key
*.pfx
.git-credentials
.netrc
.npmrc
*.crt
*.cer
*.p12
coverage/
.vite/
playwright-report/
test-results/
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json vite.config.ts index.html ./
COPY src ./src
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
+30
View File
@@ -0,0 +1,30 @@
# Otche frontend
Canonical React 19 / TypeScript / Vite source for the Otche Windows/Defender observation UI. nginx serves the built assets and proxies same-origin `/api/` to the backend. It contains no PVE credentials, database credentials, sample files or demo verdicts.
## Repository ownership
This repository owns `src/`, frontend dependencies and lockfile, Vite configuration, nginx and the frontend Docker image. The Go API/worker/watchdog and unsigned Windows runner are owned by [otche-backend](https://git.qomar.pw/otche/otche-backend); Compose and complete installation instructions are owned by [otche-deploy](https://git.qomar.pw/otche/otche-deploy). Clone all three as siblings, not inside one another. Backend processes share one Go module; they are not copied here.
## Build and development
Node.js 22 and npm are required for the local build; Docker needs no host Node installation.
```sh
npm ci
npm run build
docker build -t otche-web:local .
```
`dist/`, dependencies, `.env` overrides and build metadata are generated/ignored. There are no external static assets or submodules required by the build. Keep `package-lock.json` tracked.
For local development, start the real API with a dedicated local database, `PUBLIC_ORIGIN=http://127.0.0.1:5173` and `ALLOW_INSECURE_HTTP=true`, then:
```sh
npm ci
npm run dev
```
Vite listens on loopback port 5173 and proxies `/api` to `http://127.0.0.1:8080`. Override that target with `OTCHE_API_PROXY` if needed; retain the exact browser origin at the API. Production nginx talks to Compose service `api:8080`; use the sibling deploy repository for the actual app, not a standalone frontend container with no API.
See the backend [API contract](https://git.qomar.pw/otche/otche-backend/src/branch/main/docs/API.md). Session cookies and CSRF checks are enforced by the API. Missing configuration/qualification is displayed as a blocker, never converted into fake successful analysis. Build labels come from verified revision evidence, not release-name guesses. Grub archives are authenticated private downloads, not public static assets.
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080c15" />
<meta
name="description"
content="Отче — изолированная лаборатория наблюдения за программами Windows."
/>
<title>Отче — Software Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
client_max_body_size 0;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options DENY always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
}
location /assets/ {
try_files $uri =404;
expires 1y;
}
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
}
+1952
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "otche-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"prettier": "^3.9.8",
"typescript": "~5.7.2",
"vite": "^6.0.5"
}
}
+409
View File
@@ -0,0 +1,409 @@
import { useEffect, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import {
NavLink,
Navigate,
Outlet,
Route,
Routes,
useLocation,
Link,
} from "react-router-dom";
import {
Activity,
ArrowRight,
Box,
ChevronRight,
Cpu,
FlaskConical,
LayoutDashboard,
ListFilter,
LockKeyhole,
LogOut,
Menu,
ShieldCheck,
Terminal,
Users,
X,
} from "lucide-react";
import { api, ApiError, setSession } from "./api";
import type { Session } from "./models";
import { AuthContext, useAuth } from "./hooks";
import { ErrorState, Field, Loading, Notice } from "./components";
import { DashboardPage, EnvironmentsPage, JobsPage } from "./pages/Overview";
import { NewAnalysisPage } from "./pages/NewAnalysis";
import { JobDetailPage } from "./pages/JobDetail";
import { AdminPage } from "./pages/Admin";
function Brand() {
return (
<Link to="/" className="brand" aria-label="Отче — главная">
<span className="brand-symbol">
<FlaskConical size={25} strokeWidth={1.5} />
</span>
<span>
<strong>
ОТЧЕ<span className="brand-dot">.</span>
</strong>
<small>SOFTWARE LAB</small>
</span>
</Link>
);
}
function Login({ onLogin }: { onLogin: (session: Session) => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<Error | null>(null);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
try {
const session = await api<Session>("/auth/login", {
method: "POST",
body: { username, password },
});
setPassword("");
onLogin(session);
} catch (error) {
setError(error instanceof Error ? error : new Error("Не удалось войти"));
} finally {
setBusy(false);
}
};
return (
<main className="login-page">
<div className="login-story">
<Brand />
<div className="login-intro">
<div className="eyebrow">
<span className="live-dot" /> ИЗОЛИРОВАННАЯ ЛАБОРАТОРИЯ
</div>
<h1>
Не предполагать.
<br />
<span>Наблюдать.</span>
</h1>
<p>
Реальное поведение программы в Windows.
<br />
Видеозапись, события защиты и воспроизводимые условия — в одном
отчёте.
</p>
<div className="lab-diagram" aria-hidden="true">
<div>
<Box size={24} />
<span>ОБРАЗЕЦ</span>
</div>
<ChevronRight />
<div className="diagram-center">
<Cpu size={30} />
<span>WINDOWS</span>
</div>
<ChevronRight />
<div>
<Activity size={24} />
<span>НАБЛЮДЕНИЕ</span>
</div>
</div>
<div className="login-principles">
<span>
<ShieldCheck size={16} />
Изолированные полные клоны
</span>
<span>
<LockKeyhole size={16} />
Приватные результаты
</span>
</div>
</div>
<div className="login-footer mono">
ОТЧЕ / CONTROL PLANE <span>01 — ACCESS</span>
</div>
</div>
<div className="login-form-side">
<div className="login-card">
<div className="login-key">
<LockKeyhole size={24} />
</div>
<div className="eyebrow">ДОСТУП К ЛАБОРАТОРИИ</div>
<h2>С возвращением</h2>
<p className="muted">Войдите в рабочее пространство.</p>
<form onSubmit={submit}>
{error && <ErrorState error={error} />}
<Field label="Имя пользователя">
<input
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(event) => setUsername(event.target.value)}
required
maxLength={128}
/>
</Field>
<Field label="Пароль">
<input
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</Field>
<button className="button primary full" disabled={busy}>
{busy ? "Проверяем доступ…" : "Войти в лабораторию"}
<ArrowRight size={17} />
</button>
</form>
<div className="login-help">
<Terminal size={16} />
<p>
Первый запуск? Администратор создаёт начальную учётную запись на
сервере. Пароль по умолчанию не предусмотрен. За доступом
обратитесь к администратору.
</p>
</div>
</div>
<div className="privacy-line">
<ShieldCheck size={14} />
Авторизованный доступ · Приватные артефакты
</div>
</div>
</main>
);
}
function Shell() {
const { session, logout } = useAuth();
const location = useLocation();
const [open, setOpen] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => setOpen(false), [location.pathname]);
const navigation = [
{ to: "/", label: "Обзор", icon: LayoutDashboard },
{ to: "/new", label: "Новый анализ", icon: FlaskConical },
{ to: "/jobs", label: "Задания", icon: ListFilter },
{ to: "/environments", label: "Окружения", icon: Cpu },
];
return (
<div className="app-shell">
<a className="skip-link" href="#main">
Перейти к содержимому
</a>
{open && (
<button
type="button"
className="sidebar-scrim"
aria-label="Закрыть меню"
onClick={() => setOpen(false)}
/>
)}
<aside className={`sidebar ${open ? "open" : ""}`}>
<div className="sidebar-top">
<Brand />
<button
type="button"
className="icon-button mobile-only"
aria-label="Закрыть меню"
onClick={() => setOpen(false)}
>
<X size={20} />
</button>
</div>
<div className="nav-label">РАБОЧЕЕ ПРОСТРАНСТВО</div>
<nav aria-label="Основная навигация">
{navigation.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
isActive ? "nav-link active" : "nav-link"
}
>
<Icon size={19} />
<span>{label}</span>
<ChevronRight size={14} className="nav-chevron" />
</NavLink>
))}
</nav>
{session.user.role === "admin" && (
<>
<div className="nav-label">УПРАВЛЕНИЕ</div>
<NavLink
to="/admin"
className={({ isActive }) =>
isActive ? "nav-link active" : "nav-link"
}
>
<Users size={19} />
<span>Администрирование</span>
</NavLink>
</>
)}
<div className="sidebar-bottom">
<div className="user-card">
<div className="avatar">
{session.user.username.slice(0, 2).toUpperCase()}
</div>
<div className="user-info">
<strong>{session.user.username}</strong>
<small>
{session.user.role === "admin" ? "Администратор" : "Оператор"}
</small>
</div>
<button
type="button"
className="icon-button"
aria-label="Выйти"
title="Выйти"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await logout();
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Не удалось выйти"),
);
} finally {
setBusy(false);
}
}}
>
<LogOut size={18} />
</button>
</div>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div className="breadcrumb">
<button
type="button"
className="icon-button mobile-only"
aria-label="Открыть меню"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
<Menu size={20} />
</button>
<span>Лаборатория</span>
<ChevronRight size={13} />
<span>
{location.pathname === "/"
? "Обзор"
: location.pathname.startsWith("/new")
? "Новый анализ"
: location.pathname.startsWith("/jobs")
? "Задания"
: location.pathname.startsWith("/admin")
? "Администрирование"
: "Окружения"}
</span>
</div>
</header>
<main id="main" className="main-content">
{error && <ErrorState error={error} />}
<Outlet />
</main>
<footer className="workspace-footer">
<span>ОТЧЕ / SOFTWARE LAB</span>
<span>Факты вместо предположений</span>
</footer>
</div>
</div>
);
}
function AdminGuard({ children }: { children: ReactNode }) {
const { session } = useAuth();
return session.user.role === "admin" ? (
children
) : (
<Notice tone="warning">Этот раздел доступен только администратору.</Notice>
);
}
export function App() {
const [session, updateSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const acceptSession = (value: Session | null) => {
setSession(value);
updateSession(value);
};
const refresh = async () => {
setError(null);
try {
acceptSession(await api<Session>("/auth/session"));
} catch (error) {
if (error instanceof ApiError && error.status === 401)
acceptSession(null);
else
setError(error instanceof Error ? error : new Error("Ошибка сессии"));
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
const expired = () => acceptSession(null);
window.addEventListener("otche:session-expired", expired);
return () => window.removeEventListener("otche:session-expired", expired);
}, []);
if (loading)
return (
<div className="boot-screen">
<Brand />
<Loading label="Подключаемся к лаборатории…" />
</div>
);
if (error)
return (
<div className="boot-screen">
<Brand />
<div className="boot-error">
<ErrorState error={error} retry={() => void refresh()} />
<p className="muted">
Интерфейс не подменяет недоступный сервер демонстрационными данными.
</p>
</div>
</div>
);
if (!session) return <Login onLogin={acceptSession} />;
return (
<AuthContext.Provider
value={{
session,
refresh,
logout: async () => {
await api("/auth/logout", { method: "POST" });
acceptSession(null);
},
}}
>
<Routes>
<Route element={<Shell />}>
<Route index element={<DashboardPage />} />
<Route path="new" element={<NewAnalysisPage />} />
<Route path="jobs" element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="environments" element={<EnvironmentsPage />} />
<Route
path="admin"
element={
<AdminGuard>
<AdminPage />
</AdminGuard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthContext.Provider>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { Session, Upload } from "./models";
const base = "/api/v1";
let csrf = "";
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
export function setSession(session: Session | null) {
csrf = session?.csrf_token ?? "";
}
function errorFrom(status: number, body: unknown): ApiError {
if (body && typeof body === "object" && "error" in body) {
const error = body.error;
if (
error &&
typeof error === "object" &&
"code" in error &&
"message" in error &&
typeof error.code === "string" &&
typeof error.message === "string"
) {
return new ApiError(status, error.code, error.message);
}
}
return new ApiError(
status,
"request_failed",
`Сервер вернул HTTP ${status}. Повторите запрос позднее.`,
);
}
export async function api<T>(
path: string,
options: { method?: string; body?: unknown; signal?: AbortSignal } = {},
): Promise<T> {
if (!path.startsWith("/") || path.startsWith("//"))
throw new Error("Недопустимый путь API");
const method = options.method ?? "GET";
const headers: Record<string, string> = { Accept: "application/json" };
if (options.body !== undefined) headers["Content-Type"] = "application/json";
if (method !== "GET" && csrf) headers["X-CSRF-Token"] = csrf;
let response: Response;
try {
response = await fetch(base + path, {
method,
headers,
credentials: "include",
body:
options.body === undefined ? undefined : JSON.stringify(options.body),
signal: options.signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
throw error;
throw new ApiError(
0,
"network_error",
"Нет связи с сервером. Проверьте подключение и повторите запрос.",
);
}
if (response.status === 204) return undefined as T;
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
if (
response.status === 401 &&
path !== "/auth/login" &&
path !== "/auth/session"
)
window.dispatchEvent(new Event("otche:session-expired"));
throw errorFrom(response.status, body);
}
if (body === null)
throw new ApiError(
response.status,
"invalid_response",
"Сервер вернул некорректный ответ.",
);
return body as T;
}
export function uploadFile(
file: File,
progress: (percent: number | null) => void,
signal: AbortSignal,
): Promise<Upload> {
const { promise, resolve, reject } = Promise.withResolvers<Upload>();
const xhr = new XMLHttpRequest();
xhr.open("POST", base + "/uploads");
xhr.withCredentials = true;
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("X-CSRF-Token", csrf);
xhr.upload.onprogress = (event) =>
progress(
event.lengthComputable
? Math.round((event.loaded * 100) / event.total)
: null,
);
const abort = () => xhr.abort();
signal.addEventListener("abort", abort, { once: true });
xhr.onloadend = () => signal.removeEventListener("abort", abort);
xhr.onload = () => {
let body: unknown;
try {
body = JSON.parse(xhr.responseText);
} catch {
reject(
new ApiError(
xhr.status,
"invalid_response",
"Не удалось прочитать ответ загрузки.",
),
);
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
if (
body &&
typeof body === "object" &&
"id" in body &&
typeof body.id === "string" &&
"filename" in body &&
typeof body.filename === "string" &&
"size" in body &&
typeof body.size === "number" &&
"sha256" in body &&
typeof body.sha256 === "string" &&
"created_at" in body &&
typeof body.created_at === "string"
) {
resolve({
id: body.id,
filename: body.filename,
size: body.size,
sha256: body.sha256,
created_at: body.created_at,
});
} else
reject(
new ApiError(
xhr.status,
"invalid_response",
"Сервер не подтвердил метаданные загруженного файла.",
),
);
} else {
if (xhr.status === 401)
window.dispatchEvent(new Event("otche:session-expired"));
reject(errorFrom(xhr.status, body));
}
};
xhr.onerror = () =>
reject(
new ApiError(
0,
"network_error",
"Соединение прервано. Файл не подтверждён сервером.",
),
);
xhr.onabort = () =>
reject(new DOMException("Загрузка отменена", "AbortError"));
if (signal.aborted) {
reject(new DOMException("Загрузка отменена", "AbortError"));
return promise;
}
xhr.send(file);
return promise;
}
export function artifactUrl(id: string) {
return `${base}/artifacts/${encodeURIComponent(id)}/content`;
}
export function uploadUrl(id: string) {
return `${base}/uploads/${encodeURIComponent(id)}/content`;
}
export const idPath = (id: string) => encodeURIComponent(id);
+416
View File
@@ -0,0 +1,416 @@
import type { ReactNode } from "react";
import {
AlertTriangle,
ArrowRight,
Check,
CircleHelp,
Copy,
LoaderCircle,
RefreshCw,
Shield,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
const labels: Record<string, string> = {
queued: "В очереди",
running: "Наблюдение",
completed: "Завершено",
cancelled: "Отменено",
failed: "Ошибка",
passed: "Проверка пройдена",
provisioning: "Создание клона",
booting: "Загрузка Windows",
recording: "Видеозапись",
delivering: "Доставка файла",
preparing: "Подготовка",
collecting: "Сбор результатов",
stopping: "Остановка",
cleanup: "Очистка",
finished: "Завершено",
pending: "Ожидается",
executed: "Запущено",
blocked_before_execution: "Не запущено: блокировка",
incompatible: "Несовместимый формат",
policy_blocked: "Заблокировано политикой",
delivery_error: "Ошибка доставки",
interrupted: "Прервано",
error: "Ошибка",
unknown: "Нет достоверных данных",
detected: "Обнаружение Defender",
not_observed: "Детект не наблюдался",
complete: "Полная",
partial: "Частичная",
unavailable: "Недоступна",
evidence_held: "Доказательства сохранены",
qualified: "Квалифицирован",
unqualified: "Не квалифицирован",
drifted: "Обнаружены изменения",
maintenance: "Обслуживание",
published: "Опубликован",
healthy: "Доступно",
ready: "Готово",
not_configured: "Не настроено",
unreachable: "Нет соединения",
offline: "Без интернета",
online: "С интернетом",
disabled: "Отключено",
};
export function windowsLabel(name: string, osBuild: string | null) {
return `${name} — build ${osBuild ?? "не определён"}`;
}
export function Status({ value, label }: { value: string; label?: string }) {
const tone = [
"detected",
"failed",
"error",
"unreachable",
"drifted",
].includes(value)
? "danger"
: [
"complete",
"completed",
"qualified",
"healthy",
"ready",
"passed",
].includes(value)
? "success"
: [
"partial",
"unqualified",
"maintenance",
"evidence_held",
"not_configured",
"policy_blocked",
"blocked_before_execution",
].includes(value)
? "warning"
: ["running", "recording", "provisioning"].includes(value)
? "active"
: "neutral";
return (
<span className={`status ${tone}`}>
<span className="status-dot" />
{label ?? labels[value] ?? value}
</span>
);
}
export function PageHeader({
eyebrow,
title,
description,
action,
}: {
eyebrow?: string;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
{action && <div className="page-action">{action}</div>}
</header>
);
}
export function Panel({
title,
subtitle,
action,
children,
className = "",
}: {
title?: string;
subtitle?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={`panel ${className}`}>
{title && (
<div className="panel-heading">
<div>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
{action}
</div>
)}
{children}
</section>
);
}
export function Loading({ label = "Получаем данные…" }: { label?: string }) {
return (
<div className="state loading" role="status">
<LoaderCircle className="spin" size={24} />
<span>{label}</span>
</div>
);
}
export function ErrorState({
error,
retry,
}: {
error: Error;
retry?: () => void;
}) {
return (
<div className="notice danger" role="alert">
<AlertTriangle size={19} />
<div>
<strong>Не удалось выполнить запрос</strong>
<p>{error.message}</p>
{retry && (
<button
type="button"
className="button small secondary"
onClick={retry}
>
<RefreshCw size={14} />
Повторить
</button>
)}
</div>
</div>
);
}
export function Empty({
title,
children,
action,
}: {
title: string;
children?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="state empty">
<div className="empty-icon">
<CircleHelp size={28} />
</div>
<h3>{title}</h3>
{children && <p>{children}</p>}
{action}
</div>
);
}
export function Notice({
children,
tone = "info",
}: {
children: ReactNode;
tone?: "info" | "warning" | "danger" | "success";
}) {
return (
<div className={`notice ${tone}`}>
{tone === "warning" || tone === "danger" ? (
<AlertTriangle size={18} />
) : (
<Shield size={18} />
)}
<div>{children}</div>
</div>
);
}
export function Field({
label,
hint,
children,
className = "",
}: {
label: string;
hint?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<label className={`field ${className}`}>
<span className="field-label">{label}</span>
{children}
{hint && <span className="field-hint">{hint}</span>}
</label>
);
}
export function CopyValue({
value,
truncate = false,
}: {
value: string;
truncate?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
return (
<span className={`copy-value ${truncate ? "truncate" : ""}`}>
<code title={value}>{value}</code>
<button
type="button"
className="icon-button"
aria-label="Скопировать значение"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setError(false);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setError(true);
}
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
{error && <small role="alert">Выделите текст для копирования</small>}
</span>
);
}
export function Metadata({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data).filter(
([, value]) => value !== undefined && value !== null,
);
return entries.length ? (
<dl className="metadata">
{entries.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>
{typeof value === "object" ? (
<pre>{JSON.stringify(value, null, 2)}</pre>
) : (
String(value)
)}
</dd>
</div>
))}
</dl>
) : (
<p className="muted small-text">Метаданные ещё не получены.</p>
);
}
export function Confirm({
title,
description,
confirm,
close,
busy,
danger = false,
}: {
title: string;
description: string;
confirm: () => void;
close: () => void;
busy: boolean;
danger?: boolean;
}) {
const dialog = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement;
dialog.current?.querySelector<HTMLButtonElement>("button")?.focus();
return () => {
if (previous instanceof HTMLElement) previous.focus();
};
}, []);
return (
<div
className="modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget && !busy) close();
}}
>
<section
ref={dialog}
className="modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) close();
if (event.key === "Tab") {
const buttons = dialog.current?.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
);
if (!buttons?.length) {
event.preventDefault();
return;
}
const first = buttons[0];
const last = buttons[buttons.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}}
>
<div className="panel-heading">
<h2 id="confirm-title">{title}</h2>
<button
autoFocus
type="button"
className="icon-button"
aria-label="Закрыть"
disabled={busy}
onClick={close}
>
<X size={20} />
</button>
</div>
<p>{description}</p>
<div className="actions">
<button
type="button"
className="button secondary"
disabled={busy}
onClick={close}
>
Назад
</button>
<button
type="button"
className={`button ${danger ? "destructive" : "primary"}`}
disabled={busy}
onClick={confirm}
>
{busy ? (
<LoaderCircle size={16} className="spin" />
) : (
<ArrowRight size={16} />
)}
Подтвердить
</button>
</div>
</section>
</div>
);
}
export function formatDate(value?: string | null) {
if (!value) return "Не зафиксировано";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "medium",
}).format(date);
}
export function formatBytes(value: number) {
if (value < 1024) return `${value} Б`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} КБ`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} МБ`;
return `${(value / 1024 ** 3).toFixed(2)} ГБ`;
}
export function durationLabel(value: number) {
return value < 300 ? `${value} сек` : `${value / 60} мин`;
}
+69
View File
@@ -0,0 +1,69 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import type { Session } from "./models";
import { api } from "./api";
export const AuthContext = createContext<{
session: Session;
refresh: () => Promise<void>;
logout: () => Promise<void>;
} | null>(null);
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("Необходима авторизация");
return value;
}
export function useQuery<T>(path: string | null, interval = 0) {
const [state, setState] = useState<{
data: T | null;
error: Error | null;
loading: boolean;
}>({ data: null, error: null, loading: true });
const [revision, setRevision] = useState(0);
const previousPath = useRef<string | null>(null);
const reload = useCallback(() => setRevision((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
let active = true;
let timer: number | undefined;
const samePath = previousPath.current === path;
previousPath.current = path;
setState((previous) =>
samePath
? { ...previous, error: null, loading: !previous.data && Boolean(path) }
: { data: null, error: null, loading: Boolean(path) },
);
if (!path) return () => controller.abort();
const load = async () => {
try {
const data = await api<T>(path, { signal: controller.signal });
if (active) setState({ data, error: null, loading: false });
} catch (error) {
if (active && !controller.signal.aborted)
setState((previous) => ({
...previous,
error:
error instanceof Error
? error
: new Error("Не удалось загрузить данные"),
loading: false,
}));
} finally {
if (active && interval) timer = window.setTimeout(load, interval);
}
};
void load();
return () => {
active = false;
controller.abort();
window.clearTimeout(timer);
};
}, [path, interval, revision]);
return { ...state, reload };
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("root");
if (!root) throw new Error("Отсутствует корневой элемент приложения");
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+256
View File
@@ -0,0 +1,256 @@
export type Role = "admin" | "operator";
export interface User {
id: string;
username: string;
role: Role;
disabled?: boolean;
created_at?: string;
}
export interface Session {
user: User;
csrf_token: string;
}
export interface Collection<T> {
items: T[];
total?: number;
page?: number;
page_size?: number;
}
export interface Upload {
id: string;
filename: string;
size: number;
sha256: string;
created_at: string;
}
export const durations = [30, 60, 90, 120, 180, 300, 600, 900, 1200] as const;
export const extensions = [
"exe",
"dll",
"scr",
"com",
"ps1",
"vbs",
"js",
"bat",
"cmd",
"msi",
] as const;
export interface Settings {
internet: "offline" | "online";
duration_seconds: number;
filename: "original" | "random";
privilege: "user" | "admin";
args_mode: "none" | "custom";
args: string[];
set_zoneid: boolean;
dll_mode: "rundll32" | "regsvr32";
dll_export: string;
architecture: "auto" | "x86" | "x64";
wsh_host: "cscript" | "wscript";
msi_ui: "full" | "passive" | "quiet";
grub_paths?: string[];
}
export interface Profile {
id: string;
name: string;
os: string;
architecture: string;
os_build: string | null;
enabled: boolean;
qualification: "unqualified" | "qualified" | "drifted";
state: "maintenance" | "published";
current_revision_id: string | null;
online_available: boolean;
reason?: string;
metadata?: Record<string, unknown>;
}
export interface Artifact {
id: string;
job_id: string;
attempt_id: string;
kind: string;
filename: string;
content_type: string;
size: number;
sha256: string;
created_at: string;
url: string;
}
export interface Attempt {
id: string;
run_id: string;
command_id: string;
phase: string;
outcome: string;
findings: string;
telemetry: string;
cleanup: string;
error?: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
deadline_at: string | null;
artifacts?: Artifact[];
report: AttemptReport | null;
}
export interface Run {
id: string;
job_id: string;
profile_id: string;
profile_name: string;
revision_id: string;
antivirus: "defender";
status: string;
os_build: string | null;
attempts: Attempt[];
}
export interface Job {
id: string;
owner_id: string;
upload_id: string;
filename: string;
execution_filename: string;
sha256: string;
size: number;
settings: Settings;
status: string;
created_at: string;
updated_at: string;
cancel_requested: boolean;
runs: Run[];
}
export interface JobEvent {
id: number;
job_id: string;
attempt_id: string;
kind: string;
message: string;
created_at: string;
}
export interface VideoManifest {
state: "pending" | "recording" | "complete" | "partial" | "unavailable";
segments: Artifact[];
gaps: { at: string; reason: string }[];
started_at: string | null;
finished_at: string | null;
}
export interface Health {
database: string;
worker: string;
last_worker_seen: string | null;
integration_ready: boolean;
blockers: string[];
}
export interface Binding {
owner_id: string;
pool: string;
iso_storage: string;
disk_storage: string;
node: string;
configured: boolean;
isolation_expires_at: string | null;
reason?: string;
}
export interface Dashboard {
metrics: {
jobs: number;
queued: number;
running: number;
completed: number;
failed: number;
cancelled: number;
detected: number;
};
recent_jobs: Job[];
queue: { queued: number; running: number };
integration: { ready: boolean; blockers: string[] };
}
export interface Revision {
id: string;
profile_id: string;
fingerprint: string;
config_digest: string;
qualification: { status?: string; [key: string]: unknown } | null;
created_at: string;
}
export interface Qualification {
id: string;
profile_id: string;
status: string;
created_at: string;
result: Record<string, unknown> | null;
}
export interface DefenderState {
active: boolean;
platform: string;
engine: string;
intelligence: string;
intelligence_updated_at: string;
fingerprint: string;
preferences: Record<string, unknown>;
}
export interface AttemptReport {
execution: {
exit_code: number | null;
error: string;
actual_duration_seconds: number | null;
started_at: string | null;
finished_at: string | null;
user: string;
session_id: number | null;
pid: number | null;
path: string;
arguments: string[];
handler: string;
privilege: string;
} | null;
defender: {
before: DefenderState | null;
after: DefenderState | null;
drift: boolean;
detections: {
name: string;
id: string;
action: string;
resources: string[];
timestamp: string;
stage: string;
source: string;
}[];
} | null;
environment?: {
os_build: string;
architecture: string;
powershell_version: string;
execution_policy: string;
runner_version: string;
} | null;
collection_errors: string[];
grub?: {
status: "complete" | "partial" | "empty";
requested: number;
collected: number;
files: {
requested_path: string;
resolved_path: string | null;
member: string | null;
status:
| "collected"
| "missing"
| "access_denied"
| "invalid_path"
| "changed"
| "oversize"
| "error";
error: string;
size: number | null;
sha256: string | null;
snapshot: {
open_size: number;
changed: boolean;
consistency: "best_effort";
} | null;
}[];
} | null;
}
+928
View File
@@ -0,0 +1,928 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
Check,
Cpu,
KeyRound,
Plus,
RefreshCw,
Save,
Server,
ShieldCheck,
Users,
Wrench,
} from "lucide-react";
import { api, idPath } from "../api";
import { useAuth, useQuery } from "../hooks";
import type {
Binding,
Collection,
Health,
Profile,
Qualification,
Revision,
Role,
User,
} from "../models";
import {
Confirm,
CopyValue,
Empty,
ErrorState,
Field,
formatDate,
Loading,
Metadata,
Notice,
PageHeader,
Panel,
Status,
windowsLabel,
} from "../components";
function UsersAdmin() {
const { session } = useAuth();
const query = useQuery<Collection<User>>("/admin/users");
const [form, setForm] = useState({
username: "",
password: "",
role: "operator" as Role,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [success, setSuccess] = useState("");
const [reset, setReset] = useState<User | null>(null);
const [password, setPassword] = useState("");
const [toggle, setToggle] = useState<User | null>(null);
const create = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
setSuccess("");
try {
await api("/admin/users", { method: "POST", body: form });
setForm({ username: "", password: "", role: "operator" });
setSuccess(
"Пользователь создан. Передайте пароль через защищённый канал.",
);
query.reload();
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Не удалось создать пользователя"),
);
} finally {
setBusy(false);
}
};
return (
<>
<div className="admin-two-column">
<Panel
title="Пользователи"
subtitle="Роли панели не равны правам запуска в Windows"
>
{query.error && (
<div className="panel-padding">
<ErrorState error={query.error} retry={query.reload} />
</div>
)}
{query.loading ? (
<Loading />
) : query.data?.items.length ? (
<div className="user-list">
{query.data.items.map((user) => (
<div className="admin-user" key={user.id}>
<div className="avatar">
{user.username.slice(0, 2).toUpperCase()}
</div>
<div className="admin-user-copy">
<strong>
{user.username}
{user.id === session.user.id && (
<span className="tag">Это вы</span>
)}
</strong>
<small>
{user.role === "admin" ? "Администратор" : "Оператор"} ·{" "}
{user.disabled ? "Отключён" : "Активен"}
</small>
<span className="mono muted small-text">{user.id}</span>
</div>
<div className="actions">
<button
type="button"
className="button secondary small"
disabled={busy}
onClick={() => {
setReset(user);
setPassword("");
}}
>
<KeyRound size={14} />
Пароль
</button>
<button
type="button"
className="button secondary small"
disabled={busy || user.id === session.user.id}
onClick={() => setToggle(user)}
>
{user.disabled ? "Включить" : "Отключить"}
</button>
</div>
</div>
))}
</div>
) : (
<Empty title="Пользователи не найдены" />
)}
</Panel>
<Panel
title="Новый пользователь"
subtitle="Пароль не сохраняется в интерфейсе"
>
<form onSubmit={create} className="panel-padding stacked-form">
<Field label="Имя пользователя">
<input
value={form.username}
autoComplete="off"
required
minLength={3}
maxLength={64}
onChange={(event) =>
setForm({ ...form, username: event.target.value })
}
/>
</Field>
<Field label="Начальный пароль">
<input
type="password"
autoComplete="new-password"
required
minLength={14}
maxLength={72}
value={form.password}
onChange={(event) =>
setForm({ ...form, password: event.target.value })
}
/>
</Field>
<Field label="Роль">
<select
value={form.role}
onChange={(event) =>
setForm({
...form,
role: event.target.value === "admin" ? "admin" : "operator",
})
}
>
<option value="operator">Оператор — только свои задания</option>
<option value="admin">Администратор</option>
</select>
</Field>
<button className="button primary" disabled={busy}>
<Plus size={16} />
Создать пользователя
</button>
</form>
</Panel>
</div>
{error && <ErrorState error={error} />}
{success && <Notice tone="success">{success}</Notice>}
{reset && (
<Panel title={`Новый пароль: ${reset.username}`}>
<form
className="panel-padding stacked-form"
onSubmit={async (event) => {
event.preventDefault();
setBusy(true);
setError(null);
try {
await api(`/admin/users/${idPath(reset.id)}`, {
method: "PATCH",
body: { password },
});
setPassword("");
setReset(null);
setSuccess(
"Пароль обновлён. Передайте его пользователю через защищённый канал.",
);
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Не удалось изменить пароль"),
);
} finally {
setBusy(false);
}
}}
>
<Field label="Новый пароль (14–72 байта UTF-8)">
<input
type="password"
autoComplete="new-password"
autoFocus
required
minLength={14}
maxLength={72}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<div className="actions">
<button className="button primary" disabled={busy}>
<Save size={15} />
Обновить пароль
</button>
<button
type="button"
className="button secondary"
disabled={busy}
onClick={() => {
setReset(null);
setPassword("");
}}
>
Отмена
</button>
</div>
</form>
</Panel>
)}
{toggle && (
<Confirm
title={
toggle.disabled
? "Включить пользователя?"
: "Отключить пользователя?"
}
description={`${toggle.username}: ${toggle.disabled ? "пользователь снова сможет входить в панель." : "вход в панель будет запрещён. История заданий останется в системе."}`}
busy={busy}
close={() => setToggle(null)}
confirm={async () => {
setBusy(true);
try {
await api(`/admin/users/${idPath(toggle.id)}`, {
method: "PATCH",
body: { disabled: !toggle.disabled },
});
query.reload();
setToggle(null);
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Не удалось изменить доступ"),
);
setToggle(null);
} finally {
setBusy(false);
}
}}
/>
)}
</>
);
}
function ProfileEditor({
profile,
refresh,
}: {
profile: Profile;
refresh: () => void;
}) {
const revisions = useQuery<Collection<Revision>>(
`/admin/profiles/${idPath(profile.id)}/revisions`,
);
const qualifications = useQuery<Collection<Qualification>>(
`/admin/profiles/${idPath(profile.id)}/qualifications`,
6000,
);
const [name, setName] = useState(profile.name);
const [enabled, setEnabled] = useState(profile.enabled);
const [revision, setRevision] = useState(profile.current_revision_id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [result, setResult] = useState<Record<string, unknown> | null>(null);
const [confirm, setConfirm] = useState<
"publish" | "maintenance" | "qualify" | null
>(null);
const [qualificationId, setQualificationId] = useState<string | null>(null);
const qualification = useQuery<Qualification>(
qualificationId ? `/admin/qualifications/${idPath(qualificationId)}` : null,
5000,
);
useEffect(() => {
setName(profile.name);
setEnabled(profile.enabled);
}, [profile.name, profile.enabled]);
useEffect(
() => setRevision(profile.current_revision_id ?? ""),
[profile.current_revision_id],
);
const act = async (action: string, body?: unknown) => {
setBusy(true);
setError(null);
setResult(null);
try {
const response = await api<Record<string, unknown>>(
`/admin/profiles/${idPath(profile.id)}${action ? "/" + action : ""}`,
{ method: action ? "POST" : "PATCH", body },
);
setResult(response);
refresh();
revisions.reload();
qualifications.reload();
if (action === "qualify" && typeof response.id === "string")
setQualificationId(response.id);
} catch (error) {
setError(
error instanceof Error ? error : new Error("Операция не выполнена"),
);
} finally {
setBusy(false);
setConfirm(null);
}
};
return (
<Panel
title={windowsLabel(profile.name, profile.os_build)}
subtitle={`${profile.os} · ${profile.architecture}`}
action={<Status value={profile.qualification} />}
>
<div className="panel-padding">
<div className="tag-row">
<Status value={profile.state} />
<span className="tag">
{profile.enabled ? "Включён" : "Отключён"}
</span>
<span className="tag">
{profile.online_available
? "Online квалифицирован"
: "Online не допущен"}
</span>
</div>
{profile.reason && <Notice tone="warning">{profile.reason}</Notice>}
{error && <ErrorState error={error} />}
<form
className="profile-edit-form"
onSubmit={(event) => {
event.preventDefault();
void act("", { name, enabled });
}}
>
<Field label="Название профиля">
<input
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<label className="checkbox-field">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>Разрешён для допуска</span>
</label>
<button className="button secondary" disabled={busy}>
<Save size={15} />
Сохранить
</button>
</form>
<div className="form-grid">
<div>
<h3>Ревизия исходной Windows</h3>
<p className="field-hint">
Только зарегистрированные worker ревизии остановленного обычного
master. Полный клон текущего состояния, без snapshot-source.
</p>
{revisions.error && (
<ErrorState error={revisions.error} retry={revisions.reload} />
)}
{revisions.loading ? (
<Loading />
) : revisions.data?.items.length ? (
<>
<Field label="Ревизия для публикации">
<select
value={revision}
onChange={(event) => setRevision(event.target.value)}
>
<option value="">Выберите ревизию</option>
{revisions.data.items.map((item) => (
<option key={item.id} value={item.id}>
{item.id.slice(0, 8)} ·{" "}
{item.qualification?.status ?? "Не проверена"} ·{" "}
{formatDate(item.created_at)}
</option>
))}
</select>
</Field>
<div className="actions">
<button
type="button"
className="button secondary small"
disabled={busy || !revision}
onClick={() => setConfirm("publish")}
>
<Check size={15} />
Опубликовать
</button>
<button
type="button"
className="button secondary small"
disabled={busy}
onClick={() => setConfirm("qualify")}
>
<ShieldCheck size={15} />
Квалифицировать
</button>
</div>
<details>
<summary>Отпечатки ревизий</summary>
{revisions.data.items.map((item) => (
<Metadata
key={item.id}
data={{
id: item.id,
fingerprint: item.fingerprint,
config_digest: item.config_digest,
qualification: item.qualification,
}}
/>
))}
</details>
</>
) : (
<Notice tone="warning">
Нет зарегистрированных ревизий. Оператор инфраструктуры должен
подготовить и зарегистрировать source через worker CLI и
файловую конфигурацию. VMID и секреты в браузере не задаются.
</Notice>
)}
</div>
<div>
<h3>Обслуживание исходника</h3>
<p className="field-hint">
Сначала закрывается допуск и проверяется завершение клонирования.
Этот запрос не выключает работающий master и не обновляет его
автоматически.
</p>
<button
type="button"
className="button secondary small"
disabled={busy || profile.state === "maintenance"}
onClick={() => setConfirm("maintenance")}
>
<Wrench size={15} />
Перевести в обслуживание
</button>
<dl className="compact-details">
<div>
<dt>Текущая ревизия</dt>
<dd>
{profile.current_revision_id ? (
<CopyValue value={profile.current_revision_id} truncate />
) : (
"Не назначена"
)}
</dd>
</div>
</dl>
</div>
</div>
{result && (
<details open>
<summary>Ответ операции</summary>
<Metadata data={result} />
</details>
)}
<details>
<summary>Наблюдаемые метаданные профиля</summary>
<Metadata data={profile.metadata ?? {}} />
</details>
<h3>Проверки квалификации</h3>
{qualifications.error && (
<ErrorState
error={qualifications.error}
retry={qualifications.reload}
/>
)}
{qualifications.loading ? (
<Loading />
) : qualifications.data?.items.length ? (
<div className="qualification-list">
{qualifications.data.items.map((item) => (
<button
type="button"
className={qualificationId === item.id ? "selected" : ""}
key={item.id}
onClick={() => setQualificationId(item.id)}
>
<span className="mono">{item.id.slice(0, 8)}</span>
<span>{formatDate(item.created_at)}</span>
<Status value={item.status} />
</button>
))}
</div>
) : (
<p className="muted">Квалификационные попытки ещё не создавались.</p>
)}
{qualificationId && (
<div className="qualification-detail">
{qualification.error && (
<ErrorState
error={qualification.error}
retry={qualification.reload}
/>
)}
{qualification.loading ? (
<Loading />
) : (
qualification.data && (
<>
<Status value={qualification.data.status} />
<Metadata
data={
qualification.data.result ?? {
Состояние: "Результаты ещё не получены",
}
}
/>
</>
)
)}
</div>
)}
</div>
{confirm && (
<Confirm
title={
confirm === "publish"
? "Опубликовать ревизию?"
: confirm === "maintenance"
? "Начать обслуживание?"
: "Запустить квалификацию?"
}
description={
confirm === "publish"
? "Сервер проверит зарегистрированную ревизию и готовность остановленного исходника. Публикация не заменяет успешную квалификацию."
: confirm === "maintenance"
? "Допуск новых заданий будет закрыт. Дождитесь подтверждения дренирования перед ручным изменением исходника."
: "Контроль выполняется на отдельном одноразовом клоне, не на master и не в пользовательском прогоне. Нужна настроенная интеграция."
}
busy={busy}
close={() => setConfirm(null)}
confirm={() =>
void act(
confirm,
confirm === "publish" ? { revision_id: revision } : undefined,
)
}
/>
)}
</Panel>
);
}
function ProfilesAdmin() {
const query = useQuery<Collection<Profile>>("/admin/profiles");
const [selected, setSelected] = useState("");
const [form, setForm] = useState({ name: "", os: "", architecture: "x64" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<Error | null>(null);
const profile = query.data?.items.find((item) => item.id === selected);
return (
<>
<Notice>
Исходники — обслуживаемые обычные VM, не Proxmox Templates. VMID, узел,
CD-слот, runner и scoped credentials задаются инфраструктурным
оператором через worker CLI. Панель управляет допуском и публикацией
зарегистрированных ревизий.
</Notice>
<div className="admin-two-column">
<Panel title="Профили Windows" subtitle="Каталог и состояние допуска">
{query.error && (
<div className="panel-padding">
<ErrorState error={query.error} retry={query.reload} />
</div>
)}
{query.loading ? (
<Loading />
) : query.data?.items.length ? (
<div className="admin-profile-list">
{query.data.items.map((item) => (
<button
type="button"
className={selected === item.id ? "selected" : ""}
key={item.id}
onClick={() => setSelected(item.id)}
>
<Cpu size={23} />
<span>
<strong>{item.name}</strong>
<small>
{item.os} · {item.architecture}
</small>
</span>
<Status value={item.qualification} />
</button>
))}
</div>
) : (
<Empty title="Нет зарегистрированных профилей">
Создайте профиль, затем зарегистрируйте подготовленную ревизию на
worker.
</Empty>
)}
</Panel>
<Panel title="Создать профиль">
<form
className="panel-padding stacked-form"
onSubmit={async (event) => {
event.preventDefault();
setBusy(true);
setError(null);
try {
const created = await api<Profile>("/admin/profiles", {
method: "POST",
body: form,
});
setForm({ name: "", os: "", architecture: "x64" });
setSelected(created.id);
query.reload();
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Не удалось создать профиль"),
);
} finally {
setBusy(false);
}
}}
>
<Field label="Название">
<input
value={form.name}
required
placeholder="Windows 11 Pro · 25H2"
onChange={(event) =>
setForm({ ...form, name: event.target.value })
}
/>
</Field>
<Field label="Операционная система">
<input
value={form.os}
required
placeholder="Windows 11 Pro 25H2 English"
onChange={(event) =>
setForm({ ...form, os: event.target.value })
}
/>
</Field>
<Field label="Архитектура">
<select
value={form.architecture}
onChange={(event) =>
setForm({ ...form, architecture: event.target.value })
}
>
<option value="x64">x64</option>
<option value="x86">x86</option>
</select>
</Field>
<button className="button primary" disabled={busy}>
<Plus size={16} />
Создать профиль
</button>
{error && <ErrorState error={error} />}
</form>
</Panel>
</div>
{profile && (
<ProfileEditor
key={profile.id}
profile={profile}
refresh={query.reload}
/>
)}
</>
);
}
function IntegrationAdmin() {
const health = useQuery<Health>("/admin/health", 10000);
const bindings = useQuery<Collection<Binding>>("/admin/bindings", 10000);
return (
<>
<Panel
title="Состояние интеграции"
subtitle="Проверка базы, worker и допуска инфраструктуры"
action={
<button
type="button"
className="button secondary small"
onClick={() => {
health.reload();
bindings.reload();
}}
>
<RefreshCw size={14} />
Обновить
</button>
}
>
<div className="panel-padding">
{health.error && (
<ErrorState error={health.error} retry={health.reload} />
)}
{health.loading ? (
<Loading />
) : (
health.data && (
<>
<div className="integration-status-grid">
<div>
<Server size={23} />
<strong>База данных</strong>
<Status value={health.data.database} />
</div>
<div>
<Cpu size={23} />
<strong>Worker</strong>
<Status value={health.data.worker} />
</div>
<div>
<ShieldCheck size={23} />
<strong>Интеграция</strong>
<Status
value={
health.data.integration_ready
? "ready"
: "not_configured"
}
/>
</div>
</div>
<p className="muted small-text">
Последний heartbeat worker:{" "}
{formatDate(health.data.last_worker_seen)}
</p>
{health.data.blockers?.length > 0 && (
<Notice tone="warning">
<strong>Блокеры допуска</strong>
<ul>
{health.data.blockers.map((blocker, index) => (
<li key={index}>{blocker}</li>
))}
</ul>
</Notice>
)}
</>
)
)}
</div>
</Panel>
<Panel
title="Ресурсы по владельцам"
subtitle="Изолированные пулы, хранилища и состояние настроенных полномочий"
>
{bindings.error && (
<div className="panel-padding">
<ErrorState error={bindings.error} retry={bindings.reload} />
</div>
)}
{bindings.loading ? (
<Loading />
) : bindings.data?.items.length ? (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>Владелец</th>
<th>Узел / pool</th>
<th>Хранилища</th>
<th>Настройка</th>
</tr>
</thead>
<tbody>
{bindings.data.items.map((binding) => (
<tr key={binding.owner_id}>
<td>
<CopyValue value={binding.owner_id} truncate />
</td>
<td>
<code>{binding.node}</code>
<small className="block mono">{binding.pool}</small>
</td>
<td>
<span className="block">
ISO: <code>{binding.iso_storage}</code>
</span>
<span className="block">
Диски: <code>{binding.disk_storage}</code>
</span>
</td>
<td>
<Status
value={binding.configured ? "ready" : "not_configured"}
/>
<small className="block muted">
{binding.isolation_expires_at ? (
<>
{!binding.configured &&
new Date(binding.isolation_expires_at).getTime() <=
Date.now()
? "Срок проверки истёк: "
: "Проверено до: "}
<time dateTime={binding.isolation_expires_at}>
{formatDate(binding.isolation_expires_at)}
</time>
</>
) : (
"Проверка изоляции не подтверждена"
)}
</small>
{binding.reason && (
<small className="block warning-text">
{binding.reason}
</small>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty title="Привязки владельцев ещё не настроены">
Без owner-scoped pool, приватных хранилищ и ограниченных credentials
запуск недоступен.
</Empty>
)}
</Panel>
<Notice>
<strong>Конфигурация защищена от браузера.</strong>
<p>
Scoped credentials, настройки PVE и выделение ресурсов управляются
worker CLI и файловой конфигурацией на сервере. Здесь показаны только
доступность и несекретные метаданные. Произвольные команды,
host-консоль и редактирование токенов не предоставляются.
</p>
</Notice>
</>
);
}
export function AdminPage() {
const [params, setParams] = useSearchParams();
const tab = params.get("tab") ?? "users";
const tabs = [
{ id: "users", label: "Пользователи", icon: Users },
{ id: "profiles", label: "Профили Windows", icon: Cpu },
{ id: "integration", label: "Интеграция и ресурсы", icon: Server },
];
return (
<>
<PageHeader
eyebrow="ADMINISTRATION"
title="Управление лабораторией"
description="Доступ, ревизии исходников и готовность выделенной инфраструктуры."
/>
<div
className="tabs"
role="tablist"
aria-label="Разделы администрирования"
>
{tabs.map(({ id, label, icon: Icon }) => (
<button
type="button"
role="tab"
aria-selected={tab === id}
key={id}
className={tab === id ? "active" : ""}
onClick={() => setParams({ tab: id })}
>
<Icon size={17} />
{label}
</button>
))}
</div>
<div role="tabpanel">
{tab === "profiles" ? (
<ProfilesAdmin />
) : tab === "integration" ? (
<IntegrationAdmin />
) : (
<UsersAdmin />
)}
</div>
</>
);
}
+884
View File
@@ -0,0 +1,884 @@
import { useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
ArrowLeft,
Clock3,
Download,
Film,
Play,
RefreshCw,
ShieldCheck,
Square,
Trash2,
} from "lucide-react";
import { api, artifactUrl, idPath, uploadUrl } from "../api";
import { useAuth, useQuery } from "../hooks";
import { useJobEvents } from "../useJobEvents";
import type {
Artifact,
Attempt,
Collection,
Job,
JobEvent,
VideoManifest,
} from "../models";
import {
Confirm,
CopyValue,
durationLabel,
Empty,
ErrorState,
formatBytes,
formatDate,
Loading,
Metadata,
Notice,
PageHeader,
Panel,
Status,
windowsLabel,
} from "../components";
const phases = [
"queued",
"provisioning",
"booting",
"recording",
"delivering",
"preparing",
"running",
"collecting",
"stopping",
"cleanup",
"finished",
];
function VideoPanel({
attempt,
events,
}: {
attempt: Attempt;
events: JobEvent[];
}) {
const query = useQuery<VideoManifest>(
`/attempts/${idPath(attempt.id)}/video`,
attempt.phase === "finished" ? 0 : 6000,
);
const [segmentId, setSegmentId] = useState("");
const [playerError, setPlayerError] = useState(false);
const player = useRef<HTMLVideoElement>(null);
const segments = query.data?.segments ?? [];
const selected =
segments.find((segment) => segment.id === segmentId) ?? segments[0];
const seek = (event: JobEvent) => {
if (!player.current || !query.data?.started_at || segments.length !== 1)
return;
const seconds =
(new Date(event.created_at).getTime() -
new Date(query.data.started_at).getTime()) /
1000;
if (Number.isFinite(seconds) && seconds >= 0)
player.current.currentTime = seconds;
};
return (
<Panel
title="Видеонаблюдение"
subtitle="Внешняя запись виртуального дисплея · только просмотр"
action={query.data && <Status value={query.data.state} />}
>
<div className="video-layout">
<div className="video-view">
{query.error ? (
<ErrorState error={query.error} retry={query.reload} />
) : query.loading ? (
<Loading label="Получаем манифест записи…" />
) : selected ? (
<>
<video
ref={player}
key={selected.id}
controls
playsInline
preload="metadata"
src={artifactUrl(selected.id)}
onError={() => setPlayerError(true)}
aria-label={`Запись попытки ${attempt.id}`}
/>
{playerError && (
<Notice tone="warning">
Браузер не смог воспроизвести этот сегмент. Скачайте оригинал
и проверьте формат; это не подтверждение отсутствия записи.
</Notice>
)}
</>
) : (
<div className="video-empty">
<Film size={42} strokeWidth={1} />
<h3>
{query.data?.state === "unavailable"
? "Запись отсутствует"
: query.data?.state === "partial"
? "Запись частичная: нет доступных сегментов"
: query.data?.state === "recording"
? "Запись идёт — сегменты ещё не готовы"
: "Запись ещё не готова"}
</h3>
<p>
Видео появится только после получения реального артефакта от
сервера. Скриншоты не заменяют видеозапись.
</p>
</div>
)}
</div>
{segments.length > 0 && (
<div className="segment-list">
{segments.map((segment, index) => (
<div
className={segment.id === selected?.id ? "selected" : ""}
key={segment.id}
>
<button
type="button"
onClick={() => {
setSegmentId(segment.id);
setPlayerError(false);
}}
>
<Play size={14} />
Сегмент {index + 1}
<small>{formatBytes(segment.size)}</small>
</button>
<a
href={artifactUrl(segment.id)}
download={segment.filename}
aria-label={`Скачать сегмент ${index + 1}`}
>
<Download size={15} />
</a>
</div>
))}
</div>
)}
{Boolean(query.data?.gaps?.length) && (
<div className="panel-padding">
<Notice tone="warning">
<strong>Разрывы записи</strong>
<ul>
{query.data?.gaps.map((gap, index) => (
<li key={index}>
{formatDate(gap.at)} — {gap.reason}
</li>
))}
</ul>
Промежутки не считаются непрерывным наблюдением.
</Notice>
</div>
)}
</div>
<div className="video-events">
<h3>События этой попытки</h3>
{!events.length ? (
<p className="muted">События ещё не зарегистрированы.</p>
) : (
<ol className="event-list compact">
{events.map((event) => (
<li key={event.id}>
<span className="event-dot" />
<div>
<time>{formatDate(event.created_at)}</time>
<p>{event.message}</p>
<span className="mono muted">{event.kind}</span>
</div>
{segments.length === 1 &&
query.data?.started_at &&
new Date(event.created_at) >=
new Date(query.data.started_at) && (
<button
type="button"
className="icon-button"
aria-label="Перейти к событию в записи"
onClick={() => seek(event)}
>
<Play size={14} />
</button>
)}
</li>
))}
</ol>
)}
{segments.length > 1 && (
<p className="field-hint">
Для нескольких сегментов показаны исходные времена событий. Без
временной привязки сегментов сервером автоматический переход
недостоверен.
</p>
)}
</div>
</Panel>
);
}
function GrubPanel({ attempt }: { attempt: Attempt }) {
const grub = attempt.report?.grub;
if (!grub) return null;
const archive = attempt.artifacts?.find(
(artifact) => artifact.kind === "grub_archive",
);
return (
<Panel title="Grub" subtitle="Сбор запрошенных файлов гостевой Windows">
<div className="panel-padding">
<div className="outcome-grid">
<div>
<small>Статус сбора</small>
<Status value={grub.status} />
</div>
<div>
<small>Запрошено</small>
<strong>{grub.requested}</strong>
</div>
<div>
<small>Собрано</small>
<strong>{grub.collected}</strong>
</div>
</div>
{archive ? (
<a
className="button secondary small"
href={artifactUrl(archive.id)}
download={archive.filename}
>
<Download size={15} />
Скачать ZIP
</a>
) : (
<Notice tone="warning">ZIP-архив для этой попытки ещё недоступен.</Notice>
)}
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>Запрошенный путь</th>
<th>Файл в ZIP</th>
<th>Статус</th>
<th>Размер / SHA-256</th>
</tr>
</thead>
<tbody>
{grub.files.map((file, index) => (
<tr key={`${file.requested_path}-${index}`}>
<td>
<code>{file.requested_path}</code>
{file.resolved_path && (
<small className="mono block muted">
{file.resolved_path}
</small>
)}
</td>
<td>{file.member ? <code>{file.member}</code> : "—"}</td>
<td>
<Status value={file.status} />
{file.error && (
<small className="block warning-text">{file.error}</small>
)}
{file.snapshot && (
<small className="block warning-text">
Снимок при чтении: размер при открытии {formatBytes(file.snapshot.open_size)}; согласованность — best effort.
{file.snapshot.changed && " Файл изменился во время сбора."}
</small>
)}
</td>
<td>
{file.size != null ? formatBytes(file.size) : "Н/Д"}
{file.sha256 && (
<small className="mono block muted">{file.sha256}</small>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Panel>
);
}
function AttemptDetails({
attempt,
events,
onReleased,
}: {
attempt: Attempt;
events: JobEvent[];
onReleased: () => void;
}) {
const { session } = useAuth();
const [release, setRelease] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<Error | null>(null);
const report = attempt.report;
const execution = report?.execution;
const started = Boolean(execution?.started_at ?? attempt.started_at);
return (
<div className="attempt-details">
<div className="outcome-grid">
<div>
<small>Выполнение программы</small>
<Status value={attempt.outcome} />
</div>
<div>
<small>Наблюдения Defender</small>
<Status value={attempt.findings} />
</div>
<div>
<small>Полнота телеметрии</small>
<Status value={attempt.telemetry} />
</div>
<div>
<small>Очистка окружения</small>
<Status
value={attempt.cleanup}
label={
attempt.cleanup === "complete"
? "Удалено"
: attempt.cleanup === "failed"
? "Ошибка очистки"
: undefined
}
/>
</div>
</div>
{attempt.error && <Notice tone="danger">{attempt.error}</Notice>}
{error && <ErrorState error={error} />}
<Panel
title="Выполнение"
subtitle="Факты запуска отделены от реакции защиты"
>
<div className="panel-padding">
<dl className="metadata">
<div>
<dt>Начало выполнения</dt>
<dd>
{started
? formatDate(execution?.started_at ?? attempt.started_at)
: attempt.outcome === "pending"
? "Ещё не зафиксировано"
: "Не запускался"}
</dd>
</div>
<div>
<dt>Фактическая длительность</dt>
<dd>
{started && execution?.actual_duration_seconds != null
? `${execution.actual_duration_seconds} сек`
: "Н/Д — запуск не подтверждён или длительность не получена"}
</dd>
</div>
<div>
<dt>Код завершения</dt>
<dd>{execution?.exit_code ?? "Не получен"}</dd>
</div>
<div>
<dt>Контекст / сессия / PID</dt>
<dd>
{started
? `${execution?.user || "Не получен"} / ${execution?.session_id ?? "Н/Д"} / ${execution?.pid ?? "Н/Д"}`
: "Н/Д — не запускался"}
</dd>
</div>
{execution && (
<>
<div>
<dt>Обработчик / права</dt>
<dd>
{execution.handler || "Не получен"} /{" "}
{execution.privilege || "Не получены"}
</dd>
</div>
<div>
<dt>Разрешённый путь</dt>
<dd>
<code>{execution.path || "Не получен"}</code>
</dd>
</div>
<div>
<dt>Фактические аргументы</dt>
<dd>
{execution.arguments ? (
<pre>{JSON.stringify(execution.arguments, null, 2)}</pre>
) : (
"Не получены"
)}
</dd>
</div>
</>
)}
<div>
<dt>ID команды</dt>
<dd>
<CopyValue value={attempt.command_id} />
</dd>
</div>
<div>
<dt>Окончание попытки</dt>
<dd>{formatDate(attempt.finished_at)}</dd>
</div>
</dl>
{execution?.error && (
<Notice tone="warning">{execution.error}</Notice>
)}
{!report && (
<Notice>
Подробный отчёт ещё не получен. Неизвестные значения не
подменяются нулём или успешным результатом.
</Notice>
)}
</div>
</Panel>
<VideoPanel attempt={attempt} events={events} />
<GrubPanel attempt={attempt} />
<Panel
title="События защиты"
subtitle="Источники, действие и этап обнаружения"
>
<div className="panel-padding">
{report?.defender ? (
<>
{report.defender.drift && (
<Notice tone="warning">
Состояние Defender изменилось между началом и окончанием.
Условия сравнения не идентичны.
</Notice>
)}
{report.defender.detections?.length ? (
<div className="detection-list">
{report.defender.detections.map((detection, index) => (
<div className="detection" key={index}>
<div>
<ShieldCheck size={18} />
<strong>{detection.name || detection.id}</strong>
<span className="tag">{detection.source}</span>
</div>
<dl className="compact-details">
<div>
<dt>Время</dt>
<dd>{formatDate(detection.timestamp)}</dd>
</div>
<div>
<dt>Действие / этап</dt>
<dd>
{detection.action} / {detection.stage}
</dd>
</div>
<div>
<dt>Ресурсы</dt>
<dd>
{detection.resources?.map((resource, i) => (
<code className="block" key={i}>
{resource}
</code>
))}
</dd>
</div>
</dl>
</div>
))}
</div>
) : (
<Notice
tone={attempt.telemetry === "complete" ? "info" : "warning"}
>
{attempt.findings === "not_observed"
? "Детект не наблюдался в пределах полученных данных. Это не означает, что файл безопасен."
: "Подробные события обнаружения не получены. Пустой список не означает отсутствие угроз."}
</Notice>
)}
<div className="form-grid">
<div>
<h3>Defender до запуска</h3>
<Metadata
data={
report.defender.before
? { ...report.defender.before }
: {}
}
/>
</div>
<div>
<h3>Defender после наблюдения</h3>
<Metadata
data={
report.defender.after ? { ...report.defender.after } : {}
}
/>
</div>
</div>
</>
) : (
<Empty title="События защиты ещё не получены">
Наблюдение отсутствует или ещё продолжается.
</Empty>
)}
</div>
</Panel>
{report && (
<Panel title="Окружение и сбор данных">
<div className="panel-padding">
<Metadata
data={{
"Windows build": report.environment?.os_build,
Архитектура: report.environment?.architecture,
PowerShell: report.environment?.powershell_version,
ExecutionPolicy: report.environment?.execution_policy,
"Версия runner": report.environment?.runner_version,
}}
/>
{report.collection_errors?.map((message, index) => (
<Notice key={index} tone="warning">
{message}
</Notice>
))}
</div>
</Panel>
)}
{session.user.role === "admin" && attempt.cleanup === "evidence_held" && (
<Notice tone="warning">
<strong>Артефакты удерживаются для расследования.</strong>
<p>
Автоматическая очистка не удаляет сохранённый диск доказательств.
</p>
<button
type="button"
className="button destructive small"
onClick={() => setRelease(true)}
>
<Trash2 size={15} />
Разрешить удаление доказательств
</button>
</Notice>
)}
{release && (
<Confirm
title="Удалить удерживаемые ресурсы?"
description="Необратимо разрешить удаление одноразовой VM и удерживаемых дисков этой попытки. Сначала убедитесь, что необходимые доказательства выгружены. Исходная Windows не затрагивается."
danger
busy={busy}
close={() => setRelease(false)}
confirm={async () => {
setBusy(true);
try {
await api(
`/admin/attempts/${idPath(attempt.id)}/release-evidence`,
{ method: "POST", body: { confirm: true } },
);
setRelease(false);
onReleased();
} catch (error) {
setError(
error instanceof Error ? error : new Error("Ошибка очистки"),
);
setRelease(false);
} finally {
setBusy(false);
}
}}
/>
)}
</div>
);
}
export function JobDetailPage() {
const { id } = useParams();
const query = useQuery<Job>(id ? `/jobs/${idPath(id)}` : null, 5000);
const events = useJobEvents(id);
const artifacts = useQuery<Collection<Artifact>>(
id ? `/jobs/${idPath(id)}/artifacts` : null,
8000,
);
const [selected, setSelected] = useState("");
const [action, setAction] = useState<"cancel" | "retry" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<Error | null>(null);
const job = query.data;
const attempts = job?.runs.flatMap((run) => run.attempts ?? []) ?? [];
const attempt =
attempts.find((item) => item.id === selected) ??
attempts[attempts.length - 1];
return (
<>
<Link className="back-link" to="/jobs">
<ArrowLeft size={15} />
Все задания
</Link>
{query.error && <ErrorState error={query.error} retry={query.reload} />}
{query.loading && <Loading />}
{job && (
<>
<PageHeader
eyebrow={`JOB / ${job.id.slice(0, 8)}`}
title={job.filename}
description={`Создано ${formatDate(job.created_at)} · ${formatBytes(job.size)}`}
action={
<div className="actions">
<Status value={job.status} />
{["queued", "running"].includes(job.status) ? (
<button
type="button"
className="button secondary"
disabled={job.cancel_requested}
onClick={() => setAction("cancel")}
>
<Square size={14} />
{job.cancel_requested ? "Отмена запрошена" : "Отменить"}
</button>
) : (
<button
type="button"
className="button secondary"
onClick={() => setAction("retry")}
>
<RefreshCw size={15} />
Новая попытка
</button>
)}
</div>
}
/>
{error && <ErrorState error={error} />}
<Panel className="job-identity">
<div>
<span className="eyebrow">SHA-256</span>
<CopyValue value={job.sha256} />
</div>
<a
className="button secondary small"
href={uploadUrl(job.upload_id)}
download={job.filename}
>
<Download size={15} />
Исходный файл
</a>
</Panel>
<div className="job-conditions">
<span>
<Clock3 size={14} />
{durationLabel(job.settings.duration_seconds)}
</span>
<span>
{job.settings.internet === "offline"
? "Без интернета"
: "Изолированный Online"}
</span>
<span>
{job.settings.privilege === "admin"
? "Администратор Windows"
: "Пользователь Windows"}
</span>
<span>
Имя: <code>{job.execution_filename}</code>
</span>
<span>ZoneId: {job.settings.set_zoneid ? "3" : "не изменять"}</span>
</div>
<details className="job-settings">
<summary>Неизменяемые настройки задания</summary>
<pre>{JSON.stringify(job.settings, null, 2)}</pre>
</details>
<section className="run-comparison" aria-label="Сравнение окружений">
{job.runs.map((run) => (
<div className="run-card" key={run.id}>
<div className="run-heading">
<h2 className="windows-label">{windowsLabel(run.profile_name, run.os_build)}</h2>
<Status value={run.status} />
</div>
<p className="muted small-text">
Defender · Ревизия <code>{run.revision_id}</code>
</p>
{run.attempts?.length ? (
run.attempts.map((item, index) => (
<button
type="button"
className={`attempt-choice ${attempt?.id === item.id ? "selected" : ""}`}
key={item.id}
onClick={() => setSelected(item.id)}
>
<span>
Попытка {index + 1}
<small>{formatDate(item.created_at)}</small>
</span>
<Status value={item.phase} />
<Status value={item.findings} />
</button>
))
) : (
<p className="muted">Попытка ещё не назначена</p>
)}
</div>
))}
</section>
{attempt ? (
<>
<div
className="phase-timeline"
aria-label="Фазы выбранной попытки"
>
{phases.map((phase) => (
<div
className={phase === attempt.phase ? "current" : ""}
key={phase}
>
<i />
<Status value={phase} />
</div>
))}
</div>
<AttemptDetails
key={attempt.id}
attempt={attempt}
events={
events.data?.items.filter(
(event) => event.attempt_id === attempt.id,
) ?? []
}
onReleased={query.reload}
/>
</>
) : (
<Panel>
<Empty title="Попытки ещё не созданы">
Сервер ожидает допуска к прогону. Исполнение не имитируется.
</Empty>
</Panel>
)}
<Panel
title="Артефакты"
subtitle="Приватная выдача через авторизованный API"
>
{artifacts.error && (
<div className="panel-padding">
<ErrorState error={artifacts.error} retry={artifacts.reload} />
</div>
)}
{artifacts.loading ? (
<Loading />
) : artifacts.data?.items.length ? (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>Файл</th>
<th>Тип / попытка</th>
<th>Размер</th>
<th>SHA-256</th>
<th>Скачать</th>
</tr>
</thead>
<tbody>
{artifacts.data.items.map((artifact) => (
<tr key={artifact.id}>
<td>{artifact.filename}</td>
<td>
<span className="tag">{artifact.kind}</span>
<small className="mono block muted">
{artifact.attempt_id.slice(0, 8)}
</small>
</td>
<td>{formatBytes(artifact.size)}</td>
<td>
<CopyValue value={artifact.sha256} truncate />
</td>
<td>
<a
className="icon-button"
href={artifactUrl(artifact.id)}
download={artifact.filename}
aria-label={`Скачать ${artifact.filename}`}
>
<Download size={17} />
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty title="Артефакты пока отсутствуют">
Готовые видео, журналы и JSON появятся после реального сбора
данных.
</Empty>
)}
</Panel>
<Panel
title="Журнал задания"
subtitle="Серверные события; время отображается в вашем часовом поясе"
>
<div className="panel-padding">
{events.error && (
<ErrorState error={events.error} retry={events.reload} />
)}
{events.loading ? (
<Loading />
) : events.data?.items.length ? (
<ol className="event-list">
{events.data.items.map((event) => (
<li key={event.id}>
<span className="event-dot" />
<div>
<time>{formatDate(event.created_at)}</time>
<strong>{event.kind}</strong>
<p>{event.message}</p>
{event.attempt_id && (
<small className="mono muted">
Попытка {event.attempt_id.slice(0, 8)}
</small>
)}
</div>
</li>
))}
</ol>
) : (
<p className="muted">События ещё не получены.</p>
)}
</div>
</Panel>
{action && (
<Confirm
title={
action === "retry"
? "Создать новую попытку?"
: "Остановить задание?"
}
description={
action === "retry"
? "Для каждого завершённого прогона будет создана новая попытка на новом клоне с исходными настройками и ревизией. История и артефакты прежних попыток сохранятся. Сервер повторно проверит допуск."
: "Сервер запросит остановку и сохранит доступные результаты. Очистка ресурсов выполняется отдельно и может завершиться с ошибкой."
}
busy={busy}
close={() => setAction(null)}
confirm={async () => {
setBusy(true);
try {
await api<Job>(`/jobs/${idPath(job.id)}/${action}`, {
method: "POST",
});
setSelected("");
setAction(null);
query.reload();
events.reload();
artifacts.reload();
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Запрос не выполнен"),
);
setAction(null);
} finally {
setBusy(false);
}
}}
/>
)}
</>
)}
</>
);
}
+925
View File
@@ -0,0 +1,925 @@
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowRight,
Check,
ChevronDown,
Clock3,
FileCode2,
FileUp,
Globe,
HardDrive,
LoaderCircle,
LockKeyhole,
Plus,
RotateCcw,
ShieldCheck,
SlidersHorizontal,
Trash2,
UploadCloud,
X,
} from "lucide-react";
import { api, uploadFile } from "../api";
import { useQuery } from "../hooks";
import { durations, extensions } from "../models";
import type { Collection, Job, Profile, Settings, Upload } from "../models";
import {
CopyValue,
durationLabel,
Empty,
ErrorState,
Field,
formatBytes,
Loading,
Notice,
PageHeader,
Panel,
Status,
windowsLabel,
} from "../components";
const initialSettings: Settings = {
internet: "offline",
duration_seconds: 60,
filename: "original",
privilege: "user",
args_mode: "none",
args: [],
set_zoneid: false,
dll_mode: "rundll32",
dll_export: "",
architecture: "auto",
wsh_host: "cscript",
msi_ui: "full",
grub_paths: [],
};
export function NewAnalysisPage() {
const navigate = useNavigate();
const profiles = useQuery<Collection<Profile>>("/profiles");
const [settings, setSettings] = useState<Settings>(initialSettings);
const [file, setFile] = useState<File | null>(null);
const [uploaded, setUploaded] = useState<Upload | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [uploadError, setUploadError] = useState<Error | null>(null);
const [error, setError] = useState<Error | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
const [advanced, setAdvanced] = useState(false);
const input = useRef<HTMLInputElement>(null);
const transfer = useRef<AbortController | null>(null);
const fileType = file?.name.split(".").pop()?.toLowerCase() ?? "";
const chosenProfiles =
profiles.data?.items.filter((profile) => selected.includes(profile.id)) ??
[];
const selectedUnavailable = chosenProfiles.some(
(profile) =>
!profile.enabled ||
profile.state !== "published" ||
profile.qualification !== "qualified" ||
(settings.internet === "online" && !profile.online_available),
);
const dllInvalid =
fileType === "dll" &&
settings.dll_mode === "rundll32" &&
!settings.dll_export.trim();
useEffect(() => () => transfer.current?.abort(), []);
const change = <K extends keyof Settings>(key: K, value: Settings[K]) =>
setSettings((previous) => ({ ...previous, [key]: value }));
const chooseFile = async (files: FileList | File[] | null) => {
if (!files?.length) return;
transfer.current?.abort();
setFile(null);
setUploaded(null);
setUploading(false);
setUploadError(null);
if (files.length !== 1) {
setUploadError(new Error("Для одного задания выберите ровно один файл."));
return;
}
const next = files[0];
const extension = next.name.includes(".")
? (next.name.split(".").pop()?.toLowerCase() ?? "")
: "";
if (!extensions.some((item) => item === extension)) {
setUploadError(
new Error(
"Неподдерживаемый формат. Выберите EXE, DLL, SCR, COM, PS1, VBS, JS, BAT, CMD или MSI.",
),
);
return;
}
if (next.size === 0) {
setUploadError(new Error("Выбран пустой файл."));
return;
}
const controller = new AbortController();
transfer.current = controller;
setFile(next);
setUploaded(null);
setProgress(0);
setUploadError(null);
setUploading(true);
try {
const result = await uploadFile(next, setProgress, controller.signal);
if (!controller.signal.aborted) setUploaded(result);
} catch (error) {
if (!controller.signal.aborted)
setUploadError(
error instanceof Error ? error : new Error("Ошибка загрузки"),
);
} finally {
if (!controller.signal.aborted) setUploading(false);
}
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!uploaded || !selected.length || dllInvalid || selectedUnavailable)
return;
setBusy(true);
setError(null);
try {
const job = await api<Job>("/jobs", {
method: "POST",
body: {
upload_id: uploaded.id,
profile_ids: selected,
settings: {
...settings,
args: settings.args_mode === "none" ? [] : settings.args,
grub_paths: settings.grub_paths?.filter((path) => path !== "") ?? [],
},
},
});
navigate(`/jobs/${job.id}`);
} catch (error) {
setError(
error instanceof Error
? error
: new Error("Не удалось создать задание"),
);
} finally {
setBusy(false);
}
};
return (
<>
<PageHeader
eyebrow="NEW OBSERVATION"
title="Новый анализ"
description="Один образец. Выбранные окружения. Неизменяемые условия сравнения."
/>
<form onSubmit={submit} className="analysis-layout">
<div className="analysis-main">
<Panel
title="Образец"
subtitle="Файл передаётся в приватное хранилище"
action={<span className="section-number">01</span>}
>
<div className="panel-padding">
<input
ref={input}
type="file"
className="sr-only"
aria-label="Выбрать образец"
accept={extensions.map((ext) => `.${ext}`).join(",")}
onChange={(event) => void chooseFile(event.target.files)}
disabled={busy}
/>
<div
className={`dropzone ${dragging ? "dragging" : ""} ${file ? "has-file" : ""}`}
onDragOver={(event) => {
event.preventDefault();
if (!busy) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
if (!busy) void chooseFile(event.dataTransfer.files);
}}
>
{file ? (
<>
<div className="selected-file">
<span className="file-icon large">
<FileCode2 size={30} />
</span>
<div>
<strong>{file.name}</strong>
<p>
{formatBytes(file.size)}{" "}
<span className="tag">{fileType.toUpperCase()}</span>
</p>
</div>
<button
type="button"
className="icon-button"
aria-label="Удалить выбранный файл"
disabled={busy}
onClick={() => {
transfer.current?.abort();
setFile(null);
setUploaded(null);
setUploading(false);
setUploadError(null);
if (input.current) input.current.value = "";
}}
>
<X size={18} />
</button>
</div>
{uploading ? (
<div className="upload-progress">
<div>
<span>Передача файла</span>
<span>
{progress === null ? "…" : `${progress}%`}
</span>
</div>
<progress
max={100}
value={progress ?? undefined}
aria-label="Прогресс загрузки"
/>
<small>
{progress === 100
? "Сервер проверяет и сохраняет файл…"
: "Загрузка по защищённой сессии"}
</small>
</div>
) : uploaded ? (
<div className="uploaded-info">
<span>
<Check size={15} />
Файл принят сервером
</span>
<div className="hash-line">
<small>SHA-256</small>
<CopyValue value={uploaded.sha256} />
</div>
</div>
) : (
<button
type="button"
className="button secondary small"
onClick={() => void chooseFile([file])}
>
<RotateCcw size={15} />
Повторить загрузку
</button>
)}
</>
) : (
<>
<div className="upload-icon">
<UploadCloud size={32} strokeWidth={1.4} />
</div>
<h3>Перетащите файл сюда</h3>
<p>или выберите на компьютере</p>
<button
type="button"
className="button secondary"
onClick={() => input.current?.click()}
>
<FileUp size={16} />
Выбрать файл
</button>
<div className="file-types">
{extensions.map((ext) => (
<span key={ext}>.{ext}</span>
))}
</div>
</>
)}
</div>
{uploadError && <ErrorState error={uploadError} />}
{fileType === "com" && (
<Notice tone="warning">
.com может содержать нативный PE-файл. DOS / 16-bit программы
в Windows x64 не поддерживаются; расширение само по себе не
определяет совместимость.
</Notice>
)}
{fileType === "ps1" && (
<Notice tone="warning">
Используется Windows PowerShell и действующая ExecutionPolicy
профиля. Политика Restricted блокирует запуск через -File —
это ограничение ОС, а не детект Defender. Обход политики не
применяется.
</Notice>
)}
</div>
</Panel>
<Panel
title="Окружения Windows"
subtitle="Выберите один или несколько квалифицированных профилей"
action={<span className="section-number">02</span>}
>
<div className="panel-padding">
{profiles.error && (
<ErrorState error={profiles.error} retry={profiles.reload} />
)}
{profiles.loading ? (
<Loading />
) : (
profiles.data &&
(profiles.data.items.length ? (
<div className="profile-choices">
{profiles.data.items.map((profile) => {
const unavailable =
!selected.includes(profile.id) &&
(!profile.enabled ||
profile.state !== "published" ||
profile.qualification !== "qualified" ||
(settings.internet === "online" &&
!profile.online_available));
return (
<label
className={`profile-choice ${selected.includes(profile.id) ? "selected" : ""} ${unavailable ? "unavailable" : ""}`}
key={profile.id}
>
<input
type="checkbox"
checked={selected.includes(profile.id)}
disabled={unavailable || busy}
onChange={(event) =>
setSelected((previous) =>
event.target.checked
? [...previous, profile.id]
: previous.filter((id) => id !== profile.id),
)
}
/>
<span className="windows-mark" aria-hidden="true">
<i />
<i />
<i />
<i />
</span>
<span className="profile-choice-copy">
<strong className="windows-label">{windowsLabel(profile.name, profile.os_build)}</strong>
<small>
{profile.os} · {profile.architecture}
</small>
{profile.reason && (
<small className="warning-text">
{profile.reason}
</small>
)}
{settings.internet === "online" &&
!profile.online_available && (
<small className="warning-text">
Изолированный online-контур не квалифицирован
</small>
)}
</span>
<Status value={profile.qualification} />
</label>
);
})}
</div>
) : (
<Empty title="Нет доступных профилей">
Администратор должен добавить и квалифицировать окружение
перед запуском.
</Empty>
))
)}
<div className="antivirus-tile">
<span className="defender-icon">
<ShieldCheck size={23} />
</span>
<div>
<strong>Microsoft Defender</strong>
<small>Антивирус для каждого выбранного окружения</small>
</div>
<Check size={18} />
<span className="tag">Включён в матрицу</span>
</div>
<p className="field-hint">
Ревизия Windows, состояние Defender и политика запуска
фиксируются сервером.{" "}
<Link className="text-link" to="/environments">
Подробнее об окружениях <ArrowRight size={12} />
</Link>
</p>
</div>
</Panel>
<Panel
title="Условия выполнения"
subtitle="Одинаковы для всех прогонов задания"
action={<span className="section-number">03</span>}
>
<div className="panel-padding">
<div className="form-grid">
<fieldset className="field">
<legend>Доступ к интернету</legend>
<div className="segmented">
{(["offline", "online"] as const).map((value) => (
<label
key={value}
className={
settings.internet === value ? "selected" : ""
}
>
<input
type="radio"
name="internet"
value={value}
checked={settings.internet === value}
onChange={() => change("internet", value)}
/>
{value === "offline" ? (
<LockKeyhole size={15} />
) : (
<Globe size={15} />
)}
{value === "offline" ? "Offline" : "Online"}
</label>
))}
</div>
<span className="field-hint">
{settings.internet === "offline"
? "Виртуальная NIC отключена снаружи. Управление не требует гостевой сети."
: "Только заранее проверенный изолированный контур. Возможна передача данных облачной защите."}
</span>
</fieldset>
<Field
label="Окно наблюдения"
hint="От фактического старта runner, не от начала подготовки."
>
<select
aria-label="Окно наблюдения"
value={settings.duration_seconds}
onChange={(event) =>
change("duration_seconds", Number(event.target.value))
}
>
{durations.map((value) => (
<option value={value} key={value}>
{durationLabel(value)}
</option>
))}
</select>
</Field>
<Field
label="Имя файла при запуске"
hint="Случайное имя выбирается один раз на задание; расширение сохраняется."
>
<select
value={settings.filename}
onChange={(event) =>
change(
"filename",
event.target.value === "random" ? "random" : "original",
)
}
>
<option value="original">Исходное</option>
<option value="random">Случайное</option>
</select>
</Field>
<Field
label="Права запуска"
hint="Пользовательская сессия Windows. Не SYSTEM и не права Proxmox."
>
<select
value={settings.privilege}
onChange={(event) =>
change(
"privilege",
event.target.value === "admin" ? "admin" : "user",
)
}
>
<option value="user">Обычный пользователь</option>
<option value="admin">Администратор Windows</option>
</select>
</Field>
</div>
{settings.privilege === "admin" && (
<Notice tone="warning">
Повышенные права могут позволить программе изменить гостевые
журналы и средства наблюдения. Внешняя запись остаётся
независимым источником.
</Notice>
)}
<div className="args-section">
<Field label="Аргументы запуска">
<select
value={settings.args_mode}
onChange={(event) =>
change(
"args_mode",
event.target.value === "custom" ? "custom" : "none",
)
}
>
<option value="none">Без аргументов</option>
<option value="custom">Указать аргументы</option>
</select>
</Field>
{settings.args_mode === "custom" && (
<div className="argument-editor">
<p className="field-hint">
Одна строка — один аргумент (включая пробелы). Кавычки для
группировки не добавляйте. <code>{"{sample.path}"}</code>{" "}
и <code>{"{sample.name}"}</code> раскрываются внутри
аргумента перед quoting сервером. Это не командная строка
runner.
</p>
{settings.args.map((arg, index) => (
<div className="argument-row" key={index}>
<span className="mono">
{String(index + 1).padStart(2, "0")}
</span>
<input
aria-label={`Аргумент ${index + 1}`}
value={arg}
onChange={(event) =>
change(
"args",
settings.args.map((value, i) =>
i === index ? event.target.value : value,
),
)
}
placeholder="Например: --output={sample.name}.log"
/>
<button
type="button"
className="icon-button"
aria-label={`Удалить аргумент ${index + 1}`}
onClick={() =>
change(
"args",
settings.args.filter((_, i) => i !== index),
)
}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
type="button"
className="button secondary small"
onClick={() => change("args", [...settings.args, ""])}
>
<Plus size={15} />
Добавить аргумент
</button>
<pre
className="code-preview"
aria-label="Массив аргументов"
>
{JSON.stringify(settings.args, null, 2)}
</pre>
</div>
)}
</div>
<div className="args-section">
<Field
label="Grub"
hint="Необязательные точные абсолютные пути к файлам в гостевой Windows. По одному пути в строке; пробелы, Unicode, % и { } сохраняются буквально. Переменные среды и подстановки не раскрываются. Маски, UNC-пути и альтернативные потоки не поддерживаются; путь к папке отклоняется в гостевой Windows."
>
<div className="argument-editor">
{settings.grub_paths?.map((path, index) => (
<div className="argument-row" key={index}>
<span className="mono">
{String(index + 1).padStart(2, "0")}
</span>
<input
aria-label={`Путь Grub ${index + 1}`}
value={path}
onChange={(event) =>
change(
"grub_paths",
(settings.grub_paths ?? []).map((value, i) =>
i === index ? event.target.value : value,
),
)
}
placeholder="C:\\Users\\otche\\AppData\\Local\\Temp\\example.log"
/>
<button
type="button"
className="icon-button"
aria-label={`Удалить путь Grub ${index + 1}`}
onClick={() =>
change(
"grub_paths",
(settings.grub_paths ?? []).filter(
(_, i) => i !== index,
),
)
}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
type="button"
className="button secondary small"
disabled={(settings.grub_paths?.length ?? 0) >= 32}
onClick={() =>
change("grub_paths", [...(settings.grub_paths ?? []), ""])
}
>
<Plus size={15} />
Добавить путь
</button>
</div>
</Field>
</div>
{fileType === "dll" && (
<div className="handler-settings">
<h3>Обработчик DLL</h3>
<div className="form-grid">
<Field label="Способ запуска">
<select
value={settings.dll_mode}
onChange={(event) =>
change(
"dll_mode",
event.target.value === "regsvr32"
? "regsvr32"
: "rundll32",
)
}
>
<option value="rundll32">rundll32</option>
<option value="regsvr32">regsvr32</option>
</select>
</Field>
{settings.dll_mode === "rundll32" && (
<Field
label="Имя совместимого экспорта"
hint="Обязательное точное имя. Экспорт не угадывается."
>
<input
required
value={settings.dll_export}
onChange={(event) =>
change("dll_export", event.target.value)
}
placeholder="Имя экспорта DLL"
/>
</Field>
)}
</div>
{settings.dll_mode === "regsvr32" && (
<Notice>
Только регистрационные DLL с совместимым контрактом
regsvr32. Сервер использует фиксированный обработчик.
</Notice>
)}
</div>
)}
{["dll", "vbs", "js"].includes(fileType) && (
<div className="form-grid handler-settings">
<Field label="Архитектура обработчика">
<select
value={settings.architecture}
onChange={(event) =>
change(
"architecture",
event.target.value === "x86"
? "x86"
: event.target.value === "x64"
? "x64"
: "auto",
)
}
>
<option value="auto">Автоматически по профилю</option>
<option value="x86">x86 (32-bit)</option>
<option value="x64">x64 (64-bit)</option>
</select>
</Field>
{["vbs", "js"].includes(fileType) && (
<Field label="Windows Script Host">
<select
value={settings.wsh_host}
onChange={(event) =>
change(
"wsh_host",
event.target.value === "wscript"
? "wscript"
: "cscript",
)
}
>
<option value="cscript">cscript — консольный</option>
<option value="wscript">wscript — оконный</option>
</select>
</Field>
)}
</div>
)}
{fileType === "msi" && (
<div className="handler-settings">
<Field label="Интерфейс установщика MSI">
<select
value={settings.msi_ui}
onChange={(event) =>
change(
"msi_ui",
event.target.value === "quiet"
? "quiet"
: event.target.value === "passive"
? "passive"
: "full",
)
}
>
<option value="full">Полный интерфейс</option>
<option value="passive">Только прогресс</option>
<option value="quiet">Без интерфейса</option>
</select>
</Field>
<Notice>
Журнал MSI собирается всегда. Наблюдается установка;
установленное приложение автоматически не запускается.
</Notice>
</div>
)}
<button
type="button"
className="advanced-toggle"
aria-expanded={advanced}
onClick={() => setAdvanced(!advanced)}
>
<SlidersHorizontal size={16} />
Дополнительные параметры
<ChevronDown size={16} className={advanced ? "rotated" : ""} />
</button>
{advanced && (
<label className="checkbox-field">
<input
type="checkbox"
checked={settings.set_zoneid}
onChange={(event) =>
change("set_zoneid", event.target.checked)
}
/>
<span>
<strong>
Пометить как загруженный из интернета (ZoneId=3)
</strong>
<small>
Метка добавляется на конечную NTFS-копию. Выключено: метка
не добавляется и существующая не удаляется. Независимо от
режима сети.
</small>
</span>
</label>
)}
</div>
</Panel>
</div>
<aside className="analysis-summary">
<Panel title="Сводка задания" subtitle="Проверка перед запуском">
<div className="panel-padding">
<div className="summary-file">
<FileCode2 size={22} />
<div>
<strong>{file?.name ?? "Файл не выбран"}</strong>
<small>
{uploaded
? "Подтверждён сервером"
: uploading
? "Загружается…"
: "Ожидается загрузка"}
</small>
</div>
</div>
<dl className="summary-list">
<div>
<dt>
<HardDrive size={14} />
Окружения
</dt>
<dd>{selected.length}</dd>
</div>
<div>
<dt>
<ShieldCheck size={14} />
Антивирус
</dt>
<dd>Defender</dd>
</div>
<div>
<dt>
<Clock3 size={14} />
Наблюдение
</dt>
<dd>{durationLabel(settings.duration_seconds)}</dd>
</div>
<div>
<dt>
<Globe size={14} />
Сеть
</dt>
<dd>
{settings.internet === "offline" ? "Offline" : "Online"}
</dd>
</div>
<div>
<dt>Имя</dt>
<dd>
{settings.filename === "original"
? "Исходное"
: "Случайное"}
</dd>
</div>
<div>
<dt>Контекст</dt>
<dd>
{settings.privilege === "user"
? "Пользователь"
: "Администратор"}
</dd>
</div>
<div>
<dt>Аргументы</dt>
<dd>
{settings.args_mode === "none"
? "Нет"
: settings.args.length}
</dd>
</div>
<div>
<dt>ZoneId=3</dt>
<dd>{settings.set_zoneid ? "Добавить" : "Не изменять"}</dd>
</div>
<div>
<dt>Grub</dt>
<dd>{settings.grub_paths?.filter((path) => path !== "").length ?? 0}</dd>
</div>
</dl>
<details className="settings-preview">
<summary>Все параметры запроса</summary>
<pre>
{JSON.stringify(
{
...settings,
args: settings.args_mode === "none" ? [] : settings.args,
},
null,
2,
)}
</pre>
</details>
<div className="run-explainer">
<ShieldCheck size={17} />
<p>
Новая попытка — новый чистый клон. Сервер повторно проверит
допуск и изоляцию.
</p>
</div>
{error && <ErrorState error={error} />}
{selectedUnavailable && (
<Notice tone="warning">
Один из выбранных профилей недоступен для текущего режима.
Выберите совместимое окружение.
</Notice>
)}
<button
className="button primary full"
disabled={
busy ||
uploading ||
!uploaded ||
!selected.length ||
selectedUnavailable ||
dllInvalid
}
>
{busy ? (
<LoaderCircle size={17} className="spin" />
) : (
<ArrowRight size={17} />
)}
{busy ? "Создаём задание…" : "Начать анализ"}
</button>
{(!uploaded || !selected.length || dllInvalid) && (
<p className="submission-hint">
{!uploaded
? "Сначала загрузите файл."
: !selected.length
? "Выберите доступное окружение."
: "Укажите совместимый экспорт DLL."}
</p>
)}
</div>
</Panel>
</aside>
</form>
</>
);
}
+516
View File
@@ -0,0 +1,516 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import {
Activity,
ArrowDownToLine,
ArrowRight,
ChevronLeft,
ChevronRight,
Clock3,
Cpu,
FileCode2,
FlaskConical,
Layers3,
Plus,
Search,
ShieldCheck,
} from "lucide-react";
import { useAuth, useQuery } from "../hooks";
import type { Collection, Dashboard, Job, Profile } from "../models";
import {
CopyValue,
Empty,
ErrorState,
formatBytes,
formatDate,
Loading,
Metadata,
Notice,
PageHeader,
Panel,
Status,
windowsLabel,
} from "../components";
export function JobsTable({
jobs,
owners = false,
}: {
jobs: Job[];
owners?: boolean;
}) {
return (
<div className="table-scroll">
<table className="data-table jobs-table">
<thead>
<tr>
<th>Образец</th>
<th>Окружения</th>
<th>Состояние</th>
{owners && <th>Владелец</th>}
<th>Создано</th>
<th>
<span className="sr-only">Открыть</span>
</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<Link to={`/jobs/${job.id}`} className="file-cell">
<span className="file-icon">
<FileCode2 size={20} />
</span>
<span>
<strong>{job.filename}</strong>
<small className="mono">
{job.sha256.slice(0, 16)}… · {formatBytes(job.size)}
</small>
</span>
</Link>
</td>
<td>
<div className="table-environments">
{(job.runs ?? []).map((run) => (
<span className="windows-label" key={run.id}>{windowsLabel(run.profile_name, run.os_build)}</span>
))}
{!job.runs?.length && (
<span className="muted">Не назначены</span>
)}
</div>
</td>
<td>
<Status value={job.status} />
{job.cancel_requested && (
<small className="muted block">Запрошена отмена</small>
)}
</td>
{owners && (
<td>
<code>{job.owner_id.slice(0, 12)}</code>
</td>
)}
<td className="muted nowrap">{formatDate(job.created_at)}</td>
<td>
<Link
className="icon-button"
aria-label={`Открыть ${job.filename}`}
to={`/jobs/${job.id}`}
>
<ArrowRight size={17} />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function DashboardPage() {
const query = useQuery<Dashboard>("/dashboard", 10000);
return (
<>
<PageHeader
eyebrow="CONTROL CENTER"
title="Обзор лаборатории"
description="Задания, наблюдения и состояние инфраструктуры."
action={
<Link className="button primary" to="/new">
<Plus size={17} />
Новый анализ
</Link>
}
/>
{query.error && <ErrorState error={query.error} retry={query.reload} />}
{query.loading && <Loading />}
{query.data && (
<>
<div className="metric-grid">
{[
{
label: "Всего заданий",
value: query.data.metrics.jobs,
icon: Layers3,
note: "В вашем рабочем пространстве",
},
{
label: "Сейчас выполняются",
value: query.data.metrics.running,
icon: Activity,
note: `${query.data.metrics.queued} в очереди`,
accent: true,
},
{
label: "Завершено",
value: query.data.metrics.completed,
icon: ShieldCheck,
},
{
label: "Обнаружения Defender",
value: query.data.metrics.detected,
icon: FileCode2,
note: "Зафиксированные сигналы защиты",
},
].map(({ label, value, icon: Icon, note, accent }) => (
<div
key={label}
className={`metric-card ${accent ? "accent" : ""}`}
>
<div className="metric-top">
<span>{label}</span>
<Icon size={19} />
</div>
<strong>{value.toLocaleString("ru-RU")}</strong>
{note && <small>{note}</small>}
</div>
))}
</div>
<div className="dashboard-intro">
<div className="intro-copy">
<div className="eyebrow">ОДИН ФАЙЛ. НЕЗАВИСИМЫЕ УСЛОВИЯ.</div>
<h2>
Сравните поведение,
<br />
сохраните доказательства.
</h2>
<p>
Отдельный полный клон Windows для каждой попытки. Результат
запуска, события Defender и внешняя видеозапись — без смешивания
выводов.
</p>
<Link to="/new" className="text-link">
Настроить анализ <ArrowRight size={16} />
</Link>
</div>
<div className="pipeline" aria-label="Этапы анализа">
<span>
<FileCode2 size={22} />
Файл
</span>
<ArrowRight size={16} />
<span>
<Cpu size={22} />
Windows
</span>
<ArrowRight size={16} />
<span>
<Activity size={22} />
Отчёт
</span>
</div>
</div>
<div className="dashboard-secondary">
<Panel
title="Интеграция"
subtitle="Фактическая готовность сервера"
action={
<Status
value={
query.data.integration.ready ? "ready" : "not_configured"
}
/>
}
>
{query.data.integration.ready ? (
<div className="integration-body">
<ShieldCheck size={28} />
<p>
Сервер подтвердил готовность интеграции. Допуск каждого
профиля проверяется отдельно.
</p>
</div>
) : (
<div className="integration-body">
<Cpu size={28} />
<div>
<strong>Лаборатория ещё не допущена к запуску</strong>
<ul className="blocker-list">
{query.data.integration.blockers.map((blocker, index) => (
<li key={index}>{blocker}</li>
))}
</ul>
</div>
</div>
)}
</Panel>
<Panel
title="Очередь наблюдений"
subtitle="Только доступные вам задания"
>
<div className="queue-summary">
<div>
<Activity size={20} />
<strong>{query.data.queue.running}</strong>
<span>В работе</span>
</div>
<div>
<Clock3 size={20} />
<strong>{query.data.queue.queued}</strong>
<span>Ожидают</span>
</div>
</div>
<Link to="/jobs" className="text-link panel-link">
Открыть все задания <ArrowRight size={15} />
</Link>
</Panel>
</div>
<Panel
title="Последние задания"
subtitle="Настройки и история каждого наблюдения"
action={
<Link className="text-link" to="/jobs">
Все задания <ArrowRight size={15} />
</Link>
}
>
{query.data.recent_jobs.length ? (
<JobsTable jobs={query.data.recent_jobs} />
) : (
<Empty
title="Здесь появится первое наблюдение"
action={
<Link to="/new" className="button secondary">
<FlaskConical size={16} />
Создать анализ
</Link>
}
>
Загрузите один файл и выберите квалифицированные окружения.
Демонстрационных результатов здесь нет.
</Empty>
)}
</Panel>
</>
)}
</>
);
}
export function JobsPage() {
const { session } = useAuth();
const [search, setSearch] = useState("");
const [queryText, setQueryText] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const params = new URLSearchParams({
page: String(page),
page_size: "15",
q: queryText,
status,
});
const query = useQuery<Collection<Job>>(`/jobs?${params}`, 8000);
return (
<>
<PageHeader
eyebrow="ANALYSIS HISTORY"
title="Задания"
description="История образцов, прогонов и независимых попыток."
action={
<Link className="button primary" to="/new">
<Plus size={17} />
Новый анализ
</Link>
}
/>
<Panel>
<form
className="filter-bar"
onSubmit={(event) => {
event.preventDefault();
setQueryText(search);
setPage(1);
}}
>
<div className="search-input">
<Search size={17} />
<input
aria-label="Поиск по имени или SHA-256"
placeholder="Имя файла или SHA-256"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<button className="button secondary small">Найти</button>
</div>
<label className="filter-select">
<span className="sr-only">Состояние задания</span>
<select
value={status}
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
>
<option value="">Все состояния</option>
<option value="queued">В очереди</option>
<option value="running">В работе</option>
<option value="completed">Завершено</option>
<option value="failed">Ошибка</option>
<option value="cancelled">Отменено</option>
</select>
</label>
<span className="filter-scope">
<ShieldCheck size={15} />
{session.user.role === "admin" ? "Все владельцы" : "Мои задания"}
</span>
</form>
{query.error && (
<div className="panel-padding">
<ErrorState error={query.error} retry={query.reload} />
</div>
)}
{query.loading ? (
<Loading />
) : (
query.data &&
(query.data.items.length ? (
<JobsTable
jobs={query.data.items}
owners={session.user.role === "admin"}
/>
) : (
<Empty
title={
queryText || status ? "Ничего не найдено" : "Заданий пока нет"
}
>
{queryText || status
? "Измените поисковый запрос или фильтр состояния."
: "Первое задание появится после загрузки образца и выбора окружений."}
</Empty>
))
)}
{query.data && (
<div className="pagination">
<span>
{query.data.total ?? query.data.items.length} заданий · страница{" "}
{page}
</span>
<div className="actions">
<button
type="button"
className="button secondary small"
disabled={page === 1}
onClick={() => setPage((value) => value - 1)}
>
<ChevronLeft size={16} />
Назад
</button>
<button
type="button"
className="button secondary small"
disabled={
page * (query.data.page_size ?? 15) >=
(query.data.total ?? query.data.items.length)
}
onClick={() => setPage((value) => value + 1)}
>
Далее
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</Panel>
</>
);
}
export function EnvironmentsPage() {
const { session } = useAuth();
const query = useQuery<Collection<Profile>>("/profiles");
return (
<>
<PageHeader
eyebrow="WINDOWS ENVIRONMENTS"
title="Окружения"
description="Опубликованные ревизии Windows и фактический допуск к анализу."
action={
session.user.role === "admin" && (
<Link to="/admin?tab=profiles" className="button secondary">
<Cpu size={16} />
Управлять профилями
</Link>
)
}
/>
<Notice>
Каждая попытка выполняется на новом полном клоне остановленной исходной
VM. Наличие профиля не означает его готовность: необходима квалификация
версии, защиты, запуска и записи.
</Notice>
{query.error && <ErrorState error={query.error} retry={query.reload} />}
{query.loading ? (
<Loading />
) : (
query.data &&
(query.data.items.length ? (
<div className="environment-grid">
{query.data.items.map((profile) => (
<Panel key={profile.id} className="environment-card">
<div className="environment-visual">
<Cpu size={38} strokeWidth={1.3} />
<span className="mono">
{profile.architecture.toUpperCase()}
</span>
</div>
<div className="panel-padding">
<div className="environment-title">
<h2 className="windows-label">{windowsLabel(profile.name, profile.os_build)}</h2>
<Status value={profile.qualification} />
</div>
<p className="muted">{profile.os}</p>
<div className="tag-row">
<Status value={profile.state} />
{!profile.enabled && <Status value="disabled" />}
<span className="tag">
<ShieldCheck size={13} />
Microsoft Defender
</span>
<span className="tag">
{profile.online_available
? "Offline / Online"
: "Только Offline"}
</span>
</div>
{profile.reason && (
<Notice tone="warning">{profile.reason}</Notice>
)}
<dl className="compact-details">
<div>
<dt>Ревизия</dt>
<dd>
{profile.current_revision_id ? (
<CopyValue
value={profile.current_revision_id}
truncate
/>
) : (
"Не опубликована"
)}
</dd>
</div>
</dl>
<details>
<summary>
<ArrowDownToLine size={14} />
Наблюдаемые параметры
</summary>
<Metadata data={profile.metadata ?? {}} />
</details>
</div>
</Panel>
))}
</div>
) : (
<Panel>
<Empty title="Профили ещё не добавлены">
Администратор должен зарегистрировать окружения и квалифицировать
подготовленные ревизии. Список не заполняется вымышленными
Windows.
</Empty>
</Panel>
))
)}
</>
);
}
+2568
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
:root {
--bg: #090e18;
--text: #d9e3f1;
--muted: #8195ae;
--border: #253145;
--cyan: #68dfe2;
}
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import { api, idPath } from "./api";
import type { Collection, JobEvent } from "./models";
export function useJobEvents(jobId: string | undefined) {
const [data, setData] = useState<Collection<JobEvent> | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const reload = useCallback(() => setRevision((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
let timer: number | undefined;
let cursor = 0;
const accumulated: JobEvent[] = [];
setData(null);
setError(null);
setLoading(Boolean(jobId));
if (!jobId) return () => controller.abort();
const load = async () => {
try {
let batch: Collection<JobEvent>;
do {
batch = await api<Collection<JobEvent>>(
`/jobs/${idPath(jobId)}/events?after=${cursor}`,
{ signal: controller.signal },
);
if (controller.signal.aborted) return;
const fresh = batch.items.filter((event) => event.id > cursor);
if (!fresh.length) break;
accumulated.push(...fresh);
cursor = fresh[fresh.length - 1].id;
} while (batch.items.length === 500);
setData({ items: [...accumulated] });
setError(null);
} catch (error) {
if (!controller.signal.aborted)
setError(
error instanceof Error
? error
: new Error("Не удалось получить события"),
);
} finally {
if (!controller.signal.aborted) {
setLoading(false);
timer = window.setTimeout(load, 5000);
}
}
};
void load();
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [jobId, revision]);
return { data, error, loading, reload };
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: process.env.OTCHE_API_PROXY || "http://127.0.0.1:8080",
changeOrigin: false,
},
},
},
build: { sourcemap: false },
});