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:
Jonas Pacheco 2026-04-08 13:21:34 -03:00
parent 95ee51bf68
commit a7ecf0defb
9 changed files with 1316 additions and 110 deletions

View File

@ -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<void> {
@ -42,18 +46,23 @@ export async function registerAllTools(): Promise<void> {
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.`);

View File

@ -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<string, any>): Promise<ToolResult> {
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<string, any> = {};
result.columns.forEach((col, i) => { obj[col] = row[i]; });
@ -36,7 +48,7 @@ export class MetaSetListTablesTool extends BaseTool {
async execute(): Promise<ToolResult> {
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<string, any>): Promise<ToolResult> {
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<string, any>): Promise<ToolResult> {
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<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.";
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<ToolResult> {
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<string, any>): Promise<ToolResult> {
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<ToolResult> {
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<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.");
}
}

View File

@ -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;

View File

@ -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/*");
}

View File

@ -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<string, { category: string; description: s
export function registerBiRoutes(app: Express): void {
registerUploadRoutes(app);
registerStagingRoutes(app);
registerMetaSetRoutes(app); // MetaSet (Apache Superset) integration
app.get("/api/bi/internal-tables", async (req: Request, res: Response) => {
try {

View File

@ -3112,8 +3112,9 @@ class ManusService extends EventEmitter {
private async toolMetaSetQuery(query: string, limit?: number): Promise<ToolResult> {
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<string, any> = {};
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> {
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<ToolResult> {
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<ToolResult> {
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<string, any> = {};
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> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
try {
const { metasetClient } = await import("../metaset/client");
const { metasetClient } = await import("../bi/metaset-client/index");
const suggestions = await metasetClient.getAutoSuggestions(tableName);
return {
success: true,

290
server/metaset/README.md Normal file
View File

@ -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`