Initial commit: architect-frontend infrastructure viewer
- Vite + TypeScript + D3.js setup - Infrastructure graph view (servers, services, connections) - Tables graph view with categories (core, directus, storage, etc.) - PostgREST API integration - Deploy script for Caddy static serving Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
14
architect-frontend.service
Normal file
14
architect-frontend.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TZZR Architect Frontend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=architect
|
||||||
|
WorkingDirectory=/home/architect/captain-claude/architect-frontend
|
||||||
|
ExecStart=/home/architect/captain-claude/architect-frontend/serve.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
15
deploy.sh
Executable file
15
deploy.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy frontend ARCHITECT a /opt/architect/web/
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "Deploying..."
|
||||||
|
ssh -i ~/.ssh/tzzr root@69.62.126.110 "rm -rf /opt/architect/web/* && cp -r $DIR/dist/* /opt/architect/web/"
|
||||||
|
|
||||||
|
echo "Done: https://tzzrarchitect.me"
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ARCHITECT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1755
package-lock.json
generated
Normal file
1755
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "architect-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext .ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"d3": "^7.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
serve.sh
Executable file
3
serve.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/architect/captain-claude/architect-frontend
|
||||||
|
exec npx serve dist -l 5050
|
||||||
1
src/api/index.ts
Normal file
1
src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './infrastructure.ts';
|
||||||
176
src/api/infrastructure.ts
Normal file
176
src/api/infrastructure.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type { Server, Service, Connection } from '../types/index.ts';
|
||||||
|
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
// Tipos de respuesta de la API (snake_case)
|
||||||
|
interface ApiServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
suspended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
server_id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
url?: string;
|
||||||
|
uptime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiConnection {
|
||||||
|
id: number;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiColumn {
|
||||||
|
id: number;
|
||||||
|
schema_id: string;
|
||||||
|
table_name: string;
|
||||||
|
column_name: string;
|
||||||
|
data_type: string;
|
||||||
|
is_nullable: boolean;
|
||||||
|
is_primary: boolean;
|
||||||
|
default_value?: string;
|
||||||
|
description?: string;
|
||||||
|
ordinal_position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Column {
|
||||||
|
name: string;
|
||||||
|
dataType: string;
|
||||||
|
nullable: boolean;
|
||||||
|
primary: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
description?: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapear respuesta API a tipos del frontend
|
||||||
|
const mapServer = (s: ApiServer): Server => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
ip: s.ip || '',
|
||||||
|
description: s.description || '',
|
||||||
|
status: s.status as Server['status'],
|
||||||
|
suspended: s.suspended
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapService = (s: ApiService): Service => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
serverId: s.server_id,
|
||||||
|
type: s.type as Service['type'],
|
||||||
|
status: s.status as Service['status'],
|
||||||
|
url: s.url,
|
||||||
|
uptime: s.uptime
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapConnection = (c: ApiConnection): Connection => ({
|
||||||
|
source: c.source,
|
||||||
|
target: c.target,
|
||||||
|
type: c.type as Connection['type'],
|
||||||
|
label: c.label
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch con manejo de errores
|
||||||
|
async function apiFetch<T>(endpoint: string): Promise<T[]> {
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`);
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchServers = async (): Promise<Server[]> => {
|
||||||
|
const data = await apiFetch<ApiServer>('/servers');
|
||||||
|
return data.map(mapServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchServices = async (): Promise<Service[]> => {
|
||||||
|
const data = await apiFetch<ApiService>('/services');
|
||||||
|
return data.map(mapService);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchConnections = async (): Promise<Connection[]> => {
|
||||||
|
const data = await apiFetch<ApiConnection>('/connections');
|
||||||
|
return data.map(mapConnection);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchInfrastructure = async () => {
|
||||||
|
const [servers, services, connections] = await Promise.all([
|
||||||
|
fetchServers(),
|
||||||
|
fetchServices(),
|
||||||
|
fetchConnections()
|
||||||
|
]);
|
||||||
|
return { servers, services, connections };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch columns for a specific table
|
||||||
|
export const fetchColumns = async (schemaId: string, tableName: string): Promise<Column[]> => {
|
||||||
|
const data = await apiFetch<ApiColumn>(`/columns?schema_id=eq.${schemaId}&table_name=eq.${tableName}&order=ordinal_position`);
|
||||||
|
return data.map(c => ({
|
||||||
|
name: c.column_name,
|
||||||
|
dataType: c.data_type,
|
||||||
|
nullable: c.is_nullable,
|
||||||
|
primary: c.is_primary,
|
||||||
|
defaultValue: c.default_value,
|
||||||
|
description: c.description,
|
||||||
|
position: c.ordinal_position
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Types for tables API
|
||||||
|
interface ApiTable {
|
||||||
|
id: number;
|
||||||
|
schema_id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiTableRelation {
|
||||||
|
id: number;
|
||||||
|
schema_id: string;
|
||||||
|
source_table: string;
|
||||||
|
target_table: string;
|
||||||
|
relation_type: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
database: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRelationInfo {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
type: 'fk' | 'ref' | 'logical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch tables for a specific schema
|
||||||
|
export const fetchTables = async (schemaId: string): Promise<TableInfo[]> => {
|
||||||
|
const data = await apiFetch<ApiTable>(`/tables?schema_id=eq.${schemaId}&order=category,name`);
|
||||||
|
return data.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
category: t.category,
|
||||||
|
database: schemaId
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch table relations for a specific schema
|
||||||
|
export const fetchTableRelations = async (schemaId: string): Promise<TableRelationInfo[]> => {
|
||||||
|
const data = await apiFetch<ApiTableRelation>(`/table_relations?schema_id=eq.${schemaId}`);
|
||||||
|
return data.map(r => ({
|
||||||
|
from: r.source_table,
|
||||||
|
to: r.target_table,
|
||||||
|
type: r.relation_type as 'fk' | 'ref' | 'logical'
|
||||||
|
}));
|
||||||
|
};
|
||||||
509
src/api/schemas.ts
Normal file
509
src/api/schemas.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
export interface TableSchema {
|
||||||
|
name: string;
|
||||||
|
category: 'core' | 'directus' | 'api' | 'graph' | 'tree' | 'library' | 'data' | 'system' | 'repo' | 'user' | 'action' | 'auth' | 'agent' | 'context' | 'creds' | 'comm' | 'storage';
|
||||||
|
database: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRelation {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
type: 'fk' | 'ref' | 'logical';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DBSchema {
|
||||||
|
tables: TableSchema[];
|
||||||
|
relations: TableRelation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// HST Database Schema (hst_images)
|
||||||
|
const hstTables: TableSchema[] = [
|
||||||
|
// Core data tables
|
||||||
|
{ name: 'hst', category: 'core', database: 'hst_images' },
|
||||||
|
{ name: 'flg', category: 'core', database: 'hst_images' },
|
||||||
|
{ name: 'itm', category: 'core', database: 'hst_images' },
|
||||||
|
{ name: 'loc', category: 'core', database: 'hst_images' },
|
||||||
|
{ name: 'ply', category: 'core', database: 'hst_images' },
|
||||||
|
{ name: 'atc', category: 'core', database: 'hst_images' },
|
||||||
|
|
||||||
|
// API views
|
||||||
|
{ name: 'api_tags', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_groups', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_graph_stats', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_tree_roots', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_library_list', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_library_list_hst', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_library_list_flg', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_library_list_itm', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_library_list_loc', category: 'api', database: 'hst_images' },
|
||||||
|
{ name: 'api_library_list_ply', category: 'api', database: 'hst_images' },
|
||||||
|
|
||||||
|
// Graph tables
|
||||||
|
{ name: 'graph_hst', category: 'graph', database: 'hst_images' },
|
||||||
|
{ name: 'graph_flg', category: 'graph', database: 'hst_images' },
|
||||||
|
{ name: 'graph_itm', category: 'graph', database: 'hst_images' },
|
||||||
|
{ name: 'graph_loc', category: 'graph', database: 'hst_images' },
|
||||||
|
{ name: 'graph_ply', category: 'graph', database: 'hst_images' },
|
||||||
|
|
||||||
|
// Tree tables
|
||||||
|
{ name: 'tree_hst', category: 'tree', database: 'hst_images' },
|
||||||
|
{ name: 'tree_flg', category: 'tree', database: 'hst_images' },
|
||||||
|
{ name: 'tree_itm', category: 'tree', database: 'hst_images' },
|
||||||
|
{ name: 'tree_loc', category: 'tree', database: 'hst_images' },
|
||||||
|
{ name: 'tree_ply', category: 'tree', database: 'hst_images' },
|
||||||
|
|
||||||
|
// Library tables
|
||||||
|
{ name: 'library_hst', category: 'library', database: 'hst_images' },
|
||||||
|
{ name: 'library_flg', category: 'library', database: 'hst_images' },
|
||||||
|
{ name: 'library_itm', category: 'library', database: 'hst_images' },
|
||||||
|
{ name: 'library_loc', category: 'library', database: 'hst_images' },
|
||||||
|
{ name: 'library_ply', category: 'library', database: 'hst_images' },
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
{ name: 'set_hst_rules', category: 'system', database: 'hst_images' },
|
||||||
|
|
||||||
|
// Directus system tables
|
||||||
|
{ name: 'directus_users', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_roles', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_files', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_collections', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_fields', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_relations', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_flows', category: 'directus', database: 'hst_images' },
|
||||||
|
{ name: 'directus_operations', category: 'directus', database: 'hst_images' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hstRelations: TableRelation[] = [
|
||||||
|
// Core -> Graph (cada entidad tiene su grafo)
|
||||||
|
{ from: 'hst', to: 'graph_hst', type: 'ref' },
|
||||||
|
{ from: 'flg', to: 'graph_flg', type: 'ref' },
|
||||||
|
{ from: 'itm', to: 'graph_itm', type: 'ref' },
|
||||||
|
{ from: 'loc', to: 'graph_loc', type: 'ref' },
|
||||||
|
{ from: 'ply', to: 'graph_ply', type: 'ref' },
|
||||||
|
|
||||||
|
// Core -> Tree (cada entidad tiene su árbol)
|
||||||
|
{ from: 'hst', to: 'tree_hst', type: 'ref' },
|
||||||
|
{ from: 'flg', to: 'tree_flg', type: 'ref' },
|
||||||
|
{ from: 'itm', to: 'tree_itm', type: 'ref' },
|
||||||
|
{ from: 'loc', to: 'tree_loc', type: 'ref' },
|
||||||
|
{ from: 'ply', to: 'tree_ply', type: 'ref' },
|
||||||
|
|
||||||
|
// Core -> Library (cada entidad tiene su biblioteca)
|
||||||
|
{ from: 'hst', to: 'library_hst', type: 'ref' },
|
||||||
|
{ from: 'flg', to: 'library_flg', type: 'ref' },
|
||||||
|
{ from: 'itm', to: 'library_itm', type: 'ref' },
|
||||||
|
{ from: 'loc', to: 'library_loc', type: 'ref' },
|
||||||
|
{ from: 'ply', to: 'library_ply', type: 'ref' },
|
||||||
|
|
||||||
|
// Relaciones entre entidades core
|
||||||
|
{ from: 'atc', to: 'hst', type: 'fk' },
|
||||||
|
{ from: 'flg', to: 'hst', type: 'ref' },
|
||||||
|
{ from: 'itm', to: 'hst', type: 'ref' },
|
||||||
|
{ from: 'loc', to: 'hst', type: 'ref' },
|
||||||
|
{ from: 'ply', to: 'hst', type: 'ref' },
|
||||||
|
|
||||||
|
// API views -> Core (vistas que dependen de tablas core)
|
||||||
|
{ from: 'api_tags', to: 'hst', type: 'logical' },
|
||||||
|
{ from: 'api_tags', to: 'flg', type: 'logical' },
|
||||||
|
{ from: 'api_tags', to: 'itm', type: 'logical' },
|
||||||
|
{ from: 'api_groups', to: 'hst', type: 'logical' },
|
||||||
|
{ from: 'api_graph_stats', to: 'graph_hst', type: 'logical' },
|
||||||
|
{ from: 'api_graph_stats', to: 'graph_flg', type: 'logical' },
|
||||||
|
{ from: 'api_tree_roots', to: 'tree_hst', type: 'logical' },
|
||||||
|
{ from: 'api_tree_roots', to: 'tree_flg', type: 'logical' },
|
||||||
|
{ from: 'api_library_list', to: 'library_hst', type: 'logical' },
|
||||||
|
{ from: 'api_library_list_hst', to: 'library_hst', type: 'logical' },
|
||||||
|
{ from: 'api_library_list_flg', to: 'library_flg', type: 'logical' },
|
||||||
|
{ from: 'api_library_list_itm', to: 'library_itm', type: 'logical' },
|
||||||
|
{ from: 'api_library_list_loc', to: 'library_loc', type: 'logical' },
|
||||||
|
{ from: 'api_library_list_ply', to: 'library_ply', type: 'logical' },
|
||||||
|
|
||||||
|
// System -> Core
|
||||||
|
{ from: 'set_hst_rules', to: 'hst', type: 'ref' },
|
||||||
|
|
||||||
|
// Directus system
|
||||||
|
{ from: 'directus_users', to: 'directus_roles', type: 'fk' },
|
||||||
|
{ from: 'directus_fields', to: 'directus_collections', type: 'fk' },
|
||||||
|
{ from: 'directus_relations', to: 'directus_collections', type: 'fk' },
|
||||||
|
{ from: 'directus_relations', to: 'directus_fields', type: 'fk' },
|
||||||
|
{ from: 'directus_operations', to: 'directus_flows', type: 'fk' },
|
||||||
|
{ from: 'directus_files', to: 'directus_users', type: 'fk' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// DECK Database Schema (shlink + deck)
|
||||||
|
const deckTables: TableSchema[] = [
|
||||||
|
{ name: 'short_urls', category: 'core', database: 'shlink' },
|
||||||
|
{ name: 'visits', category: 'data', database: 'shlink' },
|
||||||
|
{ name: 'visit_locations', category: 'data', database: 'shlink' },
|
||||||
|
{ name: 'tags', category: 'core', database: 'shlink' },
|
||||||
|
{ name: 'short_urls_in_tags', category: 'data', database: 'shlink' },
|
||||||
|
{ name: 'domains', category: 'system', database: 'shlink' },
|
||||||
|
{ name: 'api_keys', category: 'system', database: 'shlink' },
|
||||||
|
{ name: 'api_key_roles', category: 'system', database: 'shlink' },
|
||||||
|
{ name: 'short_url_visits_counts', category: 'data', database: 'shlink' },
|
||||||
|
{ name: 'orphan_visits_counts', category: 'data', database: 'shlink' },
|
||||||
|
{ name: 'redirect_conditions', category: 'system', database: 'shlink' },
|
||||||
|
{ name: 'short_url_redirect_rules', category: 'system', database: 'shlink' },
|
||||||
|
{ name: 'migrations', category: 'system', database: 'shlink' },
|
||||||
|
{ name: 'mail_log', category: 'data', database: 'deck' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const deckRelations: TableRelation[] = [
|
||||||
|
{ from: 'visits', to: 'short_urls', type: 'fk' },
|
||||||
|
{ from: 'visits', to: 'visit_locations', type: 'fk' },
|
||||||
|
{ from: 'short_urls_in_tags', to: 'short_urls', type: 'fk' },
|
||||||
|
{ from: 'short_urls_in_tags', to: 'tags', type: 'fk' },
|
||||||
|
{ from: 'short_url_visits_counts', to: 'short_urls', type: 'fk' },
|
||||||
|
{ from: 'api_key_roles', to: 'api_keys', type: 'fk' },
|
||||||
|
{ from: 'short_urls', to: 'domains', type: 'fk' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// GITEA Database Schema
|
||||||
|
const giteaTables: TableSchema[] = [
|
||||||
|
// Repository tables
|
||||||
|
{ name: 'repository', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'branch', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'commit_status', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'commit_status_index', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'deploy_key', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'lfs_lock', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'lfs_meta_object', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'mirror', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'protected_branch', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'protected_tag', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'push_mirror', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'release', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_archiver', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_indexer_status', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_license', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_redirect', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_topic', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_transfer', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'repo_unit', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'star', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'topic', category: 'repo', database: 'gitea' },
|
||||||
|
{ name: 'watch', category: 'repo', database: 'gitea' },
|
||||||
|
|
||||||
|
// User tables
|
||||||
|
{ name: 'user', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'email_address', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'email_hash', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'follow', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'gpg_key', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'gpg_key_import', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'public_key', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'two_factor', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'user_badge', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'user_blocking', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'user_open_id', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'user_redirect', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'user_setting', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'webauthn_credential', category: 'user', database: 'gitea' },
|
||||||
|
|
||||||
|
// Action tables (CI/CD)
|
||||||
|
{ name: 'action', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_artifact', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_run', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_run_index', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_run_job', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_runner', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_runner_token', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_schedule', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_schedule_spec', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_task', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_task_output', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_task_step', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_tasks_version', category: 'action', database: 'gitea' },
|
||||||
|
{ name: 'action_variable', category: 'action', database: 'gitea' },
|
||||||
|
|
||||||
|
// Auth tables
|
||||||
|
{ name: 'access', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'access_token', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'auth_token', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'collaboration', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'external_login_user', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'login_source', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'oauth2_application', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'oauth2_authorization_code', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'oauth2_grant', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'secret', category: 'auth', database: 'gitea' },
|
||||||
|
{ name: 'session', category: 'auth', database: 'gitea' },
|
||||||
|
|
||||||
|
// Core data
|
||||||
|
{ name: 'issue', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'issue_assignees', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'issue_label', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'issue_user', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'issue_watch', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'pull_request', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'comment', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'label', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'milestone', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'project', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'project_board', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'project_issue', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'reaction', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'review', category: 'core', database: 'gitea' },
|
||||||
|
{ name: 'attachment', category: 'core', database: 'gitea' },
|
||||||
|
|
||||||
|
// Teams/Orgs
|
||||||
|
{ name: 'team', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'team_invite', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'team_repo', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'team_unit', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'team_user', category: 'user', database: 'gitea' },
|
||||||
|
{ name: 'org_user', category: 'user', database: 'gitea' },
|
||||||
|
|
||||||
|
// System
|
||||||
|
{ name: 'app_state', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'badge', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'hook_task', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'notice', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'notification', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'package', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'system_setting', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'task', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'version', category: 'system', database: 'gitea' },
|
||||||
|
{ name: 'webhook', category: 'system', database: 'gitea' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const giteaRelations: TableRelation[] = [
|
||||||
|
// Repository -> User (owner)
|
||||||
|
{ from: 'repository', to: 'user', type: 'fk' },
|
||||||
|
|
||||||
|
// Repository child tables
|
||||||
|
{ from: 'branch', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'commit_status', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'commit_status_index', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'deploy_key', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'lfs_lock', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'lfs_meta_object', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'mirror', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'protected_branch', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'protected_tag', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'push_mirror', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'release', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_archiver', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_indexer_status', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_license', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_redirect', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_topic', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_topic', to: 'topic', type: 'fk' },
|
||||||
|
{ from: 'repo_transfer', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'repo_unit', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'star', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'star', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'watch', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'watch', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'label', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'webhook', to: 'repository', type: 'fk' },
|
||||||
|
|
||||||
|
// User child tables
|
||||||
|
{ from: 'email_address', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'email_hash', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'follow', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'gpg_key', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'gpg_key_import', to: 'gpg_key', type: 'fk' },
|
||||||
|
{ from: 'public_key', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'two_factor', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'user_badge', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'user_badge', to: 'badge', type: 'fk' },
|
||||||
|
{ from: 'user_blocking', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'user_open_id', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'user_redirect', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'user_setting', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'webauthn_credential', to: 'user', type: 'fk' },
|
||||||
|
|
||||||
|
// Actions/CI tables
|
||||||
|
{ from: 'action', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'action', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'action_artifact', to: 'action_run', type: 'fk' },
|
||||||
|
{ from: 'action_run', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'action_run', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'action_run_index', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'action_run_job', to: 'action_run', type: 'fk' },
|
||||||
|
{ from: 'action_runner', to: 'repository', type: 'ref' },
|
||||||
|
{ from: 'action_runner_token', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'action_schedule', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'action_schedule_spec', to: 'action_schedule', type: 'fk' },
|
||||||
|
{ from: 'action_task', to: 'action_run_job', type: 'fk' },
|
||||||
|
{ from: 'action_task', to: 'action_runner', type: 'fk' },
|
||||||
|
{ from: 'action_task_output', to: 'action_task', type: 'fk' },
|
||||||
|
{ from: 'action_task_step', to: 'action_task', type: 'fk' },
|
||||||
|
{ from: 'action_tasks_version', to: 'action_run', type: 'fk' },
|
||||||
|
{ from: 'action_variable', to: 'repository', type: 'ref' },
|
||||||
|
|
||||||
|
// Auth tables
|
||||||
|
{ from: 'access', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'access', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'access_token', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'auth_token', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'collaboration', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'collaboration', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'external_login_user', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'external_login_user', to: 'login_source', type: 'fk' },
|
||||||
|
{ from: 'oauth2_application', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'oauth2_authorization_code', to: 'oauth2_grant', type: 'fk' },
|
||||||
|
{ from: 'oauth2_grant', to: 'oauth2_application', type: 'fk' },
|
||||||
|
{ from: 'oauth2_grant', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'secret', to: 'repository', type: 'ref' },
|
||||||
|
{ from: 'session', to: 'user', type: 'fk' },
|
||||||
|
|
||||||
|
// Issue/PR tables
|
||||||
|
{ from: 'issue', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'issue', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'issue', to: 'milestone', type: 'fk' },
|
||||||
|
{ from: 'issue_assignees', to: 'issue', type: 'fk' },
|
||||||
|
{ from: 'issue_assignees', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'issue_label', to: 'issue', type: 'fk' },
|
||||||
|
{ from: 'issue_label', to: 'label', type: 'fk' },
|
||||||
|
{ from: 'issue_user', to: 'issue', type: 'fk' },
|
||||||
|
{ from: 'issue_user', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'issue_watch', to: 'issue', type: 'fk' },
|
||||||
|
{ from: 'issue_watch', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'pull_request', to: 'issue', type: 'fk' },
|
||||||
|
{ from: 'comment', to: 'issue', type: 'fk' },
|
||||||
|
{ from: 'comment', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'reaction', to: 'issue', type: 'ref' },
|
||||||
|
{ from: 'reaction', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'review', to: 'pull_request', type: 'fk' },
|
||||||
|
{ from: 'review', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'attachment', to: 'issue', type: 'ref' },
|
||||||
|
{ from: 'attachment', to: 'release', type: 'ref' },
|
||||||
|
{ from: 'milestone', to: 'repository', type: 'fk' },
|
||||||
|
|
||||||
|
// Project tables
|
||||||
|
{ from: 'project', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'project', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'project_board', to: 'project', type: 'fk' },
|
||||||
|
{ from: 'project_issue', to: 'project', type: 'fk' },
|
||||||
|
{ from: 'project_issue', to: 'issue', type: 'fk' },
|
||||||
|
|
||||||
|
// Team/Org tables
|
||||||
|
{ from: 'team', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'team_invite', to: 'team', type: 'fk' },
|
||||||
|
{ from: 'team_invite', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'team_repo', to: 'team', type: 'fk' },
|
||||||
|
{ from: 'team_repo', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'team_unit', to: 'team', type: 'fk' },
|
||||||
|
{ from: 'team_user', to: 'team', type: 'fk' },
|
||||||
|
{ from: 'team_user', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'org_user', to: 'user', type: 'fk' },
|
||||||
|
|
||||||
|
// System tables
|
||||||
|
{ from: 'hook_task', to: 'webhook', type: 'fk' },
|
||||||
|
{ from: 'notification', to: 'user', type: 'fk' },
|
||||||
|
{ from: 'notification', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'notification', to: 'issue', type: 'ref' },
|
||||||
|
{ from: 'package', to: 'repository', type: 'fk' },
|
||||||
|
{ from: 'task', to: 'repository', type: 'ref' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ARCHITECT Database Schema
|
||||||
|
const architectTables: TableSchema[] = [
|
||||||
|
// Agent tables
|
||||||
|
{ name: 'agents', category: 'agent', database: 'architect' },
|
||||||
|
{ name: 'agent_context_index', category: 'agent', database: 'architect' },
|
||||||
|
|
||||||
|
// Context tables
|
||||||
|
{ name: 'context_algorithms', category: 'context', database: 'architect' },
|
||||||
|
{ name: 'context_blocks', category: 'context', database: 'architect' },
|
||||||
|
{ name: 'context_blocks_history', category: 'context', database: 'architect' },
|
||||||
|
{ name: 'ambient_context', category: 'context', database: 'architect' },
|
||||||
|
{ name: 's_contract_contexts', category: 'context', database: 'architect' },
|
||||||
|
{ name: 's_contract_datasets', category: 'context', database: 'architect' },
|
||||||
|
|
||||||
|
// Credentials tables
|
||||||
|
{ name: 'creds_architect', category: 'creds', database: 'architect' },
|
||||||
|
{ name: 'creds_corp', category: 'creds', database: 'architect' },
|
||||||
|
{ name: 'creds_deck', category: 'creds', database: 'architect' },
|
||||||
|
{ name: 'creds_hst', category: 'creds', database: 'architect' },
|
||||||
|
{ name: 'creds_locker', category: 'creds', database: 'architect' },
|
||||||
|
{ name: 'creds_runpod', category: 'creds', database: 'architect' },
|
||||||
|
|
||||||
|
// Core data
|
||||||
|
{ name: 'hilos', category: 'core', database: 'architect' },
|
||||||
|
{ name: 'messages', category: 'core', database: 'architect' },
|
||||||
|
{ name: 'sessions', category: 'core', database: 'architect' },
|
||||||
|
{ name: 'memory', category: 'core', database: 'architect' },
|
||||||
|
{ name: 'knowledge_base', category: 'core', database: 'architect' },
|
||||||
|
|
||||||
|
// System
|
||||||
|
{ name: 'algorithm_experiments', category: 'system', database: 'architect' },
|
||||||
|
{ name: 'algorithm_metrics', category: 'system', database: 'architect' },
|
||||||
|
{ name: 'app_config', category: 'system', database: 'architect' },
|
||||||
|
{ name: 'audit_violations', category: 'system', database: 'architect' },
|
||||||
|
{ name: 'immutable_log', category: 'system', database: 'architect' },
|
||||||
|
{ name: 'indices', category: 'system', database: 'architect' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const architectRelations: TableRelation[] = [
|
||||||
|
// Agent relations
|
||||||
|
{ from: 'agent_context_index', to: 'agents', type: 'fk' },
|
||||||
|
{ from: 'agent_context_index', to: 'context_blocks', type: 'fk' },
|
||||||
|
|
||||||
|
// Context relations
|
||||||
|
{ from: 'context_blocks', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'context_blocks', to: 'context_algorithms', type: 'fk' },
|
||||||
|
{ from: 'context_blocks_history', to: 'context_blocks', type: 'fk' },
|
||||||
|
{ from: 'ambient_context', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'ambient_context', to: 'sessions', type: 'ref' },
|
||||||
|
{ from: 's_contract_contexts', to: 's_contract_datasets', type: 'ref' },
|
||||||
|
{ from: 's_contract_contexts', to: 'agents', type: 'ref' },
|
||||||
|
|
||||||
|
// Core data relations
|
||||||
|
{ from: 'messages', to: 'sessions', type: 'fk' },
|
||||||
|
{ from: 'messages', to: 'hilos', type: 'fk' },
|
||||||
|
{ from: 'messages', to: 'agents', type: 'fk' },
|
||||||
|
{ from: 'hilos', to: 'sessions', type: 'fk' },
|
||||||
|
{ from: 'hilos', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'sessions', to: 'agents', type: 'fk' },
|
||||||
|
{ from: 'memory', to: 'agents', type: 'fk' },
|
||||||
|
{ from: 'memory', to: 'sessions', type: 'ref' },
|
||||||
|
{ from: 'knowledge_base', to: 'agents', type: 'ref' },
|
||||||
|
|
||||||
|
// Credentials relations (todas referenciadas por agents)
|
||||||
|
{ from: 'creds_architect', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'creds_corp', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'creds_deck', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'creds_hst', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'creds_locker', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'creds_runpod', to: 'agents', type: 'ref' },
|
||||||
|
|
||||||
|
// System relations
|
||||||
|
{ from: 'algorithm_metrics', to: 'algorithm_experiments', type: 'fk' },
|
||||||
|
{ from: 'algorithm_experiments', to: 'context_algorithms', type: 'ref' },
|
||||||
|
{ from: 'audit_violations', to: 'agents', type: 'fk' },
|
||||||
|
{ from: 'audit_violations', to: 'sessions', type: 'fk' },
|
||||||
|
{ from: 'immutable_log', to: 'agents', type: 'ref' },
|
||||||
|
{ from: 'immutable_log', to: 'sessions', type: 'ref' },
|
||||||
|
{ from: 'indices', to: 'knowledge_base', type: 'ref' },
|
||||||
|
{ from: 'app_config', to: 'agents', type: 'ref' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DB_SCHEMAS: Record<string, DBSchema> = {
|
||||||
|
hst: { tables: hstTables, relations: hstRelations },
|
||||||
|
deck: { tables: deckTables, relations: deckRelations },
|
||||||
|
gitea: { tables: giteaTables, relations: giteaRelations },
|
||||||
|
architect: { tables: architectTables, relations: architectRelations }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category colors
|
||||||
|
export const CATEGORY_COLORS: Record<TableSchema['category'], string> = {
|
||||||
|
core: '#7c8aff',
|
||||||
|
directus: '#9C27B0',
|
||||||
|
api: '#4CAF50',
|
||||||
|
graph: '#FF9800',
|
||||||
|
tree: '#00BCD4',
|
||||||
|
library: '#E91E63',
|
||||||
|
data: '#8BC34A',
|
||||||
|
system: '#607D8B',
|
||||||
|
repo: '#2196F3',
|
||||||
|
user: '#FF5722',
|
||||||
|
action: '#795548',
|
||||||
|
auth: '#009688',
|
||||||
|
agent: '#673AB7',
|
||||||
|
context: '#3F51B5',
|
||||||
|
creds: '#F44336',
|
||||||
|
comm: '#00ACC1',
|
||||||
|
storage: '#FF6F00'
|
||||||
|
};
|
||||||
148
src/main.ts
Normal file
148
src/main.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { store } from './state/index.ts';
|
||||||
|
import { fetchInfrastructure } from './api/index.ts';
|
||||||
|
import { DB_SCHEMAS } from './api/schemas.ts';
|
||||||
|
import { InfraGraph } from './views/InfraGraph.ts';
|
||||||
|
import { TablesGraph } from './views/TablesGraph.ts';
|
||||||
|
import './styles/main.css';
|
||||||
|
|
||||||
|
type ViewType = 'infra' | 'hst' | 'deck' | 'gitea' | 'architect';
|
||||||
|
|
||||||
|
class App {
|
||||||
|
private currentView: ViewType = 'infra';
|
||||||
|
private infraGraph: InfraGraph | null = null;
|
||||||
|
private tablesGraph: TablesGraph | null = null;
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const container = document.getElementById('app');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<span class="logo">ARCHITECT</span>
|
||||||
|
<button class="btn btn-sm" id="btn-refresh">Actualizar</button>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-center">
|
||||||
|
<div class="view-tabs">
|
||||||
|
<button class="view-tab active" data-view="infra">SYSTEM</button>
|
||||||
|
<button class="view-tab" data-view="architect">ARCHITECT</button>
|
||||||
|
<button class="view-tab" data-view="hst">HST</button>
|
||||||
|
<button class="view-tab" data-view="deck">DECK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="view-tabs">
|
||||||
|
<button class="view-tab" data-view="gitea">GITEA</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="main" id="graph-container">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Load infrastructure data
|
||||||
|
try {
|
||||||
|
const data = await fetchInfrastructure();
|
||||||
|
store.setState({
|
||||||
|
servers: data.servers,
|
||||||
|
services: data.services,
|
||||||
|
connections: data.connections,
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
store.setState({ error: 'Error cargando datos', loading: false });
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
this.renderView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderView(): Promise<void> {
|
||||||
|
const graphContainer = document.getElementById('graph-container');
|
||||||
|
if (!graphContainer) return;
|
||||||
|
|
||||||
|
// Cleanup previous
|
||||||
|
this.infraGraph?.unmount();
|
||||||
|
this.tablesGraph?.unmount();
|
||||||
|
this.infraGraph = null;
|
||||||
|
this.tablesGraph = null;
|
||||||
|
|
||||||
|
graphContainer.innerHTML = '<div class="loading">Cargando...</div>';
|
||||||
|
|
||||||
|
switch (this.currentView) {
|
||||||
|
case 'infra':
|
||||||
|
this.infraGraph = new InfraGraph(graphContainer, store);
|
||||||
|
await this.infraGraph.mount();
|
||||||
|
break;
|
||||||
|
case 'hst':
|
||||||
|
this.tablesGraph = new TablesGraph(graphContainer, 'hst', DB_SCHEMAS.hst);
|
||||||
|
await this.tablesGraph.mount();
|
||||||
|
break;
|
||||||
|
case 'deck':
|
||||||
|
this.tablesGraph = new TablesGraph(graphContainer, 'deck', DB_SCHEMAS.deck);
|
||||||
|
await this.tablesGraph.mount();
|
||||||
|
break;
|
||||||
|
case 'gitea':
|
||||||
|
this.tablesGraph = new TablesGraph(graphContainer, 'gitea', DB_SCHEMAS.gitea);
|
||||||
|
await this.tablesGraph.mount();
|
||||||
|
break;
|
||||||
|
case 'architect':
|
||||||
|
this.tablesGraph = new TablesGraph(graphContainer, 'architect', DB_SCHEMAS.architect);
|
||||||
|
await this.tablesGraph.mount();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindEvents(): void {
|
||||||
|
// View tabs
|
||||||
|
document.querySelectorAll('.view-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const view = (tab as HTMLElement).dataset.view as ViewType;
|
||||||
|
if (!view || view === this.currentView) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
|
||||||
|
this.currentView = view;
|
||||||
|
this.renderView();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
const refreshBtn = document.getElementById('btn-refresh');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.onclick = async () => {
|
||||||
|
refreshBtn.textContent = 'Cargando...';
|
||||||
|
refreshBtn.setAttribute('disabled', 'true');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.currentView === 'infra') {
|
||||||
|
const data = await fetchInfrastructure();
|
||||||
|
store.setState({
|
||||||
|
servers: data.servers,
|
||||||
|
services: data.services,
|
||||||
|
connections: data.connections
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.renderView();
|
||||||
|
} finally {
|
||||||
|
refreshBtn.textContent = 'Actualizar';
|
||||||
|
refreshBtn.removeAttribute('disabled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelector('#node-detail')?.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new App().start();
|
||||||
|
});
|
||||||
21
src/state/index.ts
Normal file
21
src/state/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createStore } from './store.ts';
|
||||||
|
import type { AppState } from '../types/index.ts';
|
||||||
|
|
||||||
|
const initialState: AppState = {
|
||||||
|
servers: [],
|
||||||
|
services: [],
|
||||||
|
connections: [],
|
||||||
|
selectedNode: null,
|
||||||
|
showServices: true,
|
||||||
|
showConnections: true,
|
||||||
|
filterStatus: 'all',
|
||||||
|
graphSettings: {
|
||||||
|
nodeSize: 40,
|
||||||
|
linkDist: 120,
|
||||||
|
showLabels: true
|
||||||
|
},
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const store = createStore(initialState);
|
||||||
27
src/state/store.ts
Normal file
27
src/state/store.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
type Listener<T> = (state: T, prevState: T) => void;
|
||||||
|
|
||||||
|
export interface Store<T extends object> {
|
||||||
|
getState: () => Readonly<T>;
|
||||||
|
setState: (partial: Partial<T>) => void;
|
||||||
|
subscribe: (listener: Listener<T>) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStore<T extends object>(initialState: T): Store<T> {
|
||||||
|
let state = { ...initialState };
|
||||||
|
const listeners = new Set<Listener<T>>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState: (): Readonly<T> => state,
|
||||||
|
|
||||||
|
setState: (partial: Partial<T>): void => {
|
||||||
|
const prevState = state;
|
||||||
|
state = { ...state, ...partial };
|
||||||
|
listeners.forEach(fn => fn(state, prevState));
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: (listener: Listener<T>): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
584
src/styles/main.css
Normal file
584
src/styles/main.css
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f0f0f;
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--bg-tertiary: #252525;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-secondary: #888;
|
||||||
|
--accent: #7c8aff;
|
||||||
|
--accent-hover: #9ca6ff;
|
||||||
|
--border: #333;
|
||||||
|
--success: #4CAF50;
|
||||||
|
--warning: #FFC107;
|
||||||
|
--error: #F44336;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View Tabs */
|
||||||
|
.view-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy header support */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
.main {
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graph sidebar */
|
||||||
|
.graph-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
width: 240px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
max-height: calc(100% - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-checkbox input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-select {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-select label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-select select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-slider {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-slider-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-slider-value {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-slider input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graph controls */
|
||||||
|
.graph-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graph legend */
|
||||||
|
.graph-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-ring {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-line {
|
||||||
|
width: 20px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-line.dashed {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
currentColor,
|
||||||
|
currentColor 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node detail panel */
|
||||||
|
.node-detail {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 300px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 200;
|
||||||
|
display: none;
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.healthy {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: rgba(139, 195, 74, 0.2);
|
||||||
|
color: #8BC34A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.warning {
|
||||||
|
background: rgba(255, 193, 7, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background: rgba(244, 67, 54, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.suspended {
|
||||||
|
background: rgba(158, 158, 158, 0.2);
|
||||||
|
color: #9E9E9E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row strong {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-services {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-service {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.healthy {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.running {
|
||||||
|
background: #8BC34A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.warning {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG styles */
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Columns table in detail panel */
|
||||||
|
.columns-table {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-row:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-row.primary {
|
||||||
|
background: rgba(124, 138, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-name {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-type {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-not-null {
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider detail panel for tables */
|
||||||
|
.node-detail {
|
||||||
|
max-height: calc(100vh - 150px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
101
src/types/index.ts
Normal file
101
src/types/index.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Tipos de nodos en el grafo
|
||||||
|
export type NodeType = 'server' | 'service' | 'database' | 'storage' | 'user' | 'endpoint';
|
||||||
|
|
||||||
|
// Estado de salud
|
||||||
|
export type HealthStatus = 'healthy' | 'running' | 'warning' | 'error' | 'suspended';
|
||||||
|
|
||||||
|
// Servidor
|
||||||
|
export interface Server {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
description: string;
|
||||||
|
status: HealthStatus;
|
||||||
|
suspended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servicio
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serverId: string;
|
||||||
|
type: NodeType;
|
||||||
|
status: HealthStatus;
|
||||||
|
uptime?: string;
|
||||||
|
port?: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conexión entre nodos
|
||||||
|
export interface Connection {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: 'ssh' | 'api' | 'sync' | 'depends' | 'http';
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodo del grafo
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: NodeType;
|
||||||
|
status: HealthStatus;
|
||||||
|
parent?: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
fx?: number | null;
|
||||||
|
fy?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge del grafo
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: string | GraphNode;
|
||||||
|
target: string | GraphNode;
|
||||||
|
type: Connection['type'];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado de la aplicación
|
||||||
|
export interface AppState {
|
||||||
|
servers: Server[];
|
||||||
|
services: Service[];
|
||||||
|
connections: Connection[];
|
||||||
|
selectedNode: string | null;
|
||||||
|
showServices: boolean;
|
||||||
|
showConnections: boolean;
|
||||||
|
filterStatus: HealthStatus | 'all';
|
||||||
|
graphSettings: {
|
||||||
|
nodeSize: number;
|
||||||
|
linkDist: number;
|
||||||
|
showLabels: boolean;
|
||||||
|
};
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuración de colores
|
||||||
|
export const NODE_COLORS: Record<NodeType, string> = {
|
||||||
|
server: '#7c8aff',
|
||||||
|
service: '#4CAF50',
|
||||||
|
database: '#FF9800',
|
||||||
|
storage: '#00BCD4',
|
||||||
|
user: '#E91E63',
|
||||||
|
endpoint: '#9C27B0'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<HealthStatus, string> = {
|
||||||
|
healthy: '#4CAF50',
|
||||||
|
running: '#8BC34A',
|
||||||
|
warning: '#FFC107',
|
||||||
|
error: '#F44336',
|
||||||
|
suspended: '#9E9E9E'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONNECTION_COLORS: Record<Connection['type'], string> = {
|
||||||
|
ssh: '#2196F3',
|
||||||
|
api: '#9C27B0',
|
||||||
|
sync: '#00BCD4',
|
||||||
|
depends: '#607D8B',
|
||||||
|
http: '#FF5722'
|
||||||
|
};
|
||||||
638
src/views/InfraGraph.ts
Normal file
638
src/views/InfraGraph.ts
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
import type { Store } from '../state/store.ts';
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
GraphNode,
|
||||||
|
GraphEdge,
|
||||||
|
NodeType,
|
||||||
|
HealthStatus
|
||||||
|
} from '../types/index.ts';
|
||||||
|
import { NODE_COLORS, STATUS_COLORS, CONNECTION_COLORS } from '../types/index.ts';
|
||||||
|
|
||||||
|
type D3Module = typeof import('d3');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type D3Selection = any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type D3Simulation = any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type D3Zoom = any;
|
||||||
|
|
||||||
|
export class InfraGraph {
|
||||||
|
private container: HTMLElement;
|
||||||
|
private store: Store<AppState>;
|
||||||
|
private d3: D3Module | null = null;
|
||||||
|
private simulation: D3Simulation | null = null;
|
||||||
|
private zoom: D3Zoom | null = null;
|
||||||
|
private svg: D3Selection | null = null;
|
||||||
|
private g: D3Selection | null = null;
|
||||||
|
|
||||||
|
constructor(container: HTMLElement, store: Store<AppState>) {
|
||||||
|
this.container = container;
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mount(): Promise<void> {
|
||||||
|
this.container.innerHTML = '<div class="loading">Cargando infraestructura...</div>';
|
||||||
|
|
||||||
|
if (!this.d3) {
|
||||||
|
this.d3 = await import('d3');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
if (!this.d3) return;
|
||||||
|
const d3 = this.d3;
|
||||||
|
const state = this.store.getState();
|
||||||
|
|
||||||
|
// Build nodes
|
||||||
|
const nodes: GraphNode[] = [];
|
||||||
|
const nodeMap = new Map<string, GraphNode>();
|
||||||
|
|
||||||
|
// Add servers as main nodes
|
||||||
|
state.servers.forEach(server => {
|
||||||
|
if (state.filterStatus !== 'all' && server.status !== state.filterStatus) return;
|
||||||
|
|
||||||
|
// Determine node type (user is special)
|
||||||
|
const nodeType: NodeType = server.id === 'user' ? 'user' : 'server';
|
||||||
|
|
||||||
|
const node: GraphNode = {
|
||||||
|
id: server.id,
|
||||||
|
label: server.name,
|
||||||
|
type: nodeType,
|
||||||
|
status: server.status,
|
||||||
|
metadata: { ip: server.ip, desc: server.description }
|
||||||
|
};
|
||||||
|
nodes.push(node);
|
||||||
|
nodeMap.set(server.id, node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add services if enabled
|
||||||
|
if (state.showServices) {
|
||||||
|
state.services.forEach(service => {
|
||||||
|
if (!nodeMap.has(service.serverId)) return;
|
||||||
|
if (state.filterStatus !== 'all' && service.status !== state.filterStatus) return;
|
||||||
|
|
||||||
|
const node: GraphNode = {
|
||||||
|
id: service.id,
|
||||||
|
label: service.name,
|
||||||
|
type: service.type,
|
||||||
|
status: service.status,
|
||||||
|
parent: service.serverId,
|
||||||
|
metadata: service.uptime ? { uptime: service.uptime } : undefined
|
||||||
|
};
|
||||||
|
nodes.push(node);
|
||||||
|
nodeMap.set(service.id, node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edges
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
|
||||||
|
// Server-to-service edges
|
||||||
|
if (state.showServices) {
|
||||||
|
state.services.forEach(service => {
|
||||||
|
if (nodeMap.has(service.id) && nodeMap.has(service.serverId)) {
|
||||||
|
edges.push({
|
||||||
|
source: service.serverId,
|
||||||
|
target: service.id,
|
||||||
|
type: 'depends'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection edges
|
||||||
|
if (state.showConnections) {
|
||||||
|
state.connections.forEach(conn => {
|
||||||
|
if (nodeMap.has(conn.source) && nodeMap.has(conn.target)) {
|
||||||
|
edges.push({
|
||||||
|
source: conn.source,
|
||||||
|
target: conn.target,
|
||||||
|
type: conn.type,
|
||||||
|
label: conn.label
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
this.container.innerHTML = '<div class="empty">Sin nodos para mostrar</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container structure
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="graph-sidebar" id="graph-sidebar"></div>
|
||||||
|
<div class="graph-controls">
|
||||||
|
<button class="btn btn-sm" id="graph-fit">Ajustar</button>
|
||||||
|
<button class="btn btn-sm" id="graph-zin">+</button>
|
||||||
|
<button class="btn btn-sm" id="graph-zout">-</button>
|
||||||
|
</div>
|
||||||
|
<div class="graph-legend" id="graph-legend"></div>
|
||||||
|
<div class="node-detail" id="node-detail"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.renderSidebar(nodes.length, edges.length);
|
||||||
|
this.renderLegend();
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
const width = this.container.clientWidth;
|
||||||
|
const height = this.container.clientHeight || 700;
|
||||||
|
|
||||||
|
this.svg = d3.select(this.container)
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', '100%')
|
||||||
|
.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
// Defs for markers (arrows)
|
||||||
|
const defs = this.svg.append('defs');
|
||||||
|
['ssh', 'api', 'sync', 'depends', 'http'].forEach(type => {
|
||||||
|
defs.append('marker')
|
||||||
|
.attr('id', `arrow-${type}`)
|
||||||
|
.attr('viewBox', '0 -5 10 10')
|
||||||
|
.attr('refX', 20)
|
||||||
|
.attr('refY', 0)
|
||||||
|
.attr('markerWidth', 6)
|
||||||
|
.attr('markerHeight', 6)
|
||||||
|
.attr('orient', 'auto')
|
||||||
|
.append('path')
|
||||||
|
.attr('d', 'M0,-5L10,0L0,5')
|
||||||
|
.attr('fill', CONNECTION_COLORS[type as keyof typeof CONNECTION_COLORS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.g = this.svg.append('g');
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
this.zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.2, 3])
|
||||||
|
.on('zoom', (event: { transform: string }) => {
|
||||||
|
this.g.attr('transform', event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.svg.call(this.zoom);
|
||||||
|
this.bindZoomControls();
|
||||||
|
|
||||||
|
// Force simulation
|
||||||
|
const { nodeSize, linkDist } = state.graphSettings;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.simulation = d3.forceSimulation(nodes as any)
|
||||||
|
.force('link', d3.forceLink(edges)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.id((d: any) => d.id)
|
||||||
|
.distance((d: GraphEdge) => {
|
||||||
|
// Shorter distance for server-service links
|
||||||
|
const source = d.source as GraphNode;
|
||||||
|
const target = d.target as GraphNode;
|
||||||
|
if (source.type === 'server' || target.type === 'server') {
|
||||||
|
return linkDist * 0.6;
|
||||||
|
}
|
||||||
|
return linkDist;
|
||||||
|
}))
|
||||||
|
.force('charge', d3.forceManyBody().strength(-300))
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius((d: GraphNode) =>
|
||||||
|
d.type === 'server' || d.type === 'user' ? nodeSize * 1.5 : nodeSize + 5
|
||||||
|
))
|
||||||
|
.force('x', d3.forceX(width / 2).strength(0.05))
|
||||||
|
.force('y', d3.forceY(height / 2).strength(0.05));
|
||||||
|
|
||||||
|
// Links
|
||||||
|
const link = this.g.append('g')
|
||||||
|
.attr('class', 'links')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(edges)
|
||||||
|
.join('line')
|
||||||
|
.attr('stroke', (d: GraphEdge) => CONNECTION_COLORS[d.type] || '#999')
|
||||||
|
.attr('stroke-width', (d: GraphEdge) => d.type === 'ssh' ? 2.5 : 1.5)
|
||||||
|
.attr('stroke-opacity', 0.7)
|
||||||
|
.attr('stroke-dasharray', (d: GraphEdge) => d.type === 'sync' ? '5,5' : null)
|
||||||
|
.attr('marker-end', (d: GraphEdge) => d.type !== 'depends' ? `url(#arrow-${d.type})` : null);
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
const node = this.g.append('g')
|
||||||
|
.attr('class', 'nodes')
|
||||||
|
.selectAll('g')
|
||||||
|
.data(nodes)
|
||||||
|
.join('g')
|
||||||
|
.attr('cursor', 'pointer')
|
||||||
|
.call(this.createDrag(d3, this.simulation));
|
||||||
|
|
||||||
|
// Node circles with status ring
|
||||||
|
node.append('circle')
|
||||||
|
.attr('r', (d: GraphNode) => {
|
||||||
|
if (d.type === 'server' || d.type === 'user') return nodeSize * 1.2;
|
||||||
|
if (d.type === 'endpoint') return nodeSize * 0.6;
|
||||||
|
return nodeSize * 0.7;
|
||||||
|
})
|
||||||
|
.attr('fill', (d: GraphNode) => NODE_COLORS[d.type])
|
||||||
|
.attr('stroke', (d: GraphNode) => STATUS_COLORS[d.status])
|
||||||
|
.attr('stroke-width', 3)
|
||||||
|
.attr('opacity', (d: GraphNode) => d.status === 'suspended' ? 0.4 : 1);
|
||||||
|
|
||||||
|
// Node icons/text
|
||||||
|
node.append('text')
|
||||||
|
.attr('class', 'node-icon')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', '0.35em')
|
||||||
|
.attr('font-size', (d: GraphNode) => {
|
||||||
|
if (d.type === 'server' || d.type === 'user') return 16;
|
||||||
|
if (d.type === 'endpoint') return 10;
|
||||||
|
return 12;
|
||||||
|
})
|
||||||
|
.attr('fill', '#fff')
|
||||||
|
.text((d: GraphNode) => this.getNodeIcon(d.type));
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
if (state.graphSettings.showLabels) {
|
||||||
|
node.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', (d: GraphNode) => {
|
||||||
|
if (d.type === 'server' || d.type === 'user') return nodeSize * 1.5 + 10;
|
||||||
|
if (d.type === 'endpoint') return nodeSize * 0.8 + 5;
|
||||||
|
return nodeSize + 5;
|
||||||
|
})
|
||||||
|
.attr('font-size', (d: GraphNode) => {
|
||||||
|
if (d.type === 'server' || d.type === 'user') return 12;
|
||||||
|
if (d.type === 'endpoint') return 8;
|
||||||
|
return 10;
|
||||||
|
})
|
||||||
|
.attr('fill', 'var(--text)')
|
||||||
|
.attr('font-weight', (d: GraphNode) => d.type === 'server' || d.type === 'user' ? 'bold' : 'normal')
|
||||||
|
.text((d: GraphNode) => d.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
node.on('click', (_: MouseEvent, d: GraphNode) => {
|
||||||
|
this.store.setState({ selectedNode: d.id });
|
||||||
|
this.showNodeDetail(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover effects
|
||||||
|
node.on('mouseenter', function() {
|
||||||
|
d3.select(this).select('circle')
|
||||||
|
.transition().duration(150)
|
||||||
|
.attr('transform', 'scale(1.1)');
|
||||||
|
});
|
||||||
|
node.on('mouseleave', function() {
|
||||||
|
d3.select(this).select('circle')
|
||||||
|
.transition().duration(150)
|
||||||
|
.attr('transform', 'scale(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tick
|
||||||
|
this.simulation.on('tick', () => {
|
||||||
|
link
|
||||||
|
.attr('x1', (d: GraphEdge) => (d.source as GraphNode).x!)
|
||||||
|
.attr('y1', (d: GraphEdge) => (d.source as GraphNode).y!)
|
||||||
|
.attr('x2', (d: GraphEdge) => (d.target as GraphNode).x!)
|
||||||
|
.attr('y2', (d: GraphEdge) => (d.target as GraphNode).y!);
|
||||||
|
|
||||||
|
node.attr('transform', (d: GraphNode) => `translate(${d.x},${d.y})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNodeIcon(type: NodeType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'server': return '\u2b22'; // Hexagon
|
||||||
|
case 'database': return '\u25a0'; // Square
|
||||||
|
case 'storage': return '\u25c6'; // Diamond
|
||||||
|
case 'service': return '\u25cf'; // Circle
|
||||||
|
case 'user': return '\u263a'; // Smiley
|
||||||
|
case 'endpoint': return '\u25c9'; // Fisheye
|
||||||
|
default: return '\u25cf';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSidebar(nodeCount: number, edgeCount: number): void {
|
||||||
|
const sidebar = this.container.querySelector('#graph-sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const state = this.store.getState();
|
||||||
|
const { graphSettings, showServices, showConnections, filterStatus } = state;
|
||||||
|
|
||||||
|
const serverCount = state.servers.filter(s => !s.suspended).length;
|
||||||
|
const serviceCount = state.services.length;
|
||||||
|
|
||||||
|
sidebar.innerHTML = `
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>TZZR Infrastructure</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-stat">
|
||||||
|
<span>Servidores</span>
|
||||||
|
<span class="graph-stat-value">${serverCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-stat">
|
||||||
|
<span>Servicios</span>
|
||||||
|
<span class="graph-stat-value">${serviceCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-stat">
|
||||||
|
<span>Nodos visibles</span>
|
||||||
|
<span class="graph-stat-value">${nodeCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-stat">
|
||||||
|
<span>Conexiones</span>
|
||||||
|
<span class="graph-stat-value">${edgeCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-section-title">Filtros</div>
|
||||||
|
<label class="graph-checkbox">
|
||||||
|
<input type="checkbox" id="show-services" ${showServices ? 'checked' : ''}>
|
||||||
|
Mostrar servicios
|
||||||
|
</label>
|
||||||
|
<label class="graph-checkbox">
|
||||||
|
<input type="checkbox" id="show-connections" ${showConnections ? 'checked' : ''}>
|
||||||
|
Mostrar conexiones
|
||||||
|
</label>
|
||||||
|
<div class="graph-select">
|
||||||
|
<label>Estado:</label>
|
||||||
|
<select id="filter-status">
|
||||||
|
<option value="all" ${filterStatus === 'all' ? 'selected' : ''}>Todos</option>
|
||||||
|
<option value="healthy" ${filterStatus === 'healthy' ? 'selected' : ''}>Healthy</option>
|
||||||
|
<option value="running" ${filterStatus === 'running' ? 'selected' : ''}>Running</option>
|
||||||
|
<option value="warning" ${filterStatus === 'warning' ? 'selected' : ''}>Warning</option>
|
||||||
|
<option value="error" ${filterStatus === 'error' ? 'selected' : ''}>Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-section-title">Visualizacion</div>
|
||||||
|
<label class="graph-checkbox">
|
||||||
|
<input type="checkbox" id="show-labels" ${graphSettings.showLabels ? 'checked' : ''}>
|
||||||
|
Etiquetas
|
||||||
|
</label>
|
||||||
|
<div class="graph-slider">
|
||||||
|
<div class="graph-slider-label">
|
||||||
|
<span>Nodo</span>
|
||||||
|
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="graph-node-size" min="20" max="60" value="${graphSettings.nodeSize}">
|
||||||
|
</div>
|
||||||
|
<div class="graph-slider">
|
||||||
|
<div class="graph-slider-label">
|
||||||
|
<span>Distancia</span>
|
||||||
|
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="graph-link-dist" min="60" max="250" value="${graphSettings.linkDist}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindSidebarEvents(sidebar as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindSidebarEvents(sidebar: HTMLElement): void {
|
||||||
|
// Show services
|
||||||
|
const showSvc = sidebar.querySelector<HTMLInputElement>('#show-services');
|
||||||
|
if (showSvc) {
|
||||||
|
showSvc.onchange = () => {
|
||||||
|
this.store.setState({ showServices: showSvc.checked });
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connections
|
||||||
|
const showConn = sidebar.querySelector<HTMLInputElement>('#show-connections');
|
||||||
|
if (showConn) {
|
||||||
|
showConn.onchange = () => {
|
||||||
|
this.store.setState({ showConnections: showConn.checked });
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter status
|
||||||
|
const filterSel = sidebar.querySelector<HTMLSelectElement>('#filter-status');
|
||||||
|
if (filterSel) {
|
||||||
|
filterSel.onchange = () => {
|
||||||
|
this.store.setState({ filterStatus: filterSel.value as HealthStatus | 'all' });
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show labels
|
||||||
|
const showLbl = sidebar.querySelector<HTMLInputElement>('#show-labels');
|
||||||
|
if (showLbl) {
|
||||||
|
showLbl.onchange = () => {
|
||||||
|
const state = this.store.getState();
|
||||||
|
this.store.setState({
|
||||||
|
graphSettings: { ...state.graphSettings, showLabels: showLbl.checked }
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node size
|
||||||
|
const nodeSize = sidebar.querySelector<HTMLInputElement>('#graph-node-size');
|
||||||
|
const nodeSizeVal = sidebar.querySelector('#node-size-val');
|
||||||
|
if (nodeSize) {
|
||||||
|
nodeSize.oninput = () => {
|
||||||
|
const size = parseInt(nodeSize.value, 10);
|
||||||
|
if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`;
|
||||||
|
const state = this.store.getState();
|
||||||
|
this.store.setState({
|
||||||
|
graphSettings: { ...state.graphSettings, nodeSize: size }
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link distance
|
||||||
|
const linkDist = sidebar.querySelector<HTMLInputElement>('#graph-link-dist');
|
||||||
|
const linkDistVal = sidebar.querySelector('#link-dist-val');
|
||||||
|
if (linkDist) {
|
||||||
|
linkDist.oninput = () => {
|
||||||
|
const dist = parseInt(linkDist.value, 10);
|
||||||
|
if (linkDistVal) linkDistVal.textContent = `${dist}px`;
|
||||||
|
const state = this.store.getState();
|
||||||
|
this.store.setState({
|
||||||
|
graphSettings: { ...state.graphSettings, linkDist: dist }
|
||||||
|
});
|
||||||
|
if (this.simulation) {
|
||||||
|
this.simulation.force('link').distance(dist);
|
||||||
|
this.simulation.alpha(0.3).restart();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLegend(): void {
|
||||||
|
const legend = this.container.querySelector('#graph-legend');
|
||||||
|
if (!legend) return;
|
||||||
|
|
||||||
|
legend.innerHTML = `
|
||||||
|
<div class="legend-section">
|
||||||
|
<span class="legend-title">Tipo:</span>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${NODE_COLORS.user}"></span>
|
||||||
|
<span>User</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${NODE_COLORS.endpoint}"></span>
|
||||||
|
<span>Endpoint</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${NODE_COLORS.server}"></span>
|
||||||
|
<span>Server</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${NODE_COLORS.service}"></span>
|
||||||
|
<span>Service</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${NODE_COLORS.database}"></span>
|
||||||
|
<span>DB</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${NODE_COLORS.storage}"></span>
|
||||||
|
<span>Storage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend-section">
|
||||||
|
<span class="legend-title">Conexion:</span>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line" style="background: ${CONNECTION_COLORS.http}"></span>
|
||||||
|
<span>HTTP</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line" style="background: ${CONNECTION_COLORS.ssh}"></span>
|
||||||
|
<span>SSH</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line" style="background: ${CONNECTION_COLORS.api}"></span>
|
||||||
|
<span>API</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line dashed" style="background: ${CONNECTION_COLORS.sync}"></span>
|
||||||
|
<span>Sync</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private showNodeDetail(node: GraphNode): void {
|
||||||
|
const panel = this.container.querySelector('#node-detail');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const state = this.store.getState();
|
||||||
|
let details = '';
|
||||||
|
|
||||||
|
if (node.type === 'server') {
|
||||||
|
const server = state.servers.find(s => s.id === node.id);
|
||||||
|
if (server) {
|
||||||
|
const services = state.services.filter(s => s.serverId === server.id);
|
||||||
|
details = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<h4>${server.name}</h4>
|
||||||
|
<span class="status-badge ${server.status}">${server.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row"><strong>IP:</strong> ${server.ip}</div>
|
||||||
|
<div class="detail-row"><strong>Descripcion:</strong> ${server.description}</div>
|
||||||
|
<div class="detail-row"><strong>Servicios:</strong> ${services.length}</div>
|
||||||
|
<div class="detail-services">
|
||||||
|
${services.map(s => `
|
||||||
|
<div class="detail-service">
|
||||||
|
<span class="status-dot ${s.status}"></span>
|
||||||
|
${s.name}
|
||||||
|
${s.uptime ? `<span class="uptime">${s.uptime}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const service = state.services.find(s => s.id === node.id);
|
||||||
|
if (service) {
|
||||||
|
const server = state.servers.find(s => s.id === service.serverId);
|
||||||
|
details = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<h4>${service.name}</h4>
|
||||||
|
<span class="status-badge ${service.status}">${service.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row"><strong>Servidor:</strong> ${server?.name || service.serverId}</div>
|
||||||
|
<div class="detail-row"><strong>Tipo:</strong> ${service.type}</div>
|
||||||
|
${service.uptime ? `<div class="detail-row"><strong>Uptime:</strong> ${service.uptime}</div>` : ''}
|
||||||
|
${service.url ? `<div class="detail-row"><strong>URL:</strong> <a href="${service.url}" target="_blank">${service.url}</a></div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML = details;
|
||||||
|
panel.classList.add('open');
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
const closeHandler = (e: MouseEvent) => {
|
||||||
|
if (!panel.contains(e.target as Node) && !(e.target as HTMLElement).closest('.nodes')) {
|
||||||
|
panel.classList.remove('open');
|
||||||
|
document.removeEventListener('click', closeHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closeHandler), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindZoomControls(): void {
|
||||||
|
if (!this.d3 || !this.svg || !this.zoom) return;
|
||||||
|
const d3 = this.d3;
|
||||||
|
|
||||||
|
const fitBtn = this.container.querySelector<HTMLButtonElement>('#graph-fit');
|
||||||
|
if (fitBtn) {
|
||||||
|
fitBtn.onclick = () => {
|
||||||
|
this.svg.transition().duration(300).call(
|
||||||
|
this.zoom.transform,
|
||||||
|
d3.zoomIdentity.translate(0, 0).scale(1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const zinBtn = this.container.querySelector<HTMLButtonElement>('#graph-zin');
|
||||||
|
if (zinBtn) {
|
||||||
|
zinBtn.onclick = () => {
|
||||||
|
this.svg.transition().duration(200).call(this.zoom.scaleBy, 1.5);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoutBtn = this.container.querySelector<HTMLButtonElement>('#graph-zout');
|
||||||
|
if (zoutBtn) {
|
||||||
|
zoutBtn.onclick = () => {
|
||||||
|
this.svg.transition().duration(200).call(this.zoom.scaleBy, 0.67);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private createDrag(d3: D3Module, simulation: D3Simulation): any {
|
||||||
|
return d3.drag()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.on('start', (event: any, d: any) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.on('drag', (event: any, d: any) => {
|
||||||
|
d.fx = event.x;
|
||||||
|
d.fy = event.y;
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.on('end', (event: any, d: any) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
d.fx = null;
|
||||||
|
d.fy = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount(): void {
|
||||||
|
this.simulation?.stop();
|
||||||
|
this.svg = null;
|
||||||
|
this.g = null;
|
||||||
|
this.zoom = null;
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
539
src/views/TablesGraph.ts
Normal file
539
src/views/TablesGraph.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import type { DBSchema, TableSchema, TableRelation } from '../api/schemas.ts';
|
||||||
|
import { CATEGORY_COLORS, DB_SCHEMAS } from '../api/schemas.ts';
|
||||||
|
import { fetchColumns, fetchTables, fetchTableRelations, type Column } from '../api/infrastructure.ts';
|
||||||
|
|
||||||
|
type D3Module = typeof import('d3');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type D3Selection = any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type D3Simulation = any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type D3Zoom = any;
|
||||||
|
|
||||||
|
interface TableNode extends TableSchema {
|
||||||
|
id: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
fx?: number | null;
|
||||||
|
fy?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableEdge {
|
||||||
|
source: string | TableNode;
|
||||||
|
target: string | TableNode;
|
||||||
|
type: TableRelation['type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TablesGraph {
|
||||||
|
private container: HTMLElement;
|
||||||
|
private serverName: string;
|
||||||
|
private schema: DBSchema;
|
||||||
|
private d3: D3Module | null = null;
|
||||||
|
private simulation: D3Simulation | null = null;
|
||||||
|
private zoom: D3Zoom | null = null;
|
||||||
|
private svg: D3Selection | null = null;
|
||||||
|
private g: D3Selection | null = null;
|
||||||
|
private showCategories: Set<TableSchema['category']>;
|
||||||
|
private showRelations = true;
|
||||||
|
private nodeSize = 35;
|
||||||
|
private linkDist = 100;
|
||||||
|
|
||||||
|
constructor(container: HTMLElement, serverName: string, schema: DBSchema) {
|
||||||
|
this.container = container;
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.schema = schema;
|
||||||
|
this.showCategories = new Set(['core', 'api', 'graph', 'tree', 'library', 'data', 'system', 'directus', 'comm', 'repo', 'user', 'action', 'auth', 'agent', 'context', 'creds']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mount(): Promise<void> {
|
||||||
|
this.container.innerHTML = '<div class="loading">Cargando esquema...</div>';
|
||||||
|
|
||||||
|
if (!this.d3) {
|
||||||
|
this.d3 = await import('d3');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load tables from API, fallback to static data
|
||||||
|
try {
|
||||||
|
const [apiTables, apiRelations] = await Promise.all([
|
||||||
|
fetchTables(this.serverName),
|
||||||
|
fetchTableRelations(this.serverName)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (apiTables.length > 0) {
|
||||||
|
this.schema = {
|
||||||
|
tables: apiTables.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
category: t.category as TableSchema['category'],
|
||||||
|
database: t.database
|
||||||
|
})),
|
||||||
|
relations: apiRelations.map(r => ({
|
||||||
|
from: r.from,
|
||||||
|
to: r.to,
|
||||||
|
type: r.type
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Using static schema data:', e);
|
||||||
|
// Keep using the static schema passed in constructor
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
if (!this.d3) return;
|
||||||
|
const d3 = this.d3;
|
||||||
|
|
||||||
|
// Filter tables by category
|
||||||
|
const tables = this.schema.tables.filter(t => this.showCategories.has(t.category));
|
||||||
|
const tableNames = new Set(tables.map(t => t.name));
|
||||||
|
|
||||||
|
// Build nodes
|
||||||
|
const nodes: TableNode[] = tables.map(t => ({
|
||||||
|
...t,
|
||||||
|
id: t.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Build edges (only between visible tables, if relations are enabled)
|
||||||
|
const edges: TableEdge[] = this.showRelations
|
||||||
|
? this.schema.relations
|
||||||
|
.filter(r => tableNames.has(r.from) && tableNames.has(r.to))
|
||||||
|
.map(r => ({
|
||||||
|
source: r.from,
|
||||||
|
target: r.to,
|
||||||
|
type: r.type
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
this.container.innerHTML = '<div class="empty">Sin tablas para mostrar</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container structure
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="graph-sidebar" id="graph-sidebar"></div>
|
||||||
|
<div class="graph-controls">
|
||||||
|
<button class="btn btn-sm" id="graph-fit">Ajustar</button>
|
||||||
|
<button class="btn btn-sm" id="graph-zin">+</button>
|
||||||
|
<button class="btn btn-sm" id="graph-zout">-</button>
|
||||||
|
</div>
|
||||||
|
<div class="graph-legend" id="graph-legend"></div>
|
||||||
|
<div class="node-detail" id="node-detail"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.renderSidebar(nodes.length, edges.length);
|
||||||
|
this.renderLegend();
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
const width = this.container.clientWidth;
|
||||||
|
const height = this.container.clientHeight || 700;
|
||||||
|
|
||||||
|
this.svg = d3.select(this.container)
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', '100%')
|
||||||
|
.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
// Defs for markers
|
||||||
|
const defs = this.svg.append('defs');
|
||||||
|
['fk', 'ref', 'logical'].forEach(type => {
|
||||||
|
const colors: Record<string, string> = { fk: '#2196F3', ref: '#FF9800', logical: '#9E9E9E' };
|
||||||
|
defs.append('marker')
|
||||||
|
.attr('id', `arrow-${type}`)
|
||||||
|
.attr('viewBox', '0 -5 10 10')
|
||||||
|
.attr('refX', 20)
|
||||||
|
.attr('refY', 0)
|
||||||
|
.attr('markerWidth', 5)
|
||||||
|
.attr('markerHeight', 5)
|
||||||
|
.attr('orient', 'auto')
|
||||||
|
.append('path')
|
||||||
|
.attr('d', 'M0,-5L10,0L0,5')
|
||||||
|
.attr('fill', colors[type]);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.g = this.svg.append('g');
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
this.zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.2, 3])
|
||||||
|
.on('zoom', (event: { transform: string }) => {
|
||||||
|
this.g.attr('transform', event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.svg.call(this.zoom);
|
||||||
|
this.bindZoomControls();
|
||||||
|
|
||||||
|
// Force simulation
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.simulation = d3.forceSimulation(nodes as any)
|
||||||
|
.force('link', d3.forceLink(edges)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.id((d: any) => d.id)
|
||||||
|
.distance(this.linkDist))
|
||||||
|
.force('charge', d3.forceManyBody().strength(-200))
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius(this.nodeSize + 10))
|
||||||
|
.force('x', d3.forceX(width / 2).strength(0.03))
|
||||||
|
.force('y', d3.forceY(height / 2).strength(0.03));
|
||||||
|
|
||||||
|
// Links
|
||||||
|
const link = this.g.append('g')
|
||||||
|
.attr('class', 'links')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(edges)
|
||||||
|
.join('line')
|
||||||
|
.attr('stroke', (d: TableEdge) => {
|
||||||
|
const colors: Record<string, string> = { fk: '#2196F3', ref: '#FF9800', logical: '#9E9E9E' };
|
||||||
|
return colors[d.type] || '#999';
|
||||||
|
})
|
||||||
|
.attr('stroke-width', (d: TableEdge) => d.type === 'fk' ? 2 : 1.5)
|
||||||
|
.attr('stroke-opacity', 0.6)
|
||||||
|
.attr('stroke-dasharray', (d: TableEdge) => d.type === 'logical' ? '4,4' : null)
|
||||||
|
.attr('marker-end', (d: TableEdge) => `url(#arrow-${d.type})`);
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
const node = this.g.append('g')
|
||||||
|
.attr('class', 'nodes')
|
||||||
|
.selectAll('g')
|
||||||
|
.data(nodes)
|
||||||
|
.join('g')
|
||||||
|
.attr('cursor', 'pointer')
|
||||||
|
.call(this.createDrag(d3, this.simulation));
|
||||||
|
|
||||||
|
// Node rectangles (tables look better as rectangles)
|
||||||
|
node.append('rect')
|
||||||
|
.attr('width', this.nodeSize * 2)
|
||||||
|
.attr('height', this.nodeSize)
|
||||||
|
.attr('x', -this.nodeSize)
|
||||||
|
.attr('y', -this.nodeSize / 2)
|
||||||
|
.attr('rx', 4)
|
||||||
|
.attr('fill', (d: TableNode) => CATEGORY_COLORS[d.category])
|
||||||
|
.attr('stroke', '#fff')
|
||||||
|
.attr('stroke-width', 1.5);
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
node.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', '0.35em')
|
||||||
|
.attr('font-size', 9)
|
||||||
|
.attr('fill', '#fff')
|
||||||
|
.attr('font-weight', 500)
|
||||||
|
.text((d: TableNode) => d.name.length > 12 ? d.name.slice(0, 11) + '...' : d.name);
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
node.on('click', (_: MouseEvent, d: TableNode) => {
|
||||||
|
this.showNodeDetail(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover effects
|
||||||
|
node.on('mouseenter', function() {
|
||||||
|
d3.select(this).select('rect')
|
||||||
|
.transition().duration(150)
|
||||||
|
.attr('stroke-width', 3);
|
||||||
|
});
|
||||||
|
node.on('mouseleave', function() {
|
||||||
|
d3.select(this).select('rect')
|
||||||
|
.transition().duration(150)
|
||||||
|
.attr('stroke-width', 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tick
|
||||||
|
this.simulation.on('tick', () => {
|
||||||
|
link
|
||||||
|
.attr('x1', (d: TableEdge) => (d.source as TableNode).x!)
|
||||||
|
.attr('y1', (d: TableEdge) => (d.source as TableNode).y!)
|
||||||
|
.attr('x2', (d: TableEdge) => (d.target as TableNode).x!)
|
||||||
|
.attr('y2', (d: TableEdge) => (d.target as TableNode).y!);
|
||||||
|
|
||||||
|
node.attr('transform', (d: TableNode) => `translate(${d.x},${d.y})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSidebar(nodeCount: number, edgeCount: number): void {
|
||||||
|
const sidebar = this.container.querySelector('#graph-sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const categories: TableSchema['category'][] = ['core', 'api', 'graph', 'tree', 'library', 'data', 'directus', 'system', 'comm', 'repo', 'user', 'action', 'auth', 'agent', 'context', 'creds'];
|
||||||
|
|
||||||
|
sidebar.innerHTML = `
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>${this.serverName.toUpperCase()} Tables</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-stat">
|
||||||
|
<span>Tablas</span>
|
||||||
|
<span class="graph-stat-value">${nodeCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-stat">
|
||||||
|
<span>Relaciones</span>
|
||||||
|
<span class="graph-stat-value">${edgeCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-section-title">Categorias</div>
|
||||||
|
${categories.map(cat => {
|
||||||
|
const count = this.schema.tables.filter(t => t.category === cat).length;
|
||||||
|
if (count === 0) return '';
|
||||||
|
return `
|
||||||
|
<label class="graph-checkbox">
|
||||||
|
<input type="checkbox" data-cat="${cat}" ${this.showCategories.has(cat) ? 'checked' : ''}>
|
||||||
|
<span class="color-dot" style="background: ${CATEGORY_COLORS[cat]}"></span>
|
||||||
|
${cat} (${count})
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-section-title">Relaciones</div>
|
||||||
|
<label class="graph-checkbox">
|
||||||
|
<input type="checkbox" id="toggle-relations" ${this.showRelations ? 'checked' : ''}>
|
||||||
|
<span>Mostrar relaciones</span>
|
||||||
|
</label>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line" style="background: #2196F3"></span>
|
||||||
|
<span>FK (Foreign Key)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line" style="background: #FF9800"></span>
|
||||||
|
<span>Ref (Reference)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-line dashed" style="background: #9E9E9E"></span>
|
||||||
|
<span>Logical</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="graph-section">
|
||||||
|
<div class="graph-section-title">Visualizacion</div>
|
||||||
|
<div class="graph-slider">
|
||||||
|
<div class="graph-slider-label">
|
||||||
|
<span>Nodo</span>
|
||||||
|
<span class="graph-slider-value" id="node-size-val">${this.nodeSize}px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="graph-node-size" min="25" max="50" value="${this.nodeSize}">
|
||||||
|
</div>
|
||||||
|
<div class="graph-slider">
|
||||||
|
<div class="graph-slider-label">
|
||||||
|
<span>Distancia</span>
|
||||||
|
<span class="graph-slider-value" id="link-dist-val">${this.linkDist}px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="graph-link-dist" min="60" max="200" value="${this.linkDist}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindSidebarEvents(sidebar as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindSidebarEvents(sidebar: HTMLElement): void {
|
||||||
|
// Category checkboxes
|
||||||
|
sidebar.querySelectorAll<HTMLInputElement>('[data-cat]').forEach(cb => {
|
||||||
|
cb.onchange = () => {
|
||||||
|
const cat = cb.dataset.cat as TableSchema['category'];
|
||||||
|
if (cb.checked) {
|
||||||
|
this.showCategories.add(cat);
|
||||||
|
} else {
|
||||||
|
this.showCategories.delete(cat);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle relations
|
||||||
|
const toggleRelations = sidebar.querySelector<HTMLInputElement>('#toggle-relations');
|
||||||
|
if (toggleRelations) {
|
||||||
|
toggleRelations.onchange = () => {
|
||||||
|
this.showRelations = toggleRelations.checked;
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node size
|
||||||
|
const nodeSize = sidebar.querySelector<HTMLInputElement>('#graph-node-size');
|
||||||
|
const nodeSizeVal = sidebar.querySelector('#node-size-val');
|
||||||
|
if (nodeSize) {
|
||||||
|
nodeSize.oninput = () => {
|
||||||
|
this.nodeSize = parseInt(nodeSize.value, 10);
|
||||||
|
if (nodeSizeVal) nodeSizeVal.textContent = `${this.nodeSize}px`;
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link distance
|
||||||
|
const linkDist = sidebar.querySelector<HTMLInputElement>('#graph-link-dist');
|
||||||
|
const linkDistVal = sidebar.querySelector('#link-dist-val');
|
||||||
|
if (linkDist) {
|
||||||
|
linkDist.oninput = () => {
|
||||||
|
this.linkDist = parseInt(linkDist.value, 10);
|
||||||
|
if (linkDistVal) linkDistVal.textContent = `${this.linkDist}px`;
|
||||||
|
if (this.simulation) {
|
||||||
|
this.simulation.force('link').distance(this.linkDist);
|
||||||
|
this.simulation.alpha(0.3).restart();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLegend(): void {
|
||||||
|
const legend = this.container.querySelector('#graph-legend');
|
||||||
|
if (!legend) return;
|
||||||
|
|
||||||
|
const visibleCats = Array.from(this.showCategories);
|
||||||
|
legend.innerHTML = visibleCats.map(cat => `
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="color-dot" style="background: ${CATEGORY_COLORS[cat]}"></span>
|
||||||
|
<span>${cat}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showNodeDetail(node: TableNode): Promise<void> {
|
||||||
|
const panel = this.container.querySelector('#node-detail');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
// Find relations
|
||||||
|
const incoming = this.schema.relations.filter(r => r.to === node.name);
|
||||||
|
const outgoing = this.schema.relations.filter(r => r.from === node.name);
|
||||||
|
|
||||||
|
// Show loading state first
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<h4>${node.name}</h4>
|
||||||
|
<span class="status-badge" style="background: ${CATEGORY_COLORS[node.category]}20; color: ${CATEGORY_COLORS[node.category]}">${node.category}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row"><strong>Database:</strong> ${node.database}</div>
|
||||||
|
<div class="detail-row"><strong>Server:</strong> ${this.serverName.toUpperCase()}</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section-title">Campos</div>
|
||||||
|
<div class="loading-small">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
panel.classList.add('open');
|
||||||
|
|
||||||
|
// Fetch columns from API
|
||||||
|
let columns: Column[] = [];
|
||||||
|
try {
|
||||||
|
columns = await fetchColumns(this.serverName, node.name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching columns:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render full panel with columns
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<h4>${node.name}</h4>
|
||||||
|
<span class="status-badge" style="background: ${CATEGORY_COLORS[node.category]}20; color: ${CATEGORY_COLORS[node.category]}">${node.category}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row"><strong>Database:</strong> ${node.database}</div>
|
||||||
|
<div class="detail-row"><strong>Server:</strong> ${this.serverName.toUpperCase()}</div>
|
||||||
|
${columns.length > 0 ? `
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section-title">Campos (${columns.length})</div>
|
||||||
|
<div class="columns-table">
|
||||||
|
${columns.map(c => `
|
||||||
|
<div class="column-row ${c.primary ? 'primary' : ''}">
|
||||||
|
<span class="column-name">${c.primary ? '🔑 ' : ''}${c.name}</span>
|
||||||
|
<span class="column-type">${this.formatDataType(c.dataType)}</span>
|
||||||
|
${!c.nullable ? '<span class="column-not-null">NOT NULL</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '<div class="detail-section"><div class="detail-section-title">Sin información de campos</div></div>'}
|
||||||
|
${incoming.length > 0 ? `
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section-title">Referencias entrantes (${incoming.length})</div>
|
||||||
|
${incoming.map(r => `<div class="detail-service"><span class="status-dot" style="background: #2196F3"></span>${r.from} (${r.type})</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${outgoing.length > 0 ? `
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section-title">Referencias salientes (${outgoing.length})</div>
|
||||||
|
${outgoing.map(r => `<div class="detail-service"><span class="status-dot" style="background: #FF9800"></span>${r.to} (${r.type})</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeHandler = (e: MouseEvent) => {
|
||||||
|
if (!panel.contains(e.target as Node) && !(e.target as HTMLElement).closest('.nodes')) {
|
||||||
|
panel.classList.remove('open');
|
||||||
|
document.removeEventListener('click', closeHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closeHandler), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDataType(type: string): string {
|
||||||
|
// Shorten common PostgreSQL types
|
||||||
|
return type
|
||||||
|
.replace('character varying', 'varchar')
|
||||||
|
.replace('timestamp with time zone', 'timestamptz')
|
||||||
|
.replace('timestamp without time zone', 'timestamp')
|
||||||
|
.replace('double precision', 'float8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindZoomControls(): void {
|
||||||
|
if (!this.d3 || !this.svg || !this.zoom) return;
|
||||||
|
const d3 = this.d3;
|
||||||
|
|
||||||
|
const fitBtn = this.container.querySelector<HTMLButtonElement>('#graph-fit');
|
||||||
|
if (fitBtn) {
|
||||||
|
fitBtn.onclick = () => {
|
||||||
|
this.svg.transition().duration(300).call(
|
||||||
|
this.zoom.transform,
|
||||||
|
d3.zoomIdentity.translate(0, 0).scale(1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const zinBtn = this.container.querySelector<HTMLButtonElement>('#graph-zin');
|
||||||
|
if (zinBtn) {
|
||||||
|
zinBtn.onclick = () => {
|
||||||
|
this.svg.transition().duration(200).call(this.zoom.scaleBy, 1.5);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoutBtn = this.container.querySelector<HTMLButtonElement>('#graph-zout');
|
||||||
|
if (zoutBtn) {
|
||||||
|
zoutBtn.onclick = () => {
|
||||||
|
this.svg.transition().duration(200).call(this.zoom.scaleBy, 0.67);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private createDrag(d3: D3Module, simulation: D3Simulation): any {
|
||||||
|
return d3.drag()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.on('start', (event: any, d: any) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.on('drag', (event: any, d: any) => {
|
||||||
|
d.fx = event.x;
|
||||||
|
d.fy = event.y;
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.on('end', (event: any, d: any) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
d.fx = null;
|
||||||
|
d.fy = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount(): void {
|
||||||
|
this.simulation?.stop();
|
||||||
|
this.svg = null;
|
||||||
|
this.g = null;
|
||||||
|
this.zoom = null;
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
host: true
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user