481 lines
12 KiB
TypeScript
481 lines
12 KiB
TypeScript
import type {
|
|
SpaceScene,
|
|
SpaceSourceData,
|
|
SpaceCluster,
|
|
StarObject,
|
|
PlanetObject,
|
|
MoonObject,
|
|
GatewayObject,
|
|
AnomalyObject,
|
|
} from '../types/space';
|
|
import type {
|
|
NodeInfo as CityNodeInfo,
|
|
MicroDAOInfo,
|
|
AgentInfo,
|
|
CityEvent,
|
|
} from '../../city-dashboard/types/city';
|
|
|
|
const GALAXY_CENTER = { x: 480, y: 320 };
|
|
const STAR_RING_RADIUS = 240;
|
|
const STAR_BASE_RADIUS = 58;
|
|
|
|
const CLUSTER_PRESETS = [
|
|
{
|
|
key: 'core',
|
|
id: 'cluster-core',
|
|
clusterId: 'constellation-core',
|
|
name: 'Core Infrastructure',
|
|
description: 'Hetzner, Supabase та базові дата-центри',
|
|
position: { x: 360, y: 320, radius: 240 },
|
|
},
|
|
{
|
|
key: 'frontier',
|
|
id: 'cluster-frontier',
|
|
clusterId: 'constellation-frontier',
|
|
name: 'Frontier & Edge',
|
|
description: 'MacBook / Edge вузли, польові лабораторії',
|
|
position: { x: 660, y: 260, radius: 200 },
|
|
},
|
|
] as const;
|
|
|
|
type ClusterKey = (typeof CLUSTER_PRESETS)[number]['key'];
|
|
|
|
const MIN_HEALTH = 18;
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function normaliseNodeIdentifier(
|
|
value: string | undefined,
|
|
nodes: CityNodeInfo[],
|
|
): string | null {
|
|
if (!value) return null;
|
|
const lowered = value.toLowerCase();
|
|
const direct = nodes.find(
|
|
(node) =>
|
|
node.id.toLowerCase() === lowered || node.name.toLowerCase() === lowered,
|
|
);
|
|
if (direct) {
|
|
return direct.id;
|
|
}
|
|
|
|
const partial = nodes.find(
|
|
(node) =>
|
|
lowered.includes(node.id.toLowerCase()) ||
|
|
node.id.toLowerCase().includes(lowered) ||
|
|
lowered.includes(node.name.toLowerCase()),
|
|
);
|
|
return partial ? partial.id : null;
|
|
}
|
|
|
|
function detectClusterKey(node: CityNodeInfo): ClusterKey {
|
|
const haystack = `${node.name} ${node.location}`.toLowerCase();
|
|
if (haystack.includes('hetzner') || haystack.includes('core')) {
|
|
return 'core';
|
|
}
|
|
return 'frontier';
|
|
}
|
|
|
|
function statusFromNodeStatus(status: CityNodeInfo['status']) {
|
|
switch (status) {
|
|
case 'online':
|
|
return 'stable';
|
|
case 'degraded':
|
|
case 'maintenance':
|
|
return 'warning';
|
|
default:
|
|
return 'critical';
|
|
}
|
|
}
|
|
|
|
function statusFromMicroDaoStatus(
|
|
status: MicroDAOInfo['status'],
|
|
): SpaceCluster['status'] {
|
|
switch (status) {
|
|
case 'active':
|
|
return 'stable';
|
|
case 'forming':
|
|
return 'warning';
|
|
default:
|
|
return 'critical';
|
|
}
|
|
}
|
|
|
|
function statusFromAgentStatus(status: AgentInfo['status']) {
|
|
switch (status) {
|
|
case 'active':
|
|
return 'stable';
|
|
case 'idle':
|
|
return 'warning';
|
|
default:
|
|
return 'critical';
|
|
}
|
|
}
|
|
|
|
function severityFromPriority(event: CityEvent['priority']) {
|
|
switch (event) {
|
|
case 'critical':
|
|
return 'high';
|
|
case 'high':
|
|
return 'medium';
|
|
default:
|
|
return 'low';
|
|
}
|
|
}
|
|
|
|
function buildMicroDaoHostMap(
|
|
microDaos: MicroDAOInfo[],
|
|
agents: AgentInfo[],
|
|
nodes: CityNodeInfo[],
|
|
) {
|
|
const assignment = new Map<string, string>();
|
|
|
|
microDaos.forEach((microDao, index) => {
|
|
const matchingAgents = agents.filter((agent) => {
|
|
if (!agent.microDao) return false;
|
|
const target = agent.microDao.toLowerCase();
|
|
return (
|
|
target === microDao.id.toLowerCase() ||
|
|
target === microDao.name.toLowerCase() ||
|
|
target.includes(microDao.id.toLowerCase()) ||
|
|
microDao.name.toLowerCase().includes(target)
|
|
);
|
|
});
|
|
|
|
const nodeCounts = new Map<string, number>();
|
|
matchingAgents.forEach((agent) => {
|
|
const resolved = normaliseNodeIdentifier(agent.node, nodes);
|
|
if (!resolved) return;
|
|
nodeCounts.set(resolved, (nodeCounts.get(resolved) ?? 0) + 1);
|
|
});
|
|
|
|
if (nodeCounts.size) {
|
|
const [bestNode] = [...nodeCounts.entries()].sort(
|
|
(a, b) => b[1] - a[1],
|
|
)[0];
|
|
assignment.set(microDao.id, bestNode);
|
|
} else if (nodes.length) {
|
|
assignment.set(microDao.id, nodes[index % nodes.length].id);
|
|
}
|
|
});
|
|
|
|
return assignment;
|
|
}
|
|
|
|
function computeStarPosition(index: number, total: number) {
|
|
if (total <= 1) {
|
|
return { x: GALAXY_CENTER.x, y: GALAXY_CENTER.y, radius: STAR_BASE_RADIUS };
|
|
}
|
|
|
|
const angle = (index / total) * Math.PI * 2;
|
|
const eccentricity = 0.65;
|
|
return {
|
|
x: GALAXY_CENTER.x + Math.cos(angle) * STAR_RING_RADIUS,
|
|
y: GALAXY_CENTER.y + Math.sin(angle) * STAR_RING_RADIUS * eccentricity,
|
|
radius: STAR_BASE_RADIUS,
|
|
};
|
|
}
|
|
|
|
function buildClusters(
|
|
nodes: CityNodeInfo[],
|
|
microDaos: MicroDAOInfo[],
|
|
agents: AgentInfo[],
|
|
microDaoAssignments: Map<string, string>,
|
|
): SpaceCluster[] {
|
|
const nodeGroups = new Map<ClusterKey, CityNodeInfo[]>();
|
|
const microDaoGroups = new Map<ClusterKey, number>();
|
|
const agentGroups = new Map<ClusterKey, number>();
|
|
const nodeClusterMap = new Map<string, ClusterKey>();
|
|
|
|
nodes.forEach((node) => {
|
|
const clusterKey = detectClusterKey(node);
|
|
nodeClusterMap.set(node.id, clusterKey);
|
|
const list = nodeGroups.get(clusterKey) ?? [];
|
|
list.push(node);
|
|
nodeGroups.set(clusterKey, list);
|
|
});
|
|
|
|
microDaos.forEach((microDao) => {
|
|
const hostNodeId = microDaoAssignments.get(microDao.id);
|
|
const clusterKey = hostNodeId
|
|
? nodeClusterMap.get(hostNodeId) ?? 'frontier'
|
|
: 'frontier';
|
|
microDaoGroups.set(
|
|
clusterKey,
|
|
(microDaoGroups.get(clusterKey) ?? 0) + 1,
|
|
);
|
|
});
|
|
|
|
agents.forEach((agent) => {
|
|
const nodeId = normaliseNodeIdentifier(agent.node, nodes);
|
|
const clusterKey = nodeId
|
|
? nodeClusterMap.get(nodeId) ?? 'frontier'
|
|
: 'frontier';
|
|
agentGroups.set(clusterKey, (agentGroups.get(clusterKey) ?? 0) + 1);
|
|
});
|
|
|
|
return CLUSTER_PRESETS.map((preset) => {
|
|
const nodeCount = nodeGroups.get(preset.key)?.length ?? 0;
|
|
const microCount = microDaoGroups.get(preset.key) ?? 0;
|
|
const agentCount = agentGroups.get(preset.key) ?? 0;
|
|
const density = clamp(
|
|
(nodeCount + microCount * 0.35 + agentCount * 0.02) /
|
|
Math.max(nodes.length || 1, 1),
|
|
0,
|
|
1,
|
|
);
|
|
const status =
|
|
density >= 0.75
|
|
? 'stable'
|
|
: density >= 0.4
|
|
? ('warning' as const)
|
|
: ('critical' as const);
|
|
|
|
return {
|
|
id: preset.id,
|
|
type: 'cluster',
|
|
clusterId: preset.clusterId,
|
|
name: preset.name,
|
|
description: preset.description,
|
|
nodes: nodeCount,
|
|
microDaos: microCount,
|
|
agents: agentCount,
|
|
density,
|
|
status,
|
|
position: preset.position,
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildStars(
|
|
nodes: CityNodeInfo[],
|
|
microDaos: MicroDAOInfo[],
|
|
agents: AgentInfo[],
|
|
microDaoAssignments: Map<string, string>,
|
|
): StarObject[] {
|
|
return nodes.map((node, index) => {
|
|
const position = computeStarPosition(index, nodes.length);
|
|
const assignedMicroDaos = microDaos.filter(
|
|
(microDao) => microDaoAssignments.get(microDao.id) === node.id,
|
|
);
|
|
const assignedAgents = agents.filter((agent) =>
|
|
(normaliseNodeIdentifier(agent.node, nodes) ?? '') === node.id,
|
|
);
|
|
|
|
const health =
|
|
node.metrics && node.metrics.cpuUsage !== undefined
|
|
? clamp(
|
|
100 -
|
|
(node.metrics.cpuUsage * 0.4 +
|
|
node.metrics.ramUsage * 0.35 +
|
|
(node.metrics.diskUsage ?? 30) * 0.15),
|
|
MIN_HEALTH,
|
|
100,
|
|
)
|
|
: 80;
|
|
|
|
return {
|
|
id: `star-${node.id}`,
|
|
type: 'star',
|
|
name: node.name,
|
|
nodeId: node.id,
|
|
health,
|
|
microDaos: assignedMicroDaos.length,
|
|
agents: assignedAgents.length || node.agents || 0,
|
|
status: statusFromNodeStatus(node.status),
|
|
position,
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildPlanets(
|
|
microDaos: MicroDAOInfo[],
|
|
microDaoAssignments: Map<string, string>,
|
|
stars: StarObject[],
|
|
) {
|
|
const planets: PlanetObject[] = [];
|
|
const planetsByStar = new Map<string, MicroDAOInfo[]>();
|
|
|
|
microDaos.forEach((microDao) => {
|
|
const hostNodeId =
|
|
microDaoAssignments.get(microDao.id) ??
|
|
stars[0]?.nodeId ??
|
|
microDao.id;
|
|
const arr = planetsByStar.get(hostNodeId) ?? [];
|
|
arr.push(microDao);
|
|
planetsByStar.set(hostNodeId, arr);
|
|
});
|
|
|
|
planetsByStar.forEach((daoList, nodeId) => {
|
|
const star = stars.find((s) => s.nodeId === nodeId);
|
|
if (!star) return;
|
|
|
|
daoList.forEach((microDao, index) => {
|
|
const orbitRadius = 110 + index * 45;
|
|
const angle = (index / Math.max(daoList.length, 1)) * Math.PI * 2;
|
|
const position = {
|
|
x: star.position.x + Math.cos(angle) * orbitRadius,
|
|
y: star.position.y + Math.sin(angle) * orbitRadius,
|
|
};
|
|
|
|
planets.push({
|
|
id: `planet-${microDao.id}`,
|
|
type: 'planet',
|
|
name: microDao.name,
|
|
microDaoId: microDao.id,
|
|
population: microDao.members,
|
|
agents: microDao.agents,
|
|
orbitRadius,
|
|
starId: star.id,
|
|
status: statusFromMicroDaoStatus(microDao.status),
|
|
position,
|
|
});
|
|
});
|
|
});
|
|
|
|
return planets;
|
|
}
|
|
|
|
function buildMoons(
|
|
agents: AgentInfo[],
|
|
planets: PlanetObject[],
|
|
microDaos: MicroDAOInfo[],
|
|
) {
|
|
const moons: MoonObject[] = [];
|
|
const agentsByMicroDao = new Map<string, AgentInfo[]>();
|
|
|
|
agents.forEach((agent) => {
|
|
if (!agent.microDao) return;
|
|
const target = agent.microDao.toLowerCase();
|
|
const microDao =
|
|
microDaos.find(
|
|
(dao) =>
|
|
dao.id.toLowerCase() === target ||
|
|
dao.name.toLowerCase() === target ||
|
|
target.includes(dao.id.toLowerCase()),
|
|
) ?? null;
|
|
if (!microDao) return;
|
|
const list = agentsByMicroDao.get(microDao.id) ?? [];
|
|
list.push(agent);
|
|
agentsByMicroDao.set(microDao.id, list);
|
|
});
|
|
|
|
planets.forEach((planet) => {
|
|
const list = agentsByMicroDao.get(planet.microDaoId)?.slice(0, 4) ?? [];
|
|
list.forEach((agent, index) => {
|
|
const orbitRadius = 20 + index * 6;
|
|
const angle = (index / Math.max(list.length, 1)) * Math.PI * 2;
|
|
const position = {
|
|
x: planet.position.x + Math.cos(angle) * (orbitRadius + 16),
|
|
y: planet.position.y + Math.sin(angle) * (orbitRadius + 16),
|
|
};
|
|
|
|
moons.push({
|
|
id: `moon-${agent.id}`,
|
|
type: 'moon',
|
|
name: agent.name,
|
|
agentId: agent.id,
|
|
focus: agent.role,
|
|
planetId: planet.id,
|
|
orbitRadius,
|
|
status: statusFromAgentStatus(agent.status),
|
|
position,
|
|
});
|
|
});
|
|
});
|
|
|
|
return moons;
|
|
}
|
|
|
|
function buildGateways(agents: AgentInfo[]): GatewayObject[] {
|
|
const integrations = agents
|
|
.filter(
|
|
(agent) =>
|
|
agent.type === 'service-agent' ||
|
|
agent.type === 'platform-agent' ||
|
|
/bridge|gateway|connector|monitor/i.test(agent.role),
|
|
)
|
|
.slice(0, 3);
|
|
|
|
if (!integrations.length) {
|
|
return [
|
|
{
|
|
id: 'gateway-matrix',
|
|
type: 'gateway',
|
|
name: 'Matrix Bridge',
|
|
integration: 'Matrix',
|
|
position: { x: 520, y: 160 },
|
|
status: 'stable',
|
|
},
|
|
];
|
|
}
|
|
|
|
const basePositions = [
|
|
{ x: 520, y: 160 },
|
|
{ x: 220, y: 180 },
|
|
{ x: 780, y: 200 },
|
|
];
|
|
|
|
return integrations.map((agent, index) => ({
|
|
id: `gateway-${agent.id}`,
|
|
type: 'gateway',
|
|
name: agent.name,
|
|
integration: agent.role,
|
|
position: basePositions[index] ?? { x: 520 + index * 80, y: 160 },
|
|
status: statusFromAgentStatus(agent.status),
|
|
}));
|
|
}
|
|
|
|
function buildAnomalies(events: CityEvent[]): AnomalyObject[] {
|
|
const alertEvents = events
|
|
.filter(
|
|
(event) =>
|
|
event.type.startsWith('alerts') ||
|
|
event.type.startsWith('metrics.reconciled') ||
|
|
event.priority === 'high' ||
|
|
event.priority === 'critical',
|
|
)
|
|
.slice(0, 3);
|
|
|
|
return alertEvents.map((event, index) => ({
|
|
id: `anomaly-${event.id}`,
|
|
type: 'anomaly',
|
|
name: event.title,
|
|
severity: severityFromPriority(event.priority),
|
|
description: event.description,
|
|
status: 'warning',
|
|
position: { x: 220 + index * 180, y: 480 - index * 60 },
|
|
}));
|
|
}
|
|
|
|
export function buildSpaceScene(source: SpaceSourceData): SpaceScene {
|
|
const nodes = source.nodes ?? [];
|
|
const microDaos = source.microDaos ?? [];
|
|
const agents = source.agents ?? [];
|
|
const events = source.events ?? [];
|
|
|
|
const microDaoAssignments = buildMicroDaoHostMap(microDaos, agents, nodes);
|
|
const stars = buildStars(nodes, microDaos, agents, microDaoAssignments);
|
|
const planets = buildPlanets(microDaos, microDaoAssignments, stars);
|
|
const moons = buildMoons(agents, planets, microDaos);
|
|
const clusters = buildClusters(
|
|
nodes,
|
|
microDaos,
|
|
agents,
|
|
microDaoAssignments,
|
|
);
|
|
const gateways = buildGateways(agents);
|
|
const anomalies = buildAnomalies(events);
|
|
|
|
return {
|
|
clusters,
|
|
stars,
|
|
planets,
|
|
moons,
|
|
gateways,
|
|
anomalies,
|
|
};
|
|
}
|
|
|