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:
ARCHITECT
2026-01-17 22:55:55 +00:00
commit f0d949c2a6
19 changed files with 4606 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.DS_Store
*.log
.env
.env.local

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
export * from './infrastructure.ts';

176
src/api/infrastructure.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
});