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:
omar
2026-09-24 17:55:13 +03:00
parent cf8c5007f1
commit a9c2badf2c
9 changed files with 396 additions and 90 deletions
+17 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}