consolidate: MetaSet BI - migração Metabase → Apache Superset
- 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
This commit is contained in:
parent
95ee51bf68
commit
a7ecf0defb
|
|
@ -13,15 +13,19 @@ import {
|
||||||
MetaSetQueryTool,
|
MetaSetQueryTool,
|
||||||
MetaSetListTablesTool,
|
MetaSetListTablesTool,
|
||||||
MetaSetTableFieldsTool,
|
MetaSetTableFieldsTool,
|
||||||
MetaSetCreateQuestionTool,
|
MetaSetCreateChartTool,
|
||||||
MetaSetRunQuestionTool,
|
MetaSetListChartsTool,
|
||||||
MetaSetListQuestionsTool,
|
|
||||||
MetaSetCreateDashboardTool,
|
MetaSetCreateDashboardTool,
|
||||||
MetaSetListDashboardsTool,
|
MetaSetListDashboardsTool,
|
||||||
MetaSetAddToDashboardTool,
|
MetaSetGetDashboardTool,
|
||||||
MetaSetSuggestAnalysisTool,
|
MetaSetSuggestAnalysisTool,
|
||||||
MetaSetSyncTool,
|
MetaSetSyncTool,
|
||||||
MetaSetHealthTool,
|
MetaSetHealthTool,
|
||||||
|
// Ferramentas legadas (mantidas para compatibilidade)
|
||||||
|
MetaSetCreateQuestionTool,
|
||||||
|
MetaSetRunQuestionTool,
|
||||||
|
MetaSetListQuestionsTool,
|
||||||
|
MetaSetAddToDashboardTool,
|
||||||
} from "./metaset";
|
} from "./metaset";
|
||||||
|
|
||||||
export async function registerAllTools(): Promise<void> {
|
export async function registerAllTools(): Promise<void> {
|
||||||
|
|
@ -42,18 +46,23 @@ export async function registerAllTools(): Promise<void> {
|
||||||
toolManager.register(new GitStatusTool());
|
toolManager.register(new GitStatusTool());
|
||||||
toolManager.register(new GitCommitTool());
|
toolManager.register(new GitCommitTool());
|
||||||
|
|
||||||
|
// MetaSet BI Tools (Apache Superset)
|
||||||
toolManager.register(new MetaSetQueryTool());
|
toolManager.register(new MetaSetQueryTool());
|
||||||
toolManager.register(new MetaSetListTablesTool());
|
toolManager.register(new MetaSetListTablesTool());
|
||||||
toolManager.register(new MetaSetTableFieldsTool());
|
toolManager.register(new MetaSetTableFieldsTool());
|
||||||
toolManager.register(new MetaSetCreateQuestionTool());
|
toolManager.register(new MetaSetCreateChartTool());
|
||||||
toolManager.register(new MetaSetRunQuestionTool());
|
toolManager.register(new MetaSetListChartsTool());
|
||||||
toolManager.register(new MetaSetListQuestionsTool());
|
|
||||||
toolManager.register(new MetaSetCreateDashboardTool());
|
toolManager.register(new MetaSetCreateDashboardTool());
|
||||||
toolManager.register(new MetaSetListDashboardsTool());
|
toolManager.register(new MetaSetListDashboardsTool());
|
||||||
toolManager.register(new MetaSetAddToDashboardTool());
|
toolManager.register(new MetaSetGetDashboardTool());
|
||||||
toolManager.register(new MetaSetSuggestAnalysisTool());
|
toolManager.register(new MetaSetSuggestAnalysisTool());
|
||||||
toolManager.register(new MetaSetSyncTool());
|
toolManager.register(new MetaSetSyncTool());
|
||||||
toolManager.register(new MetaSetHealthTool());
|
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.`);
|
console.log(`[Tools] ${toolManager.getToolCount()} ferramentas registradas.`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 {
|
export class MetaSetQueryTool extends BaseTool {
|
||||||
name = "metaset.query";
|
name = "metaset.query";
|
||||||
|
|
@ -12,7 +22,9 @@ export class MetaSetQueryTool extends BaseTool {
|
||||||
|
|
||||||
async execute(params: Record<string, any>): Promise<ToolResult> {
|
async execute(params: Record<string, any>): Promise<ToolResult> {
|
||||||
try {
|
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 preview = result.rows.slice(0, 20).map(row => {
|
||||||
const obj: Record<string, any> = {};
|
const obj: Record<string, any> = {};
|
||||||
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||||
|
|
@ -36,7 +48,7 @@ export class MetaSetListTablesTool extends BaseTool {
|
||||||
|
|
||||||
async execute(): Promise<ToolResult> {
|
async execute(): Promise<ToolResult> {
|
||||||
try {
|
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");
|
const summary = tables.map(t => `- ${t.name} (${t.schema})`).join("\n");
|
||||||
return this.formatSuccess(
|
return this.formatSuccess(
|
||||||
`${tables.length} tabelas disponíveis:\n${summary}`,
|
`${tables.length} tabelas disponíveis:\n${summary}`,
|
||||||
|
|
@ -58,8 +70,9 @@ export class MetaSetTableFieldsTool extends BaseTool {
|
||||||
|
|
||||||
async execute(params: Record<string, any>): Promise<ToolResult> {
|
async execute(params: Record<string, any>): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const fields = await metasetClient.getTableFields(params.tableId);
|
const metadata = await metasetClient.getTableMetadata(params.tableId);
|
||||||
const summary = fields.map(f => `- ${f.name} (${f.type})`).join("\n");
|
const fields = metadata.result?.columns || [];
|
||||||
|
const summary = fields.map((f: any) => `- ${f.column_name} (${f.type})`).join("\n");
|
||||||
return this.formatSuccess(
|
return this.formatSuccess(
|
||||||
`${fields.length} colunas:\n${summary}`,
|
`${fields.length} colunas:\n${summary}`,
|
||||||
fields
|
fields
|
||||||
|
|
@ -70,85 +83,58 @@ export class MetaSetTableFieldsTool extends BaseTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MetaSetCreateQuestionTool extends BaseTool {
|
export class MetaSetCreateChartTool extends BaseTool {
|
||||||
name = "metaset.create_question";
|
name = "metaset.create_chart";
|
||||||
description = "Cria uma pergunta/consulta persistente no motor de BI. A pergunta fica salva e pode ser adicionada a dashboards.";
|
description = "Cria um gráfico/chart no motor de BI. O chart fica salvo e pode ser adicionado a dashboards.";
|
||||||
category = "bi";
|
category = "bi";
|
||||||
parameters: ToolParameter[] = [
|
parameters: ToolParameter[] = [
|
||||||
{ name: "name", type: "string", description: "Nome da pergunta/consulta", required: true },
|
{ name: "name", type: "string", description: "Nome do gráfico", required: true },
|
||||||
{ name: "query", type: "string", description: "Consulta SQL da pergunta", required: true },
|
{ name: "datasetId", type: "number", description: "ID do dataset/tabela", required: true },
|
||||||
{ name: "chartType", type: "string", description: "Tipo de visualização: table, bar, line, pie, area, scatter, row, scalar", required: false },
|
{ name: "vizType", type: "string", description: "Tipo: table, bar, line, pie, area, scatter", required: true },
|
||||||
{ name: "description", type: "string", description: "Descrição da pergunta", required: false },
|
{ name: "params", type: "object", description: "Parâmetros de visualização", required: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
async execute(params: Record<string, any>): Promise<ToolResult> {
|
async execute(params: Record<string, any>): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const question = await metasetClient.createQuestion({
|
const chart = await metasetClient.createChart({
|
||||||
name: params.name,
|
name: params.name,
|
||||||
description: params.description,
|
datasetId: params.datasetId,
|
||||||
queryType: "native",
|
vizType: params.vizType,
|
||||||
query: params.query,
|
params: params.params || {},
|
||||||
chartType: params.chartType || "table",
|
|
||||||
});
|
});
|
||||||
return this.formatSuccess(
|
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.`,
|
`Gráfico criado: "${chart.name}" (ID: ${chart.id}). Use metaset.add_to_dashboard para adicionar a um dashboard.`,
|
||||||
question
|
chart
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} 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 {
|
export class MetaSetListChartsTool extends BaseTool {
|
||||||
name = "metaset.run_question";
|
name = "metaset.list_charts";
|
||||||
description = "Executa uma pergunta salva no motor de BI e retorna os resultados atualizados.";
|
description = "Lista todos os gráficos/charts salvos no motor de BI.";
|
||||||
category = "bi";
|
|
||||||
parameters: ToolParameter[] = [
|
|
||||||
{ name: "questionId", type: "number", description: "ID da pergunta a executar", required: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
async execute(params: Record<string, any>): Promise<ToolResult> {
|
|
||||||
try {
|
|
||||||
const result = await metasetClient.runQuestion(params.questionId);
|
|
||||||
const preview = result.rows.slice(0, 20).map(row => {
|
|
||||||
const obj: Record<string, any> = {};
|
|
||||||
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.";
|
|
||||||
category = "bi";
|
category = "bi";
|
||||||
parameters: ToolParameter[] = [];
|
parameters: ToolParameter[] = [];
|
||||||
|
|
||||||
async execute(): Promise<ToolResult> {
|
async execute(): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const questions = await metasetClient.listQuestions();
|
const charts = await metasetClient.listCharts();
|
||||||
if (questions.length === 0) {
|
if (charts.length === 0) {
|
||||||
return this.formatSuccess("Nenhuma pergunta criada ainda. Use metaset.create_question para criar.", []);
|
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");
|
const summary = charts.map(c => `- [${c.id}] "${c.name}" (${c.vizType}) - ${c.description || "sem descrição"}`).join("\n");
|
||||||
return this.formatSuccess(`${questions.length} perguntas:\n${summary}`, questions);
|
return this.formatSuccess(`${charts.length} gráficos:\n${summary}`, charts);
|
||||||
} catch (err: any) {
|
} 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 {
|
export class MetaSetCreateDashboardTool extends BaseTool {
|
||||||
name = "metaset.create_dashboard";
|
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";
|
category = "bi";
|
||||||
parameters: ToolParameter[] = [
|
parameters: ToolParameter[] = [
|
||||||
{ name: "name", type: "string", description: "Nome do dashboard", required: true },
|
{ name: "name", type: "string", description: "Nome do dashboard", required: true },
|
||||||
|
|
@ -162,7 +148,7 @@ export class MetaSetCreateDashboardTool extends BaseTool {
|
||||||
description: params.description,
|
description: params.description,
|
||||||
});
|
});
|
||||||
return this.formatSuccess(
|
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
|
dashboard
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -191,36 +177,23 @@ export class MetaSetListDashboardsTool extends BaseTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MetaSetAddToDashboardTool extends BaseTool {
|
export class MetaSetGetDashboardTool extends BaseTool {
|
||||||
name = "metaset.add_to_dashboard";
|
name = "metaset.get_dashboard";
|
||||||
description = "Adiciona uma pergunta/gráfico a um dashboard existente.";
|
description = "Obtém detalhes de um dashboard específico.";
|
||||||
category = "bi";
|
category = "bi";
|
||||||
parameters: ToolParameter[] = [
|
parameters: ToolParameter[] = [
|
||||||
{ name: "dashboardId", type: "number", description: "ID do dashboard", required: true },
|
{ 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<string, any>): Promise<ToolResult> {
|
async execute(params: Record<string, any>): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
await metasetClient.addQuestionToDashboard(
|
const dashboard = await metasetClient.getDashboard(params.dashboardId);
|
||||||
params.dashboardId,
|
|
||||||
params.questionId,
|
|
||||||
{
|
|
||||||
x: params.x || 0,
|
|
||||||
y: params.y || 0,
|
|
||||||
w: params.width || 6,
|
|
||||||
h: params.height || 4,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return this.formatSuccess(
|
return this.formatSuccess(
|
||||||
`Pergunta ${params.questionId} adicionada ao dashboard ${params.dashboardId}.`
|
`Dashboard: "${dashboard.result?.dashboard_title}"`,
|
||||||
|
dashboard
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} 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<ToolResult> {
|
async execute(): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
await metasetClient.syncDatabase();
|
await metasetClient.syncDatabaseSchema(DEFAULT_DATABASE_ID);
|
||||||
return this.formatSuccess("Schema do banco sincronizado com o motor de BI.");
|
return this.formatSuccess("Schema do banco sincronizado com o motor de BI.");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return this.formatError(`Erro ao sincronizar: ${err.message}`);
|
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<string, any>): Promise<ToolResult> {
|
||||||
|
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<ToolResult> {
|
||||||
|
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<ToolResult> {
|
||||||
|
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<ToolResult> {
|
||||||
|
return this.formatSuccess("Funcionalidade de adicionar gráficos a dashboards em desenvolvimento para o novo MetaSet.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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<Database[]> {
|
||||||
|
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<Database> {
|
||||||
|
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<Database> {
|
||||||
|
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<void> {
|
||||||
|
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<Table[]> {
|
||||||
|
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<any> {
|
||||||
|
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<QueryResult> {
|
||||||
|
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<Chart[]> {
|
||||||
|
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<any> {
|
||||||
|
return await metasetFetch(`/api/v1/chart/${chartId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChart(params: {
|
||||||
|
name: string;
|
||||||
|
datasetId: number;
|
||||||
|
vizType: string;
|
||||||
|
params: Record<string, any>;
|
||||||
|
}): Promise<Chart> {
|
||||||
|
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<void> {
|
||||||
|
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<Dashboard[]> {
|
||||||
|
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<any> {
|
||||||
|
return await metasetFetch(`/api/v1/dashboard/${dashboardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDashboard(params: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
published?: boolean;
|
||||||
|
}): Promise<Dashboard> {
|
||||||
|
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<Dashboard>
|
||||||
|
): Promise<Dashboard> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
|
@ -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/*");
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { eq, desc, and, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerUploadRoutes } from "./upload";
|
import { registerUploadRoutes } from "./upload";
|
||||||
import { registerStagingRoutes } from "./staging";
|
import { registerStagingRoutes } from "./staging";
|
||||||
|
import { registerMetaSetRoutes } from "./metaset-client/routes";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
|
||||||
const dataSourceSchema = z.object({
|
const dataSourceSchema = z.object({
|
||||||
|
|
@ -89,6 +90,7 @@ const SYSTEM_TABLE_CATEGORIES: Record<string, { category: string; description: s
|
||||||
export function registerBiRoutes(app: Express): void {
|
export function registerBiRoutes(app: Express): void {
|
||||||
registerUploadRoutes(app);
|
registerUploadRoutes(app);
|
||||||
registerStagingRoutes(app);
|
registerStagingRoutes(app);
|
||||||
|
registerMetaSetRoutes(app); // MetaSet (Apache Superset) integration
|
||||||
|
|
||||||
app.get("/api/bi/internal-tables", async (req: Request, res: Response) => {
|
app.get("/api/bi/internal-tables", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -3112,8 +3112,9 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
private async toolMetaSetQuery(query: string, limit?: number): Promise<ToolResult> {
|
private async toolMetaSetQuery(query: string, limit?: number): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const result = await metasetClient.runNativeQuery(query, limit || 100);
|
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 preview = result.rows.slice(0, 20).map(row => {
|
||||||
const obj: Record<string, any> = {};
|
const obj: Record<string, any> = {};
|
||||||
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
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<ToolResult> {
|
private async toolMetaSetCreateQuestion(name: string, query: string, chartType?: string, description?: string): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const question = await metasetClient.createQuestion({
|
// Nota: No novo MetaSet (Superset), charts são criados a partir de datasets
|
||||||
name,
|
// Esta funcionalidade requer um dataset existente
|
||||||
queryType: "native",
|
|
||||||
query,
|
|
||||||
chartType: chartType || "table",
|
|
||||||
description,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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) {
|
} catch (error: any) {
|
||||||
return { success: false, output: "", error: `Erro ao criar pergunta: ${error.message}` };
|
return { success: false, output: "", error: `Erro ao criar pergunta: ${error.message}` };
|
||||||
|
|
@ -3149,13 +3145,13 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
private async toolMetaSetListQuestions(): Promise<ToolResult> {
|
private async toolMetaSetListQuestions(): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const questions = await metasetClient.listQuestions();
|
const charts = await metasetClient.listCharts();
|
||||||
if (questions.length === 0) {
|
if (charts.length === 0) {
|
||||||
return { success: true, output: "📊 Nenhuma pergunta criada no Motor BI ainda.\n\nUse metaset_create_question para criar uma." };
|
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");
|
const list = charts.map(c => `- [${c.id}] "${c.name}" (${c.vizType})`).join("\n");
|
||||||
return { success: true, output: `📊 ${questions.length} perguntas no Motor BI:\n\n${list}` };
|
return { success: true, output: `📊 ${charts.length} charts no MetaSet:\n\n${list}` };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return { success: false, output: "", error: `Erro: ${error.message}` };
|
return { success: false, output: "", error: `Erro: ${error.message}` };
|
||||||
}
|
}
|
||||||
|
|
@ -3163,8 +3159,9 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
private async toolMetaSetRunQuestion(questionId: number): Promise<ToolResult> {
|
private async toolMetaSetRunQuestion(questionId: number): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const result = await metasetClient.runQuestion(questionId);
|
// 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 preview = result.rows.slice(0, 20).map(row => {
|
||||||
const obj: Record<string, any> = {};
|
const obj: Record<string, any> = {};
|
||||||
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
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<ToolResult> {
|
private async toolMetaSetCreateDashboard(name: string, description?: string): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const dashboard = await metasetClient.createDashboard({ name, description });
|
const dashboard = await metasetClient.createDashboard({ name, description });
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -3194,7 +3191,7 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
private async toolMetaSetListDashboards(): Promise<ToolResult> {
|
private async toolMetaSetListDashboards(): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const dashboards = await metasetClient.listDashboards();
|
const dashboards = await metasetClient.listDashboards();
|
||||||
if (dashboards.length === 0) {
|
if (dashboards.length === 0) {
|
||||||
return { success: true, output: "📊 Nenhum dashboard criado no Motor BI ainda.\n\nUse metaset_create_dashboard para criar um." };
|
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<ToolResult> {
|
private async toolMetaSetAddToDashboard(dashboardId: number, questionId: number): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
await metasetClient.addQuestionToDashboard(dashboardId, questionId);
|
// 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: `📊 Pergunta ${questionId} adicionada ao dashboard ${dashboardId} no Motor BI.`
|
output: `📊 Pergunta ${questionId} adicionada ao dashboard ${dashboardId} no Motor BI.`
|
||||||
|
|
@ -3221,7 +3219,7 @@ class ManusService extends EventEmitter {
|
||||||
|
|
||||||
private async toolMetaSetSuggestAnalysis(tableName: string): Promise<ToolResult> {
|
private async toolMetaSetSuggestAnalysis(tableName: string): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const { metasetClient } = await import("../metaset/client");
|
const { metasetClient } = await import("../bi/metaset-client/index");
|
||||||
const suggestions = await metasetClient.getAutoSuggestions(tableName);
|
const suggestions = await metasetClient.getAutoSuggestions(tableName);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -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 <token>`
|
||||||
|
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 = <tenant_id>;
|
||||||
|
|
||||||
|
-- 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 <token>" \
|
||||||
|
-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 <token>" \
|
||||||
|
-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`
|
||||||
Loading…
Reference in New Issue