feat: add Gaussian simulation workspace
This commit is contained in:
@@ -1,10 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
@@ -41,6 +36,7 @@ import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
|
||||
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
|
||||
import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
|
||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||
@@ -1193,6 +1189,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
SpatialView={SpatialWorkspace}
|
||||
/>
|
||||
);
|
||||
case "simulations":
|
||||
return <SimulationWorkspace />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { SimulationProjectWindow } from "../../components/simulation/SimulationProjectWindow";
|
||||
import { SimulationViewport } from "../../components/simulation/SimulationViewport";
|
||||
import {
|
||||
deleteSimulationProject,
|
||||
fetchSimulationProjects,
|
||||
type SimulationProject,
|
||||
type SimulationProjectStatus,
|
||||
} from "../../core/simulation/projects";
|
||||
|
||||
const ACTIVE_STATUSES = new Set<SimulationProjectStatus>(["queued", "processing", "importing"]);
|
||||
|
||||
export function SimulationWorkspace() {
|
||||
const [projects, setProjects] = useState<SimulationProject[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [windowOpen, setWindowOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SimulationProject | null>(null);
|
||||
const [deleting, setDeleting] = useState<SimulationProject | null>(null);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
try {
|
||||
const next = await fetchSimulationProjects(signal);
|
||||
setProjects(next);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
if (signal?.aborted) return;
|
||||
setError(caught instanceof Error ? caught.message : "Каталог симуляций недоступен.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projects.some((project) => ACTIVE_STATUSES.has(project.status))) return;
|
||||
const timer = window.setInterval(() => void load(), 2_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, projects]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => projects.find((project) => project.projectId === selectedId) ?? null,
|
||||
[projects, selectedId],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setWindowOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (project: SimulationProject) => {
|
||||
setEditing(project);
|
||||
setWindowOpen(true);
|
||||
};
|
||||
|
||||
const acceptSaved = (project: SimulationProject) => {
|
||||
setProjects((current) => [project, ...current.filter((item) => item.projectId !== project.projectId)]);
|
||||
setSelectedId(project.projectId);
|
||||
setWindowOpen(false);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return;
|
||||
await deleteSimulationProject(deleting.projectId);
|
||||
setProjects((current) => current.filter((project) => project.projectId !== deleting.projectId));
|
||||
if (selectedId === deleting.projectId) setSelectedId(null);
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="simulation-workspace simulation-workspace--scene">
|
||||
<header className="simulation-workspace__scene-head">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
icon={<Icon name="chevron-left" size={16} />}
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
Все проекты
|
||||
</Button>
|
||||
<div>
|
||||
<span className="section-eyebrow">LAB / SIMULATION RUNTIME</span>
|
||||
<h2>{selected.name}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={statusPresentation(selected.status).tone}>
|
||||
{statusPresentation(selected.status).label}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
{selected.status === "ready" && selected.worldManifest ? (
|
||||
<SimulationViewport project={selected} />
|
||||
) : (
|
||||
<GlassSurface className="simulation-workspace__processing" padding="lg">
|
||||
{selected.status === "failed" ? <Icon name="alert" size={20} /> : <ActivityIndicator label="Сборка мира" />}
|
||||
<div>
|
||||
<strong>{selected.status === "failed" ? "Сборка остановлена" : "Worker 006 собирает мир"}</strong>
|
||||
<p>{selected.error ?? processingMessage(selected)}</p>
|
||||
</div>
|
||||
<StatusBadge tone={selected.status === "failed" ? "warning" : "accent"}>
|
||||
{selected.provider.state ?? selected.status}
|
||||
</StatusBadge>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="simulation-workspace">
|
||||
<header className="simulation-workspace__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ</span>
|
||||
<h2>Gaussian-миры</h2>
|
||||
<p>Каталог исходников, сборок Worker 006 и полнофункциональных PlayCanvas-сцен.</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Icon name="plus" size={16} />} onClick={openCreate}>
|
||||
Новый проект
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section className="simulation-catalog" aria-label="Проекты симуляции">
|
||||
<header className="simulation-catalog__summary">
|
||||
<div><span>Проектов</span><strong>{projects.length.toLocaleString("ru-RU")}</strong></div>
|
||||
<div><span>Готово</span><strong>{projects.filter((project) => project.status === "ready").length}</strong></div>
|
||||
<div><span>В работе</span><strong>{projects.filter((project) => ACTIVE_STATUSES.has(project.status)).length}</strong></div>
|
||||
<div><span>Исходники</span><strong>{formatBytes(projects.reduce((sum, project) => sum + project.source.totalByteLength, 0))}</strong></div>
|
||||
</header>
|
||||
{loading ? (
|
||||
<div className="simulation-catalog__state" role="status">
|
||||
<ActivityIndicator label="Загрузка каталога" />
|
||||
<span>Читаем каталог сцен</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="simulation-catalog__state" role="alert">
|
||||
<Icon name="alert" size={20} />
|
||||
<div><strong>Каталог недоступен</strong><span>{error}</span></div>
|
||||
<Button size="compact" onClick={() => void load()}>Повторить</Button>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="simulation-catalog__empty">
|
||||
<span><Icon name="globe" size={20} /></span>
|
||||
<strong>Проектов пока нет</strong>
|
||||
<p>Создайте первый мир из папки LCC/LCC2 или архива ZIP, RAR, 7z.</p>
|
||||
<Button variant="primary" icon={<Icon name="upload" size={16} />} onClick={openCreate}>
|
||||
Загрузить исходник
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="simulation-catalog__table-wrap">
|
||||
<table className="simulation-catalog__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Проект</th>
|
||||
<th>Источник</th>
|
||||
<th>Размер</th>
|
||||
<th>Сплаты</th>
|
||||
<th>Статус</th>
|
||||
<th>Обновлён</th>
|
||||
<th aria-label="Действия" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => {
|
||||
const status = statusPresentation(project.status);
|
||||
return (
|
||||
<tr key={project.projectId} data-status={project.status}>
|
||||
<td>
|
||||
<button type="button" onClick={() => setSelectedId(project.projectId)}>
|
||||
<span className="simulation-catalog__project-icon"><Icon name="globe" size={18} /></span>
|
||||
<span><strong>{project.name}</strong><small>{sceneTypeLabel(project.sceneType)}</small></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{project.source.kind === "archive" ? "Архив" : "Папка"}<small>{project.source.files.length} файл(ов)</small></td>
|
||||
<td>{formatBytes(project.source.totalByteLength)}</td>
|
||||
<td>—</td>
|
||||
<td><StatusBadge tone={status.tone}>{status.label}</StatusBadge></td>
|
||||
<td>{formatDate(project.updatedAtUtc)}</td>
|
||||
<td>
|
||||
<div className="simulation-catalog__actions">
|
||||
<IconButton label={`Удалить ${project.name}`} disabled={ACTIVE_STATUSES.has(project.status)} onClick={() => setDeleting(project)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label={`Редактировать ${project.name}`} onClick={() => openEdit(project)}>
|
||||
<Icon name="edit" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SimulationProjectWindow
|
||||
open={windowOpen}
|
||||
project={editing}
|
||||
onClose={() => { if (!windowOpen) return; setWindowOpen(false); setEditing(null); }}
|
||||
onSaved={acceptSaved}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
open={deleting !== null}
|
||||
title="Удалить проект симуляции?"
|
||||
description={(
|
||||
<p>
|
||||
Проект <strong>{deleting?.name}</strong>, локальные производные артефакты и job на Worker 006 будут удалены.
|
||||
</p>
|
||||
)}
|
||||
confirmLabel="Удалить проект"
|
||||
pendingLabel="Удаляем…"
|
||||
danger
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusPresentation(status: SimulationProjectStatus): {
|
||||
label: string;
|
||||
tone: "success" | "accent" | "warning" | "neutral";
|
||||
} {
|
||||
if (status === "ready") return { label: "Готово", tone: "success" };
|
||||
if (status === "failed") return { label: "Ошибка", tone: "warning" };
|
||||
if (status === "uploading") return { label: "Загрузка", tone: "neutral" };
|
||||
if (status === "importing") return { label: "Импорт", tone: "accent" };
|
||||
return { label: "Сборка", tone: "accent" };
|
||||
}
|
||||
|
||||
function processingMessage(project: SimulationProject): string {
|
||||
const progress = project.provider.progress;
|
||||
const completed = progress?.completed_steps;
|
||||
const total = progress?.total_steps;
|
||||
if (typeof completed === "number" && typeof total === "number") {
|
||||
return `Этап ${completed.toLocaleString("ru-RU")} из ${total.toLocaleString("ru-RU")}. Каталог обновляется автоматически.`;
|
||||
}
|
||||
return "Исходник подтверждён; конвертация SOG и Streamed SOG выполняется в переносимом контейнере.";
|
||||
}
|
||||
|
||||
function sceneTypeLabel(value: SimulationProject["sceneType"]): string {
|
||||
if (value === "interior") return "Интерьер";
|
||||
if (value === "object") return "Объект";
|
||||
return "Улица";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.valueOf()) ? "—" : date.toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`;
|
||||
return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`;
|
||||
}
|
||||
Reference in New Issue
Block a user