958 lines
43 KiB
TypeScript
958 lines
43 KiB
TypeScript
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
|
||
import {
|
||
Button,
|
||
GlassSurface,
|
||
Icon,
|
||
Select,
|
||
SettingsCard,
|
||
StatusBadge,
|
||
TextAreaField,
|
||
TextField,
|
||
Window,
|
||
WindowFooterActions,
|
||
} from "@nodedc/ui-react";
|
||
|
||
import {
|
||
createConfigurationRevision,
|
||
ensureAdapterPackage,
|
||
ensureDeviceBinding,
|
||
ensureEdge,
|
||
ensureRoute,
|
||
registerAdapterVersion,
|
||
registerModelProfile,
|
||
revokeDeviceBinding,
|
||
sendServicePing,
|
||
setDesiredConfiguration,
|
||
upsertProjectGrant,
|
||
} from "./api";
|
||
import type {
|
||
AdapterPackageView,
|
||
AdapterVersionView,
|
||
BindingView,
|
||
DeviceManagerSession,
|
||
EdgeView,
|
||
ModelProfileView,
|
||
ProjectWorkspace,
|
||
} from "./types";
|
||
|
||
export type ControlViewId =
|
||
| "catalog"
|
||
| "infrastructure"
|
||
| "sessions"
|
||
| "bindings"
|
||
| "commands"
|
||
| "audit"
|
||
| "access"
|
||
| "settings";
|
||
|
||
type DialogId =
|
||
| "adapter-package"
|
||
| "adapter-version"
|
||
| "model-profile"
|
||
| "edge"
|
||
| "route"
|
||
| "binding"
|
||
| "grant"
|
||
| "configuration"
|
||
| null;
|
||
|
||
export function DeviceControlView({
|
||
view,
|
||
workspace,
|
||
session,
|
||
onRefresh,
|
||
onError,
|
||
}: {
|
||
view: ControlViewId;
|
||
workspace: ProjectWorkspace;
|
||
session: DeviceManagerSession;
|
||
onRefresh: () => Promise<void>;
|
||
onError: (reason: unknown) => void;
|
||
}) {
|
||
const [dialog, setDialog] = useState<DialogId>(null);
|
||
const capabilities = new Set(workspace.project.access.capabilities);
|
||
const platformOwner = session.actor.hubRole === "owner";
|
||
const close = () => setDialog(null);
|
||
const completed = async () => {
|
||
close();
|
||
await onRefresh();
|
||
};
|
||
const mutateAndRefresh = async (mutation: () => Promise<unknown>) => {
|
||
try {
|
||
await mutation();
|
||
await onRefresh();
|
||
} catch (reason) {
|
||
onError(reason);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{view === "catalog" ? (
|
||
<CatalogView
|
||
workspace={workspace}
|
||
canManage={platformOwner}
|
||
onCreatePackage={() => setDialog("adapter-package")}
|
||
onCreateVersion={() => setDialog("adapter-version")}
|
||
onCreateProfile={() => setDialog("model-profile")}
|
||
onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({
|
||
adapterPackageRef: version.adapterPackageRef,
|
||
version: version.version,
|
||
runtimePackageRef: version.runtimePackageRef,
|
||
contentDigest: version.contentDigest,
|
||
contractVersion: version.contractVersion,
|
||
capabilities: version.capabilities,
|
||
lifecycleState: "active",
|
||
}))}
|
||
onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({
|
||
adapterVersionRef: profile.adapterVersionRef || "",
|
||
profileRef: profile.modelProfileRef,
|
||
schemaVersion: profile.schemaVersion,
|
||
vendor: profile.vendor,
|
||
model: profile.model,
|
||
deviceType: profile.deviceType,
|
||
protocol: profile.protocol,
|
||
schemaArtifactRef: profile.schemaArtifactRef || "",
|
||
profileDigest: profile.profileDigest || "",
|
||
capabilities: profile.capabilities,
|
||
lifecycleState: "active",
|
||
}))}
|
||
/>
|
||
) : null}
|
||
{view === "infrastructure" ? (
|
||
<InfrastructureView
|
||
workspace={workspace}
|
||
canManageCatalog={platformOwner}
|
||
canManageRoutes={capabilities.has("route.manage")}
|
||
onCreateEdge={() => setDialog("edge")}
|
||
onCreateRoute={() => setDialog("route")}
|
||
onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({
|
||
edgeKey: edge.edgeKey,
|
||
displayName: edge.displayName,
|
||
deploymentRef: edge.deploymentRef,
|
||
lifecycleState: "active",
|
||
}))}
|
||
onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({
|
||
projectRef: workspace.project.projectRef,
|
||
routeKey: route.routeKey,
|
||
displayName: route.displayName,
|
||
edgeRef: route.edgeRef,
|
||
modelProfileRef: route.modelProfileRef,
|
||
listenerRef: route.listenerRef,
|
||
protocol: route.protocol,
|
||
direction: route.direction,
|
||
lifecycleState: "active",
|
||
}))}
|
||
/>
|
||
) : null}
|
||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||
{view === "bindings" ? (
|
||
<BindingsView
|
||
workspace={workspace}
|
||
canManage={capabilities.has("binding.manage")}
|
||
onCreate={() => setDialog("binding")}
|
||
onRevoke={(binding) => revokeDeviceBinding({
|
||
projectRef: workspace.project.projectRef,
|
||
bindingRef: binding.bindingRef,
|
||
resolutionCode: "operator.revoked",
|
||
}).then(onRefresh).catch(onError)}
|
||
/>
|
||
) : null}
|
||
{view === "commands" ? (
|
||
<CommandsView
|
||
workspace={workspace}
|
||
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
|
||
onRefresh={onRefresh}
|
||
onError={onError}
|
||
/>
|
||
) : null}
|
||
{view === "audit" ? <AuditView workspace={workspace} /> : null}
|
||
{view === "access" ? (
|
||
<AccessView
|
||
workspace={workspace}
|
||
canManage={capabilities.has("access.manage")}
|
||
onCreate={() => setDialog("grant")}
|
||
/>
|
||
) : null}
|
||
{view === "settings" ? (
|
||
<SettingsView
|
||
workspace={workspace}
|
||
canConfigure={capabilities.has("configuration.manage")}
|
||
onCreateConfiguration={() => setDialog("configuration")}
|
||
/>
|
||
) : null}
|
||
|
||
<AdapterPackageDialog
|
||
open={dialog === "adapter-package"}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<AdapterVersionDialog
|
||
open={dialog === "adapter-version"}
|
||
packages={workspace.adapterPackages}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<ModelProfileDialog
|
||
open={dialog === "model-profile"}
|
||
versions={workspace.adapterVersions}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<EdgeDialog
|
||
open={dialog === "edge"}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<RouteDialog
|
||
open={dialog === "route"}
|
||
workspace={workspace}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<BindingDialog
|
||
open={dialog === "binding"}
|
||
workspace={workspace}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<GrantDialog
|
||
open={dialog === "grant"}
|
||
projectRef={workspace.project.projectRef}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
<ConfigurationDialog
|
||
open={dialog === "configuration"}
|
||
workspace={workspace}
|
||
onClose={close}
|
||
onCreated={completed}
|
||
onError={onError}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: {
|
||
workspace: ProjectWorkspace;
|
||
canManage: boolean;
|
||
onCreatePackage: () => void;
|
||
onCreateVersion: () => void;
|
||
onCreateProfile: () => void;
|
||
onActivateVersion: (version: AdapterVersionView) => void;
|
||
onActivateProfile: (profile: ModelProfileView) => void;
|
||
}) {
|
||
return (
|
||
<ControlStack>
|
||
<ControlToolbar
|
||
copy="Adapter packages и model profiles — глобальный versioned каталог. B2 здесь не является отдельным продуктом."
|
||
actions={canManage ? <>
|
||
<Button size="compact" onClick={onCreatePackage}>Пакет</Button>
|
||
<Button size="compact" onClick={onCreateVersion} disabled={!workspace.adapterPackages.length}>Версия</Button>
|
||
<Button size="compact" variant="primary" onClick={onCreateProfile} disabled={!workspace.adapterVersions.length}>Профиль</Button>
|
||
</> : null}
|
||
/>
|
||
<ControlSection title="Model profiles" count={workspace.modelProfiles.length}>
|
||
<ResourceGrid empty="В доступном каталоге пока нет model profiles.">
|
||
{workspace.modelProfiles.map((profile) => (
|
||
<ResourceCard
|
||
key={profile.modelProfileRef}
|
||
eyebrow={`${profile.vendor} · ${profile.deviceType}`}
|
||
title={`${profile.model}`}
|
||
description={`${profile.protocol} · ${profile.modelProfileRef}`}
|
||
status={profile.lifecycleState}
|
||
meta={profile.capabilities}
|
||
action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? (
|
||
<Button size="compact" variant="primary" onClick={() => onActivateProfile(profile)}>Активировать</Button>
|
||
) : null}
|
||
/>
|
||
))}
|
||
</ResourceGrid>
|
||
</ControlSection>
|
||
<ControlSection title="Adapter versions" count={workspace.adapterVersions.length}>
|
||
<ResourceGrid empty="Версии адаптеров не зарегистрированы.">
|
||
{workspace.adapterVersions.map((version) => (
|
||
<ResourceCard
|
||
key={version.adapterVersionRef}
|
||
eyebrow={version.contractVersion}
|
||
title={version.version}
|
||
description={version.runtimePackageRef}
|
||
status={version.lifecycleState}
|
||
meta={[shortDigest(version.contentDigest), ...version.capabilities]}
|
||
action={canManage && version.lifecycleState === "draft" ? (
|
||
<Button size="compact" variant="primary" onClick={() => onActivateVersion(version)}>Активировать</Button>
|
||
) : null}
|
||
/>
|
||
))}
|
||
</ResourceGrid>
|
||
</ControlSection>
|
||
<ControlSection title="Adapter packages" count={workspace.adapterPackages.length}>
|
||
<ResourceGrid empty="Adapter packages не зарегистрированы.">
|
||
{workspace.adapterPackages.map((adapterPackage) => (
|
||
<ResourceCard
|
||
key={adapterPackage.adapterPackageRef}
|
||
eyebrow={adapterPackage.publisherRef}
|
||
title={adapterPackage.displayName}
|
||
description={adapterPackage.packageKey}
|
||
status={adapterPackage.lifecycleState}
|
||
meta={workspace.adapterVersions
|
||
.filter((version) => version.adapterPackageRef === adapterPackage.adapterPackageRef)
|
||
.map((version) => `${version.version} · ${version.lifecycleState}`)}
|
||
/>
|
||
))}
|
||
</ResourceGrid>
|
||
</ControlSection>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: {
|
||
workspace: ProjectWorkspace;
|
||
canManageCatalog: boolean;
|
||
canManageRoutes: boolean;
|
||
onCreateEdge: () => void;
|
||
onCreateRoute: () => void;
|
||
onActivateEdge: (edge: EdgeView) => void;
|
||
onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void;
|
||
}) {
|
||
return (
|
||
<ControlStack>
|
||
<ControlToolbar
|
||
copy="Edge — зарегистрированная внешняя роль. Route связывает проект, Edge, profile и логический listener без credentials."
|
||
actions={<>
|
||
{canManageCatalog ? <Button size="compact" onClick={onCreateEdge}>Новый Edge</Button> : null}
|
||
{canManageRoutes ? <Button size="compact" variant="primary" onClick={onCreateRoute} disabled={!workspace.edges.length || !workspace.modelProfiles.length}>Новый маршрут</Button> : null}
|
||
</>}
|
||
/>
|
||
<ControlSection title="Routes" count={workspace.routes.length}>
|
||
<ResourceGrid empty="Маршрутов в проекте пока нет.">
|
||
{workspace.routes.map((route) => (
|
||
<ResourceCard
|
||
key={route.routeRef}
|
||
eyebrow={`${route.protocol} · ${route.direction}`}
|
||
title={route.displayName}
|
||
description={`${route.edgeName} → ${route.profileName}`}
|
||
status={route.lifecycleState}
|
||
meta={[
|
||
route.listenerRef,
|
||
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
|
||
]}
|
||
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
|
||
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
|
||
) : null}
|
||
/>
|
||
))}
|
||
</ResourceGrid>
|
||
</ControlSection>
|
||
<ControlSection title="Edges" count={workspace.edges.length}>
|
||
<ResourceGrid empty="Доступных Edge registrations нет.">
|
||
{workspace.edges.map((edge) => (
|
||
<ResourceCard
|
||
key={edge.edgeRef}
|
||
eyebrow="DEVICE GATEWAY EDGE"
|
||
title={edge.displayName}
|
||
description={edge.edgeKey}
|
||
status={edge.lifecycleState}
|
||
meta={edge.deploymentRef ? [edge.deploymentRef] : []}
|
||
action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? (
|
||
<Button size="compact" variant="primary" onClick={() => onActivateEdge(edge)}>Активировать</Button>
|
||
) : null}
|
||
/>
|
||
))}
|
||
</ResourceGrid>
|
||
</ControlSection>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||
return (
|
||
<ControlStack>
|
||
<ControlToolbar copy="Сессии принадлежат Gateway runtime. Device Manager только читает bounded presence/counter projection." />
|
||
<ResourceList empty="Gateway sessions пока не наблюдались.">
|
||
{workspace.sessions.map((session) => (
|
||
<ResourceRow
|
||
key={session.sessionRef}
|
||
title={session.deviceName || "Неидентифицированная сессия"}
|
||
description={`${session.routeName} · ${session.protocol} · ${formatDate(session.lastSeenAt)}`}
|
||
status={session.lifecycleState}
|
||
trailing={`${session.frameCount} frames · ${formatBytes(session.byteCount)}`}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
|
||
workspace: ProjectWorkspace;
|
||
canManage: boolean;
|
||
onCreate: () => void;
|
||
onRevoke: (binding: BindingView) => void;
|
||
}) {
|
||
return (
|
||
<ControlStack>
|
||
<ControlToolbar
|
||
copy="Binding создаёт только source approval. Active появится лишь после отдельного external proof от целевой системы."
|
||
actions={canManage ? <Button variant="primary" onClick={onCreate} disabled={!workspace.collections.length && !workspace.devices.length}>Новый binding</Button> : null}
|
||
/>
|
||
<ResourceList empty="Data bindings пока не создавались.">
|
||
{workspace.bindings.map((binding) => (
|
||
<ResourceRow
|
||
key={binding.bindingRef}
|
||
title={binding.displayName}
|
||
description={`${binding.source.displayName} → ${binding.target.kind}:${binding.target.ref}`}
|
||
status={binding.lifecycleState}
|
||
trailing={binding.lifecycleState !== "revoked" && canManage ? (
|
||
<Button size="compact" variant="danger" onClick={() => onRevoke(binding)}>Отозвать</Button>
|
||
) : binding.capabilities.join(", ")}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
|
||
workspace: ProjectWorkspace;
|
||
canDispatch: boolean;
|
||
onRefresh: () => Promise<void>;
|
||
onError: (reason: unknown) => void;
|
||
}) {
|
||
const supportedDevices = workspace.devices.filter(
|
||
(device) => device.modelProfileRef === "arusnavi.b2.internal.v1"
|
||
&& !["suspended", "retired"].includes(device.lifecycleState),
|
||
);
|
||
const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? "");
|
||
const [accessCode, setAccessCode] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const enabled = workspace.policies.commandTransport === "typed-service-ping-v1";
|
||
useEffect(() => {
|
||
if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) {
|
||
setDeviceRef(supportedDevices[0]?.deviceRef ?? "");
|
||
}
|
||
}, [deviceRef, supportedDevices]);
|
||
const submit = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return;
|
||
setSubmitting(true);
|
||
try {
|
||
await sendServicePing({
|
||
projectRef: workspace.project.projectRef,
|
||
deviceRef,
|
||
accessCode,
|
||
expiresInSeconds: 300,
|
||
});
|
||
setAccessCode("");
|
||
await onRefresh();
|
||
} catch (reason) {
|
||
onError(reason);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
return (
|
||
<ControlStack>
|
||
<GlassSurface className="device-control-command-policy" padding="md" tone="soft">
|
||
<Icon name={enabled ? "check" : "lock"} />
|
||
<div>
|
||
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
|
||
<p>{enabled
|
||
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
|
||
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
|
||
</div>
|
||
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
|
||
</GlassSurface>
|
||
{enabled ? (
|
||
<GlassSurface padding="md" tone="soft">
|
||
<form className="device-control-command-form" onSubmit={submit}>
|
||
<Select
|
||
label="B2 трекер"
|
||
value={deviceRef}
|
||
onChange={setDeviceRef}
|
||
options={supportedDevices.map((device) => ({
|
||
value: device.deviceRef,
|
||
label: device.displayName,
|
||
description: device.session?.state || device.lifecycleState,
|
||
}))}
|
||
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
|
||
/>
|
||
<TextField
|
||
label="Код устройства"
|
||
type="password"
|
||
inputMode="numeric"
|
||
autoComplete="off"
|
||
value={accessCode}
|
||
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||
pattern="[0-9]{6}"
|
||
minLength={6}
|
||
maxLength={6}
|
||
required
|
||
disabled={!canDispatch || submitting}
|
||
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
|
||
>
|
||
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
|
||
</Button>
|
||
</form>
|
||
</GlassSurface>
|
||
) : null}
|
||
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
|
||
{workspace.commands.map((command) => (
|
||
<ResourceRow
|
||
key={command.commandRef}
|
||
title={`${command.commandType} · ${command.deviceName}`}
|
||
description={`${command.riskClass} · expires ${formatDate(command.expiresAt)}`}
|
||
status={command.lifecycleState}
|
||
trailing={command.terminalReasonCode || command.commandKey}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function AuditView({ workspace }: { workspace: ProjectWorkspace }) {
|
||
return (
|
||
<ControlStack>
|
||
<ControlToolbar copy="Показывается immutable metadata projection. Audit payload намеренно не выдаётся в браузер." />
|
||
<ResourceList empty="Audit events для проекта отсутствуют.">
|
||
{workspace.auditEvents.map((event) => (
|
||
<ResourceRow
|
||
key={event.auditEventRef}
|
||
title={event.eventType}
|
||
description={`${event.actorRef} · ${formatDate(event.occurredAt)}`}
|
||
status="recorded"
|
||
trailing={event.deviceRef || event.discoveryRef || "project"}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function AccessView({ workspace, canManage, onCreate }: {
|
||
workspace: ProjectWorkspace;
|
||
canManage: boolean;
|
||
onCreate: () => void;
|
||
}) {
|
||
return (
|
||
<ControlStack>
|
||
<ControlToolbar
|
||
copy="Hub задаёт потолок, а Device Project grant — конкретную роль. Direct user grant имеет приоритет над group grants."
|
||
actions={canManage ? <Button variant="primary" onClick={onCreate}>Добавить доступ</Button> : null}
|
||
/>
|
||
<ResourceList empty="Project grants недоступны или ещё не созданы.">
|
||
{workspace.grants.map((grant) => (
|
||
<ResourceRow
|
||
key={grant.grantRef}
|
||
title={grant.principalRef}
|
||
description={`${grant.principalKind} · ${grant.projectRole}`}
|
||
status={grant.lifecycleState}
|
||
trailing={grant.capabilityDeny.length ? `deny: ${grant.capabilityDeny.join(", ")}` : "role capabilities"}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function SettingsView({ workspace, canConfigure, onCreateConfiguration }: {
|
||
workspace: ProjectWorkspace;
|
||
canConfigure: boolean;
|
||
onCreateConfiguration: () => void;
|
||
}) {
|
||
return (
|
||
<ControlStack>
|
||
<div className="device-control-policy-grid">
|
||
<PolicyCard label="Identifiers" value={workspace.policies.identifierProjection} />
|
||
<PolicyCard label="Audit payload" value={workspace.policies.auditPayloadProjection} />
|
||
<PolicyCard label="Command API" value={workspace.policies.commandPlanningApi} />
|
||
</div>
|
||
<ControlToolbar
|
||
copy="Configuration revisions immutable. Desired и applied — разные указатели; создание desired не означает применение устройством."
|
||
actions={canConfigure ? <Button variant="primary" onClick={onCreateConfiguration} disabled={!workspace.devices.length}>Новая desired revision</Button> : null}
|
||
/>
|
||
<ResourceList empty="Configuration state пока отсутствует.">
|
||
{workspace.configurationStates.map((state) => (
|
||
<ResourceRow
|
||
key={state.deviceRef}
|
||
title={state.deviceName}
|
||
description={`desired: ${shortRef(state.desiredConfigurationRevisionRef)} · applied: ${shortRef(state.appliedConfigurationRevisionRef)}`}
|
||
status={state.appliedConfigurationRevisionRef === state.desiredConfigurationRevisionRef ? "applied" : "pending"}
|
||
trailing={formatDate(state.updatedAt)}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
<ControlSection title="Immutable revisions" count={workspace.configurationRevisions.length}>
|
||
<ResourceList empty="Configuration revisions отсутствуют.">
|
||
{workspace.configurationRevisions.map((revision) => (
|
||
<ResourceRow
|
||
key={revision.configurationRevisionRef}
|
||
title={`${revision.deviceName} · revision ${revision.revisionNumber}`}
|
||
description={revision.changeSummary || revision.modelProfileRef}
|
||
status="immutable"
|
||
trailing={shortDigest(revision.configurationDigest)}
|
||
/>
|
||
))}
|
||
</ResourceList>
|
||
</ControlSection>
|
||
</ControlStack>
|
||
);
|
||
}
|
||
|
||
function AdapterPackageDialog(props: DialogBaseProps) {
|
||
const [packageKey, setPackageKey] = useState("");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [publisherRef, setPublisherRef] = useState("");
|
||
return <FormWindow {...props} id="adapter-package-form" title="Adapter package" submit={async () => {
|
||
await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" });
|
||
}}>
|
||
<KeyField label="Ключ пакета" value={packageKey} onChange={setPackageKey} />
|
||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||
<TextField label="Publisher ref" value={publisherRef} onChange={(event) => setPublisherRef(event.target.value)} required />
|
||
</FormWindow>;
|
||
}
|
||
|
||
function AdapterVersionDialog({ packages, ...props }: DialogBaseProps & { packages: AdapterPackageView[] }) {
|
||
const [packageRef, setPackageRef] = useState(packages[0]?.adapterPackageRef ?? "");
|
||
const [version, setVersion] = useState("");
|
||
const [runtimeRef, setRuntimeRef] = useState("");
|
||
const [digest, setDigest] = useState("");
|
||
const [contractVersion, setContractVersion] = useState("");
|
||
const [capabilities, setCapabilities] = useState("");
|
||
useEffect(() => {
|
||
if (!packages.some((item) => item.adapterPackageRef === packageRef)) {
|
||
setPackageRef(packages[0]?.adapterPackageRef ?? "");
|
||
}
|
||
}, [packageRef, packages]);
|
||
return <FormWindow {...props} id="adapter-version-form" title="Версия адаптера" disabled={!packageRef} submit={async () => {
|
||
await registerAdapterVersion({
|
||
adapterPackageRef: packageRef,
|
||
version,
|
||
runtimePackageRef: runtimeRef,
|
||
contentDigest: digest,
|
||
contractVersion,
|
||
capabilities: commaList(capabilities),
|
||
lifecycleState: "draft",
|
||
});
|
||
}}>
|
||
<Select label="Adapter package" value={packageRef} onChange={setPackageRef} options={packages.map((item) => ({ value: item.adapterPackageRef, label: item.displayName }))} />
|
||
<TextField label="SemVer" value={version} onChange={(event) => setVersion(event.target.value)} required placeholder="1.0.0" />
|
||
<TextField label="Runtime artifact ref" value={runtimeRef} onChange={(event) => setRuntimeRef(event.target.value)} required />
|
||
<TextField label="Content digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
||
<TextField label="Contract version" value={contractVersion} onChange={(event) => setContractVersion(event.target.value)} required />
|
||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="Через запятую" />
|
||
</FormWindow>;
|
||
}
|
||
|
||
function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions: AdapterVersionView[] }) {
|
||
const [versionRef, setVersionRef] = useState(versions[0]?.adapterVersionRef ?? "");
|
||
const [profileRef, setProfileRef] = useState("");
|
||
const [schemaVersion, setSchemaVersion] = useState("");
|
||
const [vendor, setVendor] = useState("");
|
||
const [model, setModel] = useState("");
|
||
const [deviceType, setDeviceType] = useState("");
|
||
const [protocol, setProtocol] = useState("");
|
||
const [schemaRef, setSchemaRef] = useState("");
|
||
const [digest, setDigest] = useState("");
|
||
const [capabilities, setCapabilities] = useState("");
|
||
useEffect(() => {
|
||
if (!versions.some((item) => item.adapterVersionRef === versionRef)) {
|
||
setVersionRef(versions[0]?.adapterVersionRef ?? "");
|
||
}
|
||
}, [versionRef, versions]);
|
||
return <FormWindow {...props} id="model-profile-form" title="Model profile" disabled={!versionRef} submit={async () => {
|
||
await registerModelProfile({
|
||
adapterVersionRef: versionRef,
|
||
profileRef,
|
||
schemaVersion,
|
||
vendor,
|
||
model,
|
||
deviceType,
|
||
protocol: protocol.toUpperCase(),
|
||
schemaArtifactRef: schemaRef,
|
||
profileDigest: digest,
|
||
capabilities: commaList(capabilities),
|
||
lifecycleState: "draft",
|
||
});
|
||
}}>
|
||
<Select label="Adapter version" value={versionRef} onChange={setVersionRef} options={versions.map((item) => ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} />
|
||
<TextField label="Profile ref" value={profileRef} onChange={(event) => setProfileRef(event.target.value)} required />
|
||
<TextField label="Schema version" value={schemaVersion} onChange={(event) => setSchemaVersion(event.target.value)} required />
|
||
<TextField label="Vendor" value={vendor} onChange={(event) => setVendor(event.target.value)} required />
|
||
<TextField label="Model" value={model} onChange={(event) => setModel(event.target.value)} required />
|
||
<KeyField label="Device type" value={deviceType} onChange={setDeviceType} />
|
||
<TextField label="Protocol" value={protocol} onChange={(event) => setProtocol(event.target.value)} required />
|
||
<TextField label="Schema artifact ref" value={schemaRef} onChange={(event) => setSchemaRef(event.target.value)} required />
|
||
<TextField label="Profile digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} />
|
||
</FormWindow>;
|
||
}
|
||
|
||
function EdgeDialog(props: DialogBaseProps) {
|
||
const [edgeKey, setEdgeKey] = useState("");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [deploymentRef, setDeploymentRef] = useState("");
|
||
return <FormWindow {...props} id="edge-form" title="Новый Edge" submit={async () => {
|
||
await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" });
|
||
}}>
|
||
<KeyField label="Edge key" value={edgeKey} onChange={setEdgeKey} />
|
||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||
<TextField label="Deployment ref" value={deploymentRef} onChange={(event) => setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." />
|
||
</FormWindow>;
|
||
}
|
||
|
||
function RouteDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||
const [routeKey, setRouteKey] = useState("");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [edgeRef, setEdgeRef] = useState(workspace.edges[0]?.edgeRef ?? "");
|
||
const [profileRef, setProfileRef] = useState(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
||
const [listenerRef, setListenerRef] = useState("");
|
||
const profile = workspace.modelProfiles.find((item) => item.modelProfileRef === profileRef);
|
||
useEffect(() => {
|
||
if (!workspace.edges.some((item) => item.edgeRef === edgeRef)) {
|
||
setEdgeRef(workspace.edges[0]?.edgeRef ?? "");
|
||
}
|
||
if (!workspace.modelProfiles.some((item) => item.modelProfileRef === profileRef)) {
|
||
setProfileRef(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
||
}
|
||
}, [edgeRef, profileRef, workspace.edges, workspace.modelProfiles]);
|
||
return <FormWindow {...props} id="route-form" title="Новый маршрут" disabled={!edgeRef || !profileRef} submit={async () => {
|
||
await ensureRoute({
|
||
projectRef: workspace.project.projectRef,
|
||
routeKey,
|
||
displayName,
|
||
edgeRef,
|
||
modelProfileRef: profileRef,
|
||
listenerRef,
|
||
protocol: profile?.protocol || "INTERNAL",
|
||
direction: "telemetry",
|
||
lifecycleState: "draft",
|
||
});
|
||
}}>
|
||
<KeyField label="Route key" value={routeKey} onChange={setRouteKey} />
|
||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||
<Select label="Edge" value={edgeRef} onChange={setEdgeRef} options={workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} />
|
||
<Select label="Model profile" value={profileRef} onChange={setProfileRef} options={workspace.modelProfiles.map((item) => ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} />
|
||
<TextField label="Listener ref" value={listenerRef} onChange={(event) => setListenerRef(event.target.value)} required />
|
||
<p className="device-manager-card-copy">Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.</p>
|
||
</FormWindow>;
|
||
}
|
||
|
||
function BindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||
const sources = useMemo(() => [
|
||
...workspace.collections.map((item) => ({ value: `collection|${item.collectionRef}`, label: item.name })),
|
||
...workspace.devices.map((item) => ({ value: `device|${item.deviceRef}`, label: item.displayName })),
|
||
], [workspace]);
|
||
const [sourceValue, setSourceValue] = useState(sources[0]?.value ?? "");
|
||
const [bindingKey, setBindingKey] = useState("");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [targetKind, setTargetKind] = useState("");
|
||
const [targetRef, setTargetRef] = useState("");
|
||
const [capabilities, setCapabilities] = useState("observe");
|
||
useEffect(() => {
|
||
if (!sources.some((item) => item.value === sourceValue)) {
|
||
setSourceValue(sources[0]?.value ?? "");
|
||
}
|
||
}, [sourceValue, sources]);
|
||
return <FormWindow {...props} id="binding-form" title="Новый data binding" disabled={!sourceValue} submit={async () => {
|
||
const [kind, ref] = sourceValue.split("|");
|
||
await ensureDeviceBinding({
|
||
projectRef: workspace.project.projectRef,
|
||
bindingKey,
|
||
displayName,
|
||
source: { kind: kind as "device" | "collection", ref },
|
||
targetKind,
|
||
targetRef,
|
||
capabilities: commaList(capabilities),
|
||
});
|
||
}}>
|
||
<Select label="Source" value={sourceValue} onChange={setSourceValue} options={sources} />
|
||
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
|
||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||
<TextField label="Target kind" value={targetKind} onChange={(event) => setTargetKind(event.target.value)} required placeholder="foundry.application" />
|
||
<TextField label="Target ref" value={targetRef} onChange={(event) => setTargetRef(event.target.value)} required />
|
||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="observe, inspect, configure, command" required />
|
||
</FormWindow>;
|
||
}
|
||
|
||
function GrantDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
|
||
const [principalKind, setPrincipalKind] = useState<"user" | "group">("user");
|
||
const [principalRef, setPrincipalRef] = useState("");
|
||
const [role, setRole] = useState("viewer");
|
||
const [allow, setAllow] = useState("");
|
||
const [deny, setDeny] = useState("");
|
||
return <FormWindow {...props} id="grant-form" title="Project access" submit={async () => {
|
||
await upsertProjectGrant({
|
||
projectRef,
|
||
principalKind,
|
||
principalRef,
|
||
projectRole: role,
|
||
capabilityAllow: commaList(allow),
|
||
capabilityDeny: commaList(deny),
|
||
lifecycleState: "active",
|
||
});
|
||
}}>
|
||
<Select label="Principal type" value={principalKind} onChange={setPrincipalKind} options={[{ value: "user", label: "User" }, { value: "group", label: "Group" }]} />
|
||
<TextField label="Principal ref" value={principalRef} onChange={(event) => setPrincipalRef(event.target.value)} required />
|
||
<Select label="Project role" value={role} onChange={setRole} options={["viewer", "operator", "engineer", "admin", "owner"].map((value) => ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} />
|
||
<TextField label="Capability allow" value={allow} onChange={(event) => setAllow(event.target.value)} description="Опциональные точечные добавления" />
|
||
<TextField label="Capability deny" value={deny} onChange={(event) => setDeny(event.target.value)} description="Deny имеет приоритет" />
|
||
</FormWindow>;
|
||
}
|
||
|
||
function ConfigurationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
|
||
const [configuration, setConfiguration] = useState("{\n \"reporting_interval_seconds\": 30\n}");
|
||
const [summary, setSummary] = useState("");
|
||
useEffect(() => {
|
||
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
|
||
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
|
||
}
|
||
}, [deviceRef, workspace.devices]);
|
||
return <FormWindow {...props} id="configuration-form" title="Новая desired configuration" disabled={!deviceRef} submit={async () => {
|
||
const parsed = JSON.parse(configuration) as Record<string, unknown>;
|
||
const created = await createConfigurationRevision({
|
||
projectRef: workspace.project.projectRef,
|
||
deviceRef,
|
||
configuration: parsed,
|
||
changeSummary: summary || null,
|
||
});
|
||
await setDesiredConfiguration({
|
||
projectRef: workspace.project.projectRef,
|
||
deviceRef,
|
||
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
|
||
});
|
||
}}>
|
||
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
|
||
<TextAreaField label="Configuration JSON" value={configuration} onChange={(event) => setConfiguration(event.target.value)} required />
|
||
<TextAreaField label="Change summary" value={summary} onChange={(event) => setSummary(event.target.value)} />
|
||
<p className="device-manager-card-copy">Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.</p>
|
||
</FormWindow>;
|
||
}
|
||
|
||
interface DialogBaseProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onCreated: () => Promise<void>;
|
||
onError: (reason: unknown) => void;
|
||
}
|
||
|
||
function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & {
|
||
id: string;
|
||
title: string;
|
||
submit: () => Promise<void>;
|
||
disabled?: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
const [pending, setPending] = useState(false);
|
||
const handleSubmit = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
setPending(true);
|
||
try {
|
||
await submit();
|
||
await onCreated();
|
||
} catch (reason) {
|
||
onError(reason);
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
};
|
||
return (
|
||
<Window open={open} title={title} onClose={onClose} footer={
|
||
<WindowFooterActions>
|
||
<Button variant="ghost" onClick={onClose}>Отмена</Button>
|
||
<Button type="submit" form={id} variant="primary" disabled={disabled || pending}>{pending ? "Сохраняем…" : "Сохранить"}</Button>
|
||
</WindowFooterActions>
|
||
}>
|
||
<form id={id} className="device-manager-form device-control-form" onSubmit={handleSubmit}>{children}</form>
|
||
</Window>
|
||
);
|
||
}
|
||
|
||
function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||
return <TextField label={label} value={value} onChange={(event) => onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />;
|
||
}
|
||
|
||
function ControlStack({ children }: { children: ReactNode }) {
|
||
return <div className="device-manager-stack device-control-stack">{children}</div>;
|
||
}
|
||
|
||
function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) {
|
||
return <div className="device-manager-panel-toolbar device-control-toolbar"><p>{copy}</p>{actions ? <div className="device-control-toolbar__actions">{actions}</div> : null}</div>;
|
||
}
|
||
|
||
function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
||
return <section className="device-control-section"><div className="device-control-section__title"><h3>{title}</h3><StatusBadge>{count}</StatusBadge></div>{children}</section>;
|
||
}
|
||
|
||
function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) {
|
||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||
return hasChildren ? <div className="device-control-resource-grid">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
||
}
|
||
|
||
function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
|
||
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{action}</>}>
|
||
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <StatusBadge key={item}>{item}</StatusBadge>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
|
||
</SettingsCard>;
|
||
}
|
||
|
||
function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
|
||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||
return hasChildren ? <div className="device-manager-entity-list">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
||
}
|
||
|
||
function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
|
||
return <GlassSurface className="device-manager-entity device-control-row" padding="md" tone="soft">
|
||
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
|
||
<span className="device-manager-entity__body"><strong>{title}</strong><small>{description}</small></span>
|
||
<span className="device-control-row__status"><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{typeof trailing === "string" ? <small>{trailing}</small> : trailing}</span>
|
||
</GlassSurface>;
|
||
}
|
||
|
||
function PolicyCard({ label, value }: { label: string; value: string }) {
|
||
return <GlassSurface padding="md" tone="soft"><small>{label}</small><strong>{value}</strong></GlassSurface>;
|
||
}
|
||
|
||
function commaList(value: string) {
|
||
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort();
|
||
}
|
||
|
||
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
||
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
||
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
||
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
|
||
return "neutral";
|
||
}
|
||
|
||
function formatDate(value: string | null) {
|
||
if (!value) return "—";
|
||
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||
}
|
||
|
||
function formatBytes(value: number) {
|
||
if (value < 1024) return `${value} B`;
|
||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||
}
|
||
|
||
function shortRef(value: string | null) {
|
||
return value ? `${value.slice(0, 18)}…` : "—";
|
||
}
|
||
|
||
function shortDigest(value: string) {
|
||
return `${value.slice(0, 15)}…${value.slice(-8)}`;
|
||
}
|