@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.
Files changed (183) hide show
  1. package/README.md +16 -0
  2. package/bin/dataif.js +623 -0
  3. package/package.json +26 -0
  4. package/scripts/build-template.mjs +72 -0
  5. package/templates/dataif/README.md +157 -0
  6. package/templates/dataif/infra/.env.example +119 -0
  7. package/templates/dataif/infra/.env.stg.example +119 -0
  8. package/templates/dataif/infra/airflow/Dockerfile +11 -0
  9. package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
  10. package/templates/dataif/infra/airflow/requirements.txt +3 -0
  11. package/templates/dataif/infra/docker-compose.yml +306 -0
  12. package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
  13. package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
  14. package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
  15. package/templates/dataif/infra/keycloak/Dockerfile +4 -0
  16. package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
  17. package/templates/dataif/infra/ollama/Dockerfile +9 -0
  18. package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
  19. package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
  20. package/templates/dataif/infra/postgres/Dockerfile +4 -0
  21. package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
  22. package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
  23. package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
  24. package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
  25. package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
  26. package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
  27. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
  28. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
  29. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
  30. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
  31. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
  32. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
  33. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
  34. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
  35. package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
  36. package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
  37. package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
  38. package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
  39. package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
  40. package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
  41. package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
  42. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
  43. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
  44. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
  45. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
  46. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
  47. package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
  48. package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
  49. package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  50. package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
  51. package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
  52. package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  53. package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  54. package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  55. package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  56. package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  57. package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  58. package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  59. package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
  60. package/templates/dataif/scripts/configure-env.sh +149 -0
  61. package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
  62. package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
  63. package/templates/dataif/scripts/deploy.sh +79 -0
  64. package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
  65. package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
  66. package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
  67. package/templates/dataif/scripts/publish-images.sh +31 -0
  68. package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
  69. package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
  70. package/templates/dataif/services/api/.dockerignore +18 -0
  71. package/templates/dataif/services/api/Dockerfile +12 -0
  72. package/templates/dataif/services/api/app/__init__.py +1 -0
  73. package/templates/dataif/services/api/app/auth.py +48 -0
  74. package/templates/dataif/services/api/app/config.py +59 -0
  75. package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
  76. package/templates/dataif/services/api/app/main.py +2432 -0
  77. package/templates/dataif/services/api/app/metabase_admin.py +191 -0
  78. package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
  79. package/templates/dataif/services/api/app/metabase_embed.py +15 -0
  80. package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
  81. package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
  82. package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
  83. package/templates/dataif/services/api/app/vanna_client.py +32 -0
  84. package/templates/dataif/services/api/requirements.txt +9 -0
  85. package/templates/dataif/services/vanna/.dockerignore +18 -0
  86. package/templates/dataif/services/vanna/Dockerfile +12 -0
  87. package/templates/dataif/services/vanna/app/config.py +57 -0
  88. package/templates/dataif/services/vanna/app/main.py +108 -0
  89. package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
  90. package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
  91. package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
  92. package/templates/dataif/services/vanna/requirements.txt +8 -0
  93. package/templates/dataif/services/web/.dockerignore +13 -0
  94. package/templates/dataif/services/web/Dockerfile +16 -0
  95. package/templates/dataif/services/web/index.html +12 -0
  96. package/templates/dataif/services/web/nginx.conf +74 -0
  97. package/templates/dataif/services/web/package-lock.json +4397 -0
  98. package/templates/dataif/services/web/package.json +32 -0
  99. package/templates/dataif/services/web/postcss.config.mjs +5 -0
  100. package/templates/dataif/services/web/src/App.jsx +2817 -0
  101. package/templates/dataif/services/web/src/adminAuth.js +245 -0
  102. package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
  103. package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
  104. package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
  105. package/templates/dataif/services/web/src/assets/if.svg +0 -0
  106. package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
  107. package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
  108. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
  109. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
  110. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
  111. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
  112. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
  113. package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
  114. package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
  115. package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
  116. package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
  117. package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
  118. package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
  119. package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
  120. package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
  121. package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
  122. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
  123. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
  124. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
  125. package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
  126. package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
  127. package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
  128. package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
  129. package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
  130. package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
  131. package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
  132. package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
  133. package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
  134. package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
  135. package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
  136. package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
  137. package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
  138. package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
  139. package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
  140. package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
  141. package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
  142. package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
  143. package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
  144. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
  145. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
  146. package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
  147. package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
  148. package/templates/dataif/services/web/src/main.jsx +14 -0
  149. package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
  150. package/templates/dataif/services/web/src/styles/globals.css +60 -0
  151. package/templates/dataif/services/web/src/styles/theme.css +1326 -0
  152. package/templates/dataif/services/web/src/styles/typography.css +430 -0
  153. package/templates/dataif/services/web/src/styles.css +1287 -0
  154. package/templates/dataif/services/web/src/utils/cx.ts +24 -0
  155. package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
  156. package/templates/dataif/services/web/vite.config.js +14 -0
  157. package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
  158. package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
  159. package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
  160. package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
  161. package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
  162. package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
  163. package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
  164. package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
  165. package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
  166. package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
  167. package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
  168. package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
  169. package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
  170. package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
  171. package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
  172. package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  173. package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
  174. package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
  175. package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
  176. package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  177. package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  178. package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  179. package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  180. package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  181. package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  182. package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  183. 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">&gt;</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
+ }