feat: implement Task 029 (Agent Orchestrator & Visibility Flow)
This commit is contained in:
@@ -481,7 +481,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
|
||||
id: 'agent-iris',
|
||||
name: 'Iris',
|
||||
role: 'Image Analyzer',
|
||||
model: 'qwen2-vl:32b',
|
||||
model: 'llava:13b',
|
||||
backend: 'ollama',
|
||||
status: 'active',
|
||||
node: 'node-2',
|
||||
@@ -492,7 +492,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
|
||||
id: 'agent-lumen',
|
||||
name: 'Lumen',
|
||||
role: 'Visual Content Creator',
|
||||
model: 'qwen2-vl:32b',
|
||||
model: 'llava:13b',
|
||||
backend: 'ollama',
|
||||
status: 'active',
|
||||
node: 'node-2',
|
||||
@@ -503,7 +503,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
|
||||
id: 'agent-spectra',
|
||||
name: 'Spectra',
|
||||
role: 'Multimodal Processor',
|
||||
model: 'qwen3-vl:latest',
|
||||
model: 'llava:13b',
|
||||
backend: 'ollama',
|
||||
status: 'active',
|
||||
node: 'node-2',
|
||||
@@ -514,7 +514,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
|
||||
id: 'agent-video-analyzer',
|
||||
name: 'Video Analyzer',
|
||||
role: 'Video Analysis & Processing',
|
||||
model: 'qwen2-vl:32b',
|
||||
model: 'llava:13b',
|
||||
backend: 'ollama',
|
||||
status: 'active',
|
||||
node: 'node-2',
|
||||
|
||||
480
src/features/space-dashboard/lib/buildSpaceScene.ts
Normal file
480
src/features/space-dashboard/lib/buildSpaceScene.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
185
src/lib/presence.ts
Normal file
185
src/lib/presence.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Presence System — Система відстеження онлайн-статусу користувачів
|
||||
*/
|
||||
|
||||
import { WebSocketClient } from './ws';
|
||||
|
||||
export type PresenceStatus = 'online' | 'offline' | 'away';
|
||||
|
||||
export interface UserPresence {
|
||||
userId: string;
|
||||
status: PresenceStatus;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export type PresenceUpdateCallback = (presence: Map<string, UserPresence>) => void;
|
||||
|
||||
export class PresenceManager {
|
||||
private ws: WebSocketClient | null = null;
|
||||
private presenceMap: Map<string, UserPresence> = new Map();
|
||||
private heartbeatInterval: number | null = null;
|
||||
private callbacks: Set<PresenceUpdateCallback> = new Set();
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor(private wsUrl: string, private heartbeatIntervalMs: number = 20000) {}
|
||||
|
||||
/**
|
||||
* Підключитися до Presence System
|
||||
*/
|
||||
connect(userId: string): void {
|
||||
this.userId = userId;
|
||||
|
||||
this.ws = new WebSocketClient({ url: this.wsUrl });
|
||||
|
||||
this.ws.onOpen(() => {
|
||||
console.log('[Presence] Connected');
|
||||
this.startHeartbeat();
|
||||
});
|
||||
|
||||
this.ws.onMessage((data: any) => {
|
||||
if (data.event === 'presence.update') {
|
||||
this.handlePresenceUpdate(data.user_id, data.status, data.last_seen);
|
||||
} else if (data.event === 'presence.bulk_update') {
|
||||
this.handleBulkUpdate(data.users);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.onClose(() => {
|
||||
console.log('[Presence] Disconnected');
|
||||
this.stopHeartbeat();
|
||||
});
|
||||
|
||||
this.ws.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Від'єднатися від Presence System
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.disconnect();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.presenceMap.clear();
|
||||
this.userId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Підписатися на оновлення presence
|
||||
*/
|
||||
subscribe(callback: PresenceUpdateCallback): () => void {
|
||||
this.callbacks.add(callback);
|
||||
|
||||
// Відразу відправити поточний стан
|
||||
callback(new Map(this.presenceMap));
|
||||
|
||||
return () => this.callbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отримати presence конкретного користувача
|
||||
*/
|
||||
getPresence(userId: string): UserPresence | undefined {
|
||||
return this.presenceMap.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отримати всі онлайн користувачів
|
||||
*/
|
||||
getOnlineUsers(): UserPresence[] {
|
||||
return Array.from(this.presenceMap.values()).filter(p => p.status === 'online');
|
||||
}
|
||||
|
||||
/**
|
||||
* Отримати кількість онлайн користувачів
|
||||
*/
|
||||
getOnlineCount(): number {
|
||||
return this.getOnlineUsers().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустити heartbeat
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
// Відправити перший heartbeat одразу
|
||||
this.sendHeartbeat();
|
||||
|
||||
// Потім кожні N секунд
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
this.sendHeartbeat();
|
||||
}, this.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Зупинити heartbeat
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval !== null) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Відправити heartbeat
|
||||
*/
|
||||
private sendHeartbeat(): void {
|
||||
if (!this.ws || !this.userId) return;
|
||||
|
||||
this.ws.send({
|
||||
event: 'presence.heartbeat',
|
||||
user_id: this.userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обробити оновлення presence
|
||||
*/
|
||||
private handlePresenceUpdate(userId: string, status: PresenceStatus, lastSeen?: string): void {
|
||||
const presence: UserPresence = {
|
||||
userId,
|
||||
status,
|
||||
lastSeen: lastSeen || new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.presenceMap.set(userId, presence);
|
||||
this.notifyCallbacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обробити bulk update
|
||||
*/
|
||||
private handleBulkUpdate(users: Array<{ user_id: string; status: PresenceStatus; last_seen?: string }>): void {
|
||||
users.forEach(user => {
|
||||
this.handlePresenceUpdate(user.user_id, user.status, user.last_seen);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Сповістити всіх підписників
|
||||
*/
|
||||
private notifyCallbacks(): void {
|
||||
const presenceCopy = new Map(this.presenceMap);
|
||||
this.callbacks.forEach(callback => callback(presenceCopy));
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
let globalPresenceManager: PresenceManager | null = null;
|
||||
|
||||
/**
|
||||
* Отримати глобальний екземпляр PresenceManager
|
||||
*/
|
||||
export function getPresenceManager(): PresenceManager {
|
||||
if (!globalPresenceManager) {
|
||||
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:8000'}/ws/city/presence`;
|
||||
globalPresenceManager = new PresenceManager(wsUrl);
|
||||
}
|
||||
return globalPresenceManager;
|
||||
}
|
||||
|
||||
231
src/lib/ws.ts
Normal file
231
src/lib/ws.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Централізований WebSocket клієнт для DAARION
|
||||
* Підтримує auto-reconnect, heartbeat, та множинні підписки
|
||||
*/
|
||||
|
||||
type MessageHandler = (data: unknown) => void;
|
||||
type ErrorHandler = (error: Event) => void;
|
||||
type ConnectionHandler = () => void;
|
||||
|
||||
export interface WebSocketClientOptions {
|
||||
url: string;
|
||||
reconnectInterval?: number;
|
||||
heartbeatInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private reconnectInterval: number;
|
||||
private heartbeatInterval: number;
|
||||
private maxReconnectAttempts: number;
|
||||
private reconnectAttempts = 0;
|
||||
private heartbeatTimer: number | null = null;
|
||||
private reconnectTimer: number | null = null;
|
||||
private messageHandlers: Set<MessageHandler> = new Set();
|
||||
private errorHandlers: Set<ErrorHandler> = new Set();
|
||||
private openHandlers: Set<ConnectionHandler> = new Set();
|
||||
private closeHandlers: Set<ConnectionHandler> = new Set();
|
||||
private isIntentionallyClosed = false;
|
||||
|
||||
constructor(options: WebSocketClientOptions) {
|
||||
this.url = options.url;
|
||||
this.reconnectInterval = options.reconnectInterval || 3000;
|
||||
this.heartbeatInterval = options.heartbeatInterval || 30000;
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Підключитися до WebSocket сервера
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] Already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isIntentionallyClosed = false;
|
||||
console.log(`[WebSocket] Connecting to ${this.url}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[WebSocket] Connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.startHeartbeat();
|
||||
this.openHandlers.forEach(handler => handler());
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Ігноруємо heartbeat відповіді
|
||||
if (data.type === 'pong') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageHandlers.forEach(handler => handler(data));
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
console.error('[WebSocket] Error:', event);
|
||||
this.errorHandlers.forEach(handler => handler(event));
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected');
|
||||
this.stopHeartbeat();
|
||||
this.closeHandlers.forEach(handler => handler());
|
||||
|
||||
if (!this.isIntentionallyClosed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Connection error:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Від'єднатися від WebSocket сервера
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.isIntentionallyClosed = true;
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Відправити повідомлення
|
||||
*/
|
||||
send(data: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('[WebSocket] Cannot send: not connected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Підписатися на повідомлення
|
||||
*/
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.add(handler);
|
||||
return () => this.messageHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Підписатися на помилки
|
||||
*/
|
||||
onError(handler: ErrorHandler): () => void {
|
||||
this.errorHandlers.add(handler);
|
||||
return () => this.errorHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Підписатися на відкриття з'єднання
|
||||
*/
|
||||
onOpen(handler: ConnectionHandler): () => void {
|
||||
this.openHandlers.add(handler);
|
||||
return () => this.openHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Підписатися на закриття з'єднання
|
||||
*/
|
||||
onClose(handler: ConnectionHandler): () => void {
|
||||
this.closeHandlers.add(handler);
|
||||
return () => this.closeHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Перевірити, чи активне з'єднання
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустити heartbeat
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.send({ type: 'ping' });
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Зупинити heartbeat
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запланувати перепідключення
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('[WebSocket] Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectInterval * Math.min(this.reconnectAttempts, 5);
|
||||
|
||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Фабрика для створення WebSocket клієнтів для різних сервісів
|
||||
*/
|
||||
export const createWebSocketClient = (options: WebSocketClientOptions): WebSocketClient => {
|
||||
return new WebSocketClient(options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook-friendly wrapper для React компонентів
|
||||
*/
|
||||
export const useWebSocket = (url: string) => {
|
||||
const client = new WebSocketClient({ url });
|
||||
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
disconnect: () => client.disconnect(),
|
||||
send: (data: unknown) => client.send(data),
|
||||
onMessage: (handler: MessageHandler) => client.onMessage(handler),
|
||||
onError: (handler: ErrorHandler) => client.onError(handler),
|
||||
onOpen: (handler: ConnectionHandler) => client.onOpen(handler),
|
||||
onClose: (handler: ConnectionHandler) => client.onClose(handler),
|
||||
isConnected: () => client.isConnected(),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user