arcadiasuite/server/bi/metaset-client/index.ts

552 lines
15 KiB
TypeScript

/**
* 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;