@dataif/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/bin/dataif.js +623 -0
- package/package.json +26 -0
- package/scripts/build-template.mjs +72 -0
- package/templates/dataif/README.md +157 -0
- package/templates/dataif/infra/.env.example +119 -0
- package/templates/dataif/infra/.env.stg.example +119 -0
- package/templates/dataif/infra/airflow/Dockerfile +11 -0
- package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
- package/templates/dataif/infra/airflow/requirements.txt +3 -0
- package/templates/dataif/infra/docker-compose.yml +306 -0
- package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
- package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
- package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
- package/templates/dataif/infra/keycloak/Dockerfile +4 -0
- package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
- package/templates/dataif/infra/ollama/Dockerfile +9 -0
- package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
- package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
- package/templates/dataif/infra/postgres/Dockerfile +4 -0
- package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
- package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
- package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
- package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
- package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
- package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
- package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
- package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
- package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
- package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
- package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
- package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
- package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
- package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
- package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
- package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
- package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
- package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
- package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
- package/templates/dataif/scripts/configure-env.sh +149 -0
- package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
- package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
- package/templates/dataif/scripts/deploy.sh +79 -0
- package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
- package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
- package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
- package/templates/dataif/scripts/publish-images.sh +31 -0
- package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
- package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
- package/templates/dataif/services/api/.dockerignore +18 -0
- package/templates/dataif/services/api/Dockerfile +12 -0
- package/templates/dataif/services/api/app/__init__.py +1 -0
- package/templates/dataif/services/api/app/auth.py +48 -0
- package/templates/dataif/services/api/app/config.py +59 -0
- package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
- package/templates/dataif/services/api/app/main.py +2432 -0
- package/templates/dataif/services/api/app/metabase_admin.py +191 -0
- package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
- package/templates/dataif/services/api/app/metabase_embed.py +15 -0
- package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
- package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
- package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
- package/templates/dataif/services/api/app/vanna_client.py +32 -0
- package/templates/dataif/services/api/requirements.txt +9 -0
- package/templates/dataif/services/vanna/.dockerignore +18 -0
- package/templates/dataif/services/vanna/Dockerfile +12 -0
- package/templates/dataif/services/vanna/app/config.py +57 -0
- package/templates/dataif/services/vanna/app/main.py +108 -0
- package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
- package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
- package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
- package/templates/dataif/services/vanna/requirements.txt +8 -0
- package/templates/dataif/services/web/.dockerignore +13 -0
- package/templates/dataif/services/web/Dockerfile +16 -0
- package/templates/dataif/services/web/index.html +12 -0
- package/templates/dataif/services/web/nginx.conf +74 -0
- package/templates/dataif/services/web/package-lock.json +4397 -0
- package/templates/dataif/services/web/package.json +32 -0
- package/templates/dataif/services/web/postcss.config.mjs +5 -0
- package/templates/dataif/services/web/src/App.jsx +2817 -0
- package/templates/dataif/services/web/src/adminAuth.js +245 -0
- package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
- package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
- package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
- package/templates/dataif/services/web/src/assets/if.svg +0 -0
- package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
- package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
- package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
- package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
- package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
- package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
- package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
- package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
- package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
- package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
- package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
- package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
- package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
- package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
- package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
- package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
- package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
- package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
- package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
- package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
- package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
- package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
- package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
- package/templates/dataif/services/web/src/main.jsx +14 -0
- package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
- package/templates/dataif/services/web/src/styles/globals.css +60 -0
- package/templates/dataif/services/web/src/styles/theme.css +1326 -0
- package/templates/dataif/services/web/src/styles/typography.css +430 -0
- package/templates/dataif/services/web/src/styles.css +1287 -0
- package/templates/dataif/services/web/src/utils/cx.ts +24 -0
- package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
- package/templates/dataif/services/web/vite.config.js +14 -0
- package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
- package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
- package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
- package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
- package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
- package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
- package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
- package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
- package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
- package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
- package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
- package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
- package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
- package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
- package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
- package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
- package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
- package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
- package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
- package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
- package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
- package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
- package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
- package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
- package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
- package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
- package/templates/dataif/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
|
@@ -0,0 +1,2817 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useAdminAuth } from "./adminAuth";
|
|
4
|
+
import AppHeader from "./components/AppHeader";
|
|
5
|
+
import { cx } from "./utils/cx";
|
|
6
|
+
|
|
7
|
+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
|
|
8
|
+
const METABASE_URL = import.meta.env.VITE_METABASE_URL || "/metabase/";
|
|
9
|
+
const AIRFLOW_URL = import.meta.env.VITE_AIRFLOW_URL || "http://localhost:8088/airflow/";
|
|
10
|
+
const GITHUB_REPO_URL = import.meta.env.VITE_GITHUB_REPO_URL || "https://github.com/iruy-fr/dataif";
|
|
11
|
+
|
|
12
|
+
const NAV_ITEMS = [
|
|
13
|
+
{ path: "/", label: "Início" },
|
|
14
|
+
{ path: "/pipelines", label: "Pipelines" },
|
|
15
|
+
{ path: "/conexoes", label: "Conexões" },
|
|
16
|
+
{ path: "/dashboards", label: "Dashboards" },
|
|
17
|
+
{ path: "/sql", label: "SQL" },
|
|
18
|
+
];
|
|
19
|
+
const AUTH_REQUIRED_NAV_PATHS = new Set(["/pipelines", "/conexoes", "/dashboards", "/sql"]);
|
|
20
|
+
|
|
21
|
+
const ADMIN_NAV_ITEMS = [
|
|
22
|
+
{ path: "/admin", label: "Workspace" },
|
|
23
|
+
{ path: "/admin/airflow", label: "Airflow" },
|
|
24
|
+
{ path: "/admin/metabase", label: "Metabase" },
|
|
25
|
+
{ path: "/admin/sgbd", label: "SGBD" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const LOGIN_ROUTE = "/login";
|
|
29
|
+
const SETTINGS_ROUTE = "/configurações";
|
|
30
|
+
const ADMIN_SETTINGS_ROUTE = "/admin/configurações";
|
|
31
|
+
const CONNECTIONS_ROUTE = "/conexoes";
|
|
32
|
+
const CONNECTION_CREATE_ROUTE = "/conexoes/nova";
|
|
33
|
+
const CONNECTION_DETAIL_ROUTE = "/conexoes/detalhes";
|
|
34
|
+
const PIPELINE_CREATE_ROUTE = "/pipelines/nova";
|
|
35
|
+
const RETURN_ROUTE_KEY = "dataif.admin.returnRoute";
|
|
36
|
+
|
|
37
|
+
const ROUTE_PATHS = new Set([
|
|
38
|
+
LOGIN_ROUTE,
|
|
39
|
+
SETTINGS_ROUTE,
|
|
40
|
+
ADMIN_SETTINGS_ROUTE,
|
|
41
|
+
CONNECTIONS_ROUTE,
|
|
42
|
+
CONNECTION_CREATE_ROUTE,
|
|
43
|
+
CONNECTION_DETAIL_ROUTE,
|
|
44
|
+
PIPELINE_CREATE_ROUTE,
|
|
45
|
+
...NAV_ITEMS.map((item) => item.path),
|
|
46
|
+
...ADMIN_NAV_ITEMS.map((item) => item.path),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const INITIAL_CONNECTION_FORM = {
|
|
50
|
+
connection_name: "",
|
|
51
|
+
is_active: true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const INITIAL_PIPELINE_FORM = {
|
|
55
|
+
pipeline_name: "",
|
|
56
|
+
connection_key: "",
|
|
57
|
+
schedule: "0 3 * * *",
|
|
58
|
+
selected_years: [],
|
|
59
|
+
selected_microdados_types: [],
|
|
60
|
+
is_active: true,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const INITIAL_ADMIN_LLM_FORM = {
|
|
64
|
+
provider: "ollama",
|
|
65
|
+
ollama: {
|
|
66
|
+
base_url: "http://ollama:11434",
|
|
67
|
+
model: "sabia-7b",
|
|
68
|
+
},
|
|
69
|
+
maritaca: {
|
|
70
|
+
api_url: "https://chat.maritaca.ai/api/chat/completions",
|
|
71
|
+
model: "sabia-4",
|
|
72
|
+
timeout_seconds: 60,
|
|
73
|
+
api_key: "",
|
|
74
|
+
clear_api_key: false,
|
|
75
|
+
has_api_key: false,
|
|
76
|
+
masked_api_key: "",
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const INITIAL_ADMIN_USER_FORM = {
|
|
81
|
+
username: "",
|
|
82
|
+
email: "",
|
|
83
|
+
first_name: "",
|
|
84
|
+
last_name: "",
|
|
85
|
+
password: "",
|
|
86
|
+
enabled: true,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function getRouteFromHash() {
|
|
90
|
+
const raw = window.location.hash.replace(/^#/, "") || "/";
|
|
91
|
+
return ROUTE_PATHS.has(raw) ? raw : "/";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function navigate(path) {
|
|
95
|
+
window.location.hash = path;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function storeReturnRoute(path) {
|
|
99
|
+
const safePath = path && path !== LOGIN_ROUTE ? path : "/pipelines";
|
|
100
|
+
window.sessionStorage.setItem(RETURN_ROUTE_KEY, safePath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function consumeReturnRoute() {
|
|
104
|
+
const path = window.sessionStorage.getItem(RETURN_ROUTE_KEY) || "/pipelines";
|
|
105
|
+
window.sessionStorage.removeItem(RETURN_ROUTE_KEY);
|
|
106
|
+
return path;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isAdminRoute(path) {
|
|
110
|
+
return path === "/admin" || path.startsWith("/admin/");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatTimestamp(value) {
|
|
114
|
+
if (!value) {
|
|
115
|
+
return "-";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const date = new Date(value);
|
|
119
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("pt-BR");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatStatus(status) {
|
|
123
|
+
if (!status) {
|
|
124
|
+
return "nao iniciado";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return String(status).replaceAll("_", " ");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function statusTone(status) {
|
|
131
|
+
if (["success", "ready", "raw_loaded", "validated"].includes(status)) {
|
|
132
|
+
return "success";
|
|
133
|
+
}
|
|
134
|
+
if (["failed", "error"].includes(status)) {
|
|
135
|
+
return "danger";
|
|
136
|
+
}
|
|
137
|
+
if (["running", "queued", "pending", "not_started"].includes(status)) {
|
|
138
|
+
return "warning";
|
|
139
|
+
}
|
|
140
|
+
return "neutral";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildHeaders(token, hasJsonBody = true) {
|
|
144
|
+
return {
|
|
145
|
+
Authorization: `Bearer ${token}`,
|
|
146
|
+
...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ButtonLink({ href, children, secondary = false }) {
|
|
151
|
+
return (
|
|
152
|
+
<a
|
|
153
|
+
className={`button-link${secondary ? " secondary" : ""}`}
|
|
154
|
+
href={href}
|
|
155
|
+
target={href.startsWith("http") ? "_blank" : undefined}
|
|
156
|
+
rel={href.startsWith("http") ? "noreferrer" : undefined}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</a>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function PageHeader({ title, actions, children }) {
|
|
164
|
+
return (
|
|
165
|
+
<div className="page-header">
|
|
166
|
+
<div className="page-header-copy">
|
|
167
|
+
<h1>{title}</h1>
|
|
168
|
+
{children ? <p>{children}</p> : null}
|
|
169
|
+
</div>
|
|
170
|
+
{actions ? <div className="actions-row">{actions}</div> : null}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function Panel({ title, action, className, children }) {
|
|
176
|
+
return (
|
|
177
|
+
<article className={cx("panel", className)}>
|
|
178
|
+
{title || action ? (
|
|
179
|
+
<div className="panel-header">
|
|
180
|
+
{title ? <h2>{title}</h2> : <span />}
|
|
181
|
+
{action ? <div className="actions-row">{action}</div> : null}
|
|
182
|
+
</div>
|
|
183
|
+
) : null}
|
|
184
|
+
{children}
|
|
185
|
+
</article>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function StatusBadge({ status }) {
|
|
190
|
+
return <span className={`status-badge ${statusTone(status)}`}>{formatStatus(status)}</span>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function SummaryGrid({ items, className }) {
|
|
194
|
+
return (
|
|
195
|
+
<dl className={cx("summary-grid", className)}>
|
|
196
|
+
{items.map((item) => (
|
|
197
|
+
<div key={item.label} className="summary-item">
|
|
198
|
+
<dt>{item.label}</dt>
|
|
199
|
+
<dd>{item.value}</dd>
|
|
200
|
+
</div>
|
|
201
|
+
))}
|
|
202
|
+
</dl>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function RunsTable({ runs, emptyMessage }) {
|
|
207
|
+
return (
|
|
208
|
+
<div className="table-shell">
|
|
209
|
+
<table>
|
|
210
|
+
<thead>
|
|
211
|
+
<tr>
|
|
212
|
+
<th>DAG</th>
|
|
213
|
+
<th>Status</th>
|
|
214
|
+
<th>Início</th>
|
|
215
|
+
<th>Fim</th>
|
|
216
|
+
</tr>
|
|
217
|
+
</thead>
|
|
218
|
+
<tbody>
|
|
219
|
+
{runs.map((run) => (
|
|
220
|
+
<tr key={`${run.dag_id}-${run.dag_run_id}`}>
|
|
221
|
+
<td>
|
|
222
|
+
<strong>{run.dag_id}</strong>
|
|
223
|
+
<span>{run.dag_run_id}</span>
|
|
224
|
+
</td>
|
|
225
|
+
<td>
|
|
226
|
+
<StatusBadge status={run.state} />
|
|
227
|
+
</td>
|
|
228
|
+
<td>{formatTimestamp(run.start_date || run.logical_date)}</td>
|
|
229
|
+
<td>{formatTimestamp(run.end_date)}</td>
|
|
230
|
+
</tr>
|
|
231
|
+
))}
|
|
232
|
+
{runs.length === 0 ? (
|
|
233
|
+
<tr>
|
|
234
|
+
<td colSpan="4">{emptyMessage}</td>
|
|
235
|
+
</tr>
|
|
236
|
+
) : null}
|
|
237
|
+
</tbody>
|
|
238
|
+
</table>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function SqlResultsTable({ result, emptyMessage = "Nenhum resultado." }) {
|
|
244
|
+
if (!result) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!result.rows.length) {
|
|
249
|
+
return (
|
|
250
|
+
<div className="empty-state compact-empty-state">
|
|
251
|
+
<p>{emptyMessage}</p>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div className="table-shell">
|
|
258
|
+
<table>
|
|
259
|
+
<thead>
|
|
260
|
+
<tr>
|
|
261
|
+
{result.fields.map((field) => (
|
|
262
|
+
<th key={field.name}>{field.name}</th>
|
|
263
|
+
))}
|
|
264
|
+
</tr>
|
|
265
|
+
</thead>
|
|
266
|
+
<tbody>
|
|
267
|
+
{result.rows.map((row, rowIndex) => (
|
|
268
|
+
<tr key={`row-${rowIndex}`}>
|
|
269
|
+
{result.fields.map((field) => (
|
|
270
|
+
<td key={`${rowIndex}-${field.name}`}>{formatSqlCell(row[field.name])}</td>
|
|
271
|
+
))}
|
|
272
|
+
</tr>
|
|
273
|
+
))}
|
|
274
|
+
</tbody>
|
|
275
|
+
</table>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function formatSqlCell(value) {
|
|
281
|
+
if (value === null || value === undefined || value === "") {
|
|
282
|
+
return "-";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (typeof value === "object") {
|
|
286
|
+
return JSON.stringify(value);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return String(value);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function serializeSelection(value) {
|
|
293
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return value.join(", ");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function AdminGate({ auth, message, onLoginRequest }) {
|
|
301
|
+
return (
|
|
302
|
+
<section className="page">
|
|
303
|
+
<PageHeader title="Area restrita">{message}</PageHeader>
|
|
304
|
+
<Panel>
|
|
305
|
+
<div className="empty-state">
|
|
306
|
+
<p>{message}</p>
|
|
307
|
+
<div className="actions-row">
|
|
308
|
+
<button type="button" onClick={onLoginRequest} disabled={auth.status === "loading"}>
|
|
309
|
+
{auth.status === "loading" ? "Verificando..." : "Login"}
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</Panel>
|
|
314
|
+
</section>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function LoginPage({ auth, onSubmit, onBack }) {
|
|
319
|
+
const [username, setUsername] = useState("");
|
|
320
|
+
const [password, setPassword] = useState("");
|
|
321
|
+
|
|
322
|
+
async function handleSubmit(event) {
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
await onSubmit(username, password);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (auth.status === "authenticated") {
|
|
328
|
+
return (
|
|
329
|
+
<section className="page">
|
|
330
|
+
<PageHeader title="Sessão ativa">O acesso administrativo ja esta liberado.</PageHeader>
|
|
331
|
+
<Panel>
|
|
332
|
+
<div className="actions-row">
|
|
333
|
+
<button type="button" onClick={onBack}>
|
|
334
|
+
Continuar
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
</Panel>
|
|
338
|
+
</section>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<section className="page narrow-page">
|
|
344
|
+
<PageHeader title="Login">Acesso administrativo local.</PageHeader>
|
|
345
|
+
<Panel>
|
|
346
|
+
<form className="form-grid" onSubmit={handleSubmit}>
|
|
347
|
+
<label className="field">
|
|
348
|
+
<span>Usuario</span>
|
|
349
|
+
<input
|
|
350
|
+
type="text"
|
|
351
|
+
value={username}
|
|
352
|
+
onChange={(event) => setUsername(event.target.value)}
|
|
353
|
+
placeholder="dataif-admin"
|
|
354
|
+
autoComplete="username"
|
|
355
|
+
required
|
|
356
|
+
/>
|
|
357
|
+
</label>
|
|
358
|
+
|
|
359
|
+
<label className="field">
|
|
360
|
+
<span>Senha</span>
|
|
361
|
+
<input
|
|
362
|
+
type="password"
|
|
363
|
+
value={password}
|
|
364
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
365
|
+
placeholder="********"
|
|
366
|
+
autoComplete="current-password"
|
|
367
|
+
required
|
|
368
|
+
/>
|
|
369
|
+
</label>
|
|
370
|
+
|
|
371
|
+
<div className="actions-row">
|
|
372
|
+
<button type="submit" disabled={auth.status === "loading"}>
|
|
373
|
+
{auth.status === "loading" ? "Entrando..." : "Entrar"}
|
|
374
|
+
</button>
|
|
375
|
+
<button type="button" className="secondary" onClick={onBack}>
|
|
376
|
+
Voltar
|
|
377
|
+
</button>
|
|
378
|
+
</div>
|
|
379
|
+
</form>
|
|
380
|
+
</Panel>
|
|
381
|
+
</section>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function SettingsPage({ auth, onLoginRequest, onLogout }) {
|
|
386
|
+
if (auth.status !== "authenticated") {
|
|
387
|
+
return (
|
|
388
|
+
<AdminGate
|
|
389
|
+
auth={auth}
|
|
390
|
+
message="As configurações exigem sessão autenticada."
|
|
391
|
+
onLoginRequest={onLoginRequest}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const accountLabel = auth.claims?.preferred_username || auth.claims?.email || "Administrador";
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<section className="page">
|
|
400
|
+
<PageHeader
|
|
401
|
+
title="Conta"
|
|
402
|
+
actions={
|
|
403
|
+
<>
|
|
404
|
+
<button type="button" className="secondary" onClick={() => navigate("/admin")}>
|
|
405
|
+
Workspace
|
|
406
|
+
</button>
|
|
407
|
+
<button type="button" onClick={onLogout}>
|
|
408
|
+
Sair
|
|
409
|
+
</button>
|
|
410
|
+
</>
|
|
411
|
+
}
|
|
412
|
+
/>
|
|
413
|
+
<Panel>
|
|
414
|
+
<SummaryGrid
|
|
415
|
+
items={[
|
|
416
|
+
{ label: "Usuario", value: accountLabel },
|
|
417
|
+
{ label: "Status", value: "Sessão ativa" },
|
|
418
|
+
{ label: "Escopo", value: "Workspace administrativo" },
|
|
419
|
+
]}
|
|
420
|
+
/>
|
|
421
|
+
</Panel>
|
|
422
|
+
</section>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function normalizeAdminLlmForm(payload) {
|
|
427
|
+
const config = payload?.config || {};
|
|
428
|
+
const ollama = config.ollama || {};
|
|
429
|
+
const maritaca = config.maritaca || {};
|
|
430
|
+
return {
|
|
431
|
+
provider: config.provider || "ollama",
|
|
432
|
+
ollama: {
|
|
433
|
+
base_url: ollama.base_url || INITIAL_ADMIN_LLM_FORM.ollama.base_url,
|
|
434
|
+
model: ollama.model || INITIAL_ADMIN_LLM_FORM.ollama.model,
|
|
435
|
+
},
|
|
436
|
+
maritaca: {
|
|
437
|
+
api_url: maritaca.api_url || INITIAL_ADMIN_LLM_FORM.maritaca.api_url,
|
|
438
|
+
model: maritaca.model || INITIAL_ADMIN_LLM_FORM.maritaca.model,
|
|
439
|
+
timeout_seconds: maritaca.timeout_seconds || INITIAL_ADMIN_LLM_FORM.maritaca.timeout_seconds,
|
|
440
|
+
api_key: "",
|
|
441
|
+
clear_api_key: false,
|
|
442
|
+
has_api_key: Boolean(maritaca.has_api_key),
|
|
443
|
+
api_key_scope: maritaca.api_key_scope || "empty",
|
|
444
|
+
has_personal_api_key: Boolean(maritaca.has_personal_api_key),
|
|
445
|
+
masked_api_key: maritaca.masked_api_key || "",
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function AdminSettingsPage({ auth, onLoginRequest, adminRequest }) {
|
|
451
|
+
const [llmForm, setLlmForm] = useState(INITIAL_ADMIN_LLM_FORM);
|
|
452
|
+
const [llmStatus, setLlmStatus] = useState(null);
|
|
453
|
+
const [users, setUsers] = useState([]);
|
|
454
|
+
const [userForm, setUserForm] = useState(INITIAL_ADMIN_USER_FORM);
|
|
455
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
456
|
+
const [isSavingLlm, setIsSavingLlm] = useState(false);
|
|
457
|
+
const [isCreatingUser, setIsCreatingUser] = useState(false);
|
|
458
|
+
const [deletingUserId, setDeletingUserId] = useState("");
|
|
459
|
+
const [syncingUserId, setSyncingUserId] = useState("");
|
|
460
|
+
const [localNotice, setLocalNotice] = useState("");
|
|
461
|
+
const [localError, setLocalError] = useState("");
|
|
462
|
+
|
|
463
|
+
async function loadAdminSettings() {
|
|
464
|
+
setIsLoading(true);
|
|
465
|
+
setLocalError("");
|
|
466
|
+
try {
|
|
467
|
+
const [llmResponse, userResponse] = await Promise.all([
|
|
468
|
+
adminRequest("/api/admin/settings/llm"),
|
|
469
|
+
adminRequest("/api/admin/users"),
|
|
470
|
+
]);
|
|
471
|
+
setLlmForm(normalizeAdminLlmForm(llmResponse));
|
|
472
|
+
setLlmStatus(llmResponse.status || null);
|
|
473
|
+
setUsers(userResponse.items || []);
|
|
474
|
+
} catch (requestError) {
|
|
475
|
+
setLocalError(requestError instanceof Error ? requestError.message : "Falha ao carregar as configurações administrativas.");
|
|
476
|
+
} finally {
|
|
477
|
+
setIsLoading(false);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
if (auth.status !== "authenticated") {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
loadAdminSettings();
|
|
486
|
+
}, [auth.status]);
|
|
487
|
+
|
|
488
|
+
if (auth.status !== "authenticated") {
|
|
489
|
+
return (
|
|
490
|
+
<AdminGate
|
|
491
|
+
auth={auth}
|
|
492
|
+
message="A configuração administrativa exige sessão autenticada."
|
|
493
|
+
onLoginRequest={onLoginRequest}
|
|
494
|
+
/>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function handleSaveLlmSettings(event) {
|
|
499
|
+
event.preventDefault();
|
|
500
|
+
setIsSavingLlm(true);
|
|
501
|
+
setLocalError("");
|
|
502
|
+
setLocalNotice("");
|
|
503
|
+
try {
|
|
504
|
+
const response = await adminRequest("/api/admin/settings/llm", {
|
|
505
|
+
method: "PATCH",
|
|
506
|
+
body: {
|
|
507
|
+
provider: llmForm.provider,
|
|
508
|
+
ollama: llmForm.ollama,
|
|
509
|
+
maritaca: {
|
|
510
|
+
api_url: llmForm.maritaca.api_url,
|
|
511
|
+
model: llmForm.maritaca.model,
|
|
512
|
+
timeout_seconds: Number(llmForm.maritaca.timeout_seconds) || 60,
|
|
513
|
+
api_key: llmForm.maritaca.api_key || undefined,
|
|
514
|
+
clear_api_key: llmForm.maritaca.clear_api_key,
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
setLlmForm(normalizeAdminLlmForm(response));
|
|
519
|
+
setLlmStatus(response.status || null);
|
|
520
|
+
setLocalNotice("Configuração de LLM atualizada.");
|
|
521
|
+
} catch (requestError) {
|
|
522
|
+
setLocalError(requestError instanceof Error ? requestError.message : "Falha ao salvar a configuração de LLM.");
|
|
523
|
+
} finally {
|
|
524
|
+
setIsSavingLlm(false);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function handleCreateUser(event) {
|
|
529
|
+
event.preventDefault();
|
|
530
|
+
setIsCreatingUser(true);
|
|
531
|
+
setLocalError("");
|
|
532
|
+
setLocalNotice("");
|
|
533
|
+
try {
|
|
534
|
+
const response = await adminRequest("/api/admin/users", {
|
|
535
|
+
method: "POST",
|
|
536
|
+
body: userForm,
|
|
537
|
+
});
|
|
538
|
+
setUsers((current) => [...current, response.user].sort((left, right) => left.username.localeCompare(right.username)));
|
|
539
|
+
setUserForm(INITIAL_ADMIN_USER_FORM);
|
|
540
|
+
setLocalNotice(`Usuario admin ${response.user.username} criado no Keycloak e no Metabase.`);
|
|
541
|
+
} catch (requestError) {
|
|
542
|
+
setLocalError(requestError instanceof Error ? requestError.message : "Falha ao criar usuario admin.");
|
|
543
|
+
} finally {
|
|
544
|
+
setIsCreatingUser(false);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function handleDeleteUser(userId, username) {
|
|
549
|
+
if (!userId || !window.confirm(`Excluir o usuario admin ${username}?`)) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
setDeletingUserId(userId);
|
|
554
|
+
setLocalError("");
|
|
555
|
+
setLocalNotice("");
|
|
556
|
+
try {
|
|
557
|
+
await adminRequest(`/api/admin/users/${userId}`, { method: "DELETE" });
|
|
558
|
+
setUsers((current) => current.filter((item) => item.id !== userId));
|
|
559
|
+
setLocalNotice(`Usuario admin ${username} removido do Keycloak e desativado no Metabase.`);
|
|
560
|
+
} catch (requestError) {
|
|
561
|
+
setLocalError(requestError instanceof Error ? requestError.message : "Falha ao remover usuario admin.");
|
|
562
|
+
} finally {
|
|
563
|
+
setDeletingUserId("");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function handleSyncMetabaseUser(userId, username) {
|
|
568
|
+
const password = window.prompt(`Senha inicial do Metabase para ${username}`);
|
|
569
|
+
if (!userId || !password) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
setSyncingUserId(userId);
|
|
574
|
+
setLocalError("");
|
|
575
|
+
setLocalNotice("");
|
|
576
|
+
try {
|
|
577
|
+
const response = await adminRequest(`/api/admin/users/${userId}/metabase-sync`, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
body: { password },
|
|
580
|
+
});
|
|
581
|
+
setUsers((current) =>
|
|
582
|
+
current
|
|
583
|
+
.map((item) => (item.id === userId ? response.user : item))
|
|
584
|
+
.sort((left, right) => left.username.localeCompare(right.username)),
|
|
585
|
+
);
|
|
586
|
+
setLocalNotice(`Usuario admin ${username} sincronizado no Metabase.`);
|
|
587
|
+
} catch (requestError) {
|
|
588
|
+
setLocalError(requestError instanceof Error ? requestError.message : "Falha ao sincronizar usuario no Metabase.");
|
|
589
|
+
} finally {
|
|
590
|
+
setSyncingUserId("");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<section className="page">
|
|
596
|
+
<PageHeader title="Configurações do Administrador">Persistência de LLM e gestão de admins do Keycloak.</PageHeader>
|
|
597
|
+
|
|
598
|
+
{localNotice ? <p className="notice-banner">{localNotice}</p> : null}
|
|
599
|
+
{localError ? <p className="error-banner">{localError}</p> : null}
|
|
600
|
+
|
|
601
|
+
<Panel title="Resumo atual">
|
|
602
|
+
<SummaryGrid
|
|
603
|
+
items={[
|
|
604
|
+
{ label: "Provider", value: llmForm.provider },
|
|
605
|
+
{ label: "Status", value: llmStatus?.available ? "disponível" : "indisponível" },
|
|
606
|
+
{ label: "Admins", value: String(users.length) },
|
|
607
|
+
{ label: "Chave Maritaca", value: llmForm.maritaca.api_key_scope === "personal" ? "própria" : llmForm.maritaca.has_api_key ? "global" : "vazia" },
|
|
608
|
+
]}
|
|
609
|
+
/>
|
|
610
|
+
</Panel>
|
|
611
|
+
|
|
612
|
+
<div className="card-grid two-col">
|
|
613
|
+
<Panel title="Provider de LLM">
|
|
614
|
+
{isLoading ? (
|
|
615
|
+
<p className="muted">Carregando configurações...</p>
|
|
616
|
+
) : (
|
|
617
|
+
<form className="stack" onSubmit={handleSaveLlmSettings}>
|
|
618
|
+
<div className="form-grid two-col-form">
|
|
619
|
+
<label className="field">
|
|
620
|
+
<span>Provider ativo</span>
|
|
621
|
+
<select
|
|
622
|
+
value={llmForm.provider}
|
|
623
|
+
onChange={(event) => setLlmForm((current) => ({ ...current, provider: event.target.value }))}
|
|
624
|
+
>
|
|
625
|
+
<option value="ollama">Ollama</option>
|
|
626
|
+
<option value="maritaca">Maritaca</option>
|
|
627
|
+
</select>
|
|
628
|
+
</label>
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
{llmForm.provider === "ollama" ? (
|
|
632
|
+
<div className="form-grid two-col-form">
|
|
633
|
+
<label className="field">
|
|
634
|
+
<span>Base URL do Ollama</span>
|
|
635
|
+
<input
|
|
636
|
+
type="text"
|
|
637
|
+
value={llmForm.ollama.base_url}
|
|
638
|
+
onChange={(event) =>
|
|
639
|
+
setLlmForm((current) => ({
|
|
640
|
+
...current,
|
|
641
|
+
ollama: { ...current.ollama, base_url: event.target.value },
|
|
642
|
+
}))
|
|
643
|
+
}
|
|
644
|
+
required
|
|
645
|
+
/>
|
|
646
|
+
</label>
|
|
647
|
+
<label className="field">
|
|
648
|
+
<span>Modelo do Ollama</span>
|
|
649
|
+
<input
|
|
650
|
+
type="text"
|
|
651
|
+
value={llmForm.ollama.model}
|
|
652
|
+
onChange={(event) =>
|
|
653
|
+
setLlmForm((current) => ({
|
|
654
|
+
...current,
|
|
655
|
+
ollama: { ...current.ollama, model: event.target.value },
|
|
656
|
+
}))
|
|
657
|
+
}
|
|
658
|
+
required
|
|
659
|
+
/>
|
|
660
|
+
</label>
|
|
661
|
+
</div>
|
|
662
|
+
) : (
|
|
663
|
+
<div className="form-grid two-col-form">
|
|
664
|
+
<label className="field">
|
|
665
|
+
<span>URL da API Maritaca</span>
|
|
666
|
+
<input
|
|
667
|
+
type="text"
|
|
668
|
+
value={llmForm.maritaca.api_url}
|
|
669
|
+
onChange={(event) =>
|
|
670
|
+
setLlmForm((current) => ({
|
|
671
|
+
...current,
|
|
672
|
+
maritaca: { ...current.maritaca, api_url: event.target.value },
|
|
673
|
+
}))
|
|
674
|
+
}
|
|
675
|
+
required
|
|
676
|
+
/>
|
|
677
|
+
</label>
|
|
678
|
+
<label className="field">
|
|
679
|
+
<span>Modelo da Maritaca</span>
|
|
680
|
+
<input
|
|
681
|
+
type="text"
|
|
682
|
+
value={llmForm.maritaca.model}
|
|
683
|
+
onChange={(event) =>
|
|
684
|
+
setLlmForm((current) => ({
|
|
685
|
+
...current,
|
|
686
|
+
maritaca: { ...current.maritaca, model: event.target.value },
|
|
687
|
+
}))
|
|
688
|
+
}
|
|
689
|
+
required
|
|
690
|
+
/>
|
|
691
|
+
</label>
|
|
692
|
+
<label className="field">
|
|
693
|
+
<span>Timeout (segundos)</span>
|
|
694
|
+
<input
|
|
695
|
+
type="number"
|
|
696
|
+
min="1"
|
|
697
|
+
max="300"
|
|
698
|
+
value={llmForm.maritaca.timeout_seconds}
|
|
699
|
+
onChange={(event) =>
|
|
700
|
+
setLlmForm((current) => ({
|
|
701
|
+
...current,
|
|
702
|
+
maritaca: { ...current.maritaca, timeout_seconds: event.target.value },
|
|
703
|
+
}))
|
|
704
|
+
}
|
|
705
|
+
required
|
|
706
|
+
/>
|
|
707
|
+
</label>
|
|
708
|
+
<label className="field">
|
|
709
|
+
<span>Minha chave da Maritaca</span>
|
|
710
|
+
<input
|
|
711
|
+
type="password"
|
|
712
|
+
value={llmForm.maritaca.api_key}
|
|
713
|
+
onChange={(event) =>
|
|
714
|
+
setLlmForm((current) => ({
|
|
715
|
+
...current,
|
|
716
|
+
maritaca: { ...current.maritaca, api_key: event.target.value, clear_api_key: false },
|
|
717
|
+
}))
|
|
718
|
+
}
|
|
719
|
+
placeholder={llmForm.maritaca.masked_api_key || "Nao alterar"}
|
|
720
|
+
/>
|
|
721
|
+
</label>
|
|
722
|
+
</div>
|
|
723
|
+
)}
|
|
724
|
+
|
|
725
|
+
<label className="checkbox-row">
|
|
726
|
+
<input
|
|
727
|
+
type="checkbox"
|
|
728
|
+
checked={llmForm.maritaca.clear_api_key}
|
|
729
|
+
onChange={(event) =>
|
|
730
|
+
setLlmForm((current) => ({
|
|
731
|
+
...current,
|
|
732
|
+
maritaca: {
|
|
733
|
+
...current.maritaca,
|
|
734
|
+
clear_api_key: event.target.checked,
|
|
735
|
+
api_key: event.target.checked ? "" : current.maritaca.api_key,
|
|
736
|
+
},
|
|
737
|
+
}))
|
|
738
|
+
}
|
|
739
|
+
/>
|
|
740
|
+
<span>Limpar minha chave salva da Maritaca</span>
|
|
741
|
+
</label>
|
|
742
|
+
|
|
743
|
+
<div className="actions-row">
|
|
744
|
+
<button type="submit" disabled={isSavingLlm}>
|
|
745
|
+
{isSavingLlm ? "Salvando..." : "Salvar configuração"}
|
|
746
|
+
</button>
|
|
747
|
+
</div>
|
|
748
|
+
</form>
|
|
749
|
+
)}
|
|
750
|
+
</Panel>
|
|
751
|
+
|
|
752
|
+
<Panel title="Status do provider">
|
|
753
|
+
<div className="subsection">
|
|
754
|
+
<SummaryGrid
|
|
755
|
+
items={[
|
|
756
|
+
{ label: "Provider verificado", value: llmStatus?.provider || llmForm.provider },
|
|
757
|
+
{ label: "Disponivel", value: llmStatus?.available ? "sim" : "nao" },
|
|
758
|
+
]}
|
|
759
|
+
/>
|
|
760
|
+
<p className="muted">{llmStatus?.detail || "Sem validação recente."}</p>
|
|
761
|
+
</div>
|
|
762
|
+
</Panel>
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<div className="card-grid two-col">
|
|
766
|
+
<Panel title="Criar admin">
|
|
767
|
+
<form className="stack" onSubmit={handleCreateUser}>
|
|
768
|
+
<div className="form-grid two-col-form">
|
|
769
|
+
<label className="field">
|
|
770
|
+
<span>Usuario</span>
|
|
771
|
+
<input
|
|
772
|
+
type="text"
|
|
773
|
+
value={userForm.username}
|
|
774
|
+
onChange={(event) => setUserForm((current) => ({ ...current, username: event.target.value }))}
|
|
775
|
+
required
|
|
776
|
+
/>
|
|
777
|
+
</label>
|
|
778
|
+
<label className="field">
|
|
779
|
+
<span>Email</span>
|
|
780
|
+
<input
|
|
781
|
+
type="email"
|
|
782
|
+
value={userForm.email}
|
|
783
|
+
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
|
784
|
+
required
|
|
785
|
+
/>
|
|
786
|
+
</label>
|
|
787
|
+
<label className="field">
|
|
788
|
+
<span>Primeiro nome</span>
|
|
789
|
+
<input
|
|
790
|
+
type="text"
|
|
791
|
+
value={userForm.first_name}
|
|
792
|
+
onChange={(event) => setUserForm((current) => ({ ...current, first_name: event.target.value }))}
|
|
793
|
+
/>
|
|
794
|
+
</label>
|
|
795
|
+
<label className="field">
|
|
796
|
+
<span>Sobrenome</span>
|
|
797
|
+
<input
|
|
798
|
+
type="text"
|
|
799
|
+
value={userForm.last_name}
|
|
800
|
+
onChange={(event) => setUserForm((current) => ({ ...current, last_name: event.target.value }))}
|
|
801
|
+
/>
|
|
802
|
+
</label>
|
|
803
|
+
<label className="field">
|
|
804
|
+
<span>Senha inicial</span>
|
|
805
|
+
<input
|
|
806
|
+
type="password"
|
|
807
|
+
value={userForm.password}
|
|
808
|
+
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
|
|
809
|
+
required
|
|
810
|
+
/>
|
|
811
|
+
</label>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
814
|
+
<label className="checkbox-row">
|
|
815
|
+
<input
|
|
816
|
+
type="checkbox"
|
|
817
|
+
checked={userForm.enabled}
|
|
818
|
+
onChange={(event) => setUserForm((current) => ({ ...current, enabled: event.target.checked }))}
|
|
819
|
+
/>
|
|
820
|
+
<span>Usuario habilitado</span>
|
|
821
|
+
</label>
|
|
822
|
+
|
|
823
|
+
<div className="actions-row">
|
|
824
|
+
<button type="submit" disabled={isCreatingUser}>
|
|
825
|
+
{isCreatingUser ? "Criando..." : "Criar admin"}
|
|
826
|
+
</button>
|
|
827
|
+
</div>
|
|
828
|
+
</form>
|
|
829
|
+
</Panel>
|
|
830
|
+
|
|
831
|
+
<Panel title="Admins atuais">
|
|
832
|
+
{isLoading ? (
|
|
833
|
+
<p className="muted">Carregando usuarios...</p>
|
|
834
|
+
) : users.length === 0 ? (
|
|
835
|
+
<div className="empty-state compact-empty-state">
|
|
836
|
+
<p>Nenhum admin encontrado.</p>
|
|
837
|
+
</div>
|
|
838
|
+
) : (
|
|
839
|
+
<div className="record-list">
|
|
840
|
+
{users.map((user) => (
|
|
841
|
+
<div key={user.id} className="record-row">
|
|
842
|
+
<div>
|
|
843
|
+
<strong>{user.username}</strong>
|
|
844
|
+
<span>{user.email || "sem email"}</span>
|
|
845
|
+
</div>
|
|
846
|
+
<div className="record-actions">
|
|
847
|
+
<span className={`status-badge ${user.metabase_synced ? "success" : "warning"}`}>
|
|
848
|
+
{user.metabase_synced ? "metabase ok" : "metabase pendente"}
|
|
849
|
+
</span>
|
|
850
|
+
<span className={`status-badge ${user.enabled ? "success" : "danger"}`}>
|
|
851
|
+
{user.enabled ? "ativo" : "inativo"}
|
|
852
|
+
</span>
|
|
853
|
+
<button
|
|
854
|
+
type="button"
|
|
855
|
+
className="secondary"
|
|
856
|
+
disabled={deletingUserId === user.id}
|
|
857
|
+
onClick={() => handleDeleteUser(user.id, user.username)}
|
|
858
|
+
>
|
|
859
|
+
{deletingUserId === user.id ? "Excluindo..." : "Excluir"}
|
|
860
|
+
</button>
|
|
861
|
+
{!user.metabase_synced ? (
|
|
862
|
+
<button
|
|
863
|
+
type="button"
|
|
864
|
+
className="secondary"
|
|
865
|
+
disabled={syncingUserId === user.id}
|
|
866
|
+
onClick={() => handleSyncMetabaseUser(user.id, user.username)}
|
|
867
|
+
>
|
|
868
|
+
{syncingUserId === user.id ? "Sincronizando..." : "Sincronizar"}
|
|
869
|
+
</button>
|
|
870
|
+
) : null}
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
))}
|
|
874
|
+
</div>
|
|
875
|
+
)}
|
|
876
|
+
</Panel>
|
|
877
|
+
</div>
|
|
878
|
+
</section>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function AdminWorkspacePage({ auth, route, onLoginRequest, onLogout }) {
|
|
883
|
+
if (auth.status !== "authenticated") {
|
|
884
|
+
return (
|
|
885
|
+
<AdminGate
|
|
886
|
+
auth={auth}
|
|
887
|
+
message="O workspace administrativo exige sessão admin."
|
|
888
|
+
onLoginRequest={onLoginRequest}
|
|
889
|
+
/>
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (route === "/admin/airflow") {
|
|
894
|
+
return (
|
|
895
|
+
<section className="page">
|
|
896
|
+
<PageHeader title="Airflow" actions={<ButtonLink href={AIRFLOW_URL}>Abrir Airflow</ButtonLink>} />
|
|
897
|
+
<Panel>
|
|
898
|
+
<SummaryGrid
|
|
899
|
+
items={[
|
|
900
|
+
{ label: "Uso", value: "Orquestração" },
|
|
901
|
+
{ label: "Acesso", value: "Externo" },
|
|
902
|
+
{ label: "Sessão", value: "Admin" },
|
|
903
|
+
]}
|
|
904
|
+
/>
|
|
905
|
+
</Panel>
|
|
906
|
+
</section>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (route === "/admin/metabase") {
|
|
911
|
+
return (
|
|
912
|
+
<section className="page">
|
|
913
|
+
<PageHeader title="Metabase" actions={<ButtonLink href={METABASE_URL}>Abrir Metabase</ButtonLink>} />
|
|
914
|
+
<Panel>
|
|
915
|
+
<SummaryGrid
|
|
916
|
+
items={[
|
|
917
|
+
{ label: "Uso", value: "Dashboards" },
|
|
918
|
+
{ label: "Acesso", value: "Externo" },
|
|
919
|
+
{ label: "Sessão", value: "Admin" },
|
|
920
|
+
]}
|
|
921
|
+
/>
|
|
922
|
+
</Panel>
|
|
923
|
+
</section>
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (route === "/admin/sgbd") {
|
|
928
|
+
return (
|
|
929
|
+
<section className="page">
|
|
930
|
+
<PageHeader
|
|
931
|
+
title="SGBD"
|
|
932
|
+
actions={
|
|
933
|
+
<>
|
|
934
|
+
<button type="button" className="secondary" onClick={() => navigate("/sql")}>
|
|
935
|
+
Abrir SQL
|
|
936
|
+
</button>
|
|
937
|
+
<button type="button" onClick={onLogout}>
|
|
938
|
+
Encerrar sessão
|
|
939
|
+
</button>
|
|
940
|
+
</>
|
|
941
|
+
}
|
|
942
|
+
/>
|
|
943
|
+
<Panel>
|
|
944
|
+
<SummaryGrid
|
|
945
|
+
items={[
|
|
946
|
+
{ label: "Uso", value: "Consulta e manutenção" },
|
|
947
|
+
{ label: "Acesso", value: "Interno" },
|
|
948
|
+
{ label: "Sessão", value: "Admin" },
|
|
949
|
+
]}
|
|
950
|
+
/>
|
|
951
|
+
</Panel>
|
|
952
|
+
</section>
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return (
|
|
957
|
+
<section className="page">
|
|
958
|
+
<PageHeader title="Workspace admin" />
|
|
959
|
+
<div className="card-grid three-col">
|
|
960
|
+
<Panel
|
|
961
|
+
title="Airflow"
|
|
962
|
+
action={
|
|
963
|
+
<button type="button" onClick={() => navigate("/admin/airflow")}>
|
|
964
|
+
Abrir
|
|
965
|
+
</button>
|
|
966
|
+
}
|
|
967
|
+
>
|
|
968
|
+
<p className="muted">Orquestração.</p>
|
|
969
|
+
</Panel>
|
|
970
|
+
|
|
971
|
+
<Panel
|
|
972
|
+
title="Metabase"
|
|
973
|
+
action={
|
|
974
|
+
<button type="button" onClick={() => navigate("/admin/metabase")}>
|
|
975
|
+
Abrir
|
|
976
|
+
</button>
|
|
977
|
+
}
|
|
978
|
+
>
|
|
979
|
+
<p className="muted">Dashboards.</p>
|
|
980
|
+
</Panel>
|
|
981
|
+
|
|
982
|
+
<Panel
|
|
983
|
+
title="SGBD"
|
|
984
|
+
action={
|
|
985
|
+
<button type="button" onClick={() => navigate("/admin/sgbd")}>
|
|
986
|
+
Abrir
|
|
987
|
+
</button>
|
|
988
|
+
}
|
|
989
|
+
>
|
|
990
|
+
<p className="muted">SQL.</p>
|
|
991
|
+
</Panel>
|
|
992
|
+
</div>
|
|
993
|
+
</section>
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function HomePage({ dashboardUrl, vannaQuestion, setVannaQuestion, vannaResult, vannaState, vannaError, onAskVanna }) {
|
|
998
|
+
const resultRows = vannaResult?.rows || [];
|
|
999
|
+
const resultColumns = resultRows.length > 0 ? Object.keys(resultRows[0]) : [];
|
|
1000
|
+
|
|
1001
|
+
return (
|
|
1002
|
+
<section className="page">
|
|
1003
|
+
<PageHeader title="Início" />
|
|
1004
|
+
<div className="layout-main">
|
|
1005
|
+
<Panel title="Dados da Plataforma Nilo Peçanha" className="embed-panel">
|
|
1006
|
+
{dashboardUrl ? (
|
|
1007
|
+
<iframe title="metabase-dashboard-home" src={dashboardUrl} className="dashboard-frame" />
|
|
1008
|
+
) : (
|
|
1009
|
+
<div className="empty-state">
|
|
1010
|
+
<p>Carregando dashboard.</p>
|
|
1011
|
+
</div>
|
|
1012
|
+
)}
|
|
1013
|
+
</Panel>
|
|
1014
|
+
|
|
1015
|
+
<div className="stack">
|
|
1016
|
+
<Panel title="Vanna">
|
|
1017
|
+
<form className="vanna-form" onSubmit={onAskVanna}>
|
|
1018
|
+
<label className="field">
|
|
1019
|
+
<span>Pergunta</span>
|
|
1020
|
+
<input
|
|
1021
|
+
type="text"
|
|
1022
|
+
value={vannaQuestion}
|
|
1023
|
+
onChange={(event) => setVannaQuestion(event.target.value)}
|
|
1024
|
+
placeholder="Ex.: Qual a quantidade de matrículas no IFRS por ano?"
|
|
1025
|
+
minLength={3}
|
|
1026
|
+
maxLength={1000}
|
|
1027
|
+
/>
|
|
1028
|
+
</label>
|
|
1029
|
+
<button type="submit" disabled={vannaState === "loading" || vannaQuestion.trim().length < 3}>
|
|
1030
|
+
{vannaState === "loading" ? "Consultando..." : "Perguntar"}
|
|
1031
|
+
</button>
|
|
1032
|
+
</form>
|
|
1033
|
+
|
|
1034
|
+
{vannaError ? <p className="error-inline">{vannaError}</p> : null}
|
|
1035
|
+
|
|
1036
|
+
{vannaResult ? (
|
|
1037
|
+
<div className="answer-block">
|
|
1038
|
+
<div className="answer-section">
|
|
1039
|
+
<span className="muted">{vannaResult.row_count} registro(s)</span>
|
|
1040
|
+
<pre>{vannaResult.sql}</pre>
|
|
1041
|
+
</div>
|
|
1042
|
+
|
|
1043
|
+
{resultRows.length > 0 ? (
|
|
1044
|
+
<div className="table-shell">
|
|
1045
|
+
<table>
|
|
1046
|
+
<thead>
|
|
1047
|
+
<tr>
|
|
1048
|
+
{resultColumns.map((column) => (
|
|
1049
|
+
<th key={column}>{column}</th>
|
|
1050
|
+
))}
|
|
1051
|
+
</tr>
|
|
1052
|
+
</thead>
|
|
1053
|
+
<tbody>
|
|
1054
|
+
{resultRows.map((row, index) => (
|
|
1055
|
+
<tr key={`${index}-${JSON.stringify(row)}`}>
|
|
1056
|
+
{resultColumns.map((column) => (
|
|
1057
|
+
<td key={column}>{row[column] === null ? "-" : String(row[column])}</td>
|
|
1058
|
+
))}
|
|
1059
|
+
</tr>
|
|
1060
|
+
))}
|
|
1061
|
+
</tbody>
|
|
1062
|
+
</table>
|
|
1063
|
+
</div>
|
|
1064
|
+
) : (
|
|
1065
|
+
<p className="muted">Sem registros para a pergunta.</p>
|
|
1066
|
+
)}
|
|
1067
|
+
</div>
|
|
1068
|
+
) : null}
|
|
1069
|
+
</Panel>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
</section>
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function PipelinesPage({
|
|
1077
|
+
auth,
|
|
1078
|
+
route,
|
|
1079
|
+
onLoginRequest,
|
|
1080
|
+
pipelines,
|
|
1081
|
+
connections,
|
|
1082
|
+
selectedPipelineKey,
|
|
1083
|
+
onSelectPipeline,
|
|
1084
|
+
overview,
|
|
1085
|
+
dagRuns,
|
|
1086
|
+
pipelineAction,
|
|
1087
|
+
deleteAction,
|
|
1088
|
+
onTriggerOperation,
|
|
1089
|
+
onDeletePipeline,
|
|
1090
|
+
pipelineForm,
|
|
1091
|
+
setPipelineForm,
|
|
1092
|
+
createState,
|
|
1093
|
+
onSubmitPipeline,
|
|
1094
|
+
connectorDefinition,
|
|
1095
|
+
}) {
|
|
1096
|
+
if (auth.status !== "authenticated") {
|
|
1097
|
+
return (
|
|
1098
|
+
<AdminGate
|
|
1099
|
+
auth={auth}
|
|
1100
|
+
message="As pipelines exigem sessão admin."
|
|
1101
|
+
onLoginRequest={onLoginRequest}
|
|
1102
|
+
/>
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const selectedOverview = overview?.instance?.instance_key === selectedPipelineKey ? overview : null;
|
|
1107
|
+
const selectedPipeline = pipelines.find((instance) => instance.instance_key === selectedPipelineKey) || null;
|
|
1108
|
+
const catalog = connectorDefinition?.selection_catalog || {};
|
|
1109
|
+
const availableYears = catalog.available_years || [];
|
|
1110
|
+
const availableTypes =
|
|
1111
|
+
pipelineForm.selected_years.length === 0
|
|
1112
|
+
? []
|
|
1113
|
+
: (catalog.available_microdados_types || []).filter((type) =>
|
|
1114
|
+
pipelineForm.selected_years.every((year) => (catalog.types_by_year?.[year] || []).includes(type)),
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
function toggleYear(year) {
|
|
1118
|
+
const nextYears = pipelineForm.selected_years.includes(year)
|
|
1119
|
+
? pipelineForm.selected_years.filter((item) => item !== year)
|
|
1120
|
+
: [...pipelineForm.selected_years, year];
|
|
1121
|
+
const nextTypes = pipelineForm.selected_microdados_types.filter((type) =>
|
|
1122
|
+
nextYears.every((selectedYear) => (catalog.types_by_year?.[selectedYear] || []).includes(type)),
|
|
1123
|
+
);
|
|
1124
|
+
setPipelineForm({ ...pipelineForm, selected_years: nextYears, selected_microdados_types: nextTypes });
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function toggleType(type) {
|
|
1128
|
+
const nextTypes = pipelineForm.selected_microdados_types.includes(type)
|
|
1129
|
+
? pipelineForm.selected_microdados_types.filter((item) => item !== type)
|
|
1130
|
+
: [...pipelineForm.selected_microdados_types, type];
|
|
1131
|
+
setPipelineForm({ ...pipelineForm, selected_microdados_types: nextTypes });
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (route === PIPELINE_CREATE_ROUTE) {
|
|
1135
|
+
return (
|
|
1136
|
+
<section className="page">
|
|
1137
|
+
<PageHeader
|
|
1138
|
+
title="Nova pipeline"
|
|
1139
|
+
actions={
|
|
1140
|
+
<button type="button" className="secondary" onClick={() => navigate("/pipelines")}>
|
|
1141
|
+
Voltar
|
|
1142
|
+
</button>
|
|
1143
|
+
}
|
|
1144
|
+
/>
|
|
1145
|
+
|
|
1146
|
+
<form className="stack" onSubmit={onSubmitPipeline}>
|
|
1147
|
+
<Panel title="Pipeline">
|
|
1148
|
+
<div className="form-grid two-col-form">
|
|
1149
|
+
<label className="field">
|
|
1150
|
+
<span>Nome</span>
|
|
1151
|
+
<input
|
|
1152
|
+
type="text"
|
|
1153
|
+
value={pipelineForm.pipeline_name}
|
|
1154
|
+
onChange={(event) => setPipelineForm({ ...pipelineForm, pipeline_name: event.target.value })}
|
|
1155
|
+
placeholder="Ex.: PNP Matriculas 2024"
|
|
1156
|
+
required
|
|
1157
|
+
/>
|
|
1158
|
+
</label>
|
|
1159
|
+
|
|
1160
|
+
<label className="field">
|
|
1161
|
+
<span>conexão</span>
|
|
1162
|
+
<select
|
|
1163
|
+
value={pipelineForm.connection_key}
|
|
1164
|
+
onChange={(event) => setPipelineForm({ ...pipelineForm, connection_key: event.target.value })}
|
|
1165
|
+
required
|
|
1166
|
+
>
|
|
1167
|
+
<option value="">Selecione</option>
|
|
1168
|
+
{connections.map((connection) => (
|
|
1169
|
+
<option key={connection.connection_key} value={connection.connection_key}>
|
|
1170
|
+
{connection.connection_name}
|
|
1171
|
+
</option>
|
|
1172
|
+
))}
|
|
1173
|
+
</select>
|
|
1174
|
+
</label>
|
|
1175
|
+
|
|
1176
|
+
<label className="field">
|
|
1177
|
+
<span>Cron</span>
|
|
1178
|
+
<input
|
|
1179
|
+
type="text"
|
|
1180
|
+
value={pipelineForm.schedule}
|
|
1181
|
+
onChange={(event) => setPipelineForm({ ...pipelineForm, schedule: event.target.value })}
|
|
1182
|
+
placeholder="0 3 * * *"
|
|
1183
|
+
/>
|
|
1184
|
+
</label>
|
|
1185
|
+
</div>
|
|
1186
|
+
|
|
1187
|
+
<label className="checkbox-row">
|
|
1188
|
+
<input
|
|
1189
|
+
type="checkbox"
|
|
1190
|
+
checked={pipelineForm.is_active}
|
|
1191
|
+
onChange={(event) => setPipelineForm({ ...pipelineForm, is_active: event.target.checked })}
|
|
1192
|
+
/>
|
|
1193
|
+
<span>Ativar apos criar</span>
|
|
1194
|
+
</label>
|
|
1195
|
+
</Panel>
|
|
1196
|
+
|
|
1197
|
+
<div className="card-grid two-col">
|
|
1198
|
+
<Panel title="Anos">
|
|
1199
|
+
<div className="choice-list">
|
|
1200
|
+
{availableYears.map((year) => (
|
|
1201
|
+
<label key={year} className="choice-item">
|
|
1202
|
+
<input type="checkbox" checked={pipelineForm.selected_years.includes(year)} onChange={() => toggleYear(year)} />
|
|
1203
|
+
<span>{year}</span>
|
|
1204
|
+
</label>
|
|
1205
|
+
))}
|
|
1206
|
+
</div>
|
|
1207
|
+
</Panel>
|
|
1208
|
+
|
|
1209
|
+
<Panel title="Tipos">
|
|
1210
|
+
<div className="choice-list">
|
|
1211
|
+
{availableTypes.map((type) => (
|
|
1212
|
+
<label key={type} className="choice-item">
|
|
1213
|
+
<input
|
|
1214
|
+
type="checkbox"
|
|
1215
|
+
checked={pipelineForm.selected_microdados_types.includes(type)}
|
|
1216
|
+
onChange={() => toggleType(type)}
|
|
1217
|
+
/>
|
|
1218
|
+
<span>{type}</span>
|
|
1219
|
+
</label>
|
|
1220
|
+
))}
|
|
1221
|
+
{pipelineForm.selected_years.length === 0 ? <p className="muted">Selecione pelo menos um ano.</p> : null}
|
|
1222
|
+
</div>
|
|
1223
|
+
</Panel>
|
|
1224
|
+
</div>
|
|
1225
|
+
|
|
1226
|
+
<div className="actions-row">
|
|
1227
|
+
<button type="submit" disabled={createState === "loading" || connections.length === 0}>
|
|
1228
|
+
{createState === "loading" ? "Criando..." : "Criar pipeline"}
|
|
1229
|
+
</button>
|
|
1230
|
+
</div>
|
|
1231
|
+
</form>
|
|
1232
|
+
</section>
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return (
|
|
1237
|
+
<section className="page">
|
|
1238
|
+
<PageHeader
|
|
1239
|
+
title="Pipelines"
|
|
1240
|
+
actions={
|
|
1241
|
+
<button type="button" onClick={() => navigate(PIPELINE_CREATE_ROUTE)}>
|
|
1242
|
+
Nova pipeline
|
|
1243
|
+
</button>
|
|
1244
|
+
}
|
|
1245
|
+
/>
|
|
1246
|
+
|
|
1247
|
+
<div className="layout-sidebar">
|
|
1248
|
+
<Panel title="Pipelines">
|
|
1249
|
+
<div className="selection-list">
|
|
1250
|
+
{pipelines.map((instance) => {
|
|
1251
|
+
const isSelected = selectedPipelineKey === instance.instance_key;
|
|
1252
|
+
const status = isSelected ? selectedOverview?.ingestion?.status || "pending" : "pending";
|
|
1253
|
+
|
|
1254
|
+
return (
|
|
1255
|
+
<button
|
|
1256
|
+
key={instance.instance_key}
|
|
1257
|
+
type="button"
|
|
1258
|
+
className={`selection-item${isSelected ? " selected" : ""}`}
|
|
1259
|
+
onClick={() => onSelectPipeline(instance.instance_key)}
|
|
1260
|
+
>
|
|
1261
|
+
<div className="selection-item-head">
|
|
1262
|
+
<strong>{instance.instance_name}</strong>
|
|
1263
|
+
<StatusBadge status={status} />
|
|
1264
|
+
</div>
|
|
1265
|
+
<div className="selection-item-body">
|
|
1266
|
+
<span>{instance.connection_name || instance.connection_key}</span>
|
|
1267
|
+
<span>{serializeSelection(instance.selected_years) || "Sem anos"}</span>
|
|
1268
|
+
<span>{serializeSelection(instance.selected_microdados_types) || "Sem tipos"}</span>
|
|
1269
|
+
<span>{instance.schedule || "-"}</span>
|
|
1270
|
+
</div>
|
|
1271
|
+
</button>
|
|
1272
|
+
);
|
|
1273
|
+
})}
|
|
1274
|
+
|
|
1275
|
+
{pipelines.length === 0 ? <p className="muted">Nenhuma pipeline cadastrada.</p> : null}
|
|
1276
|
+
</div>
|
|
1277
|
+
</Panel>
|
|
1278
|
+
|
|
1279
|
+
<div className="stack">
|
|
1280
|
+
<Panel
|
|
1281
|
+
title={selectedPipeline ? selectedPipeline.instance_name : "Operacao"}
|
|
1282
|
+
action={
|
|
1283
|
+
selectedPipeline ? (
|
|
1284
|
+
<>
|
|
1285
|
+
<button
|
|
1286
|
+
type="button"
|
|
1287
|
+
className="secondary"
|
|
1288
|
+
onClick={() => onTriggerOperation("validate-sources")}
|
|
1289
|
+
disabled={pipelineAction === "validate-sources"}
|
|
1290
|
+
>
|
|
1291
|
+
{pipelineAction === "validate-sources" ? "Validando..." : "Validar"}
|
|
1292
|
+
</button>
|
|
1293
|
+
<button
|
|
1294
|
+
type="button"
|
|
1295
|
+
onClick={() => onTriggerOperation("full-sync")}
|
|
1296
|
+
disabled={pipelineAction === "full-sync"}
|
|
1297
|
+
>
|
|
1298
|
+
{pipelineAction === "full-sync" ? "Executando..." : "Executar"}
|
|
1299
|
+
</button>
|
|
1300
|
+
<button
|
|
1301
|
+
type="button"
|
|
1302
|
+
className="secondary"
|
|
1303
|
+
onClick={() => onDeletePipeline(selectedPipeline.instance_key)}
|
|
1304
|
+
disabled={deleteAction === selectedPipeline.instance_key}
|
|
1305
|
+
>
|
|
1306
|
+
{deleteAction === selectedPipeline.instance_key ? "Excluindo..." : "Excluir pipeline"}
|
|
1307
|
+
</button>
|
|
1308
|
+
</>
|
|
1309
|
+
) : null
|
|
1310
|
+
}
|
|
1311
|
+
>
|
|
1312
|
+
{selectedOverview && selectedPipeline ? (
|
|
1313
|
+
<div className="stack">
|
|
1314
|
+
<SummaryGrid
|
|
1315
|
+
items={[
|
|
1316
|
+
{ label: "conexão", value: selectedPipeline.connection_name || selectedPipeline.connection_key },
|
|
1317
|
+
{ label: "Status", value: formatStatus(selectedOverview.ingestion.status) },
|
|
1318
|
+
{ label: "Anos", value: serializeSelection(selectedPipeline.selected_years) || "-" },
|
|
1319
|
+
{ label: "Tipos", value: serializeSelection(selectedPipeline.selected_microdados_types) || "-" },
|
|
1320
|
+
{ label: "Cron", value: selectedPipeline.schedule || "-" },
|
|
1321
|
+
]}
|
|
1322
|
+
/>
|
|
1323
|
+
|
|
1324
|
+
<div className="card-grid two-col">
|
|
1325
|
+
<div className="subsection">
|
|
1326
|
+
<h3>Diagnóstico</h3>
|
|
1327
|
+
<div className="record-list">
|
|
1328
|
+
{(selectedOverview.diagnostics || []).map((item) => (
|
|
1329
|
+
<div key={item.endpoint_key} className="record-row">
|
|
1330
|
+
<div>
|
|
1331
|
+
<strong>{item.source_label || item.endpoint_key}</strong>
|
|
1332
|
+
<span>{item.operational_stage}</span>
|
|
1333
|
+
</div>
|
|
1334
|
+
<StatusBadge status={item.operational_status} />
|
|
1335
|
+
</div>
|
|
1336
|
+
))}
|
|
1337
|
+
{(selectedOverview.diagnostics || []).length === 0 ? <p className="muted">Sem diagnóstico.</p> : null}
|
|
1338
|
+
</div>
|
|
1339
|
+
</div>
|
|
1340
|
+
|
|
1341
|
+
<div className="subsection">
|
|
1342
|
+
<h3>Timeline</h3>
|
|
1343
|
+
<div className="record-list">
|
|
1344
|
+
{(selectedOverview.run_events || []).map((event) => (
|
|
1345
|
+
<div key={`${event.stage}-${event.run_id}`} className="record-row">
|
|
1346
|
+
<div>
|
|
1347
|
+
<strong>{event.stage_label}</strong>
|
|
1348
|
+
<span>{formatTimestamp(event.timestamp)}</span>
|
|
1349
|
+
</div>
|
|
1350
|
+
<StatusBadge status={event.state} />
|
|
1351
|
+
</div>
|
|
1352
|
+
))}
|
|
1353
|
+
{(selectedOverview.run_events || []).length === 0 ? <p className="muted">Sem eventos.</p> : null}
|
|
1354
|
+
</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
</div>
|
|
1358
|
+
) : (
|
|
1359
|
+
<div className="empty-state">
|
|
1360
|
+
<p>Selecione uma pipeline.</p>
|
|
1361
|
+
</div>
|
|
1362
|
+
)}
|
|
1363
|
+
</Panel>
|
|
1364
|
+
|
|
1365
|
+
<Panel title="DAG runs">
|
|
1366
|
+
<RunsTable
|
|
1367
|
+
runs={selectedPipeline ? dagRuns : []}
|
|
1368
|
+
emptyMessage={selectedPipeline ? "Nenhuma execução recente." : "Selecione uma pipeline."}
|
|
1369
|
+
/>
|
|
1370
|
+
</Panel>
|
|
1371
|
+
</div>
|
|
1372
|
+
</div>
|
|
1373
|
+
</section>
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function ConnectionsPage({
|
|
1378
|
+
auth,
|
|
1379
|
+
route,
|
|
1380
|
+
onLoginRequest,
|
|
1381
|
+
connectionForm,
|
|
1382
|
+
setConnectionForm,
|
|
1383
|
+
createState,
|
|
1384
|
+
onSubmitConnection,
|
|
1385
|
+
connections,
|
|
1386
|
+
selectedConnectionKey,
|
|
1387
|
+
detail,
|
|
1388
|
+
onOpenConnection,
|
|
1389
|
+
onOpenPipeline,
|
|
1390
|
+
deleteAction,
|
|
1391
|
+
onDeleteConnection,
|
|
1392
|
+
}) {
|
|
1393
|
+
if (auth.status !== "authenticated") {
|
|
1394
|
+
return (
|
|
1395
|
+
<AdminGate
|
|
1396
|
+
auth={auth}
|
|
1397
|
+
message="A gestao de conexoes exige sessão admin."
|
|
1398
|
+
onLoginRequest={onLoginRequest}
|
|
1399
|
+
/>
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const selectedConnection = connections.find((item) => item.connection_key === selectedConnectionKey) || detail?.connection || null;
|
|
1404
|
+
const linkedPipelines = detail?.pipelines || [];
|
|
1405
|
+
|
|
1406
|
+
if (route === CONNECTION_CREATE_ROUTE) {
|
|
1407
|
+
return (
|
|
1408
|
+
<section className="page">
|
|
1409
|
+
<PageHeader
|
|
1410
|
+
title="Nova conexão"
|
|
1411
|
+
actions={
|
|
1412
|
+
<button type="button" className="secondary" onClick={() => navigate(CONNECTIONS_ROUTE)}>
|
|
1413
|
+
Voltar
|
|
1414
|
+
</button>
|
|
1415
|
+
}
|
|
1416
|
+
/>
|
|
1417
|
+
|
|
1418
|
+
<form className="stack" onSubmit={onSubmitConnection}>
|
|
1419
|
+
<Panel title="conexão">
|
|
1420
|
+
<label className="field">
|
|
1421
|
+
<span>Nome</span>
|
|
1422
|
+
<input
|
|
1423
|
+
type="text"
|
|
1424
|
+
value={connectionForm.connection_name}
|
|
1425
|
+
onChange={(event) => setConnectionForm({ ...connectionForm, connection_name: event.target.value })}
|
|
1426
|
+
placeholder="Ex.: PNP Principal"
|
|
1427
|
+
required
|
|
1428
|
+
/>
|
|
1429
|
+
</label>
|
|
1430
|
+
|
|
1431
|
+
<label className="checkbox-row">
|
|
1432
|
+
<input
|
|
1433
|
+
type="checkbox"
|
|
1434
|
+
checked={connectionForm.is_active}
|
|
1435
|
+
onChange={(event) => setConnectionForm({ ...connectionForm, is_active: event.target.checked })}
|
|
1436
|
+
/>
|
|
1437
|
+
<span>Ativar apos criar</span>
|
|
1438
|
+
</label>
|
|
1439
|
+
</Panel>
|
|
1440
|
+
|
|
1441
|
+
<div className="actions-row">
|
|
1442
|
+
<button type="submit" disabled={createState === "loading"}>
|
|
1443
|
+
{createState === "loading" ? "Criando..." : "Criar conexão"}
|
|
1444
|
+
</button>
|
|
1445
|
+
</div>
|
|
1446
|
+
</form>
|
|
1447
|
+
</section>
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (route === CONNECTION_DETAIL_ROUTE) {
|
|
1452
|
+
return (
|
|
1453
|
+
<section className="page">
|
|
1454
|
+
<PageHeader
|
|
1455
|
+
title={selectedConnection?.connection_name || "conexão"}
|
|
1456
|
+
actions={
|
|
1457
|
+
<>
|
|
1458
|
+
<button type="button" className="secondary" onClick={() => navigate(CONNECTIONS_ROUTE)}>
|
|
1459
|
+
Voltar
|
|
1460
|
+
</button>
|
|
1461
|
+
{selectedConnection ? (
|
|
1462
|
+
<button
|
|
1463
|
+
type="button"
|
|
1464
|
+
className="secondary"
|
|
1465
|
+
onClick={() => onDeleteConnection(selectedConnection.connection_key)}
|
|
1466
|
+
disabled={deleteAction === selectedConnection.connection_key}
|
|
1467
|
+
>
|
|
1468
|
+
{deleteAction === selectedConnection.connection_key ? "Excluindo..." : "Excluir conexão"}
|
|
1469
|
+
</button>
|
|
1470
|
+
) : null}
|
|
1471
|
+
<button
|
|
1472
|
+
type="button"
|
|
1473
|
+
onClick={() => {
|
|
1474
|
+
window.sessionStorage.setItem("dataif.connection.selected", selectedConnection?.connection_key || "");
|
|
1475
|
+
navigate(PIPELINE_CREATE_ROUTE);
|
|
1476
|
+
}}
|
|
1477
|
+
>
|
|
1478
|
+
Nova pipeline
|
|
1479
|
+
</button>
|
|
1480
|
+
</>
|
|
1481
|
+
}
|
|
1482
|
+
/>
|
|
1483
|
+
|
|
1484
|
+
{selectedConnection ? (
|
|
1485
|
+
<div className="stack">
|
|
1486
|
+
<Panel title="Resumo">
|
|
1487
|
+
<SummaryGrid
|
|
1488
|
+
items={[
|
|
1489
|
+
{ label: "Chave", value: selectedConnection.connection_key },
|
|
1490
|
+
{ label: "Status", value: selectedConnection.is_active ? "Ativa" : "Inativa" },
|
|
1491
|
+
{ label: "Validação", value: formatStatus(selectedConnection.validation_status) },
|
|
1492
|
+
{ label: "Pipelines", value: selectedConnection.pipeline_count || linkedPipelines.length },
|
|
1493
|
+
{ label: "Atualizado", value: formatTimestamp(selectedConnection.updated_at) },
|
|
1494
|
+
]}
|
|
1495
|
+
/>
|
|
1496
|
+
</Panel>
|
|
1497
|
+
|
|
1498
|
+
<Panel title="Validação">
|
|
1499
|
+
<div className="record-row">
|
|
1500
|
+
<div>
|
|
1501
|
+
<strong>{selectedConnection.validation_message}</strong>
|
|
1502
|
+
<span>{selectedConnection.page_url || "-"}</span>
|
|
1503
|
+
</div>
|
|
1504
|
+
<StatusBadge status={selectedConnection.validation_status} />
|
|
1505
|
+
</div>
|
|
1506
|
+
</Panel>
|
|
1507
|
+
|
|
1508
|
+
<Panel title="Pipelines vinculadas">
|
|
1509
|
+
<div className="record-list">
|
|
1510
|
+
{linkedPipelines.map((pipeline) => (
|
|
1511
|
+
<button
|
|
1512
|
+
key={pipeline.instance_key}
|
|
1513
|
+
type="button"
|
|
1514
|
+
className="selection-item"
|
|
1515
|
+
onClick={() => onOpenPipeline(pipeline.instance_key)}
|
|
1516
|
+
>
|
|
1517
|
+
<div className="selection-item-head">
|
|
1518
|
+
<strong>{pipeline.instance_name}</strong>
|
|
1519
|
+
<StatusBadge status={pipeline.is_active ? "ready" : "pending"} />
|
|
1520
|
+
</div>
|
|
1521
|
+
<div className="selection-item-body">
|
|
1522
|
+
<span>{serializeSelection(pipeline.selected_years) || "Sem anos"}</span>
|
|
1523
|
+
<span>{serializeSelection(pipeline.selected_microdados_types) || "Sem tipos"}</span>
|
|
1524
|
+
<span>{pipeline.schedule || "-"}</span>
|
|
1525
|
+
</div>
|
|
1526
|
+
</button>
|
|
1527
|
+
))}
|
|
1528
|
+
{linkedPipelines.length === 0 ? <p className="muted">Nenhuma pipeline vinculada.</p> : null}
|
|
1529
|
+
</div>
|
|
1530
|
+
</Panel>
|
|
1531
|
+
</div>
|
|
1532
|
+
) : (
|
|
1533
|
+
<Panel>
|
|
1534
|
+
<div className="empty-state">
|
|
1535
|
+
<p>Selecione uma conexão.</p>
|
|
1536
|
+
</div>
|
|
1537
|
+
</Panel>
|
|
1538
|
+
)}
|
|
1539
|
+
</section>
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
return (
|
|
1544
|
+
<section className="page">
|
|
1545
|
+
<PageHeader
|
|
1546
|
+
title="Conexões"
|
|
1547
|
+
actions={
|
|
1548
|
+
<button type="button" onClick={() => navigate(CONNECTION_CREATE_ROUTE)}>
|
|
1549
|
+
Nova conexão
|
|
1550
|
+
</button>
|
|
1551
|
+
}
|
|
1552
|
+
/>
|
|
1553
|
+
|
|
1554
|
+
<Panel>
|
|
1555
|
+
<div className="record-list">
|
|
1556
|
+
{connections.map((instance) => (
|
|
1557
|
+
<button
|
|
1558
|
+
key={instance.connection_key}
|
|
1559
|
+
type="button"
|
|
1560
|
+
className="selection-item"
|
|
1561
|
+
onClick={() => onOpenConnection(instance.connection_key)}
|
|
1562
|
+
>
|
|
1563
|
+
<div className="selection-item-head">
|
|
1564
|
+
<strong>{instance.connection_name}</strong>
|
|
1565
|
+
<StatusBadge status={instance.validation_status || (instance.is_active ? "ready" : "pending")} />
|
|
1566
|
+
</div>
|
|
1567
|
+
<div className="selection-item-body">
|
|
1568
|
+
<span>{instance.connection_key}</span>
|
|
1569
|
+
<span>{instance.pipeline_count || 0} pipeline(s)</span>
|
|
1570
|
+
<span>{formatTimestamp(instance.updated_at)}</span>
|
|
1571
|
+
</div>
|
|
1572
|
+
</button>
|
|
1573
|
+
))}
|
|
1574
|
+
|
|
1575
|
+
{connections.length === 0 ? <p className="muted">Nenhuma conexão cadastrada.</p> : null}
|
|
1576
|
+
</div>
|
|
1577
|
+
</Panel>
|
|
1578
|
+
</section>
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function DashboardsPage({ dashboardId, setDashboardId, dashboardUrl, onLoadDashboard }) {
|
|
1583
|
+
return (
|
|
1584
|
+
<section className="page">
|
|
1585
|
+
<PageHeader title="Dashboards" actions={<ButtonLink href={METABASE_URL}>Abrir Metabase</ButtonLink>} />
|
|
1586
|
+
|
|
1587
|
+
<Panel>
|
|
1588
|
+
<div className="toolbar-row">
|
|
1589
|
+
<label className="field compact-field">
|
|
1590
|
+
<span>Dashboard ID</span>
|
|
1591
|
+
<input type="number" min="1" value={dashboardId} onChange={(event) => setDashboardId(event.target.value)} />
|
|
1592
|
+
</label>
|
|
1593
|
+
<button type="button" onClick={() => onLoadDashboard(Number(dashboardId))}>
|
|
1594
|
+
Carregar
|
|
1595
|
+
</button>
|
|
1596
|
+
</div>
|
|
1597
|
+
</Panel>
|
|
1598
|
+
|
|
1599
|
+
<Panel className="embed-panel">
|
|
1600
|
+
{dashboardUrl ? (
|
|
1601
|
+
<iframe title="metabase-dashboard" src={dashboardUrl} className="dashboard-frame" />
|
|
1602
|
+
) : (
|
|
1603
|
+
<div className="empty-state">
|
|
1604
|
+
<p>Nenhum dashboard carregado.</p>
|
|
1605
|
+
</div>
|
|
1606
|
+
)}
|
|
1607
|
+
</Panel>
|
|
1608
|
+
</section>
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const DEFAULT_POSTGRES_SQL = "SELECT now();";
|
|
1613
|
+
const SYSTEM_SCHEMAS = new Set(["pg_catalog", "information_schema"]);
|
|
1614
|
+
const RELATION_CONTEXT_KEYWORDS = new Set(["from", "join", "update", "into", "table", "view"]);
|
|
1615
|
+
const COLUMN_CONTEXT_KEYWORDS = new Set(["select", "where", "and", "or", "on", "order", "group", "by", "having", "set"]);
|
|
1616
|
+
const SQL_FUNCTION_SUGGESTIONS = [
|
|
1617
|
+
{ label: "now", detail: "function", insertText: "now()" },
|
|
1618
|
+
{ label: "count", detail: "aggregate", insertText: "count(*)" },
|
|
1619
|
+
{ label: "sum", detail: "aggregate", insertText: "sum()" },
|
|
1620
|
+
{ label: "avg", detail: "aggregate", insertText: "avg()" },
|
|
1621
|
+
{ label: "min", detail: "aggregate", insertText: "min()" },
|
|
1622
|
+
{ label: "max", detail: "aggregate", insertText: "max()" },
|
|
1623
|
+
{ label: "date_trunc", detail: "function", insertText: "date_trunc()" },
|
|
1624
|
+
{ label: "coalesce", detail: "function", insertText: "coalesce()" },
|
|
1625
|
+
{ label: "nullif", detail: "function", insertText: "nullif()" },
|
|
1626
|
+
{ label: "round", detail: "function", insertText: "round()" },
|
|
1627
|
+
];
|
|
1628
|
+
const SQL_SUGGESTION_MAX_WIDTH = 420;
|
|
1629
|
+
const SQL_SUGGESTION_MIN_WIDTH = 220;
|
|
1630
|
+
const SQL_SUGGESTION_MAX_HEIGHT = 180;
|
|
1631
|
+
const SQL_SUGGESTION_ROW_HEIGHT = 31;
|
|
1632
|
+
const SQL_SUGGESTION_GAP = 8;
|
|
1633
|
+
|
|
1634
|
+
function getTokenRange(sql, cursorPosition) {
|
|
1635
|
+
const safeCursor = Math.max(0, Math.min(cursorPosition, sql.length));
|
|
1636
|
+
const isTokenCharacter = (character) => /[A-Za-z0-9_$.]/.test(character);
|
|
1637
|
+
|
|
1638
|
+
let start = safeCursor;
|
|
1639
|
+
while (start > 0 && isTokenCharacter(sql[start - 1])) {
|
|
1640
|
+
start -= 1;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
let end = safeCursor;
|
|
1644
|
+
while (end < sql.length && isTokenCharacter(sql[end])) {
|
|
1645
|
+
end += 1;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
return {
|
|
1649
|
+
start,
|
|
1650
|
+
end,
|
|
1651
|
+
token: sql.slice(start, end),
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function getSqlEditorContext(sql, cursorPosition) {
|
|
1656
|
+
const beforeCursor = sql.slice(0, cursorPosition);
|
|
1657
|
+
const tokenRange = getTokenRange(sql, cursorPosition);
|
|
1658
|
+
const words = beforeCursor.toLowerCase().match(/[a-z_]+/g) || [];
|
|
1659
|
+
const previousWord = tokenRange.start === cursorPosition ? words.at(-1) || "" : words.at(-2) || "";
|
|
1660
|
+
const currentWord = tokenRange.token.toLowerCase();
|
|
1661
|
+
const qualifiedMatch = currentWord.match(/^([a-z_][a-z0-9_$]*)\.(.*)$/);
|
|
1662
|
+
|
|
1663
|
+
return {
|
|
1664
|
+
qualifier: qualifiedMatch?.[1] || "",
|
|
1665
|
+
qualifiedToken: qualifiedMatch?.[2] || "",
|
|
1666
|
+
previousWord,
|
|
1667
|
+
currentWord,
|
|
1668
|
+
tokenRange,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function getLineHeight(style) {
|
|
1673
|
+
const parsedLineHeight = Number.parseFloat(style.lineHeight);
|
|
1674
|
+
if (Number.isFinite(parsedLineHeight)) {
|
|
1675
|
+
return parsedLineHeight;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const parsedFontSize = Number.parseFloat(style.fontSize);
|
|
1679
|
+
return Number.isFinite(parsedFontSize) ? parsedFontSize * 1.5 : 22;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function getTextareaCaretPoint(textarea, position) {
|
|
1683
|
+
const style = window.getComputedStyle(textarea);
|
|
1684
|
+
const mirror = document.createElement("div");
|
|
1685
|
+
const marker = document.createElement("span");
|
|
1686
|
+
const mirroredProperties = [
|
|
1687
|
+
"boxSizing",
|
|
1688
|
+
"width",
|
|
1689
|
+
"borderTopWidth",
|
|
1690
|
+
"borderRightWidth",
|
|
1691
|
+
"borderBottomWidth",
|
|
1692
|
+
"borderLeftWidth",
|
|
1693
|
+
"paddingTop",
|
|
1694
|
+
"paddingRight",
|
|
1695
|
+
"paddingBottom",
|
|
1696
|
+
"paddingLeft",
|
|
1697
|
+
"fontFamily",
|
|
1698
|
+
"fontSize",
|
|
1699
|
+
"fontStyle",
|
|
1700
|
+
"fontWeight",
|
|
1701
|
+
"letterSpacing",
|
|
1702
|
+
"lineHeight",
|
|
1703
|
+
"textTransform",
|
|
1704
|
+
"textIndent",
|
|
1705
|
+
"tabSize",
|
|
1706
|
+
];
|
|
1707
|
+
|
|
1708
|
+
for (const property of mirroredProperties) {
|
|
1709
|
+
mirror.style[property] = style[property];
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
mirror.style.position = "absolute";
|
|
1713
|
+
mirror.style.top = "0";
|
|
1714
|
+
mirror.style.left = "-9999px";
|
|
1715
|
+
mirror.style.visibility = "hidden";
|
|
1716
|
+
mirror.style.whiteSpace = "pre-wrap";
|
|
1717
|
+
mirror.style.overflowWrap = "break-word";
|
|
1718
|
+
mirror.style.wordBreak = "normal";
|
|
1719
|
+
|
|
1720
|
+
mirror.textContent = textarea.value.slice(0, position);
|
|
1721
|
+
marker.textContent = textarea.value.slice(position, position + 1) || ".";
|
|
1722
|
+
mirror.appendChild(marker);
|
|
1723
|
+
document.body.appendChild(mirror);
|
|
1724
|
+
|
|
1725
|
+
const point = {
|
|
1726
|
+
left: marker.offsetLeft - textarea.scrollLeft,
|
|
1727
|
+
top: marker.offsetTop - textarea.scrollTop,
|
|
1728
|
+
lineHeight: getLineHeight(style),
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
mirror.remove();
|
|
1732
|
+
return point;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function getSqlSuggestionPosition(textarea, shell, suggestionCount = 0) {
|
|
1736
|
+
if (!textarea || !shell) {
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const cursorPosition = textarea.selectionStart ?? textarea.value.length;
|
|
1741
|
+
const caret = getTextareaCaretPoint(textarea, cursorPosition);
|
|
1742
|
+
const textareaTop = textarea.offsetTop;
|
|
1743
|
+
const textareaLeft = textarea.offsetLeft;
|
|
1744
|
+
const shellWidth = shell.clientWidth;
|
|
1745
|
+
const availableWidth = Math.max(160, shellWidth - SQL_SUGGESTION_GAP * 2);
|
|
1746
|
+
const textareaHeight = textarea.clientHeight;
|
|
1747
|
+
const width = Math.max(Math.min(SQL_SUGGESTION_MIN_WIDTH, availableWidth), Math.min(SQL_SUGGESTION_MAX_WIDTH, availableWidth));
|
|
1748
|
+
const maxLeft = Math.max(SQL_SUGGESTION_GAP, shellWidth - width - SQL_SUGGESTION_GAP);
|
|
1749
|
+
const left = Math.min(Math.max(textareaLeft + caret.left, SQL_SUGGESTION_GAP), maxLeft);
|
|
1750
|
+
const caretTop = textareaTop + caret.top;
|
|
1751
|
+
const caretBottom = caretTop + caret.lineHeight;
|
|
1752
|
+
const editorBottom = textareaTop + textareaHeight;
|
|
1753
|
+
const listHeight = suggestionCount
|
|
1754
|
+
? Math.min(SQL_SUGGESTION_MAX_HEIGHT, Math.max(SQL_SUGGESTION_ROW_HEIGHT, suggestionCount * SQL_SUGGESTION_ROW_HEIGHT + 2))
|
|
1755
|
+
: SQL_SUGGESTION_MAX_HEIGHT;
|
|
1756
|
+
const spaceBelow = Math.max(0, editorBottom - caretBottom - SQL_SUGGESTION_GAP);
|
|
1757
|
+
const spaceAbove = Math.max(0, caretTop - textareaTop - SQL_SUGGESTION_GAP);
|
|
1758
|
+
const shouldOpenBelow = spaceBelow >= Math.min(96, listHeight) || spaceBelow >= spaceAbove;
|
|
1759
|
+
const maxHeight = shouldOpenBelow
|
|
1760
|
+
? Math.min(listHeight, Math.max(SQL_SUGGESTION_ROW_HEIGHT, spaceBelow))
|
|
1761
|
+
: Math.min(listHeight, spaceAbove);
|
|
1762
|
+
const top = shouldOpenBelow
|
|
1763
|
+
? caretBottom + SQL_SUGGESTION_GAP
|
|
1764
|
+
: Math.max(SQL_SUGGESTION_GAP, caretTop - maxHeight - SQL_SUGGESTION_GAP);
|
|
1765
|
+
|
|
1766
|
+
return {
|
|
1767
|
+
left,
|
|
1768
|
+
maxHeight,
|
|
1769
|
+
top,
|
|
1770
|
+
width,
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function useSqlCatalog(catalogRows) {
|
|
1775
|
+
return useMemo(() => {
|
|
1776
|
+
const relationMap = new Map();
|
|
1777
|
+
const schemaSet = new Set();
|
|
1778
|
+
|
|
1779
|
+
for (const row of catalogRows || []) {
|
|
1780
|
+
if (!row?.schema_name || !row?.relation_name || SYSTEM_SCHEMAS.has(row.schema_name)) {
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
schemaSet.add(row.schema_name);
|
|
1785
|
+
const relationKey = `${row.schema_name}.${row.relation_name}`;
|
|
1786
|
+
if (!relationMap.has(relationKey)) {
|
|
1787
|
+
relationMap.set(relationKey, {
|
|
1788
|
+
key: relationKey,
|
|
1789
|
+
schema: row.schema_name,
|
|
1790
|
+
name: row.relation_name,
|
|
1791
|
+
type: row.relation_type,
|
|
1792
|
+
columns: [],
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
if (row.column_name) {
|
|
1797
|
+
relationMap.get(relationKey).columns.push(row.column_name);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const relations = [...relationMap.values()];
|
|
1802
|
+
const columns = relations.flatMap((relation) =>
|
|
1803
|
+
relation.columns.map((columnName) => ({
|
|
1804
|
+
key: `${relation.key}.${columnName}`,
|
|
1805
|
+
schema: relation.schema,
|
|
1806
|
+
relationName: relation.name,
|
|
1807
|
+
relationType: relation.type,
|
|
1808
|
+
name: columnName,
|
|
1809
|
+
})),
|
|
1810
|
+
);
|
|
1811
|
+
|
|
1812
|
+
return {
|
|
1813
|
+
schemas: [...schemaSet].sort(),
|
|
1814
|
+
relations,
|
|
1815
|
+
columns,
|
|
1816
|
+
};
|
|
1817
|
+
}, [catalogRows]);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function SqlSuggestionList({ activeIndex, position, suggestions, onSelectSuggestion, suggestionListRef }) {
|
|
1821
|
+
if (!suggestions.length) {
|
|
1822
|
+
return null;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return (
|
|
1826
|
+
<div ref={suggestionListRef} className="sql-suggestion-list" style={position || undefined}>
|
|
1827
|
+
{suggestions.map((suggestion, index) => (
|
|
1828
|
+
<button
|
|
1829
|
+
key={suggestion.id}
|
|
1830
|
+
type="button"
|
|
1831
|
+
className={`sql-suggestion-item${index === activeIndex ? " active" : ""}`}
|
|
1832
|
+
data-suggestion-index={index}
|
|
1833
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1834
|
+
onClick={() => onSelectSuggestion(suggestion)}
|
|
1835
|
+
>
|
|
1836
|
+
<strong>{suggestion.label}</strong>
|
|
1837
|
+
<span>{suggestion.detail}</span>
|
|
1838
|
+
</button>
|
|
1839
|
+
))}
|
|
1840
|
+
</div>
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function SqlConsoleOutput({ error, query, result }) {
|
|
1845
|
+
return (
|
|
1846
|
+
<div className="sql-output">
|
|
1847
|
+
{query ? (
|
|
1848
|
+
<div className="sql-output-query">
|
|
1849
|
+
<span aria-hidden="true">></span>
|
|
1850
|
+
<pre>{query}</pre>
|
|
1851
|
+
</div>
|
|
1852
|
+
) : null}
|
|
1853
|
+
|
|
1854
|
+
{error ? (
|
|
1855
|
+
<div className="sql-output-error">{error}</div>
|
|
1856
|
+
) : (
|
|
1857
|
+
<>
|
|
1858
|
+
<SqlResultsTable result={result} emptyMessage="A consulta PostgreSQL não retornou linhas." />
|
|
1859
|
+
{result ? (
|
|
1860
|
+
<p className="sql-row-count">
|
|
1861
|
+
{result.row_count} {result.row_count === 1 ? "row" : "rows"}
|
|
1862
|
+
{result.truncated ? ` (limit ${result.max_rows})` : ""}
|
|
1863
|
+
</p>
|
|
1864
|
+
) : null}
|
|
1865
|
+
</>
|
|
1866
|
+
)}
|
|
1867
|
+
</div>
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function SqlWorkspace({ adminRequest }) {
|
|
1872
|
+
const [localSql, setLocalSql] = useState(DEFAULT_POSTGRES_SQL);
|
|
1873
|
+
const [lastExecutedSql, setLastExecutedSql] = useState(DEFAULT_POSTGRES_SQL);
|
|
1874
|
+
const [localSqlResult, setLocalSqlResult] = useState(null);
|
|
1875
|
+
const [localSqlError, setLocalSqlError] = useState("");
|
|
1876
|
+
const [localSqlStatus, setLocalSqlStatus] = useState("syncing");
|
|
1877
|
+
const [catalogRows, setCatalogRows] = useState([]);
|
|
1878
|
+
const [cursorPosition, setCursorPosition] = useState(DEFAULT_POSTGRES_SQL.length);
|
|
1879
|
+
const [isSuggestionOpen, setIsSuggestionOpen] = useState(false);
|
|
1880
|
+
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
|
|
1881
|
+
const [suggestionPosition, setSuggestionPosition] = useState(null);
|
|
1882
|
+
const editorRef = useRef(null);
|
|
1883
|
+
const editorShellRef = useRef(null);
|
|
1884
|
+
const sqlFormRef = useRef(null);
|
|
1885
|
+
const suggestionListRef = useRef(null);
|
|
1886
|
+
const suppressedSuggestionTokenRef = useRef("");
|
|
1887
|
+
const catalog = useSqlCatalog(catalogRows);
|
|
1888
|
+
const editorContext = useMemo(() => getSqlEditorContext(localSql, cursorPosition), [cursorPosition, localSql]);
|
|
1889
|
+
|
|
1890
|
+
const currentSuggestions = useMemo(() => {
|
|
1891
|
+
const currentToken = editorContext.currentWord;
|
|
1892
|
+
const relationToken = editorContext.qualifiedToken;
|
|
1893
|
+
const schemaSuggestions = catalog.schemas.map((schemaName) => ({
|
|
1894
|
+
id: `schema:${schemaName}`,
|
|
1895
|
+
label: schemaName,
|
|
1896
|
+
detail: "schema",
|
|
1897
|
+
insertText: `${schemaName}.`,
|
|
1898
|
+
kind: "schema",
|
|
1899
|
+
}));
|
|
1900
|
+
const relationSuggestions = catalog.relations.map((relation) => ({
|
|
1901
|
+
id: `relation:${relation.key}`,
|
|
1902
|
+
label: `${relation.schema}.${relation.name}`,
|
|
1903
|
+
detail: relation.type,
|
|
1904
|
+
insertText: `${relation.schema}.${relation.name}`,
|
|
1905
|
+
kind: "relation",
|
|
1906
|
+
}));
|
|
1907
|
+
const columnSuggestions = catalog.columns.map((column) => ({
|
|
1908
|
+
id: `column:${column.key}`,
|
|
1909
|
+
label: column.name,
|
|
1910
|
+
detail: `${column.schema}.${column.relationName}`,
|
|
1911
|
+
insertText: column.name,
|
|
1912
|
+
kind: "column",
|
|
1913
|
+
}));
|
|
1914
|
+
const functionSuggestions = SQL_FUNCTION_SUGGESTIONS.map((item) => ({
|
|
1915
|
+
id: `function:${item.label}`,
|
|
1916
|
+
label: item.label,
|
|
1917
|
+
detail: item.detail,
|
|
1918
|
+
insertText: item.insertText,
|
|
1919
|
+
kind: "function",
|
|
1920
|
+
}));
|
|
1921
|
+
|
|
1922
|
+
let source = [...relationSuggestions, ...schemaSuggestions, ...columnSuggestions, ...functionSuggestions];
|
|
1923
|
+
if (editorContext.qualifier) {
|
|
1924
|
+
source = relationSuggestions.filter((item) => item.label.toLowerCase().startsWith(`${editorContext.qualifier}.`));
|
|
1925
|
+
} else if (RELATION_CONTEXT_KEYWORDS.has(editorContext.previousWord)) {
|
|
1926
|
+
source = [...schemaSuggestions, ...relationSuggestions];
|
|
1927
|
+
} else if (COLUMN_CONTEXT_KEYWORDS.has(editorContext.previousWord)) {
|
|
1928
|
+
source = [...columnSuggestions, ...functionSuggestions];
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const filterToken = editorContext.qualifier ? `${editorContext.qualifier}.${relationToken}` : currentToken;
|
|
1932
|
+
const filtered = filterToken
|
|
1933
|
+
? source.filter((item) => item.label.toLowerCase().startsWith(filterToken))
|
|
1934
|
+
: source;
|
|
1935
|
+
|
|
1936
|
+
return filtered.slice(0, 12);
|
|
1937
|
+
}, [
|
|
1938
|
+
catalog.columns,
|
|
1939
|
+
catalog.relations,
|
|
1940
|
+
catalog.schemas,
|
|
1941
|
+
editorContext.currentWord,
|
|
1942
|
+
editorContext.previousWord,
|
|
1943
|
+
editorContext.qualifiedToken,
|
|
1944
|
+
editorContext.qualifier,
|
|
1945
|
+
]);
|
|
1946
|
+
|
|
1947
|
+
function updateSuggestionPosition(target = editorRef.current) {
|
|
1948
|
+
setSuggestionPosition(getSqlSuggestionPosition(target, editorShellRef.current, currentSuggestions.length));
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function suggestionSuppressionKey(sql, position) {
|
|
1952
|
+
const context = getSqlEditorContext(sql, position);
|
|
1953
|
+
return `${context.tokenRange.start}:${context.tokenRange.end}:${context.currentWord}`;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function openSuggestionsForToken(target) {
|
|
1957
|
+
const nextCursorPosition = target.selectionStart ?? target.value.length;
|
|
1958
|
+
const nextSuppressionKey = suggestionSuppressionKey(target.value, nextCursorPosition);
|
|
1959
|
+
|
|
1960
|
+
if (suppressedSuggestionTokenRef.current === nextSuppressionKey) {
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
setIsSuggestionOpen(true);
|
|
1965
|
+
updateSuggestionPosition(target);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
useLayoutEffect(() => {
|
|
1969
|
+
if (!isSuggestionOpen || !currentSuggestions.length) {
|
|
1970
|
+
setSuggestionPosition(null);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
updateSuggestionPosition();
|
|
1975
|
+
}, [currentSuggestions.length, cursorPosition, isSuggestionOpen, localSql]);
|
|
1976
|
+
|
|
1977
|
+
useEffect(() => {
|
|
1978
|
+
if (!isSuggestionOpen) {
|
|
1979
|
+
return undefined;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function handleResize() {
|
|
1983
|
+
updateSuggestionPosition();
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
window.addEventListener("resize", handleResize);
|
|
1987
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
1988
|
+
}, [isSuggestionOpen]);
|
|
1989
|
+
|
|
1990
|
+
useEffect(() => {
|
|
1991
|
+
if (!currentSuggestions.length) {
|
|
1992
|
+
setActiveSuggestionIndex(0);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
setActiveSuggestionIndex((currentIndex) => Math.min(currentIndex, currentSuggestions.length - 1));
|
|
1997
|
+
}, [currentSuggestions]);
|
|
1998
|
+
|
|
1999
|
+
useEffect(() => {
|
|
2000
|
+
if (!isSuggestionOpen) {
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
const activeItem = suggestionListRef.current?.querySelector(`[data-suggestion-index="${activeSuggestionIndex}"]`);
|
|
2005
|
+
activeItem?.scrollIntoView({ block: "nearest" });
|
|
2006
|
+
}, [activeSuggestionIndex, isSuggestionOpen]);
|
|
2007
|
+
|
|
2008
|
+
async function loadPostgresCatalog() {
|
|
2009
|
+
setLocalSqlError("");
|
|
2010
|
+
setLocalSqlStatus("syncing");
|
|
2011
|
+
|
|
2012
|
+
try {
|
|
2013
|
+
const response = await adminRequest("/api/admin/sql/catalog");
|
|
2014
|
+
setCatalogRows(response.items || []);
|
|
2015
|
+
setLocalSqlStatus("ready");
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
setCatalogRows([]);
|
|
2018
|
+
setLocalSqlStatus("error");
|
|
2019
|
+
setLocalSqlError(error instanceof Error ? error.message : "Falha ao carregar o catalogo PostgreSQL.");
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
useEffect(() => {
|
|
2024
|
+
let cancelled = false;
|
|
2025
|
+
|
|
2026
|
+
async function loadInitialSqlState() {
|
|
2027
|
+
try {
|
|
2028
|
+
setLocalSqlStatus("syncing");
|
|
2029
|
+
const [catalogResponse, queryResponse] = await Promise.all([
|
|
2030
|
+
adminRequest("/api/admin/sql/catalog"),
|
|
2031
|
+
adminRequest("/api/admin/sql/query", {
|
|
2032
|
+
method: "POST",
|
|
2033
|
+
body: { sql: DEFAULT_POSTGRES_SQL, max_rows: 500 },
|
|
2034
|
+
}),
|
|
2035
|
+
]);
|
|
2036
|
+
|
|
2037
|
+
if (cancelled) {
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
setCatalogRows(catalogResponse.items || []);
|
|
2041
|
+
setLocalSqlResult(queryResponse);
|
|
2042
|
+
setLastExecutedSql(DEFAULT_POSTGRES_SQL);
|
|
2043
|
+
setLocalSqlStatus("ready");
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
if (!cancelled) {
|
|
2046
|
+
setLocalSqlStatus("error");
|
|
2047
|
+
setLocalSqlError(error instanceof Error ? error.message : "Falha ao conectar no PostgreSQL.");
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
loadInitialSqlState();
|
|
2053
|
+
|
|
2054
|
+
return () => {
|
|
2055
|
+
cancelled = true;
|
|
2056
|
+
};
|
|
2057
|
+
}, [adminRequest]);
|
|
2058
|
+
|
|
2059
|
+
async function handleRunLocalSql(event) {
|
|
2060
|
+
event.preventDefault();
|
|
2061
|
+
|
|
2062
|
+
try {
|
|
2063
|
+
setLocalSqlError("");
|
|
2064
|
+
setLocalSqlStatus("syncing");
|
|
2065
|
+
const result = await adminRequest("/api/admin/sql/query", {
|
|
2066
|
+
method: "POST",
|
|
2067
|
+
body: { sql: localSql, max_rows: 500 },
|
|
2068
|
+
});
|
|
2069
|
+
setLocalSqlResult(result);
|
|
2070
|
+
setLastExecutedSql(localSql);
|
|
2071
|
+
setLocalSqlStatus("ready");
|
|
2072
|
+
} catch (error) {
|
|
2073
|
+
setLocalSqlResult(null);
|
|
2074
|
+
setLocalSqlStatus("error");
|
|
2075
|
+
setLocalSqlError(error instanceof Error ? error.message : "Falha ao executar a consulta PostgreSQL.");
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function handleEditorChange(event) {
|
|
2080
|
+
setLocalSql(event.target.value);
|
|
2081
|
+
setCursorPosition(event.target.selectionStart ?? event.target.value.length);
|
|
2082
|
+
setActiveSuggestionIndex(0);
|
|
2083
|
+
openSuggestionsForToken(event.target);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function handleEditorSelection(event) {
|
|
2087
|
+
setCursorPosition(event.target.selectionStart ?? 0);
|
|
2088
|
+
openSuggestionsForToken(event.target);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function handleEditorFocus(event) {
|
|
2092
|
+
openSuggestionsForToken(event.target);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function handleEditorScroll(event) {
|
|
2096
|
+
if (isSuggestionOpen) {
|
|
2097
|
+
updateSuggestionPosition(event.target);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function handleSuggestionSelect(suggestion) {
|
|
2102
|
+
const target = editorRef.current;
|
|
2103
|
+
const { start, end } = getTokenRange(localSql, cursorPosition);
|
|
2104
|
+
const nextSql = `${localSql.slice(0, start)}${suggestion.insertText}${localSql.slice(end)}`;
|
|
2105
|
+
const nextCursor = start + suggestion.insertText.length;
|
|
2106
|
+
|
|
2107
|
+
setLocalSql(nextSql);
|
|
2108
|
+
setCursorPosition(nextCursor);
|
|
2109
|
+
setIsSuggestionOpen(false);
|
|
2110
|
+
suppressedSuggestionTokenRef.current = "";
|
|
2111
|
+
|
|
2112
|
+
window.requestAnimationFrame(() => {
|
|
2113
|
+
if (!target) {
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
target.focus();
|
|
2118
|
+
target.setSelectionRange(nextCursor, nextCursor);
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function insertEditorText(text) {
|
|
2123
|
+
const target = editorRef.current;
|
|
2124
|
+
const selectionStart = target?.selectionStart ?? cursorPosition;
|
|
2125
|
+
const selectionEnd = target?.selectionEnd ?? cursorPosition;
|
|
2126
|
+
const nextSql = `${localSql.slice(0, selectionStart)}${text}${localSql.slice(selectionEnd)}`;
|
|
2127
|
+
const nextCursor = selectionStart + text.length;
|
|
2128
|
+
|
|
2129
|
+
setLocalSql(nextSql);
|
|
2130
|
+
setCursorPosition(nextCursor);
|
|
2131
|
+
suppressedSuggestionTokenRef.current = "";
|
|
2132
|
+
|
|
2133
|
+
window.requestAnimationFrame(() => {
|
|
2134
|
+
if (!target) {
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
target.focus();
|
|
2139
|
+
target.setSelectionRange(nextCursor, nextCursor);
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function handleEditorKeyDown(event) {
|
|
2144
|
+
if (event.key === "Escape") {
|
|
2145
|
+
event.preventDefault();
|
|
2146
|
+
setIsSuggestionOpen(false);
|
|
2147
|
+
suppressedSuggestionTokenRef.current = suggestionSuppressionKey(localSql, cursorPosition);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
2152
|
+
event.preventDefault();
|
|
2153
|
+
insertEditorText("\n");
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
if (event.key === " " && (event.ctrlKey || event.metaKey)) {
|
|
2158
|
+
event.preventDefault();
|
|
2159
|
+
if (currentSuggestions.length) {
|
|
2160
|
+
handleSuggestionSelect(currentSuggestions[activeSuggestionIndex] || currentSuggestions[0]);
|
|
2161
|
+
} else {
|
|
2162
|
+
suppressedSuggestionTokenRef.current = "";
|
|
2163
|
+
setIsSuggestionOpen(true);
|
|
2164
|
+
updateSuggestionPosition();
|
|
2165
|
+
}
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
2170
|
+
event.preventDefault();
|
|
2171
|
+
if (localSqlStatus !== "syncing") {
|
|
2172
|
+
sqlFormRef.current?.requestSubmit();
|
|
2173
|
+
}
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
if (!isSuggestionOpen || !currentSuggestions.length) {
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
if (event.key === "ArrowDown") {
|
|
2182
|
+
event.preventDefault();
|
|
2183
|
+
setActiveSuggestionIndex((currentIndex) => (currentIndex + 1) % currentSuggestions.length);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
if (event.key === "ArrowUp") {
|
|
2188
|
+
event.preventDefault();
|
|
2189
|
+
setActiveSuggestionIndex((currentIndex) => (currentIndex - 1 + currentSuggestions.length) % currentSuggestions.length);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (event.key === "Tab") {
|
|
2194
|
+
event.preventDefault();
|
|
2195
|
+
handleSuggestionSelect(currentSuggestions[activeSuggestionIndex] || currentSuggestions[0]);
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
return (
|
|
2202
|
+
<section className="page sql-page">
|
|
2203
|
+
<div className="sql-console">
|
|
2204
|
+
<div className="sql-result-area">
|
|
2205
|
+
<SqlConsoleOutput error={localSqlError} query={lastExecutedSql} result={localSqlResult} />
|
|
2206
|
+
</div>
|
|
2207
|
+
|
|
2208
|
+
<form ref={sqlFormRef} className="sql-console-form" onSubmit={handleRunLocalSql}>
|
|
2209
|
+
<div ref={editorShellRef} className="sql-editor-shell">
|
|
2210
|
+
<textarea
|
|
2211
|
+
ref={editorRef}
|
|
2212
|
+
aria-label="SQL"
|
|
2213
|
+
className="sql-editor-textarea"
|
|
2214
|
+
value={localSql}
|
|
2215
|
+
onChange={handleEditorChange}
|
|
2216
|
+
onFocus={handleEditorFocus}
|
|
2217
|
+
onBlur={() => setIsSuggestionOpen(false)}
|
|
2218
|
+
onClick={handleEditorSelection}
|
|
2219
|
+
onKeyDown={handleEditorKeyDown}
|
|
2220
|
+
onKeyUp={handleEditorSelection}
|
|
2221
|
+
onScroll={handleEditorScroll}
|
|
2222
|
+
onSelect={handleEditorSelection}
|
|
2223
|
+
placeholder={DEFAULT_POSTGRES_SQL}
|
|
2224
|
+
spellCheck={false}
|
|
2225
|
+
/>
|
|
2226
|
+
{isSuggestionOpen && currentSuggestions.length ? (
|
|
2227
|
+
<SqlSuggestionList
|
|
2228
|
+
activeIndex={activeSuggestionIndex}
|
|
2229
|
+
position={suggestionPosition}
|
|
2230
|
+
suggestions={currentSuggestions}
|
|
2231
|
+
onSelectSuggestion={handleSuggestionSelect}
|
|
2232
|
+
suggestionListRef={suggestionListRef}
|
|
2233
|
+
/>
|
|
2234
|
+
) : null}
|
|
2235
|
+
</div>
|
|
2236
|
+
|
|
2237
|
+
<div className="toolbar-row sql-console-toolbar">
|
|
2238
|
+
<button type="submit" disabled={localSqlStatus === "syncing"}>
|
|
2239
|
+
Executar SQL
|
|
2240
|
+
</button>
|
|
2241
|
+
<button type="button" className="secondary" onClick={loadPostgresCatalog} disabled={localSqlStatus === "syncing"}>
|
|
2242
|
+
Atualizar catálogo
|
|
2243
|
+
</button>
|
|
2244
|
+
<span className={`status-badge ${statusTone(localSqlStatus === "error" ? "error" : localSqlStatus === "ready" ? "ready" : "pending")}`}>
|
|
2245
|
+
{localSqlStatus === "ready" ? "Database pronto" : localSqlStatus === "error" ? "Erro no Banco" : "Consultando"}
|
|
2246
|
+
</span>
|
|
2247
|
+
</div>
|
|
2248
|
+
</form>
|
|
2249
|
+
</div>
|
|
2250
|
+
|
|
2251
|
+
<div className="sql-shortcut-help" aria-label="Atalhos SQL">
|
|
2252
|
+
<span><kbd>Enter</kbd> executa</span>
|
|
2253
|
+
<span><kbd>Ctrl</kbd> + <kbd>Enter</kbd> nova linha</span>
|
|
2254
|
+
<span><kbd>Ctrl</kbd> + <kbd>Space</kbd> autocompleta</span>
|
|
2255
|
+
<span><kbd>Tab</kbd> autocompleta</span>
|
|
2256
|
+
<span><kbd>Esc</kbd> fecha sugestões</span>
|
|
2257
|
+
</div>
|
|
2258
|
+
</section>
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function SqlPage({ adminRequest }) {
|
|
2263
|
+
return <SqlWorkspace adminRequest={adminRequest} />;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
export default function App() {
|
|
2267
|
+
const [route, setRoute] = useState(getRouteFromHash());
|
|
2268
|
+
const [notice, setNotice] = useState("");
|
|
2269
|
+
const [error, setError] = useState("");
|
|
2270
|
+
const [connectorDefinition, setConnectorDefinition] = useState(null);
|
|
2271
|
+
const [connections, setConnections] = useState([]);
|
|
2272
|
+
const [pipelines, setPipelines] = useState([]);
|
|
2273
|
+
const [selectedConnectionKey, setSelectedConnectionKey] = useState("");
|
|
2274
|
+
const [selectedPipelineKey, setSelectedPipelineKey] = useState("");
|
|
2275
|
+
const [connectionDetail, setConnectionDetail] = useState(null);
|
|
2276
|
+
const [pipelineOverview, setPipelineOverview] = useState(null);
|
|
2277
|
+
const [dagRuns, setDagRuns] = useState([]);
|
|
2278
|
+
const [connectionForm, setConnectionForm] = useState(INITIAL_CONNECTION_FORM);
|
|
2279
|
+
const [pipelineForm, setPipelineForm] = useState(INITIAL_PIPELINE_FORM);
|
|
2280
|
+
const [createState, setCreateState] = useState("idle");
|
|
2281
|
+
const [pipelineAction, setPipelineAction] = useState("");
|
|
2282
|
+
const [deleteAction, setDeleteAction] = useState("");
|
|
2283
|
+
const [dashboardId, setDashboardId] = useState("");
|
|
2284
|
+
const [dashboardUrl, setDashboardUrl] = useState("");
|
|
2285
|
+
const [vannaQuestion, setVannaQuestion] = useState("");
|
|
2286
|
+
const [vannaResult, setVannaResult] = useState(null);
|
|
2287
|
+
const [vannaState, setVannaState] = useState("idle");
|
|
2288
|
+
const [vannaError, setVannaError] = useState("");
|
|
2289
|
+
|
|
2290
|
+
const auth = useAdminAuth({ apiBaseUrl: API_BASE_URL, storageKey: "dataif.admin" });
|
|
2291
|
+
|
|
2292
|
+
useEffect(() => {
|
|
2293
|
+
function handleHashChange() {
|
|
2294
|
+
setRoute(getRouteFromHash());
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
window.addEventListener("hashchange", handleHashChange);
|
|
2298
|
+
return () => window.removeEventListener("hashchange", handleHashChange);
|
|
2299
|
+
}, []);
|
|
2300
|
+
|
|
2301
|
+
async function adminRequest(path, options = {}) {
|
|
2302
|
+
const token = await auth.getAccessToken();
|
|
2303
|
+
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
2304
|
+
method: options.method || "GET",
|
|
2305
|
+
headers: buildHeaders(token, options.body !== undefined),
|
|
2306
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
if (!response.ok) {
|
|
2310
|
+
const detailText = await response.text();
|
|
2311
|
+
throw new Error(detailText || `Falha HTTP ${response.status}`);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
if (response.status === 204) {
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
return response.json();
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
async function loadConnections() {
|
|
2322
|
+
if (auth.status !== "authenticated") {
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
try {
|
|
2327
|
+
setError("");
|
|
2328
|
+
const response = await adminRequest("/api/admin/connections/pnp");
|
|
2329
|
+
const nextConnections = response.items || [];
|
|
2330
|
+
setConnections(nextConnections);
|
|
2331
|
+
setSelectedConnectionKey((current) =>
|
|
2332
|
+
nextConnections.some((item) => item.connection_key === current) ? current : nextConnections[0]?.connection_key || "",
|
|
2333
|
+
);
|
|
2334
|
+
} catch (requestError) {
|
|
2335
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar as conexoes.");
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
async function loadPipelines() {
|
|
2340
|
+
if (auth.status !== "authenticated") {
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
try {
|
|
2345
|
+
setError("");
|
|
2346
|
+
const response = await adminRequest("/api/admin/pipelines/pnp");
|
|
2347
|
+
const nextPipelines = response.items || [];
|
|
2348
|
+
setPipelines(nextPipelines);
|
|
2349
|
+
setSelectedPipelineKey((current) =>
|
|
2350
|
+
nextPipelines.some((item) => item.instance_key === current) ? current : nextPipelines[0]?.instance_key || "",
|
|
2351
|
+
);
|
|
2352
|
+
} catch (requestError) {
|
|
2353
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar as pipelines.");
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
async function loadConnectorDefinition() {
|
|
2358
|
+
if (auth.status !== "authenticated") {
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
try {
|
|
2363
|
+
setError("");
|
|
2364
|
+
const definition = await adminRequest("/api/admin/connector-definitions/pnp");
|
|
2365
|
+
setConnectorDefinition(definition);
|
|
2366
|
+
} catch (requestError) {
|
|
2367
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar o catalogo da PNP.");
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
async function loadSelectedConnection(connectionKey) {
|
|
2372
|
+
if (!connectionKey || auth.status !== "authenticated") {
|
|
2373
|
+
setConnectionDetail(null);
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
try {
|
|
2378
|
+
const detailResponse = await adminRequest(`/api/admin/connections/pnp/${connectionKey}`);
|
|
2379
|
+
setConnectionDetail(detailResponse);
|
|
2380
|
+
} catch (requestError) {
|
|
2381
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar a conexão.");
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
async function loadSelectedPipeline(instanceKey) {
|
|
2386
|
+
if (!instanceKey || auth.status !== "authenticated") {
|
|
2387
|
+
setPipelineOverview(null);
|
|
2388
|
+
setDagRuns([]);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
try {
|
|
2393
|
+
const [overviewResponse, dagRunResponse] = await Promise.all([
|
|
2394
|
+
adminRequest(`/api/admin/pipelines/pnp/${instanceKey}/admin-overview`),
|
|
2395
|
+
adminRequest(`/api/admin/pipelines/pnp/${instanceKey}/dag-runs`),
|
|
2396
|
+
]);
|
|
2397
|
+
setPipelineOverview(overviewResponse);
|
|
2398
|
+
setDagRuns(dagRunResponse.items || []);
|
|
2399
|
+
} catch (requestError) {
|
|
2400
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar a pipeline.");
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
useEffect(() => {
|
|
2405
|
+
if (auth.status === "authenticated") {
|
|
2406
|
+
loadConnections();
|
|
2407
|
+
loadPipelines();
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
setConnections([]);
|
|
2412
|
+
setPipelines([]);
|
|
2413
|
+
setConnectorDefinition(null);
|
|
2414
|
+
setSelectedConnectionKey("");
|
|
2415
|
+
setSelectedPipelineKey("");
|
|
2416
|
+
setConnectionDetail(null);
|
|
2417
|
+
setPipelineOverview(null);
|
|
2418
|
+
setDagRuns([]);
|
|
2419
|
+
}, [auth.status]);
|
|
2420
|
+
|
|
2421
|
+
useEffect(() => {
|
|
2422
|
+
if (auth.status !== "authenticated") {
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
if (route === PIPELINE_CREATE_ROUTE) {
|
|
2427
|
+
loadConnectorDefinition();
|
|
2428
|
+
}
|
|
2429
|
+
}, [route, auth.status]);
|
|
2430
|
+
|
|
2431
|
+
useEffect(() => {
|
|
2432
|
+
if (route !== PIPELINE_CREATE_ROUTE) {
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
const pendingConnection = window.sessionStorage.getItem("dataif.connection.selected");
|
|
2437
|
+
if (pendingConnection) {
|
|
2438
|
+
setPipelineForm((current) => ({ ...current, connection_key: pendingConnection }));
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
if (!pipelineForm.connection_key && selectedConnectionKey) {
|
|
2443
|
+
setPipelineForm((current) => ({ ...current, connection_key: current.connection_key || selectedConnectionKey }));
|
|
2444
|
+
}
|
|
2445
|
+
}, [route, selectedConnectionKey, pipelineForm.connection_key]);
|
|
2446
|
+
|
|
2447
|
+
useEffect(() => {
|
|
2448
|
+
loadSelectedConnection(selectedConnectionKey);
|
|
2449
|
+
}, [selectedConnectionKey, auth.status]);
|
|
2450
|
+
|
|
2451
|
+
useEffect(() => {
|
|
2452
|
+
loadSelectedPipeline(selectedPipelineKey);
|
|
2453
|
+
}, [selectedPipelineKey, auth.status]);
|
|
2454
|
+
|
|
2455
|
+
function requestAdminLogin(targetRoute = route) {
|
|
2456
|
+
storeReturnRoute(targetRoute);
|
|
2457
|
+
navigate(LOGIN_ROUTE);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
async function handleAdminLogin(username, password) {
|
|
2461
|
+
const success = await auth.login(username, password);
|
|
2462
|
+
if (success) {
|
|
2463
|
+
navigate(consumeReturnRoute());
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function handleLoginBack() {
|
|
2468
|
+
navigate(auth.status === "authenticated" ? consumeReturnRoute() : "/");
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
async function handleSubmitConnection(event) {
|
|
2472
|
+
event.preventDefault();
|
|
2473
|
+
setCreateState("loading");
|
|
2474
|
+
setNotice("");
|
|
2475
|
+
setError("");
|
|
2476
|
+
|
|
2477
|
+
try {
|
|
2478
|
+
const created = await adminRequest("/api/admin/connections/pnp", {
|
|
2479
|
+
method: "POST",
|
|
2480
|
+
body: connectionForm,
|
|
2481
|
+
});
|
|
2482
|
+
setConnectionForm(INITIAL_CONNECTION_FORM);
|
|
2483
|
+
setNotice(`conexão ${created.connection_name} criada com sucesso.`);
|
|
2484
|
+
await loadConnections();
|
|
2485
|
+
setSelectedConnectionKey(created.connection_key);
|
|
2486
|
+
navigate(CONNECTION_DETAIL_ROUTE);
|
|
2487
|
+
} catch (requestError) {
|
|
2488
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao criar conexão.");
|
|
2489
|
+
} finally {
|
|
2490
|
+
setCreateState("idle");
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
async function handleSubmitPipeline(event) {
|
|
2495
|
+
event.preventDefault();
|
|
2496
|
+
setCreateState("loading");
|
|
2497
|
+
setNotice("");
|
|
2498
|
+
setError("");
|
|
2499
|
+
|
|
2500
|
+
try {
|
|
2501
|
+
const created = await adminRequest("/api/admin/pipelines/pnp", {
|
|
2502
|
+
method: "POST",
|
|
2503
|
+
body: pipelineForm,
|
|
2504
|
+
});
|
|
2505
|
+
setPipelineForm(INITIAL_PIPELINE_FORM);
|
|
2506
|
+
window.sessionStorage.removeItem("dataif.connection.selected");
|
|
2507
|
+
setNotice(`Pipeline ${created.instance_name} criada com sucesso.`);
|
|
2508
|
+
await loadPipelines();
|
|
2509
|
+
setSelectedPipelineKey(created.instance_key);
|
|
2510
|
+
navigate("/pipelines");
|
|
2511
|
+
} catch (requestError) {
|
|
2512
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao criar pipeline.");
|
|
2513
|
+
} finally {
|
|
2514
|
+
setCreateState("idle");
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
async function handleTriggerOperation(operation) {
|
|
2519
|
+
if (!selectedPipelineKey) {
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
setPipelineAction(operation);
|
|
2524
|
+
setNotice("");
|
|
2525
|
+
setError("");
|
|
2526
|
+
|
|
2527
|
+
try {
|
|
2528
|
+
const response = await adminRequest(
|
|
2529
|
+
`/api/admin/pipelines/pnp/${selectedPipelineKey}/operations/${operation}`,
|
|
2530
|
+
{ method: "POST" },
|
|
2531
|
+
);
|
|
2532
|
+
setNotice(`Operacao ${response.dag_id} disparada para ${response.instance_key}.`);
|
|
2533
|
+
await loadSelectedPipeline(selectedPipelineKey);
|
|
2534
|
+
} catch (requestError) {
|
|
2535
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao disparar pipeline.");
|
|
2536
|
+
} finally {
|
|
2537
|
+
setPipelineAction("");
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
async function handleLoadDashboard(targetDashboardId) {
|
|
2542
|
+
if (!Number.isFinite(targetDashboardId) || targetDashboardId < 1) {
|
|
2543
|
+
setError("Informe um ID de dashboard valido.");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
try {
|
|
2548
|
+
setError("");
|
|
2549
|
+
setNotice("");
|
|
2550
|
+
const payload = await adminRequest("/api/admin/embed/metabase-default", {
|
|
2551
|
+
method: "POST",
|
|
2552
|
+
body: { dashboard_id: targetDashboardId, params: {} },
|
|
2553
|
+
});
|
|
2554
|
+
setDashboardId(String(payload.dashboard_id));
|
|
2555
|
+
setDashboardUrl(payload.signed_url);
|
|
2556
|
+
setNotice(`Dashboard ${payload.dashboard_id} definido como padrao do sistema.`);
|
|
2557
|
+
} catch (requestError) {
|
|
2558
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar dashboard.");
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
async function handleLoadDefaultDashboard() {
|
|
2563
|
+
try {
|
|
2564
|
+
setError("");
|
|
2565
|
+
const payload = await fetch(`${API_BASE_URL}/api/embed/metabase-default`).then(async (response) => {
|
|
2566
|
+
if (!response.ok) {
|
|
2567
|
+
throw new Error(await response.text());
|
|
2568
|
+
}
|
|
2569
|
+
return response.json();
|
|
2570
|
+
});
|
|
2571
|
+
setDashboardId(String(payload.dashboard_id));
|
|
2572
|
+
setDashboardUrl(payload.signed_url);
|
|
2573
|
+
} catch (requestError) {
|
|
2574
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao carregar dashboard.");
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
async function handleAskVanna(event) {
|
|
2579
|
+
event.preventDefault();
|
|
2580
|
+
const question = vannaQuestion.trim();
|
|
2581
|
+
if (question.length < 3) {
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
setVannaState("loading");
|
|
2586
|
+
setVannaError("");
|
|
2587
|
+
|
|
2588
|
+
try {
|
|
2589
|
+
let headers = { "Content-Type": "application/json" };
|
|
2590
|
+
if (auth.status === "authenticated") {
|
|
2591
|
+
const token = await auth.getAccessToken();
|
|
2592
|
+
headers = buildHeaders(token, true);
|
|
2593
|
+
}
|
|
2594
|
+
const payload = await fetch(`${API_BASE_URL}/api/vanna/ask`, {
|
|
2595
|
+
method: "POST",
|
|
2596
|
+
headers,
|
|
2597
|
+
body: JSON.stringify({ question }),
|
|
2598
|
+
}).then(async (response) => {
|
|
2599
|
+
if (!response.ok) {
|
|
2600
|
+
throw new Error(await response.text());
|
|
2601
|
+
}
|
|
2602
|
+
return response.json();
|
|
2603
|
+
});
|
|
2604
|
+
setVannaResult(payload);
|
|
2605
|
+
} catch (requestError) {
|
|
2606
|
+
setVannaError(requestError instanceof Error ? requestError.message : "Falha ao consultar o Vanna.");
|
|
2607
|
+
setVannaResult(null);
|
|
2608
|
+
} finally {
|
|
2609
|
+
setVannaState("idle");
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
async function handleDeletePipeline(instanceKey) {
|
|
2614
|
+
if (!instanceKey || !window.confirm("Excluir esta pipeline?")) {
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
setDeleteAction(instanceKey);
|
|
2619
|
+
setNotice("");
|
|
2620
|
+
setError("");
|
|
2621
|
+
|
|
2622
|
+
try {
|
|
2623
|
+
const response = await adminRequest(`/api/admin/pipelines/pnp/instances/${instanceKey}`, { method: "DELETE" });
|
|
2624
|
+
setNotice(`Pipeline ${response.instance_name} excluida com sucesso.`);
|
|
2625
|
+
setSelectedPipelineKey("");
|
|
2626
|
+
setPipelineOverview(null);
|
|
2627
|
+
setDagRuns([]);
|
|
2628
|
+
await loadPipelines();
|
|
2629
|
+
await loadConnections();
|
|
2630
|
+
} catch (requestError) {
|
|
2631
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao excluir pipeline.");
|
|
2632
|
+
} finally {
|
|
2633
|
+
setDeleteAction("");
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
async function handleDeleteConnection(instanceKey) {
|
|
2638
|
+
if (!instanceKey || !window.confirm("Excluir esta conexão?")) {
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
setDeleteAction(instanceKey);
|
|
2643
|
+
setNotice("");
|
|
2644
|
+
setError("");
|
|
2645
|
+
|
|
2646
|
+
try {
|
|
2647
|
+
const response = await adminRequest(`/api/admin/connections/pnp/${instanceKey}`, { method: "DELETE" });
|
|
2648
|
+
setNotice(`conexão ${response.connection_name} excluida com sucesso.`);
|
|
2649
|
+
setSelectedConnectionKey("");
|
|
2650
|
+
setConnectionDetail(null);
|
|
2651
|
+
setSelectedPipelineKey("");
|
|
2652
|
+
setPipelineOverview(null);
|
|
2653
|
+
setDagRuns([]);
|
|
2654
|
+
await loadConnections();
|
|
2655
|
+
await loadPipelines();
|
|
2656
|
+
navigate(CONNECTIONS_ROUTE);
|
|
2657
|
+
} catch (requestError) {
|
|
2658
|
+
setError(requestError instanceof Error ? requestError.message : "Falha ao excluir conexão.");
|
|
2659
|
+
} finally {
|
|
2660
|
+
setDeleteAction("");
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
useEffect(() => {
|
|
2665
|
+
if (route === "/" || (route === "/dashboards" && !dashboardUrl)) {
|
|
2666
|
+
handleLoadDefaultDashboard();
|
|
2667
|
+
}
|
|
2668
|
+
}, [route]);
|
|
2669
|
+
|
|
2670
|
+
const isAuthenticated = auth.status === "authenticated";
|
|
2671
|
+
const publicNavItems = NAV_ITEMS.filter((item) => item.path === "/" || isAuthenticated || !AUTH_REQUIRED_NAV_PATHS.has(item.path));
|
|
2672
|
+
const isVisitorBlockedRoute = !isAuthenticated && AUTH_REQUIRED_NAV_PATHS.has(route);
|
|
2673
|
+
|
|
2674
|
+
const homeContent = (
|
|
2675
|
+
<HomePage
|
|
2676
|
+
dashboardUrl={dashboardUrl}
|
|
2677
|
+
vannaQuestion={vannaQuestion}
|
|
2678
|
+
setVannaQuestion={setVannaQuestion}
|
|
2679
|
+
vannaResult={vannaResult}
|
|
2680
|
+
vannaState={vannaState}
|
|
2681
|
+
vannaError={vannaError}
|
|
2682
|
+
onAskVanna={handleAskVanna}
|
|
2683
|
+
/>
|
|
2684
|
+
);
|
|
2685
|
+
|
|
2686
|
+
let content = homeContent;
|
|
2687
|
+
|
|
2688
|
+
if (isVisitorBlockedRoute) {
|
|
2689
|
+
content = homeContent;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
if (!isVisitorBlockedRoute && (route === "/pipelines" || route === PIPELINE_CREATE_ROUTE)) {
|
|
2693
|
+
content = (
|
|
2694
|
+
<PipelinesPage
|
|
2695
|
+
auth={auth}
|
|
2696
|
+
route={route}
|
|
2697
|
+
onLoginRequest={() => requestAdminLogin(route)}
|
|
2698
|
+
pipelines={pipelines}
|
|
2699
|
+
connections={connections}
|
|
2700
|
+
selectedPipelineKey={selectedPipelineKey}
|
|
2701
|
+
onSelectPipeline={setSelectedPipelineKey}
|
|
2702
|
+
overview={pipelineOverview}
|
|
2703
|
+
dagRuns={dagRuns}
|
|
2704
|
+
pipelineAction={pipelineAction}
|
|
2705
|
+
deleteAction={deleteAction}
|
|
2706
|
+
onTriggerOperation={handleTriggerOperation}
|
|
2707
|
+
onDeletePipeline={handleDeletePipeline}
|
|
2708
|
+
pipelineForm={pipelineForm}
|
|
2709
|
+
setPipelineForm={setPipelineForm}
|
|
2710
|
+
createState={createState}
|
|
2711
|
+
onSubmitPipeline={handleSubmitPipeline}
|
|
2712
|
+
connectorDefinition={connectorDefinition}
|
|
2713
|
+
/>
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
if (!isVisitorBlockedRoute && [CONNECTIONS_ROUTE, CONNECTION_CREATE_ROUTE, CONNECTION_DETAIL_ROUTE].includes(route)) {
|
|
2718
|
+
content = (
|
|
2719
|
+
<ConnectionsPage
|
|
2720
|
+
auth={auth}
|
|
2721
|
+
route={route}
|
|
2722
|
+
onLoginRequest={() => requestAdminLogin(route)}
|
|
2723
|
+
connectionForm={connectionForm}
|
|
2724
|
+
setConnectionForm={setConnectionForm}
|
|
2725
|
+
createState={createState}
|
|
2726
|
+
onSubmitConnection={handleSubmitConnection}
|
|
2727
|
+
connections={connections}
|
|
2728
|
+
selectedConnectionKey={selectedConnectionKey}
|
|
2729
|
+
detail={connectionDetail}
|
|
2730
|
+
onOpenConnection={(connectionKey) => {
|
|
2731
|
+
setSelectedConnectionKey(connectionKey);
|
|
2732
|
+
navigate(CONNECTION_DETAIL_ROUTE);
|
|
2733
|
+
}}
|
|
2734
|
+
onOpenPipeline={(pipelineKey) => {
|
|
2735
|
+
setSelectedPipelineKey(pipelineKey);
|
|
2736
|
+
navigate("/pipelines");
|
|
2737
|
+
}}
|
|
2738
|
+
deleteAction={deleteAction}
|
|
2739
|
+
onDeleteConnection={handleDeleteConnection}
|
|
2740
|
+
/>
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
if (!isVisitorBlockedRoute && route === "/dashboards") {
|
|
2745
|
+
content = (
|
|
2746
|
+
<DashboardsPage
|
|
2747
|
+
dashboardId={dashboardId}
|
|
2748
|
+
setDashboardId={setDashboardId}
|
|
2749
|
+
dashboardUrl={dashboardUrl}
|
|
2750
|
+
onLoadDashboard={handleLoadDashboard}
|
|
2751
|
+
/>
|
|
2752
|
+
);
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
if (!isVisitorBlockedRoute && route === "/sql") {
|
|
2756
|
+
content = (
|
|
2757
|
+
<SqlPage adminRequest={adminRequest} />
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
if (isAdminRoute(route)) {
|
|
2762
|
+
content = (
|
|
2763
|
+
<AdminWorkspacePage
|
|
2764
|
+
auth={auth}
|
|
2765
|
+
route={route}
|
|
2766
|
+
onLoginRequest={() => requestAdminLogin(route)}
|
|
2767
|
+
onLogout={auth.logout}
|
|
2768
|
+
/>
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
if (route === LOGIN_ROUTE) {
|
|
2773
|
+
content = <LoginPage auth={auth} onSubmit={handleAdminLogin} onBack={handleLoginBack} />;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
if (route === SETTINGS_ROUTE) {
|
|
2777
|
+
content = (
|
|
2778
|
+
<SettingsPage
|
|
2779
|
+
auth={auth}
|
|
2780
|
+
onLoginRequest={() => requestAdminLogin(SETTINGS_ROUTE)}
|
|
2781
|
+
onLogout={auth.logout}
|
|
2782
|
+
/>
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
if (route === ADMIN_SETTINGS_ROUTE) {
|
|
2787
|
+
content = (
|
|
2788
|
+
<AdminSettingsPage
|
|
2789
|
+
auth={auth}
|
|
2790
|
+
onLoginRequest={() => requestAdminLogin(ADMIN_SETTINGS_ROUTE)}
|
|
2791
|
+
adminRequest={adminRequest}
|
|
2792
|
+
/>
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
return (
|
|
2797
|
+
<main className="app-shell">
|
|
2798
|
+
<AppHeader
|
|
2799
|
+
auth={auth}
|
|
2800
|
+
adminNavItems={ADMIN_NAV_ITEMS}
|
|
2801
|
+
githubRepoUrl={GITHUB_REPO_URL}
|
|
2802
|
+
onAdminSettings={() => navigate(ADMIN_SETTINGS_ROUTE)}
|
|
2803
|
+
onLogout={auth.logout}
|
|
2804
|
+
onRequestLogin={() => requestAdminLogin(route)}
|
|
2805
|
+
onSelectRoute={navigate}
|
|
2806
|
+
publicNavItems={publicNavItems}
|
|
2807
|
+
route={route}
|
|
2808
|
+
/>
|
|
2809
|
+
|
|
2810
|
+
<div className="content-shell">
|
|
2811
|
+
{notice ? <p className="notice-banner">{notice}</p> : null}
|
|
2812
|
+
{error || auth.error ? <p className="error-banner">{error || auth.error}</p> : null}
|
|
2813
|
+
{content}
|
|
2814
|
+
</div>
|
|
2815
|
+
</main>
|
|
2816
|
+
);
|
|
2817
|
+
}
|