feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,957 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
TextField,
|
||||
} from "@nodedc/ui-react";
|
||||
import {
|
||||
createConfigurationRevision,
|
||||
setDesiredConfiguration,
|
||||
updateDevice,
|
||||
} from "./api";
|
||||
import {
|
||||
accessLabel,
|
||||
getDeviceProfileCatalog,
|
||||
type DeviceFieldAccess,
|
||||
type DeviceProfileField,
|
||||
} from "./deviceProfileCatalog";
|
||||
import type {
|
||||
DeviceView,
|
||||
ProjectWorkspace,
|
||||
SessionView,
|
||||
} from "./types";
|
||||
|
||||
export type DeviceInventoryDetailState = {
|
||||
deviceRef: string;
|
||||
sectionId: string;
|
||||
editing: boolean;
|
||||
};
|
||||
|
||||
export function DeviceDetailHeaderTools({
|
||||
device,
|
||||
detail,
|
||||
canEdit,
|
||||
onDetailChange,
|
||||
}: {
|
||||
device: DeviceView;
|
||||
detail: DeviceInventoryDetailState;
|
||||
canEdit: boolean;
|
||||
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||
}) {
|
||||
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
|
||||
const activeSection = catalog.sections.find((section) => section.id === detail.sectionId)
|
||||
?? catalog.sections[0];
|
||||
|
||||
return (
|
||||
<div className="device-detail-header-tools">
|
||||
<Select
|
||||
className="device-detail-section-select"
|
||||
label="Раздел устройства"
|
||||
value={activeSection?.id ?? catalog.sections[0]?.id ?? "passport"}
|
||||
options={catalog.sections.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.label,
|
||||
}))}
|
||||
onChange={(sectionId) => onDetailChange({ ...detail, sectionId })}
|
||||
placement="bottom-end"
|
||||
minMenuWidth={320}
|
||||
menuWidth="anchor"
|
||||
variant="split"
|
||||
/>
|
||||
<IconButton
|
||||
label={detail.editing ? "Завершить редактирование" : "Редактировать устройство"}
|
||||
disabled={!canEdit}
|
||||
data-active={detail.editing || undefined}
|
||||
onClick={() => onDetailChange({
|
||||
...detail,
|
||||
editing: !detail.editing,
|
||||
})}
|
||||
>
|
||||
<Icon name="edit" size={17} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceInventoryView({
|
||||
workspace,
|
||||
canClaim,
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
detail,
|
||||
onClaim,
|
||||
onPoll,
|
||||
onError,
|
||||
onDetailChange,
|
||||
}: {
|
||||
workspace: ProjectWorkspace;
|
||||
canClaim: boolean;
|
||||
canConfigure: boolean;
|
||||
canManageProject: boolean;
|
||||
detail: DeviceInventoryDetailState | null;
|
||||
onClaim: (enrollment: ProjectWorkspace["enrollments"][number]) => void;
|
||||
onPoll: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [sortOrder, setSortOrder] = useState("activity");
|
||||
const selectedDevice = workspace.devices.find(
|
||||
(device) => device.deviceRef === detail?.deviceRef,
|
||||
) ?? null;
|
||||
const pendingEnrollments = workspace.enrollments.filter(
|
||||
(enrollment) => enrollment.lifecycleState !== "claimed",
|
||||
);
|
||||
const deviceRows = useMemo(() => workspace.devices
|
||||
.map((device) => {
|
||||
const session = latestSession(workspace, device);
|
||||
const online = session?.lifecycleState === "online" || device.session?.state === "online";
|
||||
const lastSeenAt = session?.lastSeenAt || device.session?.lastSeenAt || device.updatedAt;
|
||||
return { device, session, online, lastSeenAt };
|
||||
})
|
||||
.filter((row) => {
|
||||
if (statusFilter === "active") return row.online;
|
||||
if (statusFilter === "inactive") return !row.online;
|
||||
return statusFilter !== "pending";
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (sortOrder === "name") {
|
||||
return left.device.displayName.localeCompare(right.device.displayName, "ru");
|
||||
}
|
||||
if (sortOrder === "activity" && left.online !== right.online) {
|
||||
return left.online ? -1 : 1;
|
||||
}
|
||||
return String(right.lastSeenAt || "").localeCompare(String(left.lastSeenAt || ""));
|
||||
}), [sortOrder, statusFilter, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detail?.deviceRef && !selectedDevice) onDetailChange(null);
|
||||
}, [detail?.deviceRef, onDetailChange, selectedDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail?.deviceRef) return undefined;
|
||||
const poll = () => {
|
||||
if (document.visibilityState === "visible") onPoll().catch(onError);
|
||||
};
|
||||
const timer = window.setInterval(poll, 5_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detail?.deviceRef, onError, onPoll]);
|
||||
|
||||
if (selectedDevice && detail) {
|
||||
return (
|
||||
<DeviceDetailView
|
||||
device={selectedDevice}
|
||||
detail={detail}
|
||||
workspace={workspace}
|
||||
canConfigure={canConfigure}
|
||||
canManageProject={canManageProject}
|
||||
onDetailChange={onDetailChange}
|
||||
onSaved={onPoll}
|
||||
onError={onError}
|
||||
onBack={() => onDetailChange(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-inventory">
|
||||
<div className="device-manager-panel-toolbar device-inventory__toolbar">
|
||||
<div>
|
||||
<strong>Реестр устройств</strong>
|
||||
<p>{workspace.devices.length} зарегистрировано · {pendingEnrollments.length} ожидают подключения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="device-inventory__filters" aria-label="Фильтры устройств">
|
||||
<Select
|
||||
label="Состояние"
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "Все устройства" },
|
||||
{ value: "active", label: "Активные" },
|
||||
{ value: "inactive", label: "Неактивные" },
|
||||
{ value: "pending", label: "Ожидают подключения" },
|
||||
]}
|
||||
onChange={setStatusFilter}
|
||||
/>
|
||||
<Select
|
||||
label="Сортировка"
|
||||
value={sortOrder}
|
||||
options={[
|
||||
{ value: "activity", label: "Сначала активные" },
|
||||
{ value: "last-seen", label: "По последней активности" },
|
||||
{ value: "name", label: "По имени" },
|
||||
]}
|
||||
onChange={setSortOrder}
|
||||
disabled={statusFilter === "pending"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusFilter !== "pending" && !deviceRows.length ? (
|
||||
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
|
||||
<Icon name="inbox" size={24} />
|
||||
<h3>{workspace.devices.length ? "Устройств с таким состоянием нет" : "В проекте пока нет устройств"}</h3>
|
||||
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому процессу подключения.</p>
|
||||
</GlassSurface>
|
||||
) : statusFilter !== "pending" ? (
|
||||
<GlassSurface className="device-inventory-table" padding="sm" tone="soft" role="table" aria-label="Устройства проекта">
|
||||
<div className="device-inventory-table__head" role="row">
|
||||
<span role="columnheader">Устройство</span>
|
||||
<span role="columnheader">Профиль</span>
|
||||
<span role="columnheader">IMEI</span>
|
||||
<span role="columnheader">ID интеграционного устройства</span>
|
||||
<span role="columnheader">Канал</span>
|
||||
<span role="columnheader">Последний пакет</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
{deviceRows.map(({ device, session, online, lastSeenAt }) => {
|
||||
return (
|
||||
<Button
|
||||
key={device.deviceRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="device-inventory-row"
|
||||
role="row"
|
||||
onClick={() => onDetailChange({
|
||||
deviceRef: device.deviceRef,
|
||||
sectionId: getDeviceProfileCatalog(device.modelProfileRef).sections[0]?.id ?? "passport",
|
||||
editing: false,
|
||||
})}
|
||||
>
|
||||
<span className="device-inventory-row__device" role="cell">
|
||||
<span className="device-inventory-row__icon"><Icon name="apps" size={17} /></span>
|
||||
<span><strong>{device.displayName}</strong><small>{device.deviceKey || "ключ не назначен"}</small></span>
|
||||
</span>
|
||||
<span role="cell">{profileLabel(workspace, device)}</span>
|
||||
<span role="cell">{deviceIdentifierDisplayValue(device) || "не назначен"}</span>
|
||||
<span role="cell">{device.integrationDeviceId || "не назначен"}</span>
|
||||
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
|
||||
<span role="cell">{formatDate(lastSeenAt)}</span>
|
||||
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{(statusFilter === "all" || statusFilter === "pending") ? (
|
||||
<section className="device-inventory__pending" aria-label="Ожидают подключения">
|
||||
<div className="device-inventory__section-heading">
|
||||
<div>
|
||||
<strong>Ожидают подключения</strong>
|
||||
<p>Разрешённые идентификаторы и обнаруженные устройства.</p>
|
||||
</div>
|
||||
<StatusBadge tone={pendingEnrollments.length ? "warning" : "neutral"}>{pendingEnrollments.length}</StatusBadge>
|
||||
</div>
|
||||
{pendingEnrollments.length ? pendingEnrollments.map((enrollment) => (
|
||||
<SettingsCard
|
||||
key={enrollment.enrollmentIntentRef}
|
||||
eyebrow={enrollment.lifecycleState}
|
||||
title={enrollment.displayName}
|
||||
description={`${enrollment.modelProfileRef} · ${enrollment.expectedIdentifier.masked}`}
|
||||
actions={enrollment.lifecycleState === "observed" && enrollment.observedDiscoveryRef ? (
|
||||
<Button size="compact" variant="primary" disabled={!canClaim} onClick={() => onClaim(enrollment)}>
|
||||
Принять устройство
|
||||
</Button>
|
||||
) : <StatusBadge>{enrollment.lifecycleState}</StatusBadge>}
|
||||
>
|
||||
<p className="device-manager-card-copy">После первого пакета устройство можно принять в реестр. Исходный идентификатор в интерфейсе не раскрывается.</p>
|
||||
</SettingsCard>
|
||||
)) : (
|
||||
<GlassSurface className="device-manager-panel-empty device-inventory__pending-empty" padding="md" tone="soft">
|
||||
Нет ожидающих подключений.
|
||||
</GlassSurface>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDetailView({
|
||||
device,
|
||||
detail,
|
||||
workspace,
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
onDetailChange,
|
||||
onSaved,
|
||||
onError,
|
||||
onBack,
|
||||
}: {
|
||||
device: DeviceView;
|
||||
detail: DeviceInventoryDetailState;
|
||||
workspace: ProjectWorkspace;
|
||||
canConfigure: boolean;
|
||||
canManageProject: boolean;
|
||||
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||
onSaved: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string | boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const profile = workspace.modelProfiles.find(
|
||||
(item) => item.modelProfileRef === device.modelProfileRef,
|
||||
) ?? null;
|
||||
const session = latestSession(workspace, device);
|
||||
const configurationState = workspace.configurationStates.find(
|
||||
(item) => item.deviceRef === device.deviceRef,
|
||||
) ?? null;
|
||||
const identifierDisplayValue = deviceIdentifierDisplayValue(device);
|
||||
const context = useMemo(() => ({
|
||||
device: {
|
||||
...device,
|
||||
identifier: device.identifier ? {
|
||||
...device.identifier,
|
||||
displayValue: identifierDisplayValue,
|
||||
} : null,
|
||||
},
|
||||
profile,
|
||||
session,
|
||||
configurationState,
|
||||
reported: device.reported ?? {},
|
||||
policies: {
|
||||
...workspace.policies,
|
||||
firmwareUpdate: "blocked",
|
||||
},
|
||||
}), [configurationState, device, identifierDisplayValue, profile, session, workspace.policies]);
|
||||
const activeSection = catalog.sections.find(
|
||||
(section) => section.id === detail.sectionId,
|
||||
) ?? catalog.sections[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog.sections.some((section) => section.id === detail.sectionId)) {
|
||||
onDetailChange({
|
||||
...detail,
|
||||
sectionId: catalog.sections[0]?.id ?? "passport",
|
||||
});
|
||||
}
|
||||
}, [catalog.sections, detail, onDetailChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValues({});
|
||||
}, [detail.editing, device.deviceRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const panelBody = detailRef.current?.closest<HTMLElement>(".nodedc-application-panel__body");
|
||||
if (panelBody) panelBody.scrollTop = 0;
|
||||
}, [detail.sectionId, device.deviceRef]);
|
||||
|
||||
if (!activeSection) return null;
|
||||
|
||||
const saveDeviceChanges = async () => {
|
||||
if (!(canConfigure || canManageProject) || !Object.keys(draftValues).length) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const displayNameDraft = draftValues["device.displayName"];
|
||||
const integrationDeviceIdDraft = draftValues["device.integrationDeviceId"];
|
||||
const nextDisplayName = typeof displayNameDraft === "string"
|
||||
? displayNameDraft.trim()
|
||||
: device.displayName;
|
||||
const nextIntegrationDeviceId = typeof integrationDeviceIdDraft === "string"
|
||||
? integrationDeviceIdDraft.trim() || null
|
||||
: device.integrationDeviceId;
|
||||
if (
|
||||
canManageProject
|
||||
&& nextDisplayName
|
||||
&& (
|
||||
nextDisplayName !== device.displayName
|
||||
|| nextIntegrationDeviceId !== device.integrationDeviceId
|
||||
)
|
||||
) {
|
||||
await updateDevice({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef: device.deviceRef,
|
||||
displayName: nextDisplayName,
|
||||
integrationDeviceId: nextIntegrationDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
const configurationDrafts = Object.entries(draftValues).filter(([path]) =>
|
||||
path.startsWith("reported.configuration."),
|
||||
);
|
||||
if (configurationDrafts.length) {
|
||||
const nextConfiguration = cloneConfiguration(device.reported?.configuration);
|
||||
for (const [path, value] of configurationDrafts) {
|
||||
const field = catalog.sections.flatMap((section) => section.fields)
|
||||
.find((item) => item.path === path);
|
||||
if (!field || !isDeviceFieldEditable(field, field.access ?? activeSection.access, {
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
})) continue;
|
||||
writePath(
|
||||
nextConfiguration,
|
||||
path.replace(/^reported\.configuration\./, ""),
|
||||
normalizeDraftValue(value, field),
|
||||
);
|
||||
}
|
||||
const created = await createConfigurationRevision({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef: device.deviceRef,
|
||||
configuration: nextConfiguration,
|
||||
changeSummary: `Device Manager · ${activeSection.title}`,
|
||||
});
|
||||
await setDesiredConfiguration({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef: device.deviceRef,
|
||||
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
|
||||
});
|
||||
}
|
||||
setDraftValues({});
|
||||
onDetailChange({ ...detail, editing: false });
|
||||
await onSaved();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={detailRef} className="device-detail">
|
||||
<div className="device-detail__header">
|
||||
<IconButton label="Вернуться к списку устройств" onClick={onBack}>
|
||||
<Icon name="chevron-left" size={18} />
|
||||
</IconButton>
|
||||
<div className="device-detail__identity">
|
||||
<small>{catalog.vendor} · {catalog.model}</small>
|
||||
<h2>{device.displayName}</h2>
|
||||
<p>{identifierDisplayValue || "Идентификатор не назначен"} · {device.modelProfileRef}</p>
|
||||
</div>
|
||||
<StatusBadge tone={session?.lifecycleState === "online" ? "success" : "neutral"}>
|
||||
{session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="device-detail__legend" aria-label="Режимы доступа">
|
||||
<AccessBadge access="read-only" />
|
||||
<AccessBadge access="managed" />
|
||||
<AccessBadge access="protected" />
|
||||
</div>
|
||||
|
||||
<GlassSurface className="device-detail__connection" padding="md" tone="soft">
|
||||
<div><span>Состояние связи</span><strong>{session?.lifecycleState || device.session?.state || "Нет сессии"}</strong></div>
|
||||
<div><span>Маршрут</span><strong>{session?.routeName || "Не определён"}</strong></div>
|
||||
<div><span>Протокол</span><strong>{session?.protocol || profile?.protocol || "Нет данных"}</strong></div>
|
||||
<div><span>Последняя активность</span><strong>{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</strong></div>
|
||||
<div><span>Пакеты</span><strong>{session?.frameCount ?? 0}</strong></div>
|
||||
<div><span>Подключено</span><strong>{formatDate(session?.connectedAt)}</strong></div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="device-detail__layout">
|
||||
<section className="device-detail-section">
|
||||
<div className="device-detail-section__heading">
|
||||
<div>
|
||||
<span>{catalog.title}</span>
|
||||
<h3>{activeSection.title}</h3>
|
||||
<p>{activeSection.description}</p>
|
||||
</div>
|
||||
<AccessBadge access={activeSection.access} />
|
||||
</div>
|
||||
|
||||
<AccessNotice
|
||||
access={activeSection.access}
|
||||
commandTransport={workspace.policies.commandTransport}
|
||||
/>
|
||||
|
||||
<div className="device-detail-fields">
|
||||
{activeSection.fields.map((item) => {
|
||||
const access = item.access ?? activeSection.access;
|
||||
const editable = detail.editing
|
||||
&& isDeviceFieldEditable(item, access, {
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
});
|
||||
const value = Object.prototype.hasOwnProperty.call(draftValues, item.path)
|
||||
? draftValues[item.path]
|
||||
: readPath(context, item.path);
|
||||
return (
|
||||
<GlassSurface key={item.key} className="device-detail-field" padding="sm" tone="soft" data-access={access} data-editing={editable || undefined}>
|
||||
{editable && item.valueKind === "boolean" ? (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
label={item.label}
|
||||
disabled={saving}
|
||||
onChange={(checked) => setDraftValues((current) => ({ ...current, [item.path]: checked }))}
|
||||
/>
|
||||
) : editable ? (
|
||||
<TextField
|
||||
label={item.label}
|
||||
hint={item.unit}
|
||||
description={item.description}
|
||||
type={item.valueKind === "number" ? "number" : "text"}
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftValues((current) => ({ ...current, [item.path]: event.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="device-detail-field__label">
|
||||
<span>{item.label}</span>
|
||||
{access !== activeSection.access ? <AccessBadge access={access} compact /> : null}
|
||||
</div>
|
||||
<strong>{formatFieldValue(value, item)}</strong>
|
||||
{item.description ? <small>{item.description}</small> : null}
|
||||
</>
|
||||
)}
|
||||
</GlassSurface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{detail.editing ? (
|
||||
<div className="device-detail-edit-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setDraftValues({});
|
||||
onDetailChange({ ...detail, editing: false });
|
||||
}}
|
||||
>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon={<Icon name="save" />}
|
||||
disabled={saving || !Object.keys(draftValues).length}
|
||||
onClick={() => void saveDeviceChanges()}
|
||||
>
|
||||
{saving ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="device-detail-section__state">
|
||||
<span>Desired</span>
|
||||
<strong>{configurationState?.desiredConfigurationRevisionRef || "Не задано"}</strong>
|
||||
<span>Applied</span>
|
||||
<strong>{configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}</strong>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isDeviceFieldEditable(
|
||||
field: DeviceProfileField,
|
||||
access: DeviceFieldAccess,
|
||||
capabilities: { canConfigure: boolean; canManageProject: boolean } = {
|
||||
canConfigure: true,
|
||||
canManageProject: true,
|
||||
},
|
||||
) {
|
||||
return access === "managed"
|
||||
&& (
|
||||
(["device.displayName", "device.integrationDeviceId"].includes(field.path) && capabilities.canManageProject)
|
||||
|| (field.path.startsWith("reported.configuration.") && capabilities.canConfigure)
|
||||
)
|
||||
&& !field.sensitive;
|
||||
}
|
||||
|
||||
function deviceIdentifierDisplayValue(device: DeviceView) {
|
||||
if (!device.identifier) return null;
|
||||
if (device.identifier.value) return device.identifier.value;
|
||||
const reportedImei = device.reported?.identity?.imei;
|
||||
if (typeof reportedImei === "string" && reportedImei.trim()) return reportedImei;
|
||||
return device.identifier.masked;
|
||||
}
|
||||
|
||||
function cloneConfiguration(configuration: Record<string, unknown> | null | undefined) {
|
||||
if (!configuration) return {};
|
||||
return JSON.parse(JSON.stringify(configuration)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function writePath(target: Record<string, unknown>, path: string, value: unknown) {
|
||||
const keys = path.split(".");
|
||||
let cursor: Record<string, unknown> | unknown[] = target;
|
||||
keys.forEach((key, index) => {
|
||||
if (index === keys.length - 1) {
|
||||
if (Array.isArray(cursor)) cursor[Number(key)] = value;
|
||||
else cursor[key] = value;
|
||||
return;
|
||||
}
|
||||
const nextKey = keys[index + 1];
|
||||
const nextValue = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key];
|
||||
if (!nextValue || typeof nextValue !== "object") {
|
||||
const created: Record<string, unknown> | unknown[] = /^\d+$/.test(nextKey) ? [] : {};
|
||||
if (Array.isArray(cursor)) cursor[Number(key)] = created;
|
||||
else cursor[key] = created;
|
||||
cursor = created;
|
||||
} else {
|
||||
cursor = nextValue as Record<string, unknown> | unknown[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDraftValue(value: string | boolean, field: DeviceProfileField) {
|
||||
if (field.valueKind === "number") return value === "" ? null : Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
|
||||
const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
|
||||
return <StatusBadge className={compact ? "device-access-badge--compact" : undefined} tone={tone}>{accessLabel(access)}</StatusBadge>;
|
||||
}
|
||||
|
||||
function AccessNotice({
|
||||
access,
|
||||
commandTransport,
|
||||
}: {
|
||||
access: DeviceFieldAccess;
|
||||
commandTransport: ProjectWorkspace["policies"]["commandTransport"];
|
||||
}) {
|
||||
if (access === "read-only") {
|
||||
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></GlassSurface>;
|
||||
}
|
||||
if (access === "protected") {
|
||||
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></GlassSurface>;
|
||||
}
|
||||
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Изменение создаёт новую desired-ревизию. Статус Applied появится только после подтверждения устройством." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></GlassSurface>;
|
||||
}
|
||||
|
||||
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
|
||||
const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef);
|
||||
return sessions.sort((left, right) => {
|
||||
if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1;
|
||||
if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1;
|
||||
return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || ""));
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
function profileLabel(workspace: ProjectWorkspace, device: DeviceView) {
|
||||
const profile = workspace.modelProfiles.find(
|
||||
(item) => item.modelProfileRef === device.modelProfileRef,
|
||||
);
|
||||
return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef;
|
||||
}
|
||||
|
||||
function readPath(input: unknown, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((value, key) => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return (value as Record<string, unknown>)[key];
|
||||
}, input);
|
||||
}
|
||||
|
||||
function formatFieldValue(value: unknown, item: DeviceProfileField) {
|
||||
if (value === undefined || value === null || value === "") return "Нет данных";
|
||||
if (item.sensitive) return "Задано · значение скрыто";
|
||||
if (item.valueKind === "date") return formatDate(String(value));
|
||||
if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено";
|
||||
if (value === "blocked") return "Запрещено";
|
||||
if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return "Нет данных";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const __deviceInventoryTestables = {
|
||||
formatFieldValue,
|
||||
readPath,
|
||||
} as const;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "@nodedc/ui-react";
|
||||
import type {
|
||||
DeviceManagerEnvironmentMediaItem,
|
||||
DeviceManagerEnvironmentOverview,
|
||||
DeviceManagerMediaKind,
|
||||
DeviceManagerMediaSource,
|
||||
} from "./types";
|
||||
|
||||
type EnvironmentBackground = DeviceManagerEnvironmentOverview["background"];
|
||||
|
||||
interface EnvironmentMediaPlaylistEditorProps {
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (itemId: string, file: File) => Promise<{ fileName: string; fileSrc: string }>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp", "image/avif",
|
||||
"video/mp4", "video/webm", "video/quicktime", "video/x-quicktime",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mov",
|
||||
].join(",");
|
||||
|
||||
const maxEnvironmentMediaItems = 24;
|
||||
|
||||
function inferMediaKind(value: string): DeviceManagerMediaKind {
|
||||
const pathname = (() => {
|
||||
try { return new URL(value, window.location.origin).pathname; }
|
||||
catch { return value; }
|
||||
})();
|
||||
return /\.(?:png|jpe?g|gif|webp|avif)$/i.test(pathname) ? "image" : "video";
|
||||
}
|
||||
|
||||
function createMediaItem(): DeviceManagerEnvironmentMediaItem {
|
||||
return {
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `media-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
source: "file",
|
||||
url: "",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: null,
|
||||
};
|
||||
}
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<DeviceManagerEnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => item.id === itemId ? { ...item, ...patch } : item),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaSource(item: DeviceManagerEnvironmentMediaItem) {
|
||||
return item.source === "url" ? item.url || null : item.fileSrc;
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(() => [...background.items].reverse(), [background.items]);
|
||||
|
||||
useEffect(() => onBusyChange(uploadingIds.size > 0), [onBusyChange, uploadingIds.size]);
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const stored = await onUpload(itemId, file);
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: "",
|
||||
fileName: stored.fileName,
|
||||
fileSrc: stored.fileSrc,
|
||||
mediaKind: inferMediaKind(file.name),
|
||||
}));
|
||||
} catch (reason) {
|
||||
setItemError(itemId, reason instanceof Error ? reason.message : "Не удалось загрузить медиаконтент.");
|
||||
} finally {
|
||||
setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="environment-media-playlist">
|
||||
<div className="environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange({ ...background, items: [...background.items, createMediaItem()] })}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="environment-media-playlist__items"
|
||||
onReorder={(items) => onChange({ ...background, items: [...items].reverse() })}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex((candidate) => candidate.id === item.id);
|
||||
const preview = mediaSource(item);
|
||||
return (
|
||||
<div className="environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url}
|
||||
fileName={item.fileName}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={preview}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
path={`overview.background.items[${playbackIndex}] → Device Core media`}
|
||||
hint="Файл сохраняется в data root Device Core. URL должен вести прямо на media по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (playbackIndex === background.items.length - 1 ? error : null)}
|
||||
onSourceChange={(source: DeviceManagerMediaSource) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: "",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url,
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: url ? inferMediaKind(url) : null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange({ ...background, items: background.items.filter((candidate) => candidate.id !== item.id) });
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="environment-media-playlist__empty">Добавьте первый файл или прямую ссылку на медиаконтент.</p>
|
||||
{error ? <p className="environment-media-playlist__error" role="alert">{error}</p> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({ ...background, imageDurationSeconds })}
|
||||
/>
|
||||
<span>Новые элементы появляются сверху. Воспроизведение начинается снизу; перетаскивание меняет порядок.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type {
|
||||
AdapterPackageView,
|
||||
AdapterVersionView,
|
||||
BindingView,
|
||||
ConfigurationRevisionView,
|
||||
EdgeView,
|
||||
ModelProfileView,
|
||||
ProjectGrantView,
|
||||
DeviceManagerSession,
|
||||
DeviceManagerPresentation,
|
||||
DeviceManagerProjectPresentation,
|
||||
DeviceView,
|
||||
ProjectSummary,
|
||||
ProjectWorkspace,
|
||||
RouteView,
|
||||
ScopeKind,
|
||||
} from "./types";
|
||||
|
||||
export async function loadPresentation(): Promise<DeviceManagerPresentation> {
|
||||
return requestJson<{ ok: true; presentation: DeviceManagerPresentation }>(
|
||||
"/api/device-manager/presentation",
|
||||
).then((value) => value.presentation);
|
||||
}
|
||||
|
||||
export async function saveProjectPresentation(
|
||||
projectRef: string,
|
||||
presentation: DeviceManagerProjectPresentation,
|
||||
) {
|
||||
return putJson<{ presentation: DeviceManagerPresentation }>(
|
||||
"/api/device-manager/presentation/project",
|
||||
{ projectRef, presentation },
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveEnvironmentPresentation(
|
||||
environment: DeviceManagerPresentation["environment"],
|
||||
) {
|
||||
return putJson<{ presentation: DeviceManagerPresentation }>(
|
||||
"/api/device-manager/presentation/environment",
|
||||
{ environment },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadPresentationMedia(input: {
|
||||
file: File;
|
||||
scope: "project" | "environment";
|
||||
projectRef?: string;
|
||||
kind: "icon" | "teaser" | "background";
|
||||
}) {
|
||||
const params = new URLSearchParams({ scope: input.scope, kind: input.kind });
|
||||
if (input.projectRef) params.set("projectRef", input.projectRef);
|
||||
const response = await fetch(`/api/device-manager/presentation/media?${params}`, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": input.file.type || "application/octet-stream",
|
||||
"x-file-name": input.file.name,
|
||||
},
|
||||
body: input.file,
|
||||
});
|
||||
const body = await response.json().catch(() => null) as { ok?: boolean; fileName?: string; fileSrc?: string; error?: string } | null;
|
||||
if (!response.ok || body?.ok !== true || !body.fileSrc) {
|
||||
throw new Error(body?.error || `device_manager_http_${response.status}`);
|
||||
}
|
||||
return { fileName: body.fileName || input.file.name, fileSrc: body.fileSrc };
|
||||
}
|
||||
|
||||
export async function loadSession(): Promise<DeviceManagerSession> {
|
||||
return requestJson<{ ok: true; session: DeviceManagerSession }>(
|
||||
"/api/device-manager/session",
|
||||
).then((value) => value.session);
|
||||
}
|
||||
|
||||
export async function loadProjects(): Promise<ProjectSummary[]> {
|
||||
return requestJson<{ ok: true; projects: ProjectSummary[] }>(
|
||||
"/api/device-manager/projects",
|
||||
).then((value) => value.projects);
|
||||
}
|
||||
|
||||
export async function loadWorkspace(projectRef: string): Promise<ProjectWorkspace> {
|
||||
return requestJson<{ ok: true; workspace: ProjectWorkspace }>(
|
||||
`/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
).then((value) => value.workspace);
|
||||
}
|
||||
|
||||
export async function ensureOwnerScope(input: {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/owner-scopes:ensure", input);
|
||||
}
|
||||
|
||||
export async function ensureProject(input: {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
projectKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/projects:ensure", input);
|
||||
}
|
||||
|
||||
export async function ensureCollection(input: {
|
||||
projectRef: string;
|
||||
collectionKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/collections:ensure", input);
|
||||
}
|
||||
|
||||
export async function claimDevice(input: {
|
||||
projectRef: string;
|
||||
enrollmentIntentRef: string;
|
||||
discoveryRef: string;
|
||||
deviceKey: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/devices:claim", input);
|
||||
}
|
||||
|
||||
export async function updateDevice(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
displayName: string;
|
||||
integrationDeviceId: string | null;
|
||||
}) {
|
||||
return mutate<{ updated: boolean; device: DeviceView }>(
|
||||
"/api/device-manager/devices:update",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureEnrollmentIntent(input: {
|
||||
projectRef: string;
|
||||
enrollmentKey: string;
|
||||
routeRef: string;
|
||||
modelProfileRef: string;
|
||||
displayName: string;
|
||||
identifier: { kind: "imei"; value: string };
|
||||
expiresAt: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/enrollment-intents:ensure", input);
|
||||
}
|
||||
|
||||
export async function upsertProjectGrant(input: {
|
||||
projectRef: string;
|
||||
principalKind: "user" | "group";
|
||||
principalRef: string;
|
||||
projectRole: string;
|
||||
capabilityAllow: string[];
|
||||
capabilityDeny: string[];
|
||||
lifecycleState: "active" | "revoked";
|
||||
}) {
|
||||
return mutate<{ created: boolean; grant: ProjectGrantView }>(
|
||||
"/api/device-manager/project-grants:upsert",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureAdapterPackage(input: {
|
||||
packageKey: string;
|
||||
displayName: string;
|
||||
publisherRef: string;
|
||||
lifecycleState: "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; adapterPackage: AdapterPackageView }>(
|
||||
"/api/device-manager/adapter-packages:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerAdapterVersion(input: {
|
||||
adapterPackageRef: string;
|
||||
version: string;
|
||||
runtimePackageRef: string;
|
||||
contentDigest: string;
|
||||
contractVersion: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: "draft" | "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; adapterVersion: AdapterVersionView }>(
|
||||
"/api/device-manager/adapter-versions:register",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerModelProfile(input: {
|
||||
adapterVersionRef: string;
|
||||
profileRef: string;
|
||||
schemaVersion: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
deviceType: string;
|
||||
protocol: string;
|
||||
schemaArtifactRef: string;
|
||||
profileDigest: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: "draft" | "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; modelProfile: ModelProfileView }>(
|
||||
"/api/device-manager/model-profiles:register",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureEdge(input: {
|
||||
edgeKey: string;
|
||||
displayName: string;
|
||||
deploymentRef: string | null;
|
||||
lifecycleState: "provisioning" | "active" | "suspended" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; edge: EdgeView }>(
|
||||
"/api/device-manager/edges:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureRoute(input: {
|
||||
projectRef: string;
|
||||
routeKey: string;
|
||||
displayName: string;
|
||||
edgeRef: string;
|
||||
modelProfileRef: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: "telemetry" | "bidirectional";
|
||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; route: RouteView }>(
|
||||
"/api/device-manager/routes:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureDeviceBinding(input: {
|
||||
projectRef: string;
|
||||
bindingKey: string;
|
||||
displayName: string;
|
||||
source: { kind: "device" | "collection"; ref: string };
|
||||
targetKind: string;
|
||||
targetRef: string;
|
||||
capabilities: string[];
|
||||
}) {
|
||||
return mutate<{ created: boolean; binding: BindingView }>(
|
||||
"/api/device-manager/device-bindings:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeDeviceBinding(input: {
|
||||
projectRef: string;
|
||||
bindingRef: string;
|
||||
resolutionCode: string;
|
||||
}) {
|
||||
return mutate<{ revoked: boolean; binding: BindingView }>(
|
||||
"/api/device-manager/device-bindings:revoke",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConfigurationRevision(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
configuration: Record<string, unknown>;
|
||||
changeSummary: string | null;
|
||||
}) {
|
||||
return mutate<{
|
||||
created: boolean;
|
||||
configurationRevision: ConfigurationRevisionView;
|
||||
}>("/api/device-manager/device-configuration-revisions:create", input);
|
||||
}
|
||||
|
||||
export async function setDesiredConfiguration(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
configurationRevisionRef: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/device-configurations:set-desired", input);
|
||||
}
|
||||
|
||||
export async function sendServicePing(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
accessCode: string;
|
||||
expiresInSeconds: number;
|
||||
}) {
|
||||
return mutate("/api/device-manager/commands:service-ping", input);
|
||||
}
|
||||
|
||||
async function mutate<T = unknown>(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": `device-manager-${crypto.randomUUID()}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async function putJson<T = unknown>(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true } & T>(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.ok) {
|
||||
const error = new Error(body?.error || `device_manager_request_failed:${response.status}`);
|
||||
Object.assign(error, { status: response.status });
|
||||
throw error;
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
export type DeviceFieldAccess = "read-only" | "managed" | "protected";
|
||||
|
||||
export type DeviceFieldValueKind = "text" | "number" | "boolean" | "date";
|
||||
|
||||
export interface DeviceProfileField {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
access?: DeviceFieldAccess;
|
||||
valueKind?: DeviceFieldValueKind;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceProfileSection {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
access: DeviceFieldAccess;
|
||||
fields: DeviceProfileField[];
|
||||
}
|
||||
|
||||
export interface DeviceProfileCatalog {
|
||||
profileRef: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
title: string;
|
||||
sections: DeviceProfileSection[];
|
||||
}
|
||||
|
||||
const field = (
|
||||
key: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path"> = {},
|
||||
): DeviceProfileField => ({ key, label, path, ...options });
|
||||
|
||||
const managed = (
|
||||
key: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
||||
) => field(key, label, path, { ...options, access: "managed" });
|
||||
|
||||
const protectedField = (
|
||||
key: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
||||
) => field(key, label, path, { ...options, access: "protected" });
|
||||
|
||||
const serverFields = (slot: number) => [
|
||||
managed(`server-${slot}-host`, `Сервер ${slot}: DNS / IP`, `reported.configuration.monitoring.servers.${slot - 1}.host`),
|
||||
managed(`server-${slot}-port`, `Сервер ${slot}: порт`, `reported.configuration.monitoring.servers.${slot - 1}.port`, { valueKind: "number" }),
|
||||
managed(`server-${slot}-protocol`, `Сервер ${slot}: протокол`, `reported.configuration.monitoring.servers.${slot - 1}.protocol`),
|
||||
managed(`server-${slot}-identity`, `Сервер ${slot}: ID (SN)`, `reported.configuration.monitoring.servers.${slot - 1}.identity`),
|
||||
managed(`server-${slot}-password`, `Сервер ${slot}: пароль`, `reported.configuration.monitoring.servers.${slot - 1}.password`, { sensitive: true }),
|
||||
];
|
||||
|
||||
const managedIndexedFields = (
|
||||
count: number,
|
||||
prefix: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
||||
) => Array.from({ length: count }, (_, index) => managed(
|
||||
`${prefix}-${index + 1}`,
|
||||
`${label} ${index + 1}`,
|
||||
`${path}.${index}`,
|
||||
options,
|
||||
));
|
||||
|
||||
const phoneFields = Array.from({ length: 5 }, (_, index) => [
|
||||
managed(`phone-${index + 1}-number`, `Телефон ${index + 1}: номер`, `reported.configuration.phones.${index}.number`, { sensitive: true }),
|
||||
managed(`phone-${index + 1}-mode`, `Телефон ${index + 1}: режим`, `reported.configuration.phones.${index}.mode`),
|
||||
]).flat();
|
||||
|
||||
const simFields = (slot: number) => [
|
||||
managed(`sim-${slot}-gprs`, `SIM ${slot}: передача данных`, `reported.configuration.simCards.${slot - 1}.gprsEnabled`, { valueKind: "boolean" }),
|
||||
managed(`sim-${slot}-apn`, `SIM ${slot}: APN оператора`, `reported.configuration.simCards.${slot - 1}.apn`),
|
||||
managed(`sim-${slot}-login`, `SIM ${slot}: логин APN`, `reported.configuration.simCards.${slot - 1}.login`, { sensitive: true }),
|
||||
managed(`sim-${slot}-password`, `SIM ${slot}: пароль APN`, `reported.configuration.simCards.${slot - 1}.password`, { sensitive: true }),
|
||||
managed(`sim-${slot}-roaming`, `SIM ${slot}: роуминг`, `reported.configuration.simCards.${slot - 1}.roamingEnabled`, { valueKind: "boolean" }),
|
||||
managed(`sim-${slot}-operator`, `SIM ${slot}: приоритетный оператор`, `reported.configuration.simCards.${slot - 1}.preferredOperatorCode`),
|
||||
managed(`sim-${slot}-pin`, `SIM ${slot}: PIN`, `reported.configuration.simCards.${slot - 1}.pin`, { sensitive: true }),
|
||||
managed(`sim-${slot}-ussd`, `SIM ${slot}: USSD запроса баланса`, `reported.configuration.simCards.${slot - 1}.balanceUssd`, { sensitive: true }),
|
||||
managed(`sim-${slot}-poll`, `SIM ${slot}: период запроса баланса`, `reported.configuration.simCards.${slot - 1}.balancePollHours`, { valueKind: "number", unit: "ч" }),
|
||||
];
|
||||
|
||||
const motionEventFields = ["acceleration", "braking", "cornering", "vertical"].flatMap((event) => {
|
||||
const labels: Record<string, string> = {
|
||||
acceleration: "Разгон",
|
||||
braking: "Торможение",
|
||||
cornering: "Угловое ускорение",
|
||||
vertical: "Вертикальное ускорение",
|
||||
};
|
||||
return Array.from({ length: 3 }, (_, level) => [
|
||||
managed(`${event}-${level + 1}-threshold`, `${labels[event]} ${level + 1}: порог`, `reported.configuration.drivingStyle.${event}.${level}.thresholdMg`, { valueKind: "number", unit: "mg" }),
|
||||
managed(`${event}-${level + 1}-duration`, `${labels[event]} ${level + 1}: длительность превышения`, `reported.configuration.drivingStyle.${event}.${level}.durationMs`, { valueKind: "number", unit: "мс" }),
|
||||
managed(`${event}-${level + 1}-reset`, `${labels[event]} ${level + 1}: задержка сброса`, `reported.configuration.drivingStyle.${event}.${level}.resetDelayMs`, { valueKind: "number", unit: "мс" }),
|
||||
]).flat();
|
||||
}).flat();
|
||||
|
||||
const violationFields = ["speed", "rpm"].flatMap((kind) => Array.from({ length: 4 }, (_, level) => [
|
||||
managed(`${kind}-${level + 1}-threshold`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог`, `reported.configuration.drivingStyle.violations.${kind}.${level}.threshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
|
||||
managed(`${kind}-${level + 1}-duration`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: минимальное время`, `reported.configuration.drivingStyle.violations.${kind}.${level}.minimumDurationSeconds`, { valueKind: "number", unit: "с" }),
|
||||
managed(`${kind}-${level + 1}-reset`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог сброса`, `reported.configuration.drivingStyle.violations.${kind}.${level}.resetThreshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
|
||||
]).flat()).flat();
|
||||
|
||||
const modbusRegisterFields = Array.from({ length: 10 }, (_, index) => [
|
||||
managed(`modbus-register-${index + 1}`, `Регистр ${index + 1}: номер`, `reported.configuration.modbus.registers.${index}.number`, { valueKind: "number" }),
|
||||
managed(`modbus-register-${index + 1}-pair`, `Регистр ${index + 1}: читать два регистра`, `reported.configuration.modbus.registers.${index}.readPair`, { valueKind: "boolean" }),
|
||||
]).flat();
|
||||
|
||||
const bleFields = Array.from({ length: 10 }, (_, index) => [
|
||||
managed(`ble-${index + 1}-mac`, `BLE датчик ${index + 1}: MAC`, `reported.configuration.bluetooth.sensors.${index}.mac`),
|
||||
managed(`ble-${index + 1}-integration`, `BLE датчик ${index + 1}: интеграция`, `reported.configuration.bluetooth.sensors.${index}.integrationExpression`),
|
||||
]).flat();
|
||||
|
||||
export const ARUSNAVI_B2_CATALOG: DeviceProfileCatalog = {
|
||||
profileRef: "arusnavi.b2.internal.v1",
|
||||
vendor: "ARUSNAVI",
|
||||
model: "B2",
|
||||
title: "ARUSNAVI B2",
|
||||
sections: [
|
||||
{
|
||||
id: "passport",
|
||||
label: "Паспорт",
|
||||
title: "Паспорт и состояние устройства",
|
||||
description: "Реестровая идентичность, профиль модели и текущее состояние канала. Исходный идентификатор показывается только в проекции, разрешённой Device Core.",
|
||||
access: "read-only",
|
||||
fields: [
|
||||
managed(
|
||||
"device-display-name",
|
||||
"Название устройства",
|
||||
"device.displayName",
|
||||
{ description: "Произвольное имя, которое Device Core показывает в реестре и карточке устройства." },
|
||||
),
|
||||
managed(
|
||||
"integration-device-id",
|
||||
"ID интеграционного устройства",
|
||||
"device.integrationDeviceId",
|
||||
{ description: "Идентификатор целевого актива во внешней бизнес-системе, например ID трайка." },
|
||||
),
|
||||
field("device-key", "Ключ устройства", "device.deviceKey"),
|
||||
field("device-ref", "Device Core ref", "device.deviceRef"),
|
||||
field("vendor", "Производитель", "profile.vendor"),
|
||||
field("model", "Модель", "profile.model"),
|
||||
field("device-type", "Тип", "profile.deviceType"),
|
||||
field("profile", "Профиль модели", "device.modelProfileRef"),
|
||||
field("identifier-kind", "Тип идентификатора", "device.identifier.kind"),
|
||||
field("imei", "IMEI", "device.identifier.displayValue", {
|
||||
description: "Полное значение отображается только в разрешённой Device Core проекции; иначе показывается защищённая маска.",
|
||||
}),
|
||||
field("iccid-1", "ICCID 1", "reported.identity.iccid1"),
|
||||
field("iccid-2", "ICCID 2", "reported.identity.iccid2"),
|
||||
field("lifecycle", "Состояние реестра", "device.lifecycleState"),
|
||||
field("created", "Зарегистрирован", "device.createdAt", { valueKind: "date" }),
|
||||
field("updated", "Обновлён", "device.updatedAt", { valueKind: "date" }),
|
||||
field("reported-at", "Снимок устройства получен", "reported.observedAt", { valueKind: "date" }),
|
||||
managed("asset-model", "Модель актива", "reported.metadata.model"),
|
||||
managed("registration", "Регистрационный номер", "reported.metadata.registrationNumber"),
|
||||
managed("object", "Объект", "reported.metadata.object"),
|
||||
managed("description", "Описание", "reported.metadata.description"),
|
||||
managed("sim-label-1", "Метка SIM 1", "reported.metadata.simLabel1"),
|
||||
managed("sim-label-2", "Метка SIM 2", "reported.metadata.simLabel2"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "live",
|
||||
label: "Онлайн",
|
||||
title: "Живой канал и телеметрия",
|
||||
description: "Значения обновляются из последней gateway-сессии и безопасного снимка телеметрии. Интерфейс опрашивает Device Core, пока открыта карточка.",
|
||||
access: "read-only",
|
||||
fields: [
|
||||
field("session-state", "Состояние соединения", "session.lifecycleState"),
|
||||
field("session-route", "Маршрут", "session.routeName"),
|
||||
field("session-connected", "Подключён", "session.connectedAt", { valueKind: "date" }),
|
||||
field("session-last-seen", "Последний пакет", "session.lastSeenAt", { valueKind: "date" }),
|
||||
field("session-frames", "Принято пакетов", "session.frameCount", { valueKind: "number" }),
|
||||
field("session-bytes", "Принято данных", "session.byteCount", { valueKind: "number", unit: "байт" }),
|
||||
field("latitude", "Широта", "reported.telemetry.navigation.latitude"),
|
||||
field("longitude", "Долгота", "reported.telemetry.navigation.longitude"),
|
||||
field("speed", "Скорость", "reported.telemetry.navigation.speedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
field("altitude", "Высота", "reported.telemetry.navigation.altitudeMeters", { valueKind: "number", unit: "м" }),
|
||||
field("satellites", "Спутники", "reported.telemetry.navigation.satellites", { valueKind: "number" }),
|
||||
field("course", "Курс", "reported.telemetry.navigation.courseDegrees", { valueKind: "number", unit: "°" }),
|
||||
field("hdop", "HDOP", "reported.telemetry.navigation.hdop"),
|
||||
field("gsm-signal", "Уровень GSM", "reported.telemetry.gsm.signal"),
|
||||
field("gsm-operator", "Оператор", "reported.telemetry.gsm.operator"),
|
||||
field("gsm-lac", "LAC", "reported.telemetry.gsm.lac"),
|
||||
field("gsm-cid", "CID", "reported.telemetry.gsm.cid"),
|
||||
field("external-voltage", "Внешнее напряжение", "reported.telemetry.system.externalVoltageMv", { valueKind: "number", unit: "мВ" }),
|
||||
field("internal-voltage", "Внутреннее напряжение", "reported.telemetry.system.internalVoltageMv", { valueKind: "number", unit: "мВ" }),
|
||||
field("errors", "Ошибки и статусы", "reported.telemetry.system.status"),
|
||||
field("inputs", "Входы и выходы", "reported.telemetry.system.io"),
|
||||
field("modules", "Статусы модулей", "reported.telemetry.system.modules"),
|
||||
field("engine-hours", "Моточасы", "reported.telemetry.can.engineHours"),
|
||||
field("odometer", "Пробег", "reported.telemetry.can.odometer"),
|
||||
field("fuel-total", "Полный расход топлива", "reported.telemetry.can.fuelTotal"),
|
||||
field("fuel-level", "Уровень топлива", "reported.telemetry.can.fuelLevel"),
|
||||
field("rpm", "Обороты двигателя", "reported.telemetry.can.rpm"),
|
||||
field("engine-temp", "Температура двигателя", "reported.telemetry.can.engineTemperature"),
|
||||
field("vehicle-speed", "Скорость по CAN", "reported.telemetry.can.vehicleSpeed"),
|
||||
field("axle-pressure", "Давление на оси", "reported.telemetry.can.axlePressure"),
|
||||
field("crash", "Контроллер аварии", "reported.telemetry.can.crashController"),
|
||||
field("instant-fuel", "Моментальный расход", "reported.telemetry.can.instantFuel"),
|
||||
field("adblue", "Уровень AdBlue", "reported.telemetry.can.adBlueLevel"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firmware",
|
||||
label: "Прошивка",
|
||||
title: "Версия программного обеспечения",
|
||||
description: "Версию и доступность обновления показываем, но запуск обновления для пилотного B2 запрещён. Этот запрет не снимается включением обычного командного канала.",
|
||||
access: "protected",
|
||||
fields: [
|
||||
field("firmware-current", "Текущая версия", "reported.firmware.currentVersion"),
|
||||
field("firmware-applied", "Версия применена", "reported.firmware.appliedAt", { valueKind: "date" }),
|
||||
field("firmware-available", "Доступная версия", "reported.firmware.availableVersion"),
|
||||
field("firmware-description", "Описание версии", "reported.firmware.description"),
|
||||
protectedField("firmware-action", "Обновление прошивки", "policies.firmwareUpdate", { description: "Заблокировано для пилотного устройства" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "templates",
|
||||
label: "Шаблоны",
|
||||
title: "Шаблоны настроек",
|
||||
description: "Шаблон хранит именованный снимок конфигурации модели. Применение должно создавать новую desired-ревизию, а не менять устройство в обход command ledger.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
field("template-current", "Применённый шаблон", "reported.configurationTemplate.name"),
|
||||
field("template-applied", "Шаблон применён", "reported.configurationTemplate.appliedAt", { valueKind: "date" }),
|
||||
managed("template-select", "Выбранный шаблон", "reported.configurationTemplate.selected"),
|
||||
managed("template-name", "Название нового шаблона", "reported.configurationTemplate.draft.name"),
|
||||
managed("template-description", "Описание нового шаблона", "reported.configurationTemplate.draft.description"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "monitoring",
|
||||
label: "Серверы",
|
||||
title: "Серверы мониторинга",
|
||||
description: "B2 поддерживает четыре серверных слота. Существующий Gelios сохраняется параллельно; новый маршрут не должен его перетирать.",
|
||||
access: "managed",
|
||||
fields: [1, 2, 3, 4].flatMap(serverFields),
|
||||
},
|
||||
{
|
||||
id: "transmission",
|
||||
label: "Передача",
|
||||
title: "Набор передаваемых данных",
|
||||
description: "Флаги определяют состав телеметрии, которую формирует устройство.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("tx-nav-position", "Навигация: широта и долгота", "reported.configuration.transmission.navigation.position", { valueKind: "boolean" }),
|
||||
managed("tx-nav-motion", "Навигация: скорость, высота, спутники и курс", "reported.configuration.transmission.navigation.motion", { valueKind: "boolean" }),
|
||||
managed("tx-nav-hdop", "Навигация: HDOP", "reported.configuration.transmission.navigation.hdop", { valueKind: "boolean" }),
|
||||
managed("tx-gsm-operator", "GSM: сигнал и оператор", "reported.configuration.transmission.gsm.operator", { valueKind: "boolean" }),
|
||||
managed("tx-gsm-cell", "GSM: LAC и CID", "reported.configuration.transmission.gsm.cell", { valueKind: "boolean" }),
|
||||
managed("tx-system-status", "Системные: ошибки и статусы", "reported.configuration.transmission.system.status", { valueKind: "boolean" }),
|
||||
managed("tx-system-io", "Системные: входы, выходы и модули", "reported.configuration.transmission.system.io", { valueKind: "boolean" }),
|
||||
managed("tx-system-voltage", "Системные: напряжения", "reported.configuration.transmission.system.voltage", { valueKind: "boolean" }),
|
||||
...["statuses", "engineHours", "odometer", "fuelTotal", "fuelLevel", "rpm", "engineTemperature", "vehicleSpeed", "axlePressure", "crashController", "instantFuel", "adBlueLevel"].map((key) => managed(`tx-can-${key}`, `CAN: ${({ statuses: "статусы работы", engineHours: "моточасы", odometer: "пробег", fuelTotal: "полный расход топлива", fuelLevel: "уровень топлива", rpm: "обороты двигателя", engineTemperature: "температура двигателя", vehicleSpeed: "скорость", axlePressure: "давление на оси", crashController: "контроллер аварии", instantFuel: "моментальный расход", adBlueLevel: "уровень AdBlue" } as Record<string, string>)[key]}`, `reported.configuration.transmission.can.${key}`, { valueKind: "boolean" })),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trajectory",
|
||||
label: "Траектория",
|
||||
title: "Отрисовка траектории и датчик движения",
|
||||
description: "Обычные и роуминговые интервалы, заморозка координат и параметры встроенного датчика движения.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
...["normal", "roaming"].flatMap((mode) => {
|
||||
const label = mode === "normal" ? "Основной режим" : "Роуминг";
|
||||
return [
|
||||
managed(`${mode}-course`, `${label}: изменение курса`, `reported.configuration.trajectory.${mode}.courseDeltaDegrees`, { valueKind: "number", unit: "°" }),
|
||||
managed(`${mode}-speed`, `${label}: изменение скорости`, `reported.configuration.trajectory.${mode}.speedDeltaKph`, { valueKind: "number", unit: "км/ч" }),
|
||||
managed(`${mode}-distance`, `${label}: расстояние между точками`, `reported.configuration.trajectory.${mode}.distanceMeters`, { valueKind: "number", unit: "м" }),
|
||||
managed(`${mode}-parking`, `${label}: интервал на стоянке`, `reported.configuration.trajectory.${mode}.parkingIntervalSeconds`, { valueKind: "number", unit: "с" }),
|
||||
];
|
||||
}),
|
||||
managed("freeze-low-speed", "Заморозка координат при скорости ниже 2 км/ч", "reported.configuration.trajectory.freeze.lowSpeed", { valueKind: "boolean" }),
|
||||
managed("freeze-motion", "Заморозка по датчику движения", "reported.configuration.trajectory.freeze.motionSensor", { valueKind: "boolean" }),
|
||||
managed("freeze-ignition", "Заморозка по зажиганию", "reported.configuration.trajectory.freeze.ignition", { valueKind: "boolean" }),
|
||||
managed("freeze-quiet", "Тихоходная техника", "reported.configuration.trajectory.freeze.lowSpeedVehicle", { valueKind: "boolean" }),
|
||||
managed("motion-sensitivity", "Чувствительность датчика движения", "reported.configuration.motionSensor.sensitivity", { valueKind: "number" }),
|
||||
managed("motion-delay", "Задержка срабатывания", "reported.configuration.motionSensor.delaySeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("motion-impact", "Порог удара", "reported.configuration.motionSensor.impact", { valueKind: "number" }),
|
||||
managed("motion-tilt", "Порог наклона", "reported.configuration.motionSensor.tilt", { valueKind: "number" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "io",
|
||||
label: "Входы / выходы",
|
||||
title: "Входы и выходы",
|
||||
description: "Режимы PIN0–PIN7 и пороги. Непосредственное переключение выходов относится к защищённым командам.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
...managedIndexedFields(8, "pin-mode", "Режим PIN", "reported.configuration.io.pinModes"),
|
||||
managed("speed-coefficient", "Коэффициент датчика скорости", "reported.configuration.io.speedSensorCoefficient", { valueKind: "number" }),
|
||||
managed("virtual-ignition", "Порог виртуального зажигания", "reported.configuration.io.virtualIgnitionThresholdMv", { valueKind: "number", unit: "мВ" }),
|
||||
managed("analog-pin-2", "Порог аналогового входа PIN2", "reported.configuration.io.analogThresholds.pin2Mv", { valueKind: "number", unit: "мВ" }),
|
||||
managed("analog-pin-3", "Порог аналогового входа PIN3", "reported.configuration.io.analogThresholds.pin3Mv", { valueKind: "number", unit: "мВ" }),
|
||||
protectedField("output-4", "Команда выхода PIN4", "reported.operations.outputs.pin4"),
|
||||
protectedField("output-5", "Команда выхода PIN5", "reported.operations.outputs.pin5"),
|
||||
protectedField("output-6", "Команда выхода PIN6", "reported.operations.outputs.pin6"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ports",
|
||||
label: "Порты",
|
||||
title: "Цифровые порты и датчики",
|
||||
description: "RS232, RS485, CAN, Wi‑Fi, фотоснимки, 1‑Wire и фильтрация датчиков.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("rs232", "RS232", "reported.configuration.ports.rs232.mode"),
|
||||
managed("rs485", "RS485", "reported.configuration.ports.rs485.mode"),
|
||||
managed("can-program", "Номер программы CAN", "reported.configuration.ports.can.program", { valueKind: "number" }),
|
||||
managed("can-internal", "Активировать внутренний CAN", "reported.configuration.ports.can.internalEnabled", { valueKind: "boolean" }),
|
||||
managed("can-seatbelt", "Контролировать ремень по CAN", "reported.configuration.ports.can.seatbelt", { valueKind: "boolean" }),
|
||||
managed("can-headlight", "Контролировать ближний свет по CAN", "reported.configuration.ports.can.headlight", { valueKind: "boolean" }),
|
||||
managed("wifi-ssid", "Wi‑Fi: имя сети", "reported.configuration.ports.wifi.ssid"),
|
||||
managed("wifi-password", "Wi‑Fi: пароль", "reported.configuration.ports.wifi.password", { sensitive: true }),
|
||||
managed("photo-interval", "Интервал фотоснимков", "reported.configuration.ports.camera.intervalMinutes", { valueKind: "number", unit: "мин" }),
|
||||
managed("photo-resolution", "Разрешение фотоснимков", "reported.configuration.ports.camera.resolution"),
|
||||
managed("one-wire-auto", "Сохранять новые термодатчики", "reported.configuration.ports.oneWire.autoDiscover", { valueKind: "boolean" }),
|
||||
...managedIndexedFields(10, "one-wire", "Адрес термодатчика", "reported.configuration.ports.oneWire.sensorAddresses"),
|
||||
managed("median-filter", "Медианный фильтр датчиков", "reported.configuration.ports.sensorFilter.medianEnabled", { valueKind: "boolean" }),
|
||||
...managedIndexedFields(4, "lls-filter", "Степень фильтрации LLS", "reported.configuration.ports.sensorFilter.lls", { valueKind: "number" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "modbus",
|
||||
label: "Modbus",
|
||||
title: "Параметры Modbus",
|
||||
description: "Последовательный порт, сетевые адреса и до десяти читаемых регистров.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("modbus-baud", "Скорость обмена", "reported.configuration.modbus.baudRate", { valueKind: "number" }),
|
||||
managed("modbus-poll", "Таймер опроса", "reported.configuration.modbus.pollSeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("modbus-parity", "Проверка на чётность", "reported.configuration.modbus.parity"),
|
||||
managed("modbus-stop", "Stop bits", "reported.configuration.modbus.stopBits"),
|
||||
managed("modbus-address-a", "Сетевой адрес датчика для регистров 1–5", "reported.configuration.modbus.addresses.first", { valueKind: "number" }),
|
||||
managed("modbus-address-b", "Сетевой адрес датчика для регистров 6–10", "reported.configuration.modbus.addresses.second", { valueKind: "number" }),
|
||||
...modbusRegisterFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bluetooth",
|
||||
label: "Bluetooth",
|
||||
title: "Bluetooth (BLE) датчики",
|
||||
description: "Режим BLE-модуля, код сопряжения и десять датчиков с выражениями универсальной интеграции.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("ble-mode", "Режим работы Bluetooth", "reported.configuration.bluetooth.mode"),
|
||||
managed("ble-pairing", "Код сопряжения", "reported.configuration.bluetooth.pairingCode", { sensitive: true }),
|
||||
...bleFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "driving-style",
|
||||
label: "Стиль вождения",
|
||||
title: "Стиль вождения",
|
||||
description: "Пороговые профили акселерометра и превышений скорости/оборотов.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
...motionEventFields,
|
||||
managed("accelerometer-transmit", "Передавать данные акселерометра", "reported.configuration.drivingStyle.transmitAccelerometer", { valueKind: "boolean" }),
|
||||
managed("accelerometer-reset-events", "Передавать события сброса", "reported.configuration.drivingStyle.transmitResetEvents", { valueKind: "boolean" }),
|
||||
managed("accelerometer-bitmask", "Передавать состояния сработок", "reported.configuration.drivingStyle.transmitTriggerMask", { valueKind: "boolean" }),
|
||||
managed("accelerometer-average", "Глубина усреднения акселерометра", "reported.configuration.drivingStyle.averagingDepth", { valueKind: "number" }),
|
||||
...violationFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "phones",
|
||||
label: "Телефоны",
|
||||
title: "Разрешённые телефоны",
|
||||
description: "До пяти номеров и индивидуальный режим доступа для SMS-управления.",
|
||||
access: "managed",
|
||||
fields: phoneFields,
|
||||
},
|
||||
{
|
||||
id: "sim",
|
||||
label: "SIM-карты",
|
||||
title: "SIM-карты и мобильная сеть",
|
||||
description: "Параметры двух SIM-профилей. Пароли, PIN и USSD не возвращаются в открытом виде.",
|
||||
access: "managed",
|
||||
fields: [...simFields(1), ...simFields(2)],
|
||||
},
|
||||
{
|
||||
id: "navigation",
|
||||
label: "Навигация",
|
||||
title: "Навигация и фильтрация координат",
|
||||
description: "Источники координат, спутниковые группировки, внешний локатор и фильтры качества.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("nav-satellite", "Спутниковая навигация", "reported.configuration.navigation.sources.satellite", { valueKind: "boolean" }),
|
||||
managed("nav-wifi", "Wi‑Fi локатор", "reported.configuration.navigation.sources.wifi", { valueKind: "boolean" }),
|
||||
managed("nav-lbs", "LBS локатор", "reported.configuration.navigation.sources.lbs", { valueKind: "boolean" }),
|
||||
managed("nav-tag", "Навигационная метка", "reported.configuration.navigation.sources.tag", { valueKind: "boolean" }),
|
||||
...["gps", "glonass", "galileo", "beidou"].map((key) => managed(`nav-${key}`, key.toUpperCase(), `reported.configuration.navigation.constellations.${key}`, { valueKind: "boolean" })),
|
||||
managed("locator-url", "URL локатора", "reported.configuration.navigation.locator.url", { sensitive: true }),
|
||||
managed("locator-moving", "Интервал локатора в движении", "reported.configuration.navigation.locator.movingIntervalSeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("locator-parked", "Интервал локатора на стоянке", "reported.configuration.navigation.locator.parkedIntervalSeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("filter-satellites", "Минимальное число спутников", "reported.configuration.navigation.filter.minimumSatellites", { valueKind: "number" }),
|
||||
managed("filter-hdop", "Максимальный HDOP × 10", "reported.configuration.navigation.filter.maximumHdopTimesTen", { valueKind: "number" }),
|
||||
managed("filter-altitude-min", "Минимальная высота", "reported.configuration.navigation.filter.minimumAltitudeMeters", { valueKind: "number", unit: "м" }),
|
||||
managed("filter-altitude-max", "Максимальная высота", "reported.configuration.navigation.filter.maximumAltitudeMeters", { valueKind: "number", unit: "м" }),
|
||||
managed("filter-speed-min", "Минимальная мгновенная скорость", "reported.configuration.navigation.filter.minimumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
managed("filter-speed-max", "Максимальная мгновенная скорость", "reported.configuration.navigation.filter.maximumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
managed("filter-speed-average", "Максимальная средняя скорость", "reported.configuration.navigation.filter.maximumAverageSpeedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
managed("filter-time", "Максимальное время фильтрации", "reported.configuration.navigation.filter.maximumSeconds", { valueKind: "number", unit: "с" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: "Системные",
|
||||
title: "Системные параметры",
|
||||
description: "Системные интервалы и энергосбережение. Секретные значения отображаются только как факт наличия.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("sms-password", "Пароль устройства (SMS)", "reported.configuration.system.smsPassword", { sensitive: true }),
|
||||
managed("web-check-hours", "Проверять WEB-конфигуратор каждые", "reported.configuration.system.webConfiguration.checkHours", { valueKind: "number", unit: "ч" }),
|
||||
managed("web-check-start", "Проверять WEB-конфигуратор при старте", "reported.configuration.system.webConfiguration.onStart", { valueKind: "boolean" }),
|
||||
managed("sleep-mode", "Режим сна", "reported.configuration.system.powerSaving.mode"),
|
||||
managed("sleep-wake-interval", "Выходить на связь каждые", "reported.configuration.system.powerSaving.wakeIntervalMinutes", { valueKind: "number", unit: "мин" }),
|
||||
managed("sleep-online", "Время пребывания на связи", "reported.configuration.system.powerSaving.onlineMinutes", { valueKind: "number", unit: "мин" }),
|
||||
managed("sleep-motion", "Выходить из сна по датчику движения", "reported.configuration.system.powerSaving.wakeOnMotion", { valueKind: "boolean" }),
|
||||
managed("sleep-input", "Выходить из сна по изменению входа", "reported.configuration.system.powerSaving.wakeOnInput", { valueKind: "boolean" }),
|
||||
managed("battery-ignition", "Заряжать АКБ только при включённом зажигании", "reported.configuration.system.chargeBatteryOnIgnitionOnly", { valueKind: "boolean" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "diagnostics",
|
||||
label: "Диагностика",
|
||||
title: "Диагностика и операции",
|
||||
description: "Доступные B2 операции отражены полностью, но выполняются только через подтверждённый двусторонний канал и отдельный command ledger.",
|
||||
access: "protected",
|
||||
fields: [
|
||||
field("debug-last-session", "Последняя удалённая отладка", "reported.diagnostics.lastSessionAt", { valueKind: "date" }),
|
||||
field("debug-output", "Результат удалённой отладки", "reported.diagnostics.output"),
|
||||
protectedField("op-packet", "Запросить пакет телеметрии", "reported.operations.requestTelemetry"),
|
||||
protectedField("op-info", "Запросить информацию", "reported.operations.requestInfo"),
|
||||
protectedField("op-coordinates", "Запросить координаты", "reported.operations.requestCoordinates"),
|
||||
protectedField("op-config", "Синхронизировать настройки", "reported.operations.syncConfiguration"),
|
||||
protectedField("op-restart", "Перезапустить устройство", "reported.operations.restart"),
|
||||
protectedField("op-clear", "Очистить память", "reported.operations.clearMemory"),
|
||||
protectedField("op-firmware", "Обновить прошивку", "reported.operations.updateFirmware", { description: "Запрещено для пилотного B2" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const genericCatalog: DeviceProfileCatalog = {
|
||||
profileRef: "generic.device.v1",
|
||||
vendor: "NODE.DC",
|
||||
model: "Generic device",
|
||||
title: "Устройство",
|
||||
sections: ARUSNAVI_B2_CATALOG.sections.filter((section) => ["passport", "live"].includes(section.id)),
|
||||
};
|
||||
|
||||
const catalogs = new Map<string, DeviceProfileCatalog>([
|
||||
[ARUSNAVI_B2_CATALOG.profileRef, ARUSNAVI_B2_CATALOG],
|
||||
]);
|
||||
|
||||
export function getDeviceProfileCatalog(profileRef: string): DeviceProfileCatalog {
|
||||
return catalogs.get(profileRef) ?? { ...genericCatalog, profileRef };
|
||||
}
|
||||
|
||||
export function accessLabel(access: DeviceFieldAccess) {
|
||||
return ({
|
||||
"read-only": "Только чтение",
|
||||
managed: "Управляемая настройка",
|
||||
protected: "Защищённая операция",
|
||||
})[access];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
import "./styles.css";
|
||||
import { DeviceManagerApp } from "./DeviceManagerApp";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<DeviceManagerApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
export type HubRole = "viewer" | "member" | "admin" | "owner";
|
||||
export type ScopeKind = "company" | "personal";
|
||||
|
||||
export interface OwnerScopeClaim {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface DeviceManagerSession {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
initials: string;
|
||||
};
|
||||
actor: {
|
||||
userRef: string;
|
||||
hubRole: HubRole;
|
||||
groupRefs: string[];
|
||||
ownerScopes: OwnerScopeClaim[];
|
||||
};
|
||||
profileUrl: string;
|
||||
}
|
||||
|
||||
export type DeviceManagerTheme = "dark" | "light";
|
||||
export type DeviceManagerMediaSource = "file" | "url";
|
||||
export type DeviceManagerMediaKind = "image" | "video";
|
||||
|
||||
export interface DeviceManagerMediaValue {
|
||||
source: DeviceManagerMediaSource;
|
||||
url: string;
|
||||
fileName: string | null;
|
||||
fileSrc: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceManagerEnvironmentMediaItem extends DeviceManagerMediaValue {
|
||||
id: string;
|
||||
mediaKind: DeviceManagerMediaKind | null;
|
||||
}
|
||||
|
||||
export interface DeviceManagerEnvironmentOverview {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primarySection: string | null;
|
||||
secondarySection: string | null;
|
||||
background: {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: DeviceManagerEnvironmentMediaItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeviceManagerProjectPresentation {
|
||||
icon: DeviceManagerMediaValue;
|
||||
teaser: DeviceManagerMediaValue;
|
||||
}
|
||||
|
||||
export interface DeviceManagerPresentation {
|
||||
environment: {
|
||||
theme: DeviceManagerTheme;
|
||||
accentHex: string;
|
||||
overview: DeviceManagerEnvironmentOverview;
|
||||
};
|
||||
projects: Record<string, DeviceManagerProjectPresentation>;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
projectRef: string;
|
||||
projectKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycleState: string;
|
||||
ownerScope: {
|
||||
ownerScopeRef: string;
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
};
|
||||
access: {
|
||||
projectRole: string | null;
|
||||
capabilities: string[];
|
||||
};
|
||||
counts: { devices: number; collections: number; discoveries: number };
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceView {
|
||||
deviceRef: string;
|
||||
deviceKey: string | null;
|
||||
displayName: string;
|
||||
integrationDeviceId: string | null;
|
||||
modelProfileRef: string;
|
||||
lifecycleState: string;
|
||||
identifier: { kind: string; masked: string; value?: string } | null;
|
||||
session: { state: string; lastSeenAt: string | null } | null;
|
||||
reported?: {
|
||||
observedAt?: string | null;
|
||||
identity?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
firmware?: Record<string, unknown>;
|
||||
configuration?: Record<string, unknown>;
|
||||
telemetry?: Record<string, unknown>;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
operations?: Record<string, unknown>;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CollectionView {
|
||||
collectionRef: string;
|
||||
collectionKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycleState: string;
|
||||
memberCount: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveryView {
|
||||
discoveryRef: string;
|
||||
identifier: { kind: string; masked: string };
|
||||
modelProfileRef: string;
|
||||
protocol: string;
|
||||
lifecycleState: string;
|
||||
enrollmentIntentRef: string | null;
|
||||
claimedDeviceRef: string | null;
|
||||
firstObservedAt: string | null;
|
||||
lastObservedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EnrollmentView {
|
||||
enrollmentIntentRef: string;
|
||||
enrollmentKey: string;
|
||||
displayName: string;
|
||||
modelProfileRef: string;
|
||||
expectedIdentifier: { kind: string; masked: string };
|
||||
lifecycleState: string;
|
||||
observedDiscoveryRef: string | null;
|
||||
claimedDeviceRef: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterPackageView {
|
||||
adapterPackageRef: string;
|
||||
packageKey: string;
|
||||
displayName: string;
|
||||
publisherRef: string;
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterVersionView {
|
||||
adapterVersionRef: string;
|
||||
adapterPackageRef: string;
|
||||
version: string;
|
||||
runtimePackageRef: string;
|
||||
contentDigest: string;
|
||||
contractVersion: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ModelProfileView {
|
||||
modelProfileRef: string;
|
||||
adapterVersionRef: string | null;
|
||||
schemaVersion: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
deviceType: string;
|
||||
protocol: string;
|
||||
schemaArtifactRef: string | null;
|
||||
profileDigest: string | null;
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EdgeView {
|
||||
edgeRef: string;
|
||||
edgeKey: string;
|
||||
displayName: string;
|
||||
deploymentRef: string | null;
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RouteView {
|
||||
routeRef: string;
|
||||
routeKey: string;
|
||||
displayName: string;
|
||||
edgeRef: string;
|
||||
edgeName: string;
|
||||
modelProfileRef: string;
|
||||
profileName: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: "telemetry" | "bidirectional";
|
||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
||||
sessionCount: number;
|
||||
activeSessionCount: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
sessionRef: string;
|
||||
routeRef: string;
|
||||
routeName: string;
|
||||
deviceRef: string | null;
|
||||
deviceName: string | null;
|
||||
protocol: string;
|
||||
lifecycleState: string;
|
||||
connectedAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
disconnectedAt: string | null;
|
||||
closeReasonCode: string | null;
|
||||
frameCount: number;
|
||||
byteCount: number;
|
||||
}
|
||||
|
||||
export interface BindingView {
|
||||
bindingRef: string;
|
||||
bindingKey: string;
|
||||
displayName: string;
|
||||
source: { kind: string; ref: string; displayName: string };
|
||||
target: { kind: string; ref: string };
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
sourceApprovedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigurationRevisionView {
|
||||
configurationRevisionRef: string;
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
revisionNumber: number;
|
||||
modelProfileRef: string;
|
||||
schemaArtifactRef: string;
|
||||
configurationDigest: string;
|
||||
changeSummary: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigurationStateView {
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
desiredConfigurationRevisionRef: string | null;
|
||||
appliedConfigurationRevisionRef: string | null;
|
||||
appliedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CommandView {
|
||||
commandRef: string;
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
commandKey: string;
|
||||
commandCatalogRef: string;
|
||||
commandType: string;
|
||||
riskClass: string;
|
||||
lifecycleState: string;
|
||||
plannedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
confirmedAt: string | null;
|
||||
dispatchedAt: string | null;
|
||||
acknowledgedAt: string | null;
|
||||
terminalAt: string | null;
|
||||
terminalReasonCode: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEventView {
|
||||
auditEventRef: string;
|
||||
eventType: string;
|
||||
actorRef: string;
|
||||
deviceRef: string | null;
|
||||
discoveryRef: string | null;
|
||||
occurredAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectGrantView {
|
||||
grantRef: string;
|
||||
principalKind: "user" | "group";
|
||||
principalRef: string;
|
||||
projectRole: string;
|
||||
capabilityAllow: string[];
|
||||
capabilityDeny: string[];
|
||||
lifecycleState: string;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspace {
|
||||
project: ProjectSummary;
|
||||
devices: DeviceView[];
|
||||
collections: CollectionView[];
|
||||
discoveries: DiscoveryView[];
|
||||
enrollments: EnrollmentView[];
|
||||
adapterPackages: AdapterPackageView[];
|
||||
adapterVersions: AdapterVersionView[];
|
||||
modelProfiles: ModelProfileView[];
|
||||
edges: EdgeView[];
|
||||
routes: RouteView[];
|
||||
sessions: SessionView[];
|
||||
bindings: BindingView[];
|
||||
configurationRevisions: ConfigurationRevisionView[];
|
||||
configurationStates: ConfigurationStateView[];
|
||||
commands: CommandView[];
|
||||
auditEvents: AuditEventView[];
|
||||
grants: ProjectGrantView[];
|
||||
policies: {
|
||||
commandTransport: "disabled" | "typed-service-ping-v1";
|
||||
commandPlanningApi: "disabled" | "enabled";
|
||||
identifierProjection: string;
|
||||
auditPayloadProjection: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user