Show parallel run progress and manage held clones and qualifications
- Job page: stable active-attempt default, per-run status grid with elapsed/countdown, completed k/n progress, queue position with an approximate ETA, past phases marked; artifacts come from the job payload. - Polling stops for settled jobs and pauses in hidden tabs; events polling no longer re-renders without new events. - Evidence release also for cleanup=failed with allocation details; new admin held-clones panel. - Admin: all-profiles qualification overview, live refresh while qualifications run, Publish only for passed revisions. - Russian labels for API error codes, event kinds and Grub statuses. - nginx: gzip, immutable assets, request-time API upstream resolution, security headers only on static locations.
This commit is contained in:
+17
-6
@@ -5,13 +5,14 @@ server {
|
||||
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;
|
||||
gzip on;
|
||||
gzip_proxied any;
|
||||
gzip_types application/json application/javascript text/css;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
set $api http://api:8080;
|
||||
proxy_pass $api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -22,10 +23,20 @@ server {
|
||||
}
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy same-origin always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy same-origin always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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;
|
||||
}
|
||||
}
|
||||
|
||||
+78
-3
@@ -12,6 +12,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApiError } from "./api";
|
||||
import type { JobEvent } from "./models";
|
||||
const labels: Record<string, string> = {
|
||||
queued: "В очереди",
|
||||
running: "Наблюдение",
|
||||
@@ -55,8 +57,72 @@ const labels: Record<string, string> = {
|
||||
offline: "Без интернета",
|
||||
online: "С интернетом",
|
||||
disabled: "Отключено",
|
||||
collected: "Файл собран",
|
||||
missing: "Файл отсутствует",
|
||||
access_denied: "Доступ запрещён",
|
||||
invalid_path: "Недопустимый путь",
|
||||
changed: "Изменён во время сбора",
|
||||
oversize: "Превышен размер",
|
||||
empty: "Нет собранных файлов",
|
||||
};
|
||||
|
||||
export const errorLabels: Record<string, string> = {
|
||||
job_active: "Дождитесь завершения всех попыток перед повторным запуском",
|
||||
stale_revision: "Ревизия устарела: выберите новую ревизию исходника",
|
||||
not_found: "Ресурс не найден или недоступен",
|
||||
csrf_rejected: "Защита сеанса отклонила запрос. Обновите страницу",
|
||||
origin_rejected: "Запрос отправлен с недопустимого адреса панели",
|
||||
unqualified_revision: "Ревизия ещё не прошла квалификацию",
|
||||
source_not_drained: "Дождитесь завершения клонирования прежней ревизии",
|
||||
qualification_active: "Квалификация этого профиля уже выполняется",
|
||||
source_not_configured: "Исходник ещё не настроен и не проверен оператором",
|
||||
quota_exceeded: "Достигнут лимит хранилища владельца",
|
||||
upload_too_large: "Размер файла превышает доступный лимит",
|
||||
admission_closed: "Допуск закрыт: проверьте готовность worker, изоляцию и лимит очереди",
|
||||
profile_unavailable: "Выбранный профиль недоступен для этого режима запуска",
|
||||
revision_unavailable: "Исходная ревизия больше не допущена к запуску",
|
||||
invalid_request: "Проверьте параметры запроса",
|
||||
invalid_settings: "Проверьте настройки запуска",
|
||||
invalid_filename: "Недопустимое имя или расширение файла",
|
||||
invalid_profile: "Проверьте название, ОС и архитектуру профиля",
|
||||
invalid_revision: "Выберите зарегистрированную ревизию",
|
||||
invalid_user: "Проверьте имя пользователя, пароль и роль",
|
||||
invalid_role: "Недопустимая роль пользователя",
|
||||
invalid_password: "Пароль должен содержать 14–72 байта без пробелов по краям",
|
||||
last_administrator: "Нельзя отключить последнего администратора",
|
||||
invalid_credentials: "Неверное имя пользователя или пароль",
|
||||
unauthenticated: "Войдите в систему заново",
|
||||
forbidden: "Недостаточно прав для этой операции",
|
||||
rate_limited: "Слишком много попыток входа. Повторите позднее",
|
||||
confirmation_required: "Необходимо явное подтверждение операции",
|
||||
not_retained: "У попытки нет доступных для удаления удержанных ресурсов",
|
||||
artifact_unavailable: "Артефакт недоступен",
|
||||
upload_failed: "Загрузка не завершена или превышен лимит",
|
||||
empty_upload: "Нельзя загрузить пустой файл",
|
||||
storage_error: "Приватное хранилище недоступно",
|
||||
internal_error: "Внутренняя ошибка сервера",
|
||||
unavailable: "Сервис временно недоступен",
|
||||
invalid_page: "Недопустимый номер страницы",
|
||||
invalid_query: "Поисковый запрос слишком длинный",
|
||||
request_failed: "Не удалось выполнить запрос",
|
||||
network_error: "Нет связи с сервером",
|
||||
invalid_response: "Сервер вернул некорректный ответ",
|
||||
};
|
||||
|
||||
export function eventLabel(event: JobEvent) {
|
||||
if (event.kind === "phase") return labels[event.message] ?? "Изменение фазы";
|
||||
return ({
|
||||
queued: "Задание принято в очередь",
|
||||
retry: "Созданы повторные попытки",
|
||||
cancel: "Запрошена отмена",
|
||||
phase: "Изменение фазы",
|
||||
retention: "Доказательства сохранены",
|
||||
release_error: "Не удалось удалить удержанные ресурсы",
|
||||
dispatch: "Проверка доставки команды",
|
||||
media_generation: "Обновление носителя задания",
|
||||
worker: "Состояние обработчика",
|
||||
} as Record<string, string>)[event.kind] ?? "Событие задания";
|
||||
}
|
||||
export function Status({ value, label }: { value: string; label?: string }) {
|
||||
const tone = [
|
||||
"detected",
|
||||
@@ -64,6 +130,8 @@ export function Status({ value, label }: { value: string; label?: string }) {
|
||||
"error",
|
||||
"unreachable",
|
||||
"drifted",
|
||||
"invalid_path",
|
||||
"access_denied",
|
||||
].includes(value)
|
||||
? "danger"
|
||||
: [
|
||||
@@ -73,6 +141,7 @@ export function Status({ value, label }: { value: string; label?: string }) {
|
||||
"healthy",
|
||||
"ready",
|
||||
"passed",
|
||||
"collected",
|
||||
].includes(value)
|
||||
? "success"
|
||||
: [
|
||||
@@ -83,6 +152,10 @@ export function Status({ value, label }: { value: string; label?: string }) {
|
||||
"not_configured",
|
||||
"policy_blocked",
|
||||
"blocked_before_execution",
|
||||
"missing",
|
||||
"changed",
|
||||
"oversize",
|
||||
"empty",
|
||||
].includes(value)
|
||||
? "warning"
|
||||
: ["running", "recording", "provisioning"].includes(value)
|
||||
@@ -164,8 +237,8 @@ export function ErrorState({
|
||||
<div className="notice danger" role="alert">
|
||||
<AlertTriangle size={19} />
|
||||
<div>
|
||||
<strong>Не удалось выполнить запрос</strong>
|
||||
<p>{error.message}</p>
|
||||
<strong>{error instanceof ApiError ? errorLabels[error.code] ?? "Не удалось выполнить запрос" : "Не удалось выполнить запрос"}</strong>
|
||||
<p className="muted small-text">{error.message}</p>
|
||||
{retry && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -409,5 +482,7 @@ export function formatBytes(value: number) {
|
||||
return `${(value / 1024 ** 3).toFixed(2)} ГБ`;
|
||||
}
|
||||
export function durationLabel(value: number) {
|
||||
return value < 300 ? `${value} сек` : `${value / 60} мин`;
|
||||
if (value < 60) return `${value} сек`;
|
||||
const seconds = value % 60;
|
||||
return `${Math.floor(value / 60)} мин${seconds ? ` ${seconds} сек` : ""}`;
|
||||
}
|
||||
|
||||
+17
-2
@@ -19,7 +19,7 @@ export function useAuth() {
|
||||
if (!value) throw new Error("Необходима авторизация");
|
||||
return value;
|
||||
}
|
||||
export function useQuery<T>(path: string | null, interval = 0) {
|
||||
export function useQuery<T>(path: string | null, interval: number | ((data: T | null) => number) = 0) {
|
||||
const [state, setState] = useState<{
|
||||
data: T | null;
|
||||
error: Error | null;
|
||||
@@ -32,6 +32,10 @@ export function useQuery<T>(path: string | null, interval = 0) {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
let timer: number | undefined;
|
||||
let inFlight = false;
|
||||
let latest: T | null = null;
|
||||
let loaded = false;
|
||||
const delay = () => typeof interval === "function" ? interval(latest) : interval;
|
||||
const samePath = previousPath.current === path;
|
||||
previousPath.current = path;
|
||||
setState((previous) =>
|
||||
@@ -41,8 +45,12 @@ export function useQuery<T>(path: string | null, interval = 0) {
|
||||
);
|
||||
if (!path) return () => controller.abort();
|
||||
const load = async () => {
|
||||
if (document.hidden || inFlight || !active) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const data = await api<T>(path, { signal: controller.signal });
|
||||
latest = data;
|
||||
loaded = true;
|
||||
if (active) setState({ data, error: null, loading: false });
|
||||
} catch (error) {
|
||||
if (active && !controller.signal.aborted)
|
||||
@@ -55,14 +63,21 @@ export function useQuery<T>(path: string | null, interval = 0) {
|
||||
loading: false,
|
||||
}));
|
||||
} finally {
|
||||
if (active && interval) timer = window.setTimeout(load, interval);
|
||||
inFlight = false;
|
||||
if (active && !document.hidden && delay()) timer = window.setTimeout(load, delay());
|
||||
}
|
||||
};
|
||||
const visibility = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!document.hidden && (!loaded || delay())) void load();
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
};
|
||||
}, [path, interval, revision]);
|
||||
return { ...state, reload };
|
||||
|
||||
@@ -87,11 +87,27 @@ export interface Attempt {
|
||||
cleanup: string;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
claimed_at: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
deadline_at: string | null;
|
||||
artifacts?: Artifact[];
|
||||
report: AttemptReport | null;
|
||||
allocation: { id: string; node: string; vmid: number; state: string } | null;
|
||||
video?: VideoManifest | null;
|
||||
}
|
||||
export interface HeldAttempt {
|
||||
attempt_id: string;
|
||||
job_id: string;
|
||||
run_id: string;
|
||||
profile_name: string;
|
||||
filename: string;
|
||||
outcome: string;
|
||||
cleanup: string;
|
||||
error: string;
|
||||
finished_at: string | null;
|
||||
release_requested: boolean;
|
||||
allocation: Attempt["allocation"];
|
||||
}
|
||||
export interface Run {
|
||||
id: string;
|
||||
@@ -116,6 +132,7 @@ export interface Job {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
cancel_requested: boolean;
|
||||
queue_ahead: number | null;
|
||||
runs: Run[];
|
||||
}
|
||||
export interface JobEvent {
|
||||
|
||||
+145
-21
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Check,
|
||||
Cpu,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Wrench,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, idPath } from "../api";
|
||||
import { useAuth, useQuery } from "../hooks";
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
Binding,
|
||||
Collection,
|
||||
Health,
|
||||
HeldAttempt,
|
||||
Profile,
|
||||
Qualification,
|
||||
Revision,
|
||||
@@ -288,20 +290,103 @@ function UsersAdmin() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
function ProfileConfirmation({ action, busy, close, confirm }: {
|
||||
action: "publish" | "maintenance" | "qualify";
|
||||
busy: boolean;
|
||||
close: () => void;
|
||||
confirm: () => void;
|
||||
}) {
|
||||
return <Confirm
|
||||
title={action === "publish" ? "Опубликовать ревизию?" : action === "maintenance" ? "Начать обслуживание?" : "Запустить квалификацию?"}
|
||||
description={action === "publish"
|
||||
? "Сервер проверит зарегистрированную ревизию и готовность остановленного исходника. Публикация не заменяет успешную квалификацию."
|
||||
: action === "maintenance"
|
||||
? "Допуск новых заданий будет закрыт. Дождитесь подтверждения дренирования перед ручным изменением исходника."
|
||||
: "Контроль выполняется на отдельном одноразовом клоне, не на master и не в пользовательском прогоне. Нужна настроенная интеграция."}
|
||||
busy={busy} close={close} confirm={confirm}
|
||||
/>;
|
||||
}
|
||||
|
||||
function ProfileOverviewRow({ profile, refresh, polling, onActive, select }: {
|
||||
profile: Profile;
|
||||
refresh: () => void;
|
||||
polling: boolean;
|
||||
onActive: (id: string, active: boolean) => void;
|
||||
select: () => void;
|
||||
}) {
|
||||
const qualifications = useQuery<Collection<Qualification>>(`/admin/profiles/${idPath(profile.id)}/qualifications`, 5000);
|
||||
const revisions = useQuery<Collection<Revision>>(`/admin/profiles/${idPath(profile.id)}/revisions`, polling ? 5000 : 0);
|
||||
const active = qualifications.data?.items.some((item) => ["queued", "running"].includes(item.status)) ?? false;
|
||||
const latest = qualifications.data?.items[0];
|
||||
const revision = revisions.data?.items[0];
|
||||
const previous = useRef<string | undefined>(undefined);
|
||||
const signature = qualifications.data?.items.map((item) => `${item.id}:${item.status}`).join(",");
|
||||
const [action, setAction] = useState<"publish" | "qualify" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
useEffect(() => { onActive(profile.id, active); }, [profile.id, active, onActive]);
|
||||
useEffect(() => {
|
||||
if (signature !== undefined && previous.current !== undefined && signature !== previous.current) {
|
||||
revisions.reload();
|
||||
refresh();
|
||||
}
|
||||
previous.current = signature;
|
||||
}, [signature, refresh, revisions.reload]);
|
||||
return <tr>
|
||||
<td><button className="button secondary small windows-label" onClick={select}>{profile.name}</button></td>
|
||||
<td><Status value={profile.state} /></td>
|
||||
<td><Status value={profile.qualification} /></td>
|
||||
<td>{latest ? <><Status value={latest.status} /><small className="block muted">{formatDate(latest.created_at)}</small></> : "Проверок нет"}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="button secondary small" disabled={busy || active || !revision} onClick={() => setAction("qualify")}>Квалифицировать</button>
|
||||
<button className="button secondary small" disabled={busy || active || revision?.qualification?.status !== "passed"} onClick={() => setAction("publish")}>Опубликовать</button>
|
||||
</div>
|
||||
{(error || qualifications.error || revisions.error) && <ErrorState error={(error || qualifications.error || revisions.error)!} />}
|
||||
{action && <ProfileConfirmation action={action} busy={busy} close={() => setAction(null)} confirm={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/admin/profiles/${idPath(profile.id)}/${action}`, { method: "POST", body: action === "publish" ? { revision_id: revision?.id } : undefined });
|
||||
qualifications.reload();
|
||||
revisions.reload();
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error : new Error("Операция не выполнена"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAction(null);
|
||||
}
|
||||
}} />}
|
||||
</td>
|
||||
</tr>;
|
||||
}
|
||||
|
||||
function ProfileEditor({
|
||||
profile,
|
||||
refresh,
|
||||
polling,
|
||||
}: {
|
||||
profile: Profile;
|
||||
refresh: () => void;
|
||||
polling: boolean;
|
||||
}) {
|
||||
const revisions = useQuery<Collection<Revision>>(
|
||||
`/admin/profiles/${idPath(profile.id)}/revisions`,
|
||||
polling ? 5000 : 0,
|
||||
);
|
||||
const qualifications = useQuery<Collection<Qualification>>(
|
||||
`/admin/profiles/${idPath(profile.id)}/qualifications`,
|
||||
6000,
|
||||
5000,
|
||||
);
|
||||
const qualificationState = qualifications.data?.items.map((item) => `${item.id}:${item.status}`).join(",");
|
||||
const previousQualificationState = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (qualificationState !== undefined && previousQualificationState.current !== undefined && qualificationState !== previousQualificationState.current) {
|
||||
revisions.reload();
|
||||
}
|
||||
previousQualificationState.current = qualificationState;
|
||||
}, [qualificationState, revisions.reload]);
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [enabled, setEnabled] = useState(profile.enabled);
|
||||
const [revision, setRevision] = useState(profile.current_revision_id ?? "");
|
||||
@@ -314,7 +399,7 @@ function ProfileEditor({
|
||||
const [qualificationId, setQualificationId] = useState<string | null>(null);
|
||||
const qualification = useQuery<Qualification>(
|
||||
qualificationId ? `/admin/qualifications/${idPath(qualificationId)}` : null,
|
||||
5000,
|
||||
qualificationPollInterval,
|
||||
);
|
||||
useEffect(() => {
|
||||
setName(profile.name);
|
||||
@@ -432,7 +517,7 @@ function ProfileEditor({
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary small"
|
||||
disabled={busy || !revision}
|
||||
disabled={busy || revisions.data?.items.find((item) => item.id === revision)?.qualification?.status !== "passed"}
|
||||
onClick={() => setConfirm("publish")}
|
||||
>
|
||||
<Check size={15} />
|
||||
@@ -566,21 +651,7 @@ function ProfileEditor({
|
||||
)}
|
||||
</div>
|
||||
{confirm && (
|
||||
<Confirm
|
||||
title={
|
||||
confirm === "publish"
|
||||
? "Опубликовать ревизию?"
|
||||
: confirm === "maintenance"
|
||||
? "Начать обслуживание?"
|
||||
: "Запустить квалификацию?"
|
||||
}
|
||||
description={
|
||||
confirm === "publish"
|
||||
? "Сервер проверит зарегистрированную ревизию и готовность остановленного исходника. Публикация не заменяет успешную квалификацию."
|
||||
: confirm === "maintenance"
|
||||
? "Допуск новых заданий будет закрыт. Дождитесь подтверждения дренирования перед ручным изменением исходника."
|
||||
: "Контроль выполняется на отдельном одноразовом клоне, не на master и не в пользовательском прогоне. Нужна настроенная интеграция."
|
||||
}
|
||||
<ProfileConfirmation action={confirm}
|
||||
busy={busy}
|
||||
close={() => setConfirm(null)}
|
||||
confirm={() =>
|
||||
@@ -595,7 +666,12 @@ function ProfileEditor({
|
||||
);
|
||||
}
|
||||
function ProfilesAdmin() {
|
||||
const query = useQuery<Collection<Profile>>("/admin/profiles");
|
||||
const [active, setActive] = useState<Record<string, boolean>>({});
|
||||
const polling = Object.values(active).some(Boolean);
|
||||
const query = useQuery<Collection<Profile>>("/admin/profiles", polling ? 5000 : 0);
|
||||
const onActive = useCallback((id: string, value: boolean) => {
|
||||
setActive((previous) => previous[id] === value ? previous : { ...previous, [id]: value });
|
||||
}, []);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [form, setForm] = useState({ name: "", os: "", architecture: "x64" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -609,6 +685,12 @@ function ProfilesAdmin() {
|
||||
оператором через worker CLI. Панель управляет допуском и публикацией
|
||||
зарегистрированных ревизий.
|
||||
</Notice>
|
||||
<Panel title="Все профили" subtitle="Квалификация и публикация зарегистрированных исходников">
|
||||
<div className="table-scroll"><table className="data-table">
|
||||
<thead><tr><th>Профиль</th><th>Состояние</th><th>Квалификация</th><th>Последняя проверка</th><th>Действия</th></tr></thead>
|
||||
<tbody>{query.data?.items.map((item) => <ProfileOverviewRow key={item.id} profile={item} refresh={query.reload} polling={polling} onActive={onActive} select={() => setSelected(item.id)} />)}</tbody>
|
||||
</table></div>
|
||||
</Panel>
|
||||
<div className="admin-two-column">
|
||||
<Panel title="Профили Windows" subtitle="Каталог и состояние допуска">
|
||||
{query.error && (
|
||||
@@ -715,11 +797,50 @@ function ProfilesAdmin() {
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
refresh={query.reload}
|
||||
polling={polling}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function qualificationPollInterval(item: Qualification | null) {
|
||||
return !item || ["queued", "running"].includes(item.status) ? 5000 : 0;
|
||||
}
|
||||
|
||||
function HeldAttemptsAdmin() {
|
||||
const query = useQuery<Collection<HeldAttempt>>("/admin/attempts/held", 5000);
|
||||
const [selected, setSelected] = useState<HeldAttempt | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
return <Panel title="Удержанные клоны" subtitle="Последние 200 попыток с сохранёнными доказательствами или ошибкой очистки">
|
||||
{query.error && <ErrorState error={query.error} retry={query.reload} />}
|
||||
{error && <ErrorState error={error} />}
|
||||
{query.loading ? <Loading /> : query.data?.items.length ? <div className="table-scroll"><table className="data-table">
|
||||
<thead><tr><th>Задание / профиль</th><th>Результат</th><th>Клон</th><th>Завершено</th><th>Действия</th></tr></thead>
|
||||
<tbody>{query.data.items.map((item) => <tr key={item.attempt_id}>
|
||||
<td><Link to={`/jobs/${item.job_id}`}>{item.filename}</Link><small className="block windows-label">{item.profile_name}</small></td>
|
||||
<td><Status value={item.outcome} /><Status value={item.cleanup} />{item.error && <small className="block muted">{item.error}</small>}</td>
|
||||
<td>{item.allocation ? <>VM {item.allocation.vmid}<small className="block muted">{item.allocation.node} · {item.allocation.state}</small></> : "Нет выделенного клона"}</td>
|
||||
<td>{formatDate(item.finished_at)}</td>
|
||||
<td><button className="button destructive small" disabled={!item.allocation || item.release_requested} onClick={() => setSelected(item)}>{item.release_requested ? "Удаление запрошено" : "Разрешить удаление"}</button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div> : <Empty title="Удержанных клонов нет" />}
|
||||
{selected && <Confirm title={`Удалить удержанный клон VM ${selected.allocation?.vmid}?`} description="Необратимо разрешить удаление одноразовой VM и удерживаемых дисков. Сначала выгрузите необходимые доказательства. Исходная Windows не затрагивается." danger busy={busy} close={() => setSelected(null)} confirm={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/admin/attempts/${idPath(selected.attempt_id)}/release-evidence`, { method: "POST", body: { confirm: true } });
|
||||
query.reload();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error : new Error("Ошибка очистки"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setSelected(null);
|
||||
}
|
||||
}} />}
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
function IntegrationAdmin() {
|
||||
const health = useQuery<Health>("/admin/health", 10000);
|
||||
const bindings = useQuery<Collection<Binding>>("/admin/bindings", 10000);
|
||||
@@ -890,6 +1011,7 @@ export function AdminPage() {
|
||||
{ id: "users", label: "Пользователи", icon: Users },
|
||||
{ id: "profiles", label: "Профили Windows", icon: Cpu },
|
||||
{ id: "integration", label: "Интеграция и ресурсы", icon: Server },
|
||||
{ id: "held", label: "Удержанные клоны", icon: Trash2 },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
@@ -922,6 +1044,8 @@ export function AdminPage() {
|
||||
<ProfilesAdmin />
|
||||
) : tab === "integration" ? (
|
||||
<IntegrationAdmin />
|
||||
) : tab === "held" ? (
|
||||
<HeldAttemptsAdmin />
|
||||
) : (
|
||||
<UsersAdmin />
|
||||
)}
|
||||
|
||||
+64
-31
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -15,9 +15,7 @@ import { api, artifactUrl, idPath, uploadUrl } from "../api";
|
||||
import { useAuth, useQuery } from "../hooks";
|
||||
import { useJobEvents } from "../useJobEvents";
|
||||
import type {
|
||||
Artifact,
|
||||
Attempt,
|
||||
Collection,
|
||||
Job,
|
||||
JobEvent,
|
||||
VideoManifest,
|
||||
@@ -26,6 +24,7 @@ import {
|
||||
Confirm,
|
||||
CopyValue,
|
||||
durationLabel,
|
||||
eventLabel,
|
||||
Empty,
|
||||
ErrorState,
|
||||
formatBytes,
|
||||
@@ -51,6 +50,38 @@ const phases = [
|
||||
"cleanup",
|
||||
"finished",
|
||||
];
|
||||
function jobPollInterval(job: Job | null) {
|
||||
return job && ["completed", "cancelled", "failed"].includes(job.status) &&
|
||||
job.runs.every((run) => (run.attempts ?? []).every((attempt) => attempt.phase === "finished")) ? 0 : 5000;
|
||||
}
|
||||
|
||||
function AttemptTiming({ attempt }: { attempt: Attempt }) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
if (!attempt.claimed_at || attempt.phase === "finished") return;
|
||||
let timer: number | undefined;
|
||||
const resume = () => {
|
||||
window.clearInterval(timer);
|
||||
if (!document.hidden) {
|
||||
setNow(Date.now());
|
||||
timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
}
|
||||
};
|
||||
resume();
|
||||
document.addEventListener("visibilitychange", resume);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", resume);
|
||||
};
|
||||
}, [attempt.claimed_at, attempt.phase]);
|
||||
const end = attempt.finished_at ? Date.parse(attempt.finished_at) : now;
|
||||
const elapsed = attempt.claimed_at ? Math.max(0, Math.floor((end - Date.parse(attempt.claimed_at)) / 1000)) : null;
|
||||
const remaining = attempt.phase === "running" && attempt.deadline_at ? Math.max(0, Math.ceil((Date.parse(attempt.deadline_at) - now) / 1000)) : null;
|
||||
return <span className="attempt-timing">
|
||||
{elapsed === null ? "Ещё не начато" : `Прошло ${durationLabel(elapsed)}`}
|
||||
{remaining !== null && <span>До конца наблюдения: {durationLabel(remaining)}</span>}
|
||||
</span>;
|
||||
}
|
||||
function VideoPanel({
|
||||
attempt,
|
||||
events,
|
||||
@@ -184,8 +215,8 @@ function VideoPanel({
|
||||
<span className="event-dot" />
|
||||
<div>
|
||||
<time>{formatDate(event.created_at)}</time>
|
||||
<p>{event.message}</p>
|
||||
<span className="mono muted">{event.kind}</span>
|
||||
<p>{eventLabel(event)}</p>
|
||||
<small className="muted">{event.message}</small>
|
||||
</div>
|
||||
{segments.length === 1 &&
|
||||
query.data?.started_at &&
|
||||
@@ -538,11 +569,12 @@ function AttemptDetails({
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{session.user.role === "admin" && attempt.cleanup === "evidence_held" && (
|
||||
{session.user.role === "admin" && attempt.allocation && attempt.phase === "finished" && ["evidence_held", "failed"].includes(attempt.cleanup) && (
|
||||
<Notice tone="warning">
|
||||
<strong>Артефакты удерживаются для расследования.</strong>
|
||||
<p>
|
||||
Автоматическая очистка не удаляет сохранённый диск доказательств.
|
||||
{" "}VM {attempt.allocation.vmid} · {attempt.allocation.state}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -586,21 +618,21 @@ function AttemptDetails({
|
||||
}
|
||||
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 query = useQuery<Job>(id ? `/jobs/${idPath(id)}` : null, jobPollInterval);
|
||||
const terminal = jobPollInterval(query.data) === 0;
|
||||
const events = useJobEvents(id, terminal);
|
||||
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];
|
||||
const artifacts = attempts.flatMap((item) => item.artifacts ?? []);
|
||||
const finished = attempts.reduce<Attempt | undefined>((latest, item) =>
|
||||
item.phase === "finished" && (!latest || (item.finished_at ?? "") > (latest.finished_at ?? "")) ? item : latest, undefined);
|
||||
const attempt = attempts.find((item) => item.id === selected) ??
|
||||
attempts.find((item) => !["queued", "finished"].includes(item.phase)) ?? finished ?? attempts[0];
|
||||
const done = job?.runs.filter((run) => ["completed", "failed", "cancelled"].includes(run.status)).length ?? 0;
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/jobs">
|
||||
@@ -633,6 +665,7 @@ export function JobDetailPage() {
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={() => setAction("retry")}
|
||||
disabled={!terminal}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
Новая попытка
|
||||
@@ -641,6 +674,14 @@ export function JobDetailPage() {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="job-progress">
|
||||
<strong>Готово {done} из {job.runs.length}</strong>
|
||||
<progress value={done} max={job.runs.length || 1} aria-label="Завершённые прогоны" />
|
||||
{(job.queue_ahead ?? 0) > 0 && <p className="muted small-text">
|
||||
В очереди перед вами: {job.queue_ahead}. Приблизительно до завершения: {durationLabel(((job.queue_ahead ?? 0) + job.runs.length - done) * 90)}.
|
||||
{" "}Оценка по 90 с на попытку, без учёта параллельности и выбранной длительности; не гарантия.
|
||||
</p>}
|
||||
</div>
|
||||
{error && <ErrorState error={error} />}
|
||||
<Panel className="job-identity">
|
||||
<div>
|
||||
@@ -685,7 +726,7 @@ export function JobDetailPage() {
|
||||
<div className="run-card" key={run.id}>
|
||||
<div className="run-heading">
|
||||
<h2 className="windows-label">{run.profile_name}</h2>
|
||||
<Status value={run.status} />
|
||||
<Status value={run.attempts?.length ? (run.attempts[run.attempts.length - 1].phase === "finished" ? run.attempts[run.attempts.length - 1].outcome : run.attempts[run.attempts.length - 1].phase) : run.status} />
|
||||
</div>
|
||||
<p className="muted small-text">
|
||||
Defender · Ревизия <code>{run.revision_id}</code>
|
||||
@@ -700,7 +741,7 @@ export function JobDetailPage() {
|
||||
>
|
||||
<span>
|
||||
Попытка {index + 1}
|
||||
<small>{formatDate(item.created_at)}</small>
|
||||
<AttemptTiming attempt={item} />
|
||||
</span>
|
||||
<Status value={item.phase} />
|
||||
<Status value={item.findings} />
|
||||
@@ -720,7 +761,7 @@ export function JobDetailPage() {
|
||||
>
|
||||
{phases.map((phase) => (
|
||||
<div
|
||||
className={phase === attempt.phase ? "current" : ""}
|
||||
className={phase === attempt.phase ? "current" : phases.indexOf(phase) < phases.indexOf(attempt.phase) ? "past" : ""}
|
||||
key={phase}
|
||||
>
|
||||
<i />
|
||||
@@ -750,14 +791,7 @@ export function JobDetailPage() {
|
||||
title="Артефакты"
|
||||
subtitle="Приватная выдача через авторизованный API"
|
||||
>
|
||||
{artifacts.error && (
|
||||
<div className="panel-padding">
|
||||
<ErrorState error={artifacts.error} retry={artifacts.reload} />
|
||||
</div>
|
||||
)}
|
||||
{artifacts.loading ? (
|
||||
<Loading />
|
||||
) : artifacts.data?.items.length ? (
|
||||
{artifacts.length ? (
|
||||
<div className="table-scroll">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
@@ -770,7 +804,7 @@ export function JobDetailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{artifacts.data.items.map((artifact) => (
|
||||
{artifacts.map((artifact) => (
|
||||
<tr key={artifact.id}>
|
||||
<td>{artifact.filename}</td>
|
||||
<td>
|
||||
@@ -822,8 +856,8 @@ export function JobDetailPage() {
|
||||
<span className="event-dot" />
|
||||
<div>
|
||||
<time>{formatDate(event.created_at)}</time>
|
||||
<strong>{event.kind}</strong>
|
||||
<p>{event.message}</p>
|
||||
<strong>{eventLabel(event)}</strong>
|
||||
<p className="muted small-text">{event.message}</p>
|
||||
{event.attempt_id && (
|
||||
<small className="mono muted">
|
||||
Попытка {event.attempt_id.slice(0, 8)}
|
||||
@@ -847,7 +881,7 @@ export function JobDetailPage() {
|
||||
}
|
||||
description={
|
||||
action === "retry"
|
||||
? "Для каждого завершённого прогона будет создана новая попытка на новом клоне с исходными настройками и ревизией. История и артефакты прежних попыток сохранятся. Сервер повторно проверит допуск."
|
||||
? "Для завершённых неуспешных прогонов будет создана новая попытка на новом клоне с исходными настройками и ревизией. История и артефакты прежних попыток сохранятся. Сервер повторно проверит допуск."
|
||||
: "Сервер запросит остановку и сохранит доступные результаты. Очистка ресурсов выполняется отдельно и может завершиться с ошибкой."
|
||||
}
|
||||
busy={busy}
|
||||
@@ -862,7 +896,6 @@ export function JobDetailPage() {
|
||||
setAction(null);
|
||||
query.reload();
|
||||
events.reload();
|
||||
artifacts.reload();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
|
||||
@@ -70,14 +70,10 @@ export function JobsTable({
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-environments">
|
||||
{(job.runs ?? []).map((run) => (
|
||||
<span className="windows-label" key={run.id}>{run.profile_name}</span>
|
||||
))}
|
||||
{!job.runs?.length && (
|
||||
<span className="muted">Не назначены</span>
|
||||
)}
|
||||
</div>
|
||||
<span title={(job.runs ?? []).map((run) => run.profile_name).join("\n")}>
|
||||
{(job.runs ?? []).filter((run) => ["completed", "failed", "cancelled"].includes(run.status)).length}/{job.runs?.length ?? 0}
|
||||
<small className="block muted">прогонов завершено</small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Status value={job.status} />
|
||||
|
||||
+24
-1
@@ -1486,12 +1486,33 @@ summary svg {
|
||||
.job-settings {
|
||||
margin: 20px 0 25px;
|
||||
}
|
||||
.job-progress {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.job-progress progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
accent-color: #71e6e6;
|
||||
}
|
||||
.job-progress p {
|
||||
margin: 0;
|
||||
}
|
||||
.attempt-timing,
|
||||
.attempt-timing > span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #91adbd;
|
||||
font-size: 10px;
|
||||
}
|
||||
.run-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.run-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid #2b3d52;
|
||||
border-radius: 8px;
|
||||
@@ -1502,6 +1523,7 @@ summary svg {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.run-heading h2 {
|
||||
font-size: 14px;
|
||||
@@ -1516,6 +1538,7 @@ summary svg {
|
||||
text-align: left;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
border: 1px solid #2c3a4e;
|
||||
background: #0b1320;
|
||||
border-radius: 5px;
|
||||
@@ -1528,7 +1551,7 @@ summary svg {
|
||||
background: #15313d;
|
||||
}
|
||||
.attempt-choice > span:first-child {
|
||||
flex: 1;
|
||||
flex: 1 0 100%;
|
||||
font-size: 10px;
|
||||
}
|
||||
.attempt-choice small {
|
||||
|
||||
+30
-18
@@ -1,57 +1,69 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, idPath } from "./api";
|
||||
import type { Collection, JobEvent } from "./models";
|
||||
|
||||
export function useJobEvents(jobId: string | undefined) {
|
||||
export function useJobEvents(jobId: string | undefined, terminal = false) {
|
||||
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 history = useRef<{ jobId?: string; cursor: number; items: JobEvent[] }>({ cursor: 0, items: [] });
|
||||
const reload = useCallback(() => setRevision((value) => value + 1), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timer: number | undefined;
|
||||
let cursor = 0;
|
||||
const accumulated: JobEvent[] = [];
|
||||
setData(null);
|
||||
let inFlight = false;
|
||||
let fetched = false;
|
||||
if (history.current.jobId !== jobId) {
|
||||
history.current = { jobId, cursor: 0, items: [] };
|
||||
setData(null);
|
||||
setLoading(Boolean(jobId));
|
||||
}
|
||||
setError(null);
|
||||
setLoading(Boolean(jobId));
|
||||
if (!jobId) return () => controller.abort();
|
||||
const current = history.current;
|
||||
const load = async () => {
|
||||
if (document.hidden || inFlight || controller.signal.aborted) return;
|
||||
inFlight = true;
|
||||
const previousCount = current.items.length;
|
||||
try {
|
||||
let batch: Collection<JobEvent>;
|
||||
do {
|
||||
batch = await api<Collection<JobEvent>>(
|
||||
`/jobs/${idPath(jobId)}/events?after=${cursor}`,
|
||||
`/jobs/${idPath(jobId)}/events?after=${current.cursor}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
const fresh = batch.items.filter((event) => event.id > cursor);
|
||||
const fresh = batch.items.filter((event) => event.id > current.cursor);
|
||||
if (!fresh.length) break;
|
||||
accumulated.push(...fresh);
|
||||
cursor = fresh[fresh.length - 1].id;
|
||||
current.items.push(...fresh);
|
||||
current.cursor = fresh[fresh.length - 1].id;
|
||||
} while (batch.items.length === 500);
|
||||
setData({ items: [...accumulated] });
|
||||
fetched = true;
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Не удалось получить события"),
|
||||
);
|
||||
setError(error instanceof Error ? error : new Error("Не удалось получить события"));
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!controller.signal.aborted) {
|
||||
if (current.items.length !== previousCount) setData({ items: [...current.items] });
|
||||
setLoading(false);
|
||||
timer = window.setTimeout(load, 5000);
|
||||
if (!terminal && !document.hidden) timer = window.setTimeout(load, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
const visibility = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!document.hidden && (!terminal || !fetched)) void load();
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
void load();
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
};
|
||||
}, [jobId, revision]);
|
||||
}, [jobId, terminal, revision]);
|
||||
return { data, error, loading, reload };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user