From 5571cb51e6cab9b2941098d219e81b481abb3470 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 17 Mar 2026 13:20:10 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Superset=20gateway=20=E2=80=94=20proxy?= =?UTF-8?q?=20/superset/*,=20guest-token,=20dashboards=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routes.ts | 5 ++ server/superset/proxy.ts | 39 ++++++++++ server/superset/routes.ts | 151 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 server/superset/proxy.ts create mode 100644 server/superset/routes.ts diff --git a/server/routes.ts b/server/routes.ts index 1b0fe2a..8b5fd26 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -48,6 +48,8 @@ import xosRoutes from "./xos/routes"; import governanceRoutes from "./governance/routes"; import { setupPlusProxy } from "./plus/proxy"; import { setupMetabaseProxy } from "./metabase/proxy"; +import { setupSupersetProxy } from "./superset/proxy"; +import { registerSupersetRoutes } from "./superset/routes"; import { registerEngineRoomRoutes } from "./engine-room/routes"; import { registerMetaSetRoutes } from "./metaset/routes"; import plusSsoRoutes from "./plus/sso"; @@ -70,6 +72,8 @@ export async function registerRoutes( // Arcádia Plus - Proxy registered AFTER session but BEFORE auth-protected routes await setupPlusProxy(app); + // Superset BI - Proxy + setupSupersetProxy(app); // Metabase BI - Proxy to Metabase instance setupMetabaseProxy(app); @@ -87,6 +91,7 @@ export async function registerRoutes( app.use("/api/graph", graphRoutes); registerBiEngineRoutes(app); registerMetaSetRoutes(app); + registerSupersetRoutes(app); registerCommEngineRoutes(app); registerLearningRoutes(app); app.use("/api/compass", compassRoutes); diff --git a/server/superset/proxy.ts b/server/superset/proxy.ts new file mode 100644 index 0000000..b17a1b5 --- /dev/null +++ b/server/superset/proxy.ts @@ -0,0 +1,39 @@ +import { Express, Response } from "express"; +import { createProxyMiddleware } from "http-proxy-middleware"; + +const SUPERSET_HOST = process.env.SUPERSET_HOST || "superset"; +const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10); +const SUPERSET_TIMEOUT = 60000; + +export function setupSupersetProxy(app: Express): void { + const target = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`; + + const supersetProxy = createProxyMiddleware({ + target, + changeOrigin: true, + timeout: SUPERSET_TIMEOUT, + proxyTimeout: SUPERSET_TIMEOUT, + pathRewrite: { "^/superset": "" }, + on: { + error: (err, _req, res) => { + console.error("[Superset Proxy] Error:", err.message); + if (res && typeof (res as Response).status === "function") { + (res as Response).status(502).json({ + error: "Superset indisponível", + message: "O Superset está iniciando. Tente novamente em alguns segundos.", + target, + }); + } + }, + proxyRes: (proxyRes) => { + const location = proxyRes.headers["location"]; + if (location && typeof location === "string" && location.startsWith("/")) { + proxyRes.headers["location"] = `/superset${location}`; + } + }, + }, + }); + + app.use("/superset", supersetProxy); + console.log(`[Superset Proxy] Configurado -> /superset/* => ${target}`); +} diff --git a/server/superset/routes.ts b/server/superset/routes.ts new file mode 100644 index 0000000..aee867a --- /dev/null +++ b/server/superset/routes.ts @@ -0,0 +1,151 @@ +import type { Express, Request, Response } from "express"; + +const SUPERSET_HOST = process.env.SUPERSET_HOST || "superset"; +const SUPERSET_PORT = parseInt(process.env.SUPERSET_PORT || "8088", 10); +const SUPERSET_URL = `http://${SUPERSET_HOST}:${SUPERSET_PORT}`; +const ADMIN_USER = process.env.SUPERSET_ADMIN_USERNAME || "admin"; +const ADMIN_PASS = process.env.SUPERSET_ADMIN_PASSWORD || "arcadia1337"; + +// Cache do service token (expira em 50 min) +let serviceToken: string | null = null; +let serviceTokenExpiry = 0; + +async function getServiceToken(): Promise { + if (serviceToken && Date.now() < serviceTokenExpiry) return serviceToken; + + const resp = await fetch(`${SUPERSET_URL}/api/v1/security/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: ADMIN_USER, + password: ADMIN_PASS, + provider: "db", + refresh: true, + }), + }); + + if (!resp.ok) { + const err = await resp.text().catch(() => ""); + throw new Error(`Falha ao autenticar no Superset: ${resp.status} ${err}`); + } + + const data = await resp.json(); + serviceToken = data.access_token; + serviceTokenExpiry = Date.now() + 50 * 60 * 1000; + return serviceToken!; +} + +async function supersetFetch(path: string, options: RequestInit = {}): Promise { + const token = await getServiceToken(); + const resp = await fetch(`${SUPERSET_URL}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(options.headers || {}), + }, + }); + + if (resp.status === 401) { + // Token expirado — forçar renovação + serviceToken = null; + serviceTokenExpiry = 0; + return supersetFetch(path, options); + } + + if (!resp.ok) { + const err = await resp.text().catch(() => ""); + throw new Error(`Superset API ${resp.status}: ${err}`); + } + + return resp.json().catch(() => ({})); +} + +export function registerSupersetRoutes(app: Express): void { + + // Guest Token — para embedding em qualquer tela React + app.post("/api/superset/guest-token", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + + const { dashboardId } = req.body; + if (!dashboardId) return res.status(400).json({ error: "dashboardId obrigatório" }); + + const token = await getServiceToken(); + const user = req.user as any; + + const guestResp = await fetch(`${SUPERSET_URL}/api/v1/security/guest_token/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: { + username: `arcadia_${user?.id || "guest"}`, + first_name: user?.name?.split(" ")[0] || "User", + last_name: user?.name?.split(" ").slice(1).join(" ") || "", + }, + resources: [{ type: "dashboard", id: dashboardId }], + rls: [], + }), + }); + + if (!guestResp.ok) { + const err = await guestResp.text(); + return res.status(502).json({ error: "Falha ao gerar guest token", detail: err }); + } + + const { token: guestToken } = await guestResp.json(); + res.json({ token: guestToken, supersetUrl: "/superset" }); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // Lista dashboards + app.get("/api/superset/dashboards", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const data = await supersetFetch( + "/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page_size:50)" + ); + res.json(data.result || []); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // Lista charts + app.get("/api/superset/charts", async (req: Request, res: Response) => { + try { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const data = await supersetFetch("/api/v1/chart/?q=(page_size:50)"); + res.json(data.result || []); + } catch (err: any) { + res.status(502).json({ error: err.message }); + } + }); + + // Health check + app.get("/api/superset/health", async (_req: Request, res: Response) => { + try { + const resp = await fetch(`${SUPERSET_URL}/health`); + res.json({ online: resp.ok, url: SUPERSET_URL }); + } catch { + res.json({ online: false, url: SUPERSET_URL }); + } + }); + + // Manter compatibilidade com rotas antigas do MetaSet + app.get("/api/bi/metaset/health", async (_req: Request, res: Response) => { + try { + const resp = await fetch(`${SUPERSET_URL}/health`); + res.json({ service: "Superset", online: resp.ok }); + } catch { + res.json({ service: "Superset", online: false }); + } + }); + + console.log("[Superset] Rotas registradas em /api/superset/*"); +}