fix(ui): align telemetry and laboratory navigation

This commit is contained in:
DCCONSTRUCTIONS
2026-07-28 03:18:27 +03:00
parent 6db5ac05ef
commit e35f81ac34
13 changed files with 195 additions and 55 deletions
+1 -1
View File
@@ -619,7 +619,7 @@ export default function App() {
refreshRuntime: runtime.refresh,
saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityAction: computeContourSettings.utilityAction,
systemUtilityActions: computeContourSettings.utilityActions,
});
const header = (
@@ -2,6 +2,7 @@ interface TelemetrySeriesProps {
label: string;
values: Array<number | null>;
value: string;
resource?: string | null;
ceiling?: number;
}
@@ -24,13 +25,17 @@ export function TelemetrySeries({
label,
values,
value,
resource,
ceiling,
}: TelemetrySeriesProps) {
const points = linePoints(values, ceiling);
return (
<div className="system-telemetry-series">
<div>
<span>{label}</span>
<span className="system-telemetry-series__label">
<span>{label}</span>
{resource ? <small>{resource}</small> : null}
</span>
<strong>{value}</strong>
</div>
<svg
@@ -8,7 +8,7 @@ interface ComputeContourSettingsController {
canEdit: boolean;
openCreate: () => void;
openEdit: () => void;
utilityAction: ApplicationPanelUtilityAction;
utilityActions: readonly ApplicationPanelUtilityAction[];
window: ReactNode;
}
@@ -24,18 +24,30 @@ export function useComputeContourSettings(): ComputeContourSettingsController {
setMode("edit");
setOpen(true);
}, []);
const utilityAction = useMemo<ApplicationPanelUtilityAction>(() => ({
label: "Настроить выбранный вычислительный контур",
icon: "settings",
disabled: contours.selectedContour === null,
onClick: openEdit,
}), [contours.selectedContour, openEdit]);
const utilityActions = useMemo<readonly ApplicationPanelUtilityAction[]>(() => [
{
label: "Обновить телеметрию выбранного контура",
icon: "refresh",
disabled: contours.selectedContour?.contour_id !== "worker-006",
onClick: contours.refreshTelemetry,
},
{
label: "Настроить выбранный вычислительный контур",
icon: "settings",
disabled: contours.selectedContour === null,
onClick: openEdit,
},
], [
contours.refreshTelemetry,
contours.selectedContour,
openEdit,
]);
return {
canEdit: contours.selectedContour !== null,
openCreate,
openEdit,
utilityAction,
utilityActions,
window: (
<ComputeContourSettingsWindow
open={open}
@@ -8,7 +8,7 @@ interface ApplicationPanelActionsOptions {
refreshRuntime: () => void;
saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean;
systemUtilityAction: ApplicationPanelUtilityAction;
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
}
export function useApplicationPanelActions({
@@ -16,7 +16,7 @@ export function useApplicationPanelActions({
refreshRuntime,
saveWorkspaceLayout,
workspaceLayoutSaving,
systemUtilityAction,
systemUtilityActions,
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
@@ -35,13 +35,13 @@ export function useApplicationPanelActions({
onClick: () => void saveWorkspaceLayout(),
});
}
if (definition?.root === "system") actions.push(systemUtilityAction);
if (definition?.root === "system") actions.push(...systemUtilityActions);
return actions;
}, [
definition,
refreshRuntime,
saveWorkspaceLayout,
systemUtilityAction,
systemUtilityActions,
workspaceLayoutSaving,
]);
}
@@ -23,6 +23,7 @@ interface ComputeContourContextValue {
selectedContour: ComputeContour | null;
loading: boolean;
error: string | null;
telemetryRefreshGeneration: number;
selectContour: (contourId: string) => void;
createContour: (draft: ComputeContourDraft) => Promise<ComputeContour>;
updateContour: (
@@ -30,6 +31,7 @@ interface ComputeContourContextValue {
draft: ComputeContourDraft,
) => Promise<ComputeContour>;
refresh: () => void;
refreshTelemetry: () => void;
}
const ComputeContourContext = createContext<ComputeContourContextValue | null>(null);
@@ -42,7 +44,12 @@ export function ComputeContourProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const [telemetryRefreshGeneration, setTelemetryRefreshGeneration] = useState(0);
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
const refreshTelemetry = useCallback(
() => setTelemetryRefreshGeneration((value) => value + 1),
[],
);
useEffect(() => {
const controller = new AbortController();
@@ -103,19 +110,23 @@ export function ComputeContourProvider({ children }: { children: ReactNode }) {
selectedContour,
loading,
error,
telemetryRefreshGeneration,
selectContour,
createContour,
updateContour,
refresh,
refreshTelemetry,
}), [
contours,
selectedContour,
loading,
error,
telemetryRefreshGeneration,
selectContour,
createContour,
updateContour,
refresh,
refreshTelemetry,
]);
return (
@@ -18,6 +18,7 @@ export interface WorkerTelemetryState {
export function useWorkerTelemetry(
pollMilliseconds = DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS,
enabled = true,
externalRefreshGeneration = 0,
): WorkerTelemetryState {
const normalizedPollMilliseconds = normalizeWorkerTelemetryPollMilliseconds(
pollMilliseconds,
@@ -51,7 +52,7 @@ export function useWorkerTelemetry(
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [enabled, generation]);
}, [enabled, externalRefreshGeneration, generation]);
useEffect(() => {
if (!enabled || loading) return;
+2 -2
View File
@@ -338,10 +338,10 @@
.scene-operation-status-stack {
position: absolute;
z-index: 11;
right: 0.85rem;
left: 0.85rem;
bottom: 7.15rem;
display: grid;
justify-items: end;
justify-items: start;
gap: 0.3rem;
}
@@ -142,7 +142,7 @@
.system-telemetry-series > div {
display: flex;
align-items: baseline;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
@@ -153,6 +153,22 @@
font-size: 0.62rem;
}
.system-telemetry-series__label {
display: grid;
min-width: 0;
gap: 0.16rem;
}
.system-telemetry-series__label small {
overflow: hidden;
max-width: 11rem;
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-telemetry-series strong,
.network-stat-card strong {
color: var(--nodedc-text-primary);
@@ -1,6 +1,7 @@
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
@@ -71,6 +72,11 @@ const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = {
e37: null,
};
function laboratoryWorkOrdinal(value: string): number {
const match = value.match(/\bE(\d+)\b/i);
return match ? Number(match[1]) : -1;
}
function digestFromContentId(value: string | null | undefined): string | null {
const digest = value?.split("-").at(-1) ?? "";
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
@@ -559,6 +565,7 @@ function PublishedLaboratoryResult({
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
const initialWorkSelectedRef = useRef(false);
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
@@ -580,7 +587,12 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
&& session.status === "ready"
&& session.replayable
&& session.modalities.includes("point-cloud")
)),
)).sort((left, right) => {
const ordinalDelta = laboratoryWorkOrdinal(right.lab?.labId ?? "")
- laboratoryWorkOrdinal(left.lab?.labId ?? "");
if (ordinalDelta !== 0) return ordinalDelta;
return (right.startedAtUtc ?? "").localeCompare(left.startedAtUtc ?? "");
}),
[sessions.items],
);
const sourceSessions = useMemo(
@@ -661,7 +673,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
advancedResults,
sourceSessions,
));
return items;
return items.sort(
(left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label),
);
}, [
advancedResults,
e28Model,
@@ -717,13 +731,26 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
setProfileId(firstProfile.id);
if (firstProfile.id === "sensor-fusion") {
const firstWork = sensorWorks[0];
if (firstWork) setWorkId(firstWork.id);
if (firstWork) {
setWorkId(firstWork.id);
initialWorkSelectedRef.current = true;
}
} else {
const first = publishedWorks[0];
if (first) setWorkId(`session:${first.id}`);
if (first) {
setWorkId(`session:${first.id}`);
initialWorkSelectedRef.current = true;
}
}
return;
}
if (!initialWorkSelectedRef.current) {
const freshestWork = workOptions[0];
if (!freshestWork) return;
setWorkId(freshestWork.id);
initialWorkSelectedRef.current = true;
return;
}
if (!workOptions.some((work) => work.id === workId)) {
const firstWork = workOptions[0];
if (firstWork) setWorkId(firstWork.id);
@@ -741,6 +768,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
const selectProfile = (next: LaboratoryProfileId) => {
setProfileId(next);
initialWorkSelectedRef.current = true;
if (next === "sensor-fusion") {
const first = sensorWorks[0];
if (first) setWorkId(first.id);
@@ -1,7 +1,5 @@
import {
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
@@ -24,14 +22,53 @@ function pipelineStateLabel(state: string): string {
return "Нет live-состояния";
}
function formatResourcePair(
used: number | null | undefined,
total: number | null | undefined,
): string | null {
if (
typeof used !== "number"
|| !Number.isFinite(used)
|| typeof total !== "number"
|| !Number.isFinite(total)
|| total <= 0
) {
return null;
}
return `${formatBytes(used)} / ${formatBytes(total)}`;
}
function formatCpuCapacity(
percent: number | null | undefined,
logicalProcessors: number | null | undefined,
): string | null {
if (
typeof percent !== "number"
|| !Number.isFinite(percent)
|| typeof logicalProcessors !== "number"
|| !Number.isFinite(logicalProcessors)
|| logicalProcessors <= 0
) {
return null;
}
const used = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
}).format(percent / 100 * logicalProcessors);
return `${used} / ${logicalProcessors} лог. ядра`;
}
export function ComputeModulesWorkspace() {
const { selectedContour } = useComputeContours();
const {
selectedContour,
telemetryRefreshGeneration,
} = useComputeContours();
const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
&& selectedContour.telemetry_mode === "legacy-ssh";
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
const { telemetry, error, refresh } = useWorkerTelemetry(
const { telemetry, error } = useWorkerTelemetry(
(selectedContour?.telemetry_poll_interval_seconds ?? 3) * 1_000,
supportsLiveTelemetry,
telemetryRefreshGeneration,
);
const node = telemetry?.node ?? null;
const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? [];
@@ -45,6 +82,12 @@ export function ComputeModulesWorkspace() {
const memoryPercent = node?.memory.used_percent;
const gpuPercent = node?.gpu?.utilization_percent;
const gpuMemoryPercent = node?.gpu?.memory_used_percent;
const gpuMemoryUsedBytes = typeof node?.gpu?.memory_used_mib === "number"
? node.gpu.memory_used_mib * 1024 * 1024
: null;
const gpuMemoryTotalBytes = typeof node?.gpu?.memory_total_mib === "number"
? node.gpu.memory_total_mib * 1024 * 1024
: null;
return (
<div className="system-workspace compute-modules-workspace">
@@ -63,15 +106,6 @@ export function ComputeModulesWorkspace() {
? agentTelemetry ? "Агент доступен" : "SSH-диагностика"
: "Нет свежих данных"}
</StatusBadge>
<Button
size="compact"
variant="secondary"
disabled={!supportsLiveTelemetry}
onClick={refresh}
icon={<Icon name="refresh" size={14} />}
>
Обновить
</Button>
</div>
</section>
@@ -92,24 +126,37 @@ export function ComputeModulesWorkspace() {
<TelemetrySeries
label="CPU"
value={formatOptionalPercent(cpuPercent)}
resource={formatCpuCapacity(
cpuPercent,
node?.cpu.logical_processors,
)}
ceiling={100}
values={history.map((item) => item.cpu_percent)}
/>
<TelemetrySeries
label="RAM"
value={formatOptionalPercent(memoryPercent)}
resource={formatResourcePair(
node?.memory.used_bytes,
node?.memory.total_bytes,
)}
ceiling={100}
values={history.map((item) => item.memory_percent)}
/>
<TelemetrySeries
label="GPU"
value={formatOptionalPercent(gpuPercent)}
resource={node?.gpu?.name ?? null}
ceiling={100}
values={history.map((item) => item.gpu_percent)}
/>
<TelemetrySeries
label="VRAM"
value={formatOptionalPercent(gpuMemoryPercent)}
resource={formatResourcePair(
gpuMemoryUsedBytes,
gpuMemoryTotalBytes,
)}
ceiling={100}
values={history.map((item) => item.gpu_memory_percent)}
/>
@@ -135,11 +182,7 @@ export function ComputeModulesWorkspace() {
</dl>
<dl>
<div><dt>GPU</dt><dd>{node?.gpu?.name ?? "—"}</dd></div>
<div><dt>VRAM занята</dt><dd>{formatBytes(
typeof node?.gpu?.memory_used_mib === "number"
? node.gpu.memory_used_mib * 1024 * 1024
: null,
)}</dd></div>
<div><dt>VRAM занята</dt><dd>{formatBytes(gpuMemoryUsedBytes)}</dd></div>
<div><dt>Температура</dt><dd>{
typeof node?.gpu?.temperature_celsius === "number"
? `${node.gpu.temperature_celsius} °C`
@@ -1,7 +1,5 @@
import {
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
@@ -18,13 +16,17 @@ import {
} from "../../core/system/useWorkerTelemetry";
export function NetworkWorkspace() {
const { selectedContour } = useComputeContours();
const {
selectedContour,
telemetryRefreshGeneration,
} = useComputeContours();
const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
&& selectedContour.telemetry_mode === "legacy-ssh";
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
const { telemetry, error, refresh } = useWorkerTelemetry(
const { telemetry, error } = useWorkerTelemetry(
(selectedContour?.telemetry_poll_interval_seconds ?? 3) * 1_000,
supportsLiveTelemetry,
telemetryRefreshGeneration,
);
const aggregate = telemetry?.network.aggregate ?? null;
const connected = Boolean(
@@ -50,15 +52,6 @@ export function NetworkWorkspace() {
<StatusBadge tone={connected ? "success" : "danger"}>
{connected ? "Маршрут доступен" : "Нет свежих данных"}
</StatusBadge>
<Button
size="compact"
variant="secondary"
disabled={!supportsLiveTelemetry}
onClick={refresh}
icon={<Icon name="refresh" size={14} />}
>
Обновить
</Button>
</div>
</section>