Publish reviewed standalone otche-frontend sources
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1952
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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} мин`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
:root {
|
||||
--bg: #090e18;
|
||||
--text: #d9e3f1;
|
||||
--muted: #8195ae;
|
||||
--border: #253145;
|
||||
--cyan: #68dfe2;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user