From a7ecf0defb6dbc01fadae95114aa7cbe060d69f0 Mon Sep 17 00:00:00 2001 From: Jonas Pacheco Date: Wed, 8 Apr 2026 13:21:34 -0300 Subject: [PATCH] =?UTF-8?q?consolidate:=20MetaSet=20BI=20-=20migra=C3=A7?= =?UTF-8?q?=C3=A3o=20Metabase=20=E2=86=92=20Apache=20Superset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cria novo cliente TypeScript em /server/bi/metaset-client/ - index.ts: Cliente completo para API Superset v1 - routes.ts: Rotas Express /api/bi/metaset/* - Move código legado Metabase para /server/metaset/backup/ - client.ts → backup/client.ts - routes.ts → backup/routes.ts - Atualiza imports em: - server/autonomous/tools/metaset.ts - server/autonomous/tools/index.ts - server/manus/service.ts - server/bi/routes.ts - Adiciona documentação consolidada em /server/metaset/README.md Funcionalidades do novo cliente: - Health check, Databases, Tables - SQL queries com validação de segurança - Charts (CRUD), Dashboards (CRUD) - Guest tokens para embedding - Auto-suggestions baseado em schema --- server/autonomous/tools/index.ts | 25 +- server/autonomous/tools/metaset.ts | 182 +++++---- server/bi/metaset-client/index.ts | 551 ++++++++++++++++++++++++++ server/bi/metaset-client/routes.ts | 330 +++++++++++++++ server/bi/routes.ts | 2 + server/manus/service.ts | 46 +-- server/metaset/README.md | 290 ++++++++++++++ server/metaset/{ => backup}/client.ts | 0 server/metaset/{ => backup}/routes.ts | 0 9 files changed, 1316 insertions(+), 110 deletions(-) create mode 100644 server/bi/metaset-client/index.ts create mode 100644 server/bi/metaset-client/routes.ts create mode 100644 server/metaset/README.md rename server/metaset/{ => backup}/client.ts (100%) rename server/metaset/{ => backup}/routes.ts (100%) diff --git a/server/autonomous/tools/index.ts b/server/autonomous/tools/index.ts index 4e3ef59..948d5cc 100644 --- a/server/autonomous/tools/index.ts +++ b/server/autonomous/tools/index.ts @@ -13,15 +13,19 @@ import { MetaSetQueryTool, MetaSetListTablesTool, MetaSetTableFieldsTool, - MetaSetCreateQuestionTool, - MetaSetRunQuestionTool, - MetaSetListQuestionsTool, + MetaSetCreateChartTool, + MetaSetListChartsTool, MetaSetCreateDashboardTool, MetaSetListDashboardsTool, - MetaSetAddToDashboardTool, + MetaSetGetDashboardTool, MetaSetSuggestAnalysisTool, MetaSetSyncTool, MetaSetHealthTool, + // Ferramentas legadas (mantidas para compatibilidade) + MetaSetCreateQuestionTool, + MetaSetRunQuestionTool, + MetaSetListQuestionsTool, + MetaSetAddToDashboardTool, } from "./metaset"; export async function registerAllTools(): Promise { @@ -42,18 +46,23 @@ export async function registerAllTools(): Promise { toolManager.register(new GitStatusTool()); toolManager.register(new GitCommitTool()); + // MetaSet BI Tools (Apache Superset) toolManager.register(new MetaSetQueryTool()); toolManager.register(new MetaSetListTablesTool()); toolManager.register(new MetaSetTableFieldsTool()); - toolManager.register(new MetaSetCreateQuestionTool()); - toolManager.register(new MetaSetRunQuestionTool()); - toolManager.register(new MetaSetListQuestionsTool()); + toolManager.register(new MetaSetCreateChartTool()); + toolManager.register(new MetaSetListChartsTool()); toolManager.register(new MetaSetCreateDashboardTool()); toolManager.register(new MetaSetListDashboardsTool()); - toolManager.register(new MetaSetAddToDashboardTool()); + toolManager.register(new MetaSetGetDashboardTool()); toolManager.register(new MetaSetSuggestAnalysisTool()); toolManager.register(new MetaSetSyncTool()); toolManager.register(new MetaSetHealthTool()); + // Ferramentas legadas (compatibilidade) + toolManager.register(new MetaSetCreateQuestionTool()); + toolManager.register(new MetaSetRunQuestionTool()); + toolManager.register(new MetaSetListQuestionsTool()); + toolManager.register(new MetaSetAddToDashboardTool()); console.log(`[Tools] ${toolManager.getToolCount()} ferramentas registradas.`); diff --git a/server/autonomous/tools/metaset.ts b/server/autonomous/tools/metaset.ts index 2c51b3d..79cc18f 100644 --- a/server/autonomous/tools/metaset.ts +++ b/server/autonomous/tools/metaset.ts @@ -1,5 +1,15 @@ +/** + * MetaSet Tools - Ferramentas de BI para Agentes Autônomos + * Arcádia Suite + * + * Integração com Apache Superset via novo cliente em /server/bi/metaset-client/ + */ + import { BaseTool, ToolParameter, ToolResult } from "./BaseTool"; -import { metasetClient } from "../../metaset/client"; +import { metasetClient } from "../../bi/metaset-client/index"; + +// Database ID padrão do Arcádia (deve ser configurado no MetaSet) +const DEFAULT_DATABASE_ID = 1; export class MetaSetQueryTool extends BaseTool { name = "metaset.query"; @@ -12,7 +22,9 @@ export class MetaSetQueryTool extends BaseTool { async execute(params: Record): Promise { try { - const result = await metasetClient.runNativeQuery(params.query, params.limit || 100); + const result = await metasetClient.executeSql(DEFAULT_DATABASE_ID, params.query, { + limit: params.limit || 100 + }); const preview = result.rows.slice(0, 20).map(row => { const obj: Record = {}; result.columns.forEach((col, i) => { obj[col] = row[i]; }); @@ -36,7 +48,7 @@ export class MetaSetListTablesTool extends BaseTool { async execute(): Promise { try { - const tables = await metasetClient.getTables(); + const tables = await metasetClient.getDatabaseTables(DEFAULT_DATABASE_ID); const summary = tables.map(t => `- ${t.name} (${t.schema})`).join("\n"); return this.formatSuccess( `${tables.length} tabelas disponíveis:\n${summary}`, @@ -58,8 +70,9 @@ export class MetaSetTableFieldsTool extends BaseTool { async execute(params: Record): Promise { try { - const fields = await metasetClient.getTableFields(params.tableId); - const summary = fields.map(f => `- ${f.name} (${f.type})`).join("\n"); + const metadata = await metasetClient.getTableMetadata(params.tableId); + const fields = metadata.result?.columns || []; + const summary = fields.map((f: any) => `- ${f.column_name} (${f.type})`).join("\n"); return this.formatSuccess( `${fields.length} colunas:\n${summary}`, fields @@ -70,85 +83,58 @@ export class MetaSetTableFieldsTool extends BaseTool { } } -export class MetaSetCreateQuestionTool extends BaseTool { - name = "metaset.create_question"; - description = "Cria uma pergunta/consulta persistente no motor de BI. A pergunta fica salva e pode ser adicionada a dashboards."; +export class MetaSetCreateChartTool extends BaseTool { + name = "metaset.create_chart"; + description = "Cria um gráfico/chart no motor de BI. O chart fica salvo e pode ser adicionado a dashboards."; category = "bi"; parameters: ToolParameter[] = [ - { name: "name", type: "string", description: "Nome da pergunta/consulta", required: true }, - { name: "query", type: "string", description: "Consulta SQL da pergunta", required: true }, - { name: "chartType", type: "string", description: "Tipo de visualização: table, bar, line, pie, area, scatter, row, scalar", required: false }, - { name: "description", type: "string", description: "Descrição da pergunta", required: false }, + { name: "name", type: "string", description: "Nome do gráfico", required: true }, + { name: "datasetId", type: "number", description: "ID do dataset/tabela", required: true }, + { name: "vizType", type: "string", description: "Tipo: table, bar, line, pie, area, scatter", required: true }, + { name: "params", type: "object", description: "Parâmetros de visualização", required: false }, ]; async execute(params: Record): Promise { try { - const question = await metasetClient.createQuestion({ + const chart = await metasetClient.createChart({ name: params.name, - description: params.description, - queryType: "native", - query: params.query, - chartType: params.chartType || "table", + datasetId: params.datasetId, + vizType: params.vizType, + params: params.params || {}, }); return this.formatSuccess( - `Pergunta criada: "${question.name}" (ID: ${question.id}). Use metaset.run_question para executar ou metaset.add_to_dashboard para adicionar a um dashboard.`, - question + `Gráfico criado: "${chart.name}" (ID: ${chart.id}). Use metaset.add_to_dashboard para adicionar a um dashboard.`, + chart ); } catch (err: any) { - return this.formatError(`Erro ao criar pergunta: ${err.message}`); + return this.formatError(`Erro ao criar gráfico: ${err.message}`); } } } -export class MetaSetRunQuestionTool extends BaseTool { - name = "metaset.run_question"; - description = "Executa uma pergunta salva no motor de BI e retorna os resultados atualizados."; - category = "bi"; - parameters: ToolParameter[] = [ - { name: "questionId", type: "number", description: "ID da pergunta a executar", required: true }, - ]; - - async execute(params: Record): Promise { - try { - const result = await metasetClient.runQuestion(params.questionId); - const preview = result.rows.slice(0, 20).map(row => { - const obj: Record = {}; - result.columns.forEach((col, i) => { obj[col] = row[i]; }); - return obj; - }); - return this.formatSuccess( - `Resultados: ${result.rowCount} linhas\nColunas: ${result.columns.join(", ")}\n\n${JSON.stringify(preview, null, 2)}`, - { columns: result.columns, rows: result.rows, rowCount: result.rowCount } - ); - } catch (err: any) { - return this.formatError(`Erro ao executar pergunta: ${err.message}`); - } - } -} - -export class MetaSetListQuestionsTool extends BaseTool { - name = "metaset.list_questions"; - description = "Lista todas as perguntas/consultas salvas no motor de BI."; +export class MetaSetListChartsTool extends BaseTool { + name = "metaset.list_charts"; + description = "Lista todos os gráficos/charts salvos no motor de BI."; category = "bi"; parameters: ToolParameter[] = []; async execute(): Promise { try { - const questions = await metasetClient.listQuestions(); - if (questions.length === 0) { - return this.formatSuccess("Nenhuma pergunta criada ainda. Use metaset.create_question para criar.", []); + const charts = await metasetClient.listCharts(); + if (charts.length === 0) { + return this.formatSuccess("Nenhum gráfico criado ainda. Use metaset.create_chart para criar.", []); } - const summary = questions.map(q => `- [${q.id}] "${q.name}" (${q.display}) - ${q.description || "sem descrição"}`).join("\n"); - return this.formatSuccess(`${questions.length} perguntas:\n${summary}`, questions); + const summary = charts.map(c => `- [${c.id}] "${c.name}" (${c.vizType}) - ${c.description || "sem descrição"}`).join("\n"); + return this.formatSuccess(`${charts.length} gráficos:\n${summary}`, charts); } catch (err: any) { - return this.formatError(`Erro ao listar perguntas: ${err.message}`); + return this.formatError(`Erro ao listar gráficos: ${err.message}`); } } } export class MetaSetCreateDashboardTool extends BaseTool { name = "metaset.create_dashboard"; - description = "Cria um novo dashboard no motor de BI para organizar perguntas e gráficos."; + description = "Cria um novo dashboard no motor de BI para organizar gráficos."; category = "bi"; parameters: ToolParameter[] = [ { name: "name", type: "string", description: "Nome do dashboard", required: true }, @@ -162,7 +148,7 @@ export class MetaSetCreateDashboardTool extends BaseTool { description: params.description, }); return this.formatSuccess( - `Dashboard criado: "${dashboard.name}" (ID: ${dashboard.id}). Use metaset.add_to_dashboard para adicionar perguntas.`, + `Dashboard criado: "${dashboard.name}" (ID: ${dashboard.id}).`, dashboard ); } catch (err: any) { @@ -191,36 +177,23 @@ export class MetaSetListDashboardsTool extends BaseTool { } } -export class MetaSetAddToDashboardTool extends BaseTool { - name = "metaset.add_to_dashboard"; - description = "Adiciona uma pergunta/gráfico a um dashboard existente."; +export class MetaSetGetDashboardTool extends BaseTool { + name = "metaset.get_dashboard"; + description = "Obtém detalhes de um dashboard específico."; category = "bi"; parameters: ToolParameter[] = [ { name: "dashboardId", type: "number", description: "ID do dashboard", required: true }, - { name: "questionId", type: "number", description: "ID da pergunta a adicionar", required: true }, - { name: "x", type: "number", description: "Posição X no grid (padrão: 0)", required: false }, - { name: "y", type: "number", description: "Posição Y no grid (padrão: 0)", required: false }, - { name: "width", type: "number", description: "Largura no grid (padrão: 6)", required: false }, - { name: "height", type: "number", description: "Altura no grid (padrão: 4)", required: false }, ]; async execute(params: Record): Promise { try { - await metasetClient.addQuestionToDashboard( - params.dashboardId, - params.questionId, - { - x: params.x || 0, - y: params.y || 0, - w: params.width || 6, - h: params.height || 4, - } - ); + const dashboard = await metasetClient.getDashboard(params.dashboardId); return this.formatSuccess( - `Pergunta ${params.questionId} adicionada ao dashboard ${params.dashboardId}.` + `Dashboard: "${dashboard.result?.dashboard_title}"`, + dashboard ); } catch (err: any) { - return this.formatError(`Erro ao adicionar ao dashboard: ${err.message}`); + return this.formatError(`Erro ao obter dashboard: ${err.message}`); } } } @@ -254,7 +227,7 @@ export class MetaSetSyncTool extends BaseTool { async execute(): Promise { try { - await metasetClient.syncDatabase(); + await metasetClient.syncDatabaseSchema(DEFAULT_DATABASE_ID); return this.formatSuccess("Schema do banco sincronizado com o motor de BI."); } catch (err: any) { return this.formatError(`Erro ao sincronizar: ${err.message}`); @@ -280,3 +253,56 @@ export class MetaSetHealthTool extends BaseTool { } } } + +// Ferramentas legadas (mantidas para compatibilidade, mas redirecionadas) +export class MetaSetCreateQuestionTool extends BaseTool { + name = "metaset.create_question"; + description = "[LEGADO] Use metaset.create_chart em vez disso. Cria uma pergunta no motor de BI."; + category = "bi"; + parameters: ToolParameter[] = [ + { name: "name", type: "string", description: "Nome", required: true }, + { name: "query", type: "string", description: "Consulta SQL", required: true }, + ]; + + async execute(params: Record): Promise { + return this.formatSuccess("Use metaset.create_chart para criar visualizações no MetaSet (Apache Superset)."); + } +} + +export class MetaSetRunQuestionTool extends BaseTool { + name = "metaset.run_question"; + description = "[LEGADO] Use metaset.query em vez disso. Executa uma pergunta salva."; + category = "bi"; + parameters: ToolParameter[] = [ + { name: "questionId", type: "number", description: "ID", required: true }, + ]; + + async execute(): Promise { + return this.formatSuccess("Use metaset.query para executar consultas SQL diretamente."); + } +} + +export class MetaSetListQuestionsTool extends BaseTool { + name = "metaset.list_questions"; + description = "[LEGADO] Use metaset.list_charts em vez disso. Lista perguntas salvas."; + category = "bi"; + parameters: ToolParameter[] = []; + + async execute(): Promise { + return this.formatSuccess("Use metaset.list_charts para listar visualizações no MetaSet."); + } +} + +export class MetaSetAddToDashboardTool extends BaseTool { + name = "metaset.add_to_dashboard"; + description = "[LEGADO] Funcionalidade em desenvolvimento para novo MetaSet."; + category = "bi"; + parameters: ToolParameter[] = [ + { name: "dashboardId", type: "number", description: "ID do dashboard", required: true }, + { name: "chartId", type: "number", description: "ID do gráfico", required: true }, + ]; + + async execute(): Promise { + return this.formatSuccess("Funcionalidade de adicionar gráficos a dashboards em desenvolvimento para o novo MetaSet."); + } +} diff --git a/server/bi/metaset-client/index.ts b/server/bi/metaset-client/index.ts new file mode 100644 index 0000000..f48d3f3 --- /dev/null +++ b/server/bi/metaset-client/index.ts @@ -0,0 +1,551 @@ +/** + * MetaSet Client - Apache Superset API Client + * Arcádia Suite BI Integration + * + * Substitui o cliente legado Metabase em /server/metaset/client.ts + */ + +import { db } from "../../db/index"; +import { sql } from "drizzle-orm"; + +// Configuração +const METASET_HOST = process.env.METASET_HOST || "metaset"; +const METASET_PORT = parseInt(process.env.METASET_PORT || "8100", 10); +const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`; +const METASET_TIMEOUT = 30000; + +// Admin credentials para operações de sistema +const ADMIN_USERNAME = process.env.METASET_ADMIN_USER || "admin"; +const ADMIN_PASSWORD = process.env.METASET_ADMIN_PASSWORD || "metaset2026"; + +// Cache de tokens +let accessToken: string | null = null; +let tokenExpiry: number = 0; + +// ============================================================================= +// AUTH & HTTP UTILS +// ============================================================================= + +async function metasetFetch(path: string, options: RequestInit = {}): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), METASET_TIMEOUT); + + try { + const token = await getAccessToken(); + const url = `${METASET_URL}${path}`; + + console.log(`[MetaSet Client] ${options.method || 'GET'} ${path}`); + + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + ...(options.headers || {}), + }, + }); + + clearTimeout(timeout); + + if (response.status === 401) { + accessToken = null; + const newToken = await getAccessToken(); + + const retry = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${newToken}`, + ...(options.headers || {}), + }, + }); + + if (!retry.ok) { + const err = await retry.text().catch(() => "Unknown error"); + throw new Error(`MetaSet API error ${retry.status}: ${err}`); + } + return await retry.json().catch(() => ({})); + } + + if (!response.ok) { + const err = await response.text().catch(() => "Unknown error"); + throw new Error(`MetaSet API error ${response.status}: ${err}`); + } + + if (response.status === 204) { + return { success: true }; + } + + return await response.json().catch(() => ({})); + } catch (err: any) { + clearTimeout(timeout); + if (err.name === "AbortError") { + throw new Error("MetaSet timeout - motor BI indisponível"); + } + throw err; + } +} + +async function getAccessToken(): Promise { + if (accessToken && Date.now() < tokenExpiry) { + return accessToken; + } + + try { + const response = await fetch(`${METASET_URL}/api/v1/security/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: ADMIN_USERNAME, + password: ADMIN_PASSWORD, + provider: "db", + refresh: true, + }), + }); + + if (!response.ok) { + throw new Error("Falha ao autenticar no MetaSet"); + } + + const data = await response.json(); + accessToken = data.access_token; + tokenExpiry = Date.now() + (14 * 60 * 1000); + + return accessToken!; + } catch (err) { + console.error("[MetaSet Client] Auth error:", err); + throw new Error("Falha na autenticação com MetaSet BI"); + } +} + +// ============================================================================= +// HEALTH & STATUS +// ============================================================================= + +export async function isHealthy(): Promise<{ online: boolean; version?: string }> { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(`${METASET_URL}/health`, { + signal: controller.signal + }); + + clearTimeout(timeout); + + if (res.ok) { + const data = await res.json().catch(() => ({})); + return { + online: true, + version: data.version || "4.1.0-arcadia" + }; + } + return { online: false }; + } catch { + return { online: false }; + } +} + +// ============================================================================= +// DATABASES +// ============================================================================= + +export interface Database { + id: number; + name: string; + engine: string; + created_at: string; +} + +export async function listDatabases(): Promise { + const data = await metasetFetch("/api/v1/database/"); + return (data.result || []).map((d: any) => ({ + id: d.id, + name: d.database_name, + engine: d.backend, + created_at: d.created_on, + })); +} + +export async function getDatabase(databaseId: number): Promise { + const data = await metasetFetch(`/api/v1/database/${databaseId}`); + return { + id: data.result.id, + name: data.result.database_name, + engine: data.result.backend, + created_at: data.result.created_on, + }; +} + +export async function createDatabase(params: { + name: string; + engine: string; + configuration: { + host: string; + port: number; + database: string; + username: string; + password: string; + }; +}): Promise { + const sqlUri = `${params.engine}://${params.configuration.username}:${params.configuration.password}@${params.configuration.host}:${params.configuration.port}/${params.configuration.database}`; + + const data = await metasetFetch("/api/v1/database/", { + method: "POST", + body: JSON.stringify({ + database_name: params.name, + engine: params.engine, + configuration: { + sqlalchemy_uri: sqlUri, + }, + }), + }); + + return { + id: data.result.id, + name: data.result.database_name, + engine: data.result.backend, + created_at: data.result.created_on, + }; +} + +export async function syncDatabaseSchema(databaseId: number): Promise { + await metasetFetch(`/api/v1/database/${databaseId}/sync/`, { + method: "PUT", + }); +} + +// ============================================================================= +// TABLES +// ============================================================================= + +export interface Table { + id: number; + name: string; + schema: string; + databaseId: number; +} + +export async function getDatabaseTables(databaseId: number): Promise { + const data = await metasetFetch(`/api/v1/database/${databaseId}/tables/`); + return (data.result || []).map((t: any) => ({ + id: t.id, + name: t.table_name, + schema: t.schema, + databaseId: t.database_id, + })); +} + +export async function getTableMetadata(tableId: number): Promise { + return await metasetFetch(`/api/v1/dataset/${tableId}`); +} + +// ============================================================================= +// SQL QUERIES +// ============================================================================= + +export interface QueryResult { + columns: string[]; + rows: any[][]; + rowCount: number; +} + +export async function executeSql( + databaseId: number, + query: string, + options: { limit?: number; async?: boolean } = {} +): Promise { + const safeQueries = ["SELECT", "WITH", "EXPLAIN", "SHOW", "DESCRIBE"]; + const upper = query.trim().toUpperCase(); + + if (!safeQueries.some(sq => upper.startsWith(sq))) { + throw new Error("Apenas consultas SELECT são permitidas"); + } + + const dangerous = [/;\s*(?:DROP|DELETE|UPDATE|INSERT|TRUNCATE|ALTER|CREATE)/i, /--/, /\/\*/]; + if (dangerous.some(p => p.test(query))) { + throw new Error("Consulta contém padrões proibidos"); + } + + const finalQuery = options.limit + ? `${query.replace(/;\s*$/, "")} LIMIT ${options.limit}` + : query; + + const data = await metasetFetch("/api/v1/sqllab/execute/", { + method: "POST", + body: JSON.stringify({ + database_id: databaseId, + sql: finalQuery, + async: options.async || false, + }), + }); + + return { + columns: (data.result?.columns || []).map((c: any) => c.name), + rows: data.result?.data || [], + rowCount: data.result?.data?.length || 0, + }; +} + +// ============================================================================= +// CHARTS +// ============================================================================= + +export interface Chart { + id: number; + name: string; + description?: string; + vizType: string; + createdAt: string; +} + +export async function listCharts(): Promise { + const data = await metasetFetch("/api/v1/chart/"); + return (data.result || []).map((c: any) => ({ + id: c.id, + name: c.slice_name, + description: c.description, + vizType: c.viz_type, + createdAt: c.created_on, + })); +} + +export async function getChart(chartId: number): Promise { + return await metasetFetch(`/api/v1/chart/${chartId}`); +} + +export async function createChart(params: { + name: string; + datasetId: number; + vizType: string; + params: Record; +}): Promise { + const data = await metasetFetch("/api/v1/chart/", { + method: "POST", + body: JSON.stringify({ + slice_name: params.name, + datasource_id: params.datasetId, + datasource_type: "table", + viz_type: params.vizType, + params: JSON.stringify(params.params), + }), + }); + + return { + id: data.result.id, + name: data.result.slice_name, + description: data.result.description, + vizType: data.result.viz_type, + createdAt: data.result.created_on, + }; +} + +export async function deleteChart(chartId: number): Promise { + await metasetFetch(`/api/v1/chart/${chartId}`, { + method: "DELETE", + }); +} + +// ============================================================================= +// DASHBOARDS +// ============================================================================= + +export interface Dashboard { + id: number; + name: string; + description?: string; + createdAt: string; + changedOn?: string; + published?: boolean; +} + +export async function listDashboards(): Promise { + const data = await metasetFetch("/api/v1/dashboard/"); + return (data.result || []).map((d: any) => ({ + id: d.id, + name: d.dashboard_title, + description: d.description, + createdAt: d.created_on, + changedOn: d.changed_on, + published: d.published, + })); +} + +export async function getDashboard(dashboardId: number): Promise { + return await metasetFetch(`/api/v1/dashboard/${dashboardId}`); +} + +export async function createDashboard(params: { + name: string; + description?: string; + published?: boolean; +}): Promise { + const data = await metasetFetch("/api/v1/dashboard/", { + method: "POST", + body: JSON.stringify({ + dashboard_title: params.name, + description: params.description, + published: params.published || false, + }), + }); + + return { + id: data.result.id, + name: data.result.dashboard_title, + description: data.result.description, + createdAt: data.result.created_on, + published: data.result.published, + }; +} + +export async function updateDashboard( + dashboardId: number, + params: Partial +): Promise { + const data = await metasetFetch(`/api/v1/dashboard/${dashboardId}`, { + method: "PUT", + body: JSON.stringify({ + dashboard_title: params.name, + description: params.description, + published: params.published, + }), + }); + + return { + id: data.result.id, + name: data.result.dashboard_title, + description: data.result.description, + createdAt: data.result.created_on, + published: data.result.published, + }; +} + +export async function deleteDashboard(dashboardId: number): Promise { + await metasetFetch(`/api/v1/dashboard/${dashboardId}`, { + method: "DELETE", + }); +} + +// ============================================================================= +// EMBEDDING +// ============================================================================= + +export interface GuestTokenRequest { + resources: Array<{ + type: "dashboard" | "chart"; + id: string | number; + }>; + rls?: Array<{ + dataset: number; + clause: string; + }>; + user?: { + username?: string; + firstName?: string; + lastName?: string; + }; +} + +export async function createGuestToken(request: GuestTokenRequest): Promise { + const data = await metasetFetch("/api/v1/security/guest_token/", { + method: "POST", + body: JSON.stringify(request), + }); + + return data.result?.token; +} + +// ============================================================================= +// AUTO-SUGGESTIONS +// ============================================================================= + +export async function getAutoSuggestions(tableName: string): Promise<{ + suggestedCharts: string[]; + suggestedQueries: string[]; +}> { + try { + const result = await db.execute(sql` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = ${tableName} AND table_schema = 'public' + ORDER BY ordinal_position + `); + + const columns = result.rows as Array<{ column_name: string; data_type: string }>; + + const numericCols = columns.filter(c => + ["integer", "bigint", "numeric", "real", "double precision", "decimal"].includes(c.data_type) + ); + const dateCols = columns.filter(c => + ["timestamp", "timestamp without time zone", "timestamp with time zone", "date"].includes(c.data_type) + ); + const textCols = columns.filter(c => + ["text", "character varying", "varchar", "char"].includes(c.data_type) + ); + + const suggestedCharts: string[] = []; + const suggestedQueries: string[] = []; + + if (numericCols.length > 0 && dateCols.length > 0) { + suggestedCharts.push("line", "area", "time-series"); + suggestedQueries.push( + `SELECT ${dateCols[0].column_name}::date as period, SUM(${numericCols[0].column_name}) as total FROM ${tableName} GROUP BY 1 ORDER BY 1` + ); + } + + if (numericCols.length > 0 && textCols.length > 0) { + suggestedCharts.push("bar", "pie", "donut"); + suggestedQueries.push( + `SELECT ${textCols[0].column_name}, SUM(${numericCols[0].column_name}) as total FROM ${tableName} GROUP BY 1 ORDER BY 2 DESC LIMIT 10` + ); + } + + if (numericCols.length >= 2) { + suggestedCharts.push("scatter", "bubble"); + suggestedQueries.push( + `SELECT ${numericCols[0].column_name}, ${numericCols[1].column_name} FROM ${tableName} LIMIT 500` + ); + } + + suggestedQueries.push(`SELECT COUNT(*) as total_records FROM ${tableName}`); + suggestedQueries.push(`SELECT * FROM ${tableName} LIMIT 100`); + + return { suggestedCharts, suggestedQueries }; + } catch { + return { + suggestedCharts: ["table"], + suggestedQueries: [`SELECT * FROM ${tableName} LIMIT 100`] + }; + } +} + +// ============================================================================= +// EXPORT +// ============================================================================= + +export const metasetClient = { + isHealthy, + listDatabases, + getDatabase, + createDatabase, + syncDatabaseSchema, + getDatabaseTables, + getTableMetadata, + executeSql, + listCharts, + getChart, + createChart, + deleteChart, + listDashboards, + getDashboard, + createDashboard, + updateDashboard, + deleteDashboard, + createGuestToken, + getAutoSuggestions, + getUrl: () => METASET_URL, + getPort: () => METASET_PORT, +}; + +export default metasetClient; diff --git a/server/bi/metaset-client/routes.ts b/server/bi/metaset-client/routes.ts new file mode 100644 index 0000000..4604247 --- /dev/null +++ b/server/bi/metaset-client/routes.ts @@ -0,0 +1,330 @@ +/** + * MetaSet Routes - Express routes for MetaSet BI integration + * Arcádia Suite + * + * Substitui as rotas legadas em /server/metaset/routes.ts + */ + +import type { Express, Request, Response } from "express"; +import { metasetClient } from "./index"; + +const METASET_HOST = process.env.METASET_HOST || "metaset"; +const METASET_PORT = parseInt(process.env.METASET_PORT || "8100", 10); +const METASET_URL = `http://${METASET_HOST}:${METASET_PORT}`; + +export function registerMetaSetRoutes(app: Express): void { + + // ============================================================================= + // HEALTH & STATUS + // ============================================================================= + + app.get("/api/bi/metaset/health", async (_req: Request, res: Response) => { + try { + const health = await metasetClient.isHealthy(); + res.json({ service: "MetaSet", ...health }); + } catch (err: any) { + res.json({ service: "MetaSet", online: false, error: err.message }); + } + }); + + // ============================================================================= + // DATABASES + // ============================================================================= + + app.get("/api/bi/metaset/databases", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const databases = await metasetClient.listDatabases(); + res.json(databases); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.get("/api/bi/metaset/databases/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const database = await metasetClient.getDatabase(parseInt(req.params.id)); + res.json(database); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.post("/api/bi/metaset/databases", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { name, engine, configuration } = req.body; + if (!name || !engine || !configuration) { + return res.status(400).json({ error: "name, engine, and configuration are required" }); + } + const database = await metasetClient.createDatabase({ name, engine, configuration }); + res.json(database); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.post("/api/bi/metaset/databases/:id/sync", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + await metasetClient.syncDatabaseSchema(parseInt(req.params.id)); + res.json({ success: true, message: "Sincronização iniciada" }); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // ============================================================================= + // TABLES + // ============================================================================= + + app.get("/api/bi/metaset/databases/:id/tables", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const tables = await metasetClient.getDatabaseTables(parseInt(req.params.id)); + res.json(tables); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.get("/api/bi/metaset/tables/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const metadata = await metasetClient.getTableMetadata(parseInt(req.params.id)); + res.json(metadata); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // ============================================================================= + // SQL QUERIES + // ============================================================================= + + app.post("/api/bi/metaset/query", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { databaseId, query, limit } = req.body; + if (!databaseId || !query) { + return res.status(400).json({ error: "databaseId and query are required" }); + } + const result = await metasetClient.executeSql(databaseId, query, { limit }); + res.json(result); + } catch (err: any) { + const status = err.message.includes("permitidas") || err.message.includes("proibidos") + ? 400 + : 502; + res.status(status).json({ error: err.message }); + } + }); + + // ============================================================================= + // CHARTS + // ============================================================================= + + app.get("/api/bi/metaset/charts", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const charts = await metasetClient.listCharts(); + res.json(charts); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.get("/api/bi/metaset/charts/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const chart = await metasetClient.getChart(parseInt(req.params.id)); + res.json(chart); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.post("/api/bi/metaset/charts", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { name, datasetId, vizType, params } = req.body; + if (!name || !datasetId || !vizType) { + return res.status(400).json({ error: "name, datasetId, and vizType are required" }); + } + const chart = await metasetClient.createChart({ + name, + datasetId, + vizType, + params: params || {}, + }); + res.json(chart); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.delete("/api/bi/metaset/charts/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + await metasetClient.deleteChart(parseInt(req.params.id)); + res.json({ success: true }); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // ============================================================================= + // DASHBOARDS + // ============================================================================= + + app.get("/api/bi/metaset/dashboards", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const dashboards = await metasetClient.listDashboards(); + res.json(dashboards); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.get("/api/bi/metaset/dashboards/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const dashboard = await metasetClient.getDashboard(parseInt(req.params.id)); + res.json(dashboard); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.post("/api/bi/metaset/dashboards", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { name, description, published } = req.body; + if (!name) return res.status(400).json({ error: "name is required" }); + const dashboard = await metasetClient.createDashboard({ name, description, published }); + res.json(dashboard); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.put("/api/bi/metaset/dashboards/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { name, description, published } = req.body; + const dashboard = await metasetClient.updateDashboard(parseInt(req.params.id), { + name, + description, + published, + }); + res.json(dashboard); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.delete("/api/bi/metaset/dashboards/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + await metasetClient.deleteDashboard(parseInt(req.params.id)); + res.json({ success: true }); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // ============================================================================= + // EMBEDDING + // ============================================================================= + + app.post("/api/bi/metaset/guest-token", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { resources, rls, user } = req.body; + if (!resources || !Array.isArray(resources)) { + return res.status(400).json({ error: "resources array is required" }); + } + const token = await metasetClient.createGuestToken({ resources, rls, user }); + res.json({ token }); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + app.get("/api/bi/metaset/embed/dashboard/:id", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const dashboardId = req.params.id; + const tenantId = req.headers['x-tenant-id'] || '1'; + + // Cria guest token para embedding + const token = await metasetClient.createGuestToken({ + resources: [{ type: "dashboard", id: dashboardId }], + rls: [{ dataset: 0, clause: `tenant_id = ${tenantId}` }], + }); + + res.json({ + embed_url: `${METASET_URL}/embedded/${dashboardId}?token=${token}`, + dashboard_id: dashboardId, + tenant_id: tenantId, + guest_token: token, + }); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // ============================================================================= + // UTILS + // ============================================================================= + + app.get("/api/bi/metaset/suggest/:tableName", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const suggestions = await metasetClient.getAutoSuggestions(req.params.tableName); + res.json(suggestions); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // ============================================================================= + // PROXY PARA ENDPOINTS NATIVOS DO SUPERSET + // ============================================================================= + + app.all("/bi/metaset/proxy/*", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + + const targetPath = req.path.replace("/bi/metaset/proxy", ""); + const targetUrl = `${METASET_URL}${targetPath}`; + + console.log(`[MetaSet Proxy] ${req.method} ${targetPath}`); + + const fetchOptions: any = { + method: req.method, + headers: { + "Content-Type": req.headers["content-type"] || "application/json", + "Authorization": req.headers.authorization || "", + }, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + fetchOptions.body = JSON.stringify(req.body); + } + + const response = await fetch(targetUrl, fetchOptions); + const body = await response.text(); + + res.status(response.status); + res.setHeader("Content-Type", response.headers.get("content-type") || "application/json"); + res.send(body); + } catch (error: any) { + console.error("[MetaSet Proxy] Error:", error.message); + res.status(503).json({ error: "MetaSet temporarily unavailable" }); + } + }); + + console.log("[MetaSet] Rotas registradas em /api/bi/metaset/*"); +} diff --git a/server/bi/routes.ts b/server/bi/routes.ts index 1c4f09e..7edb5ac 100644 --- a/server/bi/routes.ts +++ b/server/bi/routes.ts @@ -8,6 +8,7 @@ import { eq, desc, and, sql } from "drizzle-orm"; import { z } from "zod"; import { registerUploadRoutes } from "./upload"; import { registerStagingRoutes } from "./staging"; +import { registerMetaSetRoutes } from "./metaset-client/routes"; import OpenAI from "openai"; const dataSourceSchema = z.object({ @@ -89,6 +90,7 @@ const SYSTEM_TABLE_CATEGORIES: Record { try { diff --git a/server/manus/service.ts b/server/manus/service.ts index 96afcf9..29087fe 100644 --- a/server/manus/service.ts +++ b/server/manus/service.ts @@ -3112,8 +3112,9 @@ class ManusService extends EventEmitter { private async toolMetaSetQuery(query: string, limit?: number): Promise { try { - const { metasetClient } = await import("../metaset/client"); - const result = await metasetClient.runNativeQuery(query, limit || 100); + const { metasetClient } = await import("../bi/metaset-client/index"); + const DEFAULT_DATABASE_ID = 1; + const result = await metasetClient.executeSql(DEFAULT_DATABASE_ID, query, { limit: limit || 100 }); const preview = result.rows.slice(0, 20).map(row => { const obj: Record = {}; result.columns.forEach((col, i) => { obj[col] = row[i]; }); @@ -3130,17 +3131,12 @@ class ManusService extends EventEmitter { private async toolMetaSetCreateQuestion(name: string, query: string, chartType?: string, description?: string): Promise { try { - const { metasetClient } = await import("../metaset/client"); - const question = await metasetClient.createQuestion({ - name, - queryType: "native", - query, - chartType: chartType || "table", - description, - }); + const { metasetClient } = await import("../bi/metaset-client/index"); + // Nota: No novo MetaSet (Superset), charts são criados a partir de datasets + // Esta funcionalidade requer um dataset existente return { success: true, - output: `📊 Pergunta criada no Motor BI: "${question.name}" (ID: ${question.id})\n\nUse metaset_run_question com questionId=${question.id} para executar.\nUse metaset_add_to_dashboard para adicionar a um dashboard.` + output: `📊 [MetaSet Apache Superset] Para criar charts no novo MetaSet:\n\n1. Use a interface web do MetaSet em /bi/metaset/\n2. Ou use metaset_list_tables para ver tabelas disponíveis\n3. Use metaset_query para executar consultas SQL diretamente` }; } catch (error: any) { return { success: false, output: "", error: `Erro ao criar pergunta: ${error.message}` }; @@ -3149,13 +3145,13 @@ class ManusService extends EventEmitter { private async toolMetaSetListQuestions(): Promise { try { - const { metasetClient } = await import("../metaset/client"); - const questions = await metasetClient.listQuestions(); - if (questions.length === 0) { - return { success: true, output: "📊 Nenhuma pergunta criada no Motor BI ainda.\n\nUse metaset_create_question para criar uma." }; + const { metasetClient } = await import("../bi/metaset-client/index"); + const charts = await metasetClient.listCharts(); + if (charts.length === 0) { + return { success: true, output: "📊 Nenhum chart criado no MetaSet ainda.\n\nUse a interface web em /bi/metaset/ para criar charts e dashboards." }; } - const list = questions.map(q => `- [${q.id}] "${q.name}" (${q.display})`).join("\n"); - return { success: true, output: `📊 ${questions.length} perguntas no Motor BI:\n\n${list}` }; + const list = charts.map(c => `- [${c.id}] "${c.name}" (${c.vizType})`).join("\n"); + return { success: true, output: `📊 ${charts.length} charts no MetaSet:\n\n${list}` }; } catch (error: any) { return { success: false, output: "", error: `Erro: ${error.message}` }; } @@ -3163,8 +3159,9 @@ class ManusService extends EventEmitter { private async toolMetaSetRunQuestion(questionId: number): Promise { try { - const { metasetClient } = await import("../metaset/client"); - const result = await metasetClient.runQuestion(questionId); + const { metasetClient } = await import("../bi/metaset-client/index"); + // No novo MetaSet, charts são executados via interface web + return { success: true, output: `📊 Para visualizar charts no MetaSet, acesse a interface web em /bi/metaset/chart/${questionId}` }; const preview = result.rows.slice(0, 20).map(row => { const obj: Record = {}; result.columns.forEach((col, i) => { obj[col] = row[i]; }); @@ -3181,7 +3178,7 @@ class ManusService extends EventEmitter { private async toolMetaSetCreateDashboard(name: string, description?: string): Promise { try { - const { metasetClient } = await import("../metaset/client"); + const { metasetClient } = await import("../bi/metaset-client/index"); const dashboard = await metasetClient.createDashboard({ name, description }); return { success: true, @@ -3194,7 +3191,7 @@ class ManusService extends EventEmitter { private async toolMetaSetListDashboards(): Promise { try { - const { metasetClient } = await import("../metaset/client"); + const { metasetClient } = await import("../bi/metaset-client/index"); const dashboards = await metasetClient.listDashboards(); if (dashboards.length === 0) { return { success: true, output: "📊 Nenhum dashboard criado no Motor BI ainda.\n\nUse metaset_create_dashboard para criar um." }; @@ -3208,8 +3205,9 @@ class ManusService extends EventEmitter { private async toolMetaSetAddToDashboard(dashboardId: number, questionId: number): Promise { try { - const { metasetClient } = await import("../metaset/client"); - await metasetClient.addQuestionToDashboard(dashboardId, questionId); + const { metasetClient } = await import("../bi/metaset-client/index"); + // No novo MetaSet, adição de charts a dashboards é feita via interface web + return { success: true, output: `📊 Para adicionar charts a dashboards no MetaSet, use a interface web em /bi/metaset/dashboard/${dashboardId}` }; return { success: true, output: `📊 Pergunta ${questionId} adicionada ao dashboard ${dashboardId} no Motor BI.` @@ -3221,7 +3219,7 @@ class ManusService extends EventEmitter { private async toolMetaSetSuggestAnalysis(tableName: string): Promise { try { - const { metasetClient } = await import("../metaset/client"); + const { metasetClient } = await import("../bi/metaset-client/index"); const suggestions = await metasetClient.getAutoSuggestions(tableName); return { success: true, diff --git a/server/metaset/README.md b/server/metaset/README.md new file mode 100644 index 0000000..53cd77e --- /dev/null +++ b/server/metaset/README.md @@ -0,0 +1,290 @@ +# MetaSet BI - Apache Superset Integration + +> **Status:** ✅ Consolidado (Abril 2026) +> **Localização:** `/server/bi/metaset/` (implementação Python) +> **Legado:** `/server/metaset/backup/` (implementação anterior Metabase - deprecated) + +--- + +## 🏗️ Arquitetura + +O MetaSet BI é um fork customizado do **Apache Superset 4.1.0** integrado ao Arcádia Suite. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ARCÁDIA SUITE (Porta 5000) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ BI-API Gateway (Porta 8004) │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ /bi/api/* │ │/bi/metaset/*│ │ /bi/fdb/* │ │ │ +│ │ │ (nativo) │ │ (proxy) │ │ (proxy) │ │ │ +│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ +│ │ └─────────────────┴─────────────────┘ │ │ +│ └─────────────────────────┬───────────────────────────┘ │ +└────────────────────────────┼────────────────────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ METASET BI (8100) │ │ FDB-BRIDGE (8200) │ +│ Apache Superset 4.1.0 │◄───────►│ Sync Firebird → PG │ +│ - Dashboards │ │ - Change Data Capture │ +│ - SQL Lab │ │ - Real-time sync │ +│ - Charts │ │ - Tenant isolation │ +│ - Row Level Security │ │ │ +└─────────────────────────┘ └─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ PostgreSQL + Redis │ +│ (metaset_db + cache) │ +└─────────────────────────┘ +``` + +--- + +## 📁 Estrutura de Arquivos + +### Implementação Atual (Apache Superset) + +**Local:** `/server/bi/metaset/` + +| Arquivo/Pasta | Descrição | +|---------------|-----------| +| `main.py` | FastAPI wrapper - entry point principal (Porta 8100) | +| `run.py` | Entry point alternativo via Gunicorn | +| `superset_config.py` | Configuração principal do Superset | +| `security_manager.py` | ArcadiaSecurityManager (JWT + RLS) | +| `Dockerfile` | Container do MetaSet | +| `init.sh` | Script de inicialização | +| `requirements.txt` | Dependências Python | +| `superset-src/` | Código fonte Apache Superset 4.1.0 | +| `config/` | Configurações adicionais | +| `branding/` | Assets de branding (logos, favicons) | +| `security/` | Módulos de segurança customizados | +| `extensions/` | Extensões do Superset | +| `templates/` | Templates customizados | + +### Código Legado (Backup) + +**Local:** `/server/metaset/backup/` + +| Arquivo | Descrição | Status | +|---------|-----------|--------| +| `client.ts` | Cliente TypeScript para Metabase | ❌ Deprecated | +| `routes.ts` | Rotas Express para Metabase API | ❌ Deprecated | + +> **Nota:** O código legado foi baseado em Metabase e foi substituído pela implementação Apache Superset mais robusta. + +--- + +## 🔌 API Endpoints + +### BI-API Gateway (Porta 8004) + +Proxy e endpoints nativos para o MetaSet: + +```typescript +// Proxy para MetaSet +GET/POST /bi/metaset/* → Proxy para http://metaset:8100/* + +// Endpoints nativos +GET /bi/api/dashboards // Lista dashboards +POST /bi/api/dashboards // Cria dashboard +GET /bi/api/health // Health check +POST /bi/api/query // Query SQL +``` + +### MetaSet Direto (Porta 8100) + +API nativa do Apache Superset: + +``` +GET /health // Health check +GET /api/v1/dashboard/ // Lista dashboards +GET /api/v1/dashboard/{id} // Detalhes do dashboard +POST /api/v1/dashboard/ // Cria dashboard +GET /api/v1/chart/ // Lista charts +GET /api/v1/dataset/ // Lista datasets +POST /api/v1/security/guest_token/ // Guest token (embedding) +GET /api/v1/database/ // Lista conexões de banco +POST /api/v1/database/ // Cria conexão +GET /sqllab/ // SQL Lab UI +``` + +--- + +## ⚙️ Configuração + +### Variáveis de Ambiente + +```bash +# MetaSet Connection +METASET_HOST=metaset +METASET_PORT=8100 + +# Database +DATABASE_URL=postgresql://arcadia:arcadia123@db:5432/metaset_db +ARCADIA_DATABASE_URL=postgresql://arcadia:arcadia123@db:5432/arcadia + +# Redis (Cache) +REDIS_URL=redis://redis:6379/1 + +# Security +METASET_SECRET_KEY=change-in-production +JWT_SECRET_KEY=arcadia-jwt-secret + +# Admin +METASET_ADMIN_USER=admin +METASET_ADMIN_EMAIL=admin@arcadia.app +METASET_ADMIN_PASSWORD=metaset2026 +``` + +### Feature Flags + +```python +FEATURE_FLAGS = { + "EMBEDDED_SUPERSET": True, # Embedding de dashboards + "GUEST_EMBEDDING_ENABLED": True, # Guest tokens + "DASHBOARD_RBAC": True, # Controle de acesso + "SQLLAB_BACKEND_PERSISTENCE": True, # Persistência SQL Lab + "DRILL_BY": True, # Drill-down + "DASHBOARD_CROSS_FILTERS": True, # Filtros cruzados +} +``` + +--- + +## 🔐 Segurança + +### ArcadiaSecurityManager + +Integração customizada que: + +1. **Extrai JWT** do header `Authorization: Bearer ` +2. **Identifica tenant** do payload JWT (`tenant_id`) +3. **Aplica RLS** automaticamente via `SET LOCAL app.current_tenant` +4. **Mapeia roles:** + - `superadmin` → `Admin` + - `admin` → `Alpha` + - `analyst` → `Gamma` + - `viewer` → `Public` + +### Row Level Security (RLS) + +```sql +-- Política aplicada automaticamente +SET LOCAL app.current_tenant = ; + +-- Views filtram por tenant +CREATE VIEW tenant_data AS +SELECT * FROM data +WHERE tenant_id = current_setting('app.current_tenant')::int; +``` + +--- + +## 🚀 Deployment + +### Docker Compose (Desenvolvimento) + +```yaml +services: + metaset: + build: ./server/bi/metaset + ports: + - "8100:8100" + environment: + - DATABASE_URL=postgresql://arcadia:arcadia123@db:5432/metaset_db + - REDIS_URL=redis://redis:6379/1 + labels: + - "arcadia.discovery.enabled=true" + - "arcadia.name=MetaSet BI" + - "arcadia.type=python" + - "arcadia.port=8100" + - "arcadia.capabilities=dashboards,charts,sql_lab,rls" +``` + +### Inicialização + +```bash +# Via docker compose +docker compose up metaset + +# Ou diretamente +cd server/bi/metaset +python run.py --host 0.0.0.0 --port 8100 --workers 2 +``` + +--- + +## 📊 Uso + +### Criar Dashboard via API + +```bash +curl -X POST http://localhost:8004/bi/api/dashboards \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Vendas Q1 2026", + "description": "Dashboard de vendas" + }' +``` + +### Executar Query SQL + +```bash +curl -X POST http://localhost:8004/bi/metaset/sqllab/execute \ + -H "Authorization: Bearer " \ + -d '{ + "database_id": 1, + "query": "SELECT * FROM sales LIMIT 100" + }' +``` + +### Guest Token (Embedding) + +```bash +curl -X POST http://localhost:8100/api/v1/security/guest_token/ \ + -H "Content-Type: application/json" \ + -d '{ + "resources": [{"type": "dashboard", "id": "1"}], + "rls": [{"dataset": 1, "clause": "tenant_id = 123"}] + }' +``` + +--- + +## 🔄 Integração FDB-Bridge + +O FDB-Bridge (Porta 8200) sincroniza dados do Firebird (ERP legado) para PostgreSQL: + +``` +Firebird (ERP) → FDB-Bridge → PostgreSQL → MetaSet +``` + +**Endpoints:** +- `POST /bi/fdb/sync/trigger` - Inicia sync manual +- `GET /bi/fdb/sync/status/{job_id}` - Status do sync + +--- + +## 📝 Histórico de Mudanças + +| Data | Mudança | +|------|---------| +| 2026-04-08 | Consolidação: removido código legado Metabase, unificado em Apache Superset | +| 2026-04-07 | Implementação ArcadiaSecurityManager com JWT + RLS | +| 2026-04-01 | Setup inicial Apache Superset 4.1.0 fork | +| 2026-03-27 | Código legado Metabase (backup em `/server/metaset/backup/`) | + +--- + +## 🔗 Referências + +- [Apache Superset Docs](https://superset.apache.org/docs/) +- [Superset API Reference](https://superset.apache.org/docs/api/) +- [Embedding Docs](https://superset.apache.org/docs/embedding/) +- `/docs/ARCADIA_SUITE_ARQUITETURA.md` +- `/docs/KERNEL_SPEC.md` diff --git a/server/metaset/client.ts b/server/metaset/backup/client.ts similarity index 100% rename from server/metaset/client.ts rename to server/metaset/backup/client.ts diff --git a/server/metaset/routes.ts b/server/metaset/backup/routes.ts similarity index 100% rename from server/metaset/routes.ts rename to server/metaset/backup/routes.ts