From 47365ccf3cf3678ef0f3ae544f6837032aeab003 Mon Sep 17 00:00:00 2001 From: Samir Raut Date: Tue, 21 Jul 2026 20:18:16 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20DataMarket=20=E2=80=94=20self-ser?= =?UTF-8?q?vice=20data=20catalog=20on=20Databricks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataMarket is a production-ready data catalog built on Databricks Apps, Unity Catalog, Lakebase, and FMAPI. It provides self-service data product discovery, governed access requests, and automated UC grant execution for enterprise and public sector customers. - One-step deploy via deploy.sh (CLI path) - Marketplace-ready via manifest.yaml build-on-startup - No external dependencies — 100% Databricks native --- CODEOWNERS | 1 + datamarket/README.md | 89 + datamarket/app.js | 97 + datamarket/app.yaml | 28 + datamarket/app.yaml.example | 41 + datamarket/auth.js | 151 + datamarket/components.json | 21 + datamarket/databricks.js | 151 + datamarket/db.js | 284 + datamarket/deploy.sh | 927 +++ datamarket/index.html | 17 + datamarket/jsconfig.json | 9 + datamarket/manifest.yaml | 77 + datamarket/package-lock.json | 5097 +++++++++++++++++ datamarket/package.json | 53 + datamarket/postcss.config.js | 6 + datamarket/public/databricks-favicon.svg | 6 + datamarket/public/databricks-logo.svg | 1 + datamarket/public/datamarket-logo.svg | 10 + datamarket/public/favicon.ico | Bin 0 -> 34494 bytes datamarket/routes/ask-catalog.js | 89 + datamarket/routes/config.js | 247 + datamarket/routes/demo.js | 123 + datamarket/routes/feature-requests.js | 92 + datamarket/routes/insights.js | 4 + datamarket/routes/products.js | 793 +++ datamarket/routes/requests.js | 318 + datamarket/routes/users.js | 220 + datamarket/src/App.jsx | 91 + .../src/components/DataMarketAssistant.jsx | 498 ++ datamarket/src/components/DatabricksCard.jsx | 81 + datamarket/src/components/DatabricksChart.jsx | 149 + datamarket/src/components/DatabricksLogo.jsx | 36 + datamarket/src/components/FirstRunWizard.jsx | 442 ++ datamarket/src/components/ImportUCModal.jsx | 429 ++ .../components/layout/DataMarketLayout.jsx | 326 ++ datamarket/src/components/ui/badge.jsx | 34 + datamarket/src/components/ui/button.jsx | 47 + datamarket/src/components/ui/card.jsx | 50 + datamarket/src/components/ui/dialog.jsx | 96 + .../src/components/ui/dropdown-menu.jsx | 155 + .../src/components/ui/navigation-menu.jsx | 104 + datamarket/src/components/ui/separator.jsx | 23 + datamarket/src/components/ui/sheet.jsx | 108 + datamarket/src/config/navigation.js | 138 + datamarket/src/context/AppConfigContext.jsx | 54 + datamarket/src/context/PersonaContext.jsx | 357 ++ datamarket/src/index.css | 167 + datamarket/src/main.jsx | 10 + datamarket/src/pages/AIExplorerPage.jsx | 365 ++ datamarket/src/pages/AboutPage.jsx | 105 + datamarket/src/pages/ContactPage.jsx | 84 + datamarket/src/pages/DataMarketAdminPage.jsx | 571 ++ .../src/pages/DataMarketCatalogPage.jsx | 455 ++ datamarket/src/pages/DataMarketHomePage.jsx | 317 + .../src/pages/DataMarketInsightsPage.jsx | 362 ++ .../src/pages/DataMarketLibraryPage.jsx | 233 + .../src/pages/DataMarketProductDetailPage.jsx | 1003 ++++ .../src/pages/DataMarketRegisterPage.jsx | 626 ++ datamarket/src/pages/FAQPage.jsx | 95 + datamarket/src/pages/FeatureRequestPage.jsx | 290 + .../src/pages/admin/AdminApprovalsTab.jsx | 26 + .../src/pages/admin/AdminProductsTab.jsx | 229 + .../src/pages/admin/AdminSettingsPanel.jsx | 421 ++ datamarket/src/pages/admin/AdminUsersTab.jsx | 485 ++ .../src/pages/admin/AnalystMyDataView.jsx | 116 + datamarket/src/pages/admin/adminConstants.js | 28 + datamarket/src/styles/responsive.css | 57 + datamarket/tailwind.config.js | 157 + datamarket/vite.config.js | 25 + 70 files changed, 18397 insertions(+) create mode 100644 datamarket/README.md create mode 100644 datamarket/app.js create mode 100644 datamarket/app.yaml create mode 100644 datamarket/app.yaml.example create mode 100644 datamarket/auth.js create mode 100644 datamarket/components.json create mode 100644 datamarket/databricks.js create mode 100644 datamarket/db.js create mode 100755 datamarket/deploy.sh create mode 100644 datamarket/index.html create mode 100644 datamarket/jsconfig.json create mode 100644 datamarket/manifest.yaml create mode 100644 datamarket/package-lock.json create mode 100644 datamarket/package.json create mode 100644 datamarket/postcss.config.js create mode 100644 datamarket/public/databricks-favicon.svg create mode 100644 datamarket/public/databricks-logo.svg create mode 100644 datamarket/public/datamarket-logo.svg create mode 100644 datamarket/public/favicon.ico create mode 100644 datamarket/routes/ask-catalog.js create mode 100644 datamarket/routes/config.js create mode 100644 datamarket/routes/demo.js create mode 100644 datamarket/routes/feature-requests.js create mode 100644 datamarket/routes/insights.js create mode 100644 datamarket/routes/products.js create mode 100644 datamarket/routes/requests.js create mode 100644 datamarket/routes/users.js create mode 100644 datamarket/src/App.jsx create mode 100644 datamarket/src/components/DataMarketAssistant.jsx create mode 100644 datamarket/src/components/DatabricksCard.jsx create mode 100644 datamarket/src/components/DatabricksChart.jsx create mode 100644 datamarket/src/components/DatabricksLogo.jsx create mode 100644 datamarket/src/components/FirstRunWizard.jsx create mode 100644 datamarket/src/components/ImportUCModal.jsx create mode 100644 datamarket/src/components/layout/DataMarketLayout.jsx create mode 100644 datamarket/src/components/ui/badge.jsx create mode 100644 datamarket/src/components/ui/button.jsx create mode 100644 datamarket/src/components/ui/card.jsx create mode 100644 datamarket/src/components/ui/dialog.jsx create mode 100644 datamarket/src/components/ui/dropdown-menu.jsx create mode 100644 datamarket/src/components/ui/navigation-menu.jsx create mode 100644 datamarket/src/components/ui/separator.jsx create mode 100644 datamarket/src/components/ui/sheet.jsx create mode 100644 datamarket/src/config/navigation.js create mode 100644 datamarket/src/context/AppConfigContext.jsx create mode 100644 datamarket/src/context/PersonaContext.jsx create mode 100644 datamarket/src/index.css create mode 100644 datamarket/src/main.jsx create mode 100644 datamarket/src/pages/AIExplorerPage.jsx create mode 100644 datamarket/src/pages/AboutPage.jsx create mode 100644 datamarket/src/pages/ContactPage.jsx create mode 100644 datamarket/src/pages/DataMarketAdminPage.jsx create mode 100644 datamarket/src/pages/DataMarketCatalogPage.jsx create mode 100644 datamarket/src/pages/DataMarketHomePage.jsx create mode 100644 datamarket/src/pages/DataMarketInsightsPage.jsx create mode 100644 datamarket/src/pages/DataMarketLibraryPage.jsx create mode 100644 datamarket/src/pages/DataMarketProductDetailPage.jsx create mode 100644 datamarket/src/pages/DataMarketRegisterPage.jsx create mode 100644 datamarket/src/pages/FAQPage.jsx create mode 100644 datamarket/src/pages/FeatureRequestPage.jsx create mode 100644 datamarket/src/pages/admin/AdminApprovalsTab.jsx create mode 100644 datamarket/src/pages/admin/AdminProductsTab.jsx create mode 100644 datamarket/src/pages/admin/AdminSettingsPanel.jsx create mode 100644 datamarket/src/pages/admin/AdminUsersTab.jsx create mode 100644 datamarket/src/pages/admin/AnalystMyDataView.jsx create mode 100644 datamarket/src/pages/admin/adminConstants.js create mode 100644 datamarket/src/styles/responsive.css create mode 100644 datamarket/tailwind.config.js create mode 100644 datamarket/vite.config.js diff --git a/CODEOWNERS b/CODEOWNERS index 174adc20..90d5bdd3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -9,6 +9,7 @@ acceptance @nfx @alexott conversational-agent-app @vivian-xie-db @yuanchaoma-db database-diagram-builder @alexott +datamarket @rautsamir downstreams @nfx @alexott feature-registry-app @yang-chengg @mparkhe @mingyangge-db @stephanielu5 go-libs @nfx @alexott diff --git a/datamarket/README.md b/datamarket/README.md new file mode 100644 index 00000000..1c5e9a09 --- /dev/null +++ b/datamarket/README.md @@ -0,0 +1,89 @@ +--- +title: "DataMarket — Self-Service Data Catalog" +language: JavaScript +author: "Samir Raut" +date: 2026-06-29 + +tags: +- databricks-apps +- unity-catalog +- lakebase +- data-catalog +- data-governance +- access-management +--- + +# DataMarket — Self-Service Data Catalog on Databricks + +A production-ready data catalog built entirely on Databricks. Gives every team a single place to discover certified data products, understand what they mean, and request access — without filing a ticket or waiting for an engineer. + +Built on Databricks Apps, Unity Catalog, Lakebase, and Foundation Model APIs. No external dependencies. + +![DataMarket](../../../docs/datamarket-thumbnail.png) + +## What it does + +- **Discover** — Browse and search all certified data products across your organization with domain filters, classification tags, and per-user access status +- **Request** — Submit access requests with business justification; data stewards review and approve +- **Govern** — Approvals automatically execute Unity Catalog `GRANT` statements via SQL Warehouse — no manual permission management +- **Ask AI** — Natural language data discovery backed by Databricks Foundation Model APIs (Llama 3.3-70B) +- **Insights** — Usage analytics and access request trends for stewards and managers + +## Databricks Services Used + +| Component | Service | +|---|---| +| Application hosting | Databricks Apps (serverless) | +| Data governance | Unity Catalog | +| OLTP backend | Lakebase Autoscaling (managed Postgres) | +| AI discovery | Foundation Model APIs (Llama 3.3-70B) | +| UC grant execution | SQL Statement Execution API | + +## Deploy + +### One-step CLI deploy (recommended for development) + +```bash +git clone https://github.com/databricks-field-eng/datamarket.git +cd datamarket/src/app +databricks auth login --host https://your-workspace.azuredatabricks.net --profile my-profile +./deploy.sh --profile my-profile +``` + +The script handles everything: Lakebase project creation, schema init, SP grants, frontend build, workspace upload, and app deploy. Prints the app URL when done. + +### Databricks Marketplace (coming soon) + +A Marketplace listing is in progress. When published, no CLI or pre-build step will be required — the app builds its frontend on first startup via `manifest.yaml`. + +## After Deploying + +A 4-step onboarding wizard guides the admin through: +1. SQL Warehouse ID (auto-detected) +2. Branding (app name, logo, tagline) +3. Unity Catalog access grants for the app service principal +4. Creating the first data product + +Admin access is automatic — the deployer's email is promoted to admin on first SSO login. + +## Requirements + +- Databricks workspace with Unity Catalog enabled +- Databricks CLI (`pip install databricks-cli`) +- Node.js ≥ 18 +- Python 3 + +## Architecture + +``` +Browser → Databricks Apps (Express.js + React/Vite) + ↓ ↓ + Lakebase (Postgres) Unity Catalog REST API + access requests catalog metadata + approvals GRANT execution + audit log +``` + +## Source + +Full source, documentation, and deploy guide: [github.com/databricks-field-eng/datamarket](https://github.com/databricks-field-eng/datamarket) diff --git a/datamarket/app.js b/datamarket/app.js new file mode 100644 index 00000000..3283c7a1 --- /dev/null +++ b/datamarket/app.js @@ -0,0 +1,97 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import compression from 'compression'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { getPool, runMigrations, closePool, LAKEBASE_HOST, LAKEBASE_DB, LAKEBASE_SCHEMA, DEMO_MODE, RFA_ENABLED, SQL_WAREHOUSE_ID } from './db.js'; +import { registerRoutes as registerConfig } from './routes/config.js'; +import { registerRoutes as registerProducts, maybeAutoDiscover } from './routes/products.js'; +import { registerRoutes as registerRequests } from './routes/requests.js'; +import { registerRoutes as registerUsers } from './routes/users.js'; +import { registerRoutes as registerFeatureRequests } from './routes/feature-requests.js'; +import { registerRoutes as registerAskCatalog } from './routes/ask-catalog.js'; +import { registerRoutes as registerInsights } from './routes/insights.js'; +import { registerRoutes as registerDemo } from './routes/demo.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.DATABRICKS_APP_PORT || process.env.PORT || 3000; + +// ─── Middleware ─────────────────────────────────────────────────────────────── +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + connectSrc: ["'self'", "https://*.azuredatabricks.net"], + imgSrc: ["'self'", "data:", "https:"], + }, + }, +})); +app.use(compression()); +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'dist'))); +app.use('/public', express.static(path.join(__dirname, 'public'))); + +// ─── Routes ─────────────────────────────────────────────────────────────────── +registerConfig(app); +registerProducts(app); +registerRequests(app); +registerUsers(app); +registerFeatureRequests(app); +registerAskCatalog(app); +registerInsights(app); +registerDemo(app); + +// ─── SPA fallback ───────────────────────────────────────────────────────────── +app.get('*', (req, res) => { + if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' }); + res.sendFile(path.join(__dirname, 'dist', 'index.html')); +}); + +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Something went wrong!' }); +}); + +// ─── Startup ────────────────────────────────────────────────────────────────── +const server = app.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 DataMarket running on 0.0.0.0:${PORT}`); + console.log(`🗄️ Lakebase: ${LAKEBASE_HOST}/${LAKEBASE_DB}/${LAKEBASE_SCHEMA}`); + console.log(`🔧 Mode: ${DEMO_MODE ? 'DEMO (persona switcher, no real grants)' : 'PRODUCTION (SSO identity, UC grants enabled)'}`); + console.log(`📡 RFA: ${RFA_ENABLED ? 'ENABLED' : 'disabled'} | UC Grants: ${!DEMO_MODE && SQL_WAREHOUSE_ID ? 'ENABLED' : 'disabled'}`); + console.log(`📊 Health: http://localhost:${PORT}/api/health`); + getPool() + .then(() => { + console.log('✅ Lakebase pool initialized'); + // Retry migrations — Autoscaling Lakebase may need a moment to wake from idle + const tryMigrate = (attempt) => runMigrations().catch(e => { + if (attempt < 5) { + console.warn(`⚠️ Migration attempt ${attempt} failed (${e.message}) — retrying in 8s...`); + setTimeout(() => tryMigrate(attempt + 1), 8000); + } else { + console.warn('⚠️ Migrations skipped after 5 attempts:', e.message); + } + }); + tryMigrate(1); + // Run auto-discover after migrations settle (non-blocking) + setTimeout(() => maybeAutoDiscover(), 15000); + }) + .catch(e => console.warn('⚠️ Lakebase init deferred:', e.message)); +}); + +process.on('SIGTERM', () => { + console.log('Received SIGTERM, shutting down...'); + server.close(async () => { + await closePool(); + process.exit(0); + }); + setTimeout(() => process.exit(1), 14000); +}); diff --git a/datamarket/app.yaml b/datamarket/app.yaml new file mode 100644 index 00000000..1b8ef393 --- /dev/null +++ b/datamarket/app.yaml @@ -0,0 +1,28 @@ +command: + - "node" + - "app.js" +env: + # ── Databricks Identity ──────────────────────────────────────────────────── + - name: DATABRICKS_HOST + value: "https://dbc-7e8bd036-20cc.cloud.databricks.com" + + # ── Admin bootstrap ──────────────────────────────────────────────────────── + # On first SSO login the app auto-promotes this email to admin. + # Set this to your own admin email before deploying. + - name: ADMIN_EMAIL + value: "admin@example.com" + + # ── Lakebase Connection ──────────────────────────────────────────────────── + - name: LAKEBASE_HOST + value: "ep-spring-dust-d8wvin0s.database.us-east-2.cloud.databricks.com" + - name: LAKEBASE_DB + value: "databricks_postgres" + - name: LAKEBASE_SCHEMA + value: "datamarket" + - name: LAKEBASE_ENDPOINT + value: "projects/datamarket/branches/production/endpoints/primary" + + # ── Mode ────────────────────────────────────────────────────────────────── + # false = real SSO identity + UC grants. true = persona switcher (demos only) + - name: DEMO_MODE + value: "false" diff --git a/datamarket/app.yaml.example b/datamarket/app.yaml.example new file mode 100644 index 00000000..a3f104d2 --- /dev/null +++ b/datamarket/app.yaml.example @@ -0,0 +1,41 @@ +command: + - "node" + - "app.js" +env: + # ── Databricks Identity ────────────────────────────────────────────────────── + # DATABRICKS_TOKEN is auto-injected by Databricks Apps at runtime. + # Set DATABRICKS_HOST to your workspace URL. + - name: DATABRICKS_HOST + value: "https://your-workspace.azuredatabricks.net" + + # ── IMPORTANT: Admin bootstrap ─────────────────────────────────────────────── + # Set this to the deployer's email address. On first SSO login the app will + # automatically promote this user to admin so they can access Manage → Settings. + # Without this, the deployer lands as a regular analyst with no admin access. + # Comma-separate multiple emails for co-admins: "alice@org.com,bob@org.com" + - name: ADMIN_EMAIL + value: "your-email@company.com" + + # ── Lakebase Connection ─────────────────────────────────────────────────────── + # Autoscaling Lakebase: Get the endpoint URL from Compute → Lakebase → your project + - name: LAKEBASE_HOST + value: "ep-your-project.database.region.azuredatabricks.net" + - name: LAKEBASE_DB + value: "databricks_postgres" + - name: LAKEBASE_SCHEMA + value: "datamarket" + - name: LAKEBASE_ENDPOINT + value: "projects/your-project-name/branches/production/endpoints/primary" + + # ── Mode ───────────────────────────────────────────────────────────────────── + # "true" = persona switcher visible (for demos and internal POCs) + # "false" = real SSO identity from Databricks Apps headers (for production) + # NOTE: When switching from true → false, make sure ADMIN_EMAIL is set first + # or you will land as a regular analyst with no way to configure the app. + - name: DEMO_MODE + value: "false" + + # ── Branding & integrations ─────────────────────────────────────────────────── + # Everything else (portal name, tagline, logo, SQL Warehouse ID, search chips, + # About/FAQ/Contact content) is configured via Admin → Settings in the app UI. + # No redeploy needed for those changes. diff --git a/datamarket/auth.js b/datamarket/auth.js new file mode 100644 index 00000000..dd63c2fb --- /dev/null +++ b/datamarket/auth.js @@ -0,0 +1,151 @@ +import https from 'https'; + +export function getDatabricksHost() { + return (process.env.DATABRICKS_HOST || '').replace(/^https?:\/\//, ''); +} + +export function isPat(token) { + return typeof token === 'string' && token.startsWith('dapi'); +} + +export function httpsJsonRequest({ hostname, path, method = 'GET', headers = {}, body = null, timeoutMs = 12000 }) { + return new Promise((resolve, reject) => { + const req = https.request({ hostname, path, method, headers }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} }); + } catch (_) { + reject(new Error(`Bad JSON response from ${path}`)); + } + }); + }); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request to ${path} timed out after ${timeoutMs}ms`)); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +export async function fetchM2MToken() { + const clientId = process.env.DATABRICKS_CLIENT_ID || ''; + const clientSecret = process.env.DATABRICKS_CLIENT_SECRET || ''; + const host = getDatabricksHost(); + if (!clientId || !clientSecret || !host) { + throw new Error('DATABRICKS_CLIENT_ID, DATABRICKS_CLIENT_SECRET, and DATABRICKS_HOST are required'); + } + const body = 'grant_type=client_credentials&scope=all-apis'; + const { status, data } = await httpsJsonRequest({ + hostname: host, + path: '/oidc/v1/token', + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(body), + }, + body, + }); + if (status !== 200 || !data.access_token) { + throw new Error(`OAuth token request failed (${status}): ${JSON.stringify(data)}`); + } + return data.access_token; +} + +// Workspace auth for Databricks REST APIs (UC import, SQL, RFA). +export async function getWorkspaceOAuthToken() { + const envToken = process.env.DATABRICKS_TOKEN || process.env.DATABRICKS_RUNTIME_TOKEN || ''; + if (envToken && !isPat(envToken)) return envToken; + + const apiPat = process.env.DATABRICKS_API_TOKEN || (isPat(envToken) ? envToken : ''); + if (apiPat) return apiPat; + + return fetchM2MToken(); +} + +export async function generateProvisionedDbCredential(oauthToken) { + const LAKEBASE_INSTANCE_NAME = process.env.LAKEBASE_INSTANCE_NAME || ''; + const host = getDatabricksHost(); + const payload = JSON.stringify({ + instance_names: [LAKEBASE_INSTANCE_NAME], + request_id: `dm-${Date.now()}`, + }); + const { status, data } = await httpsJsonRequest({ + hostname: host, + path: '/api/2.0/database/credentials', + method: 'POST', + headers: { + Authorization: `Bearer ${oauthToken}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + body: payload, + }); + if (!data.token) { + throw new Error(`DB credential generation failed (${status}): ${JSON.stringify(data)}`); + } + return data.token; +} + +export async function generateAutoscaleDbCredential(oauthToken, endpoint) { + const host = getDatabricksHost(); + const payload = JSON.stringify({ endpoint }); + const { status, data } = await httpsJsonRequest({ + hostname: host, + path: '/api/2.0/postgres/credentials', + method: 'POST', + headers: { + Authorization: `Bearer ${oauthToken}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + body: payload, + }); + if (!data.token) { + throw new Error(`Autoscale DB credential failed (${status}): ${JSON.stringify(data)}`); + } + return data.token; +} + +export async function resolveAutoscaleLakebaseAuth() { + const LAKEBASE_ENDPOINT = process.env.LAKEBASE_ENDPOINT || ''; + const userToken = process.env.DATABRICKS_TOKEN || process.env.DATABRICKS_RUNTIME_TOKEN + || process.env.LAKEBASE_PASSWORD || ''; + + // Legacy/user path: OAuth JWT used directly as Postgres password (local deploy + some Apps). + if (userToken && !isPat(userToken) && !LAKEBASE_ENDPOINT) { + const pgUser = process.env.LAKEBASE_PGUSER || process.env.DATABRICKS_USER; + if (!pgUser) throw new Error('DATABRICKS_USER env var is required'); + return { pgPassword: userToken, pgUser, mode: 'Autoscaling (user OAuth token)' }; + } + + if (isPat(userToken)) { + throw new Error( + 'A PAT cannot be used as the Lakebase Postgres password. ' + + 'Remove DATABRICKS_TOKEN from app.yaml (use DATABRICKS_API_TOKEN for UC Import via --pat).' + ); + } + + const endpoint = LAKEBASE_ENDPOINT; + if (!endpoint) { + throw new Error( + 'LAKEBASE_ENDPOINT is required for Lakebase Autoscaling in Databricks Apps. ' + + 'Find it with: databricks postgres list-endpoints projects//branches/production ' + + '— then redeploy with --lakebase-endpoint.' + ); + } + + const oauthToken = await fetchM2MToken(); + const pgPassword = await generateAutoscaleDbCredential(oauthToken, endpoint); + // Apps connect as the app service principal (UUID). deploy.sh creates this Postgres role + schema grants. + const pgUser = process.env.LAKEBASE_PGUSER + || process.env.DATABRICKS_CLIENT_ID + || process.env.DATABRICKS_USER; + if (!pgUser) { + throw new Error('LAKEBASE_PGUSER, DATABRICKS_CLIENT_ID, or DATABRICKS_USER is required'); + } + return { pgPassword, pgUser, mode: `Autoscaling (Apps SP ${pgUser.slice(0, 8)}… + credential API)` }; +} diff --git a/datamarket/components.json b/datamarket/components.json new file mode 100644 index 00000000..684400ba --- /dev/null +++ b/datamarket/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/datamarket/databricks.js b/datamarket/databricks.js new file mode 100644 index 00000000..5104005e --- /dev/null +++ b/datamarket/databricks.js @@ -0,0 +1,151 @@ +import https from 'https'; +import { getDatabricksHost, getWorkspaceOAuthToken, httpsJsonRequest } from './auth.js'; +import { DEMO_MODE, getSetting, loadSettings, RFA_ENABLED } from './db.js'; + +function getWarehouseId() { + return getSetting('sql_warehouse_id', process.env.SQL_WAREHOUSE_ID || ''); +} + +// ─── Databricks REST API helper ────────────────────────────────────────────── +export async function databricksApi(method, apiPath, body = null) { + const host = getDatabricksHost(); + const token = await getWorkspaceOAuthToken(); + if (!host || !token) throw new Error('DATABRICKS_HOST or workspace credentials not set'); + + const payload = body ? JSON.stringify(body) : null; + const { status, data } = await httpsJsonRequest({ + hostname: host, + path: apiPath, + method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + body: payload, + }); + return { status, data }; +} + +// ─── RFA: Send access request notification to configured destinations ──────── +export async function rfaNotify(ucFullName, requesterEmail, comment) { + if (!RFA_ENABLED || !ucFullName) return null; + try { + const parts = ucFullName.split('.'); + if (parts.length < 3) return null; + const securableType = parts.length === 3 ? 'TABLE' : 'CATALOG'; + const result = await databricksApi('POST', '/api/3.0/rfa/access-requests', { + requests: [{ + comment: comment || `Access requested via DataMarket`, + securable_permissions: [{ + permissions: ['SELECT'], + securable: { full_name: ucFullName, type: securableType } + }] + }] + }); + console.log(`[RFA] Notification sent for ${ucFullName} → status ${result.status}`); + return result; + } catch (e) { + console.warn('[RFA] Notification failed (non-fatal):', e.message); + return null; + } +} + +// ─── UC: Execute GRANT/REVOKE via SQL Statement Execution API ──────────────── +export async function executeUcStatement(sql) { + if (DEMO_MODE || !getWarehouseId()) return { executed: false, reason: DEMO_MODE ? 'demo_mode' : 'no_warehouse' }; + try { + const result = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: getWarehouseId(), + statement: sql, + wait_timeout: '10s' + }); + const status = result.data?.status?.state || 'UNKNOWN'; + console.log(`[UC] Executed: ${sql.substring(0, 80)}... → ${status}`); + return { executed: true, status, result: result.data }; + } catch (e) { + console.warn('[UC] Statement execution failed (non-fatal):', e.message); + return { executed: false, reason: e.message }; + } +} + +// ─── UC: Fetch column schema + tags for a table ───────────────────────────── +export async function fetchUcSchema(ucFullName) { + const warehouseId = getWarehouseId(); + if (!warehouseId || !ucFullName) return null; + const parts = ucFullName.split('.'); + if (parts.length !== 3) return null; + const [catalog, schema, table] = parts; + try { + const colResult = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: warehouseId, + // Query the catalog's own information_schema, not system.information_schema + statement: `SELECT column_name, data_type, comment FROM \`${catalog}\`.information_schema.columns + WHERE table_schema = '${schema}' AND table_name = '${table}' + ORDER BY ordinal_position`, + wait_timeout: '10s' + }); + if (colResult.data?.status?.state !== 'SUCCEEDED') return null; + const columns = (colResult.data.result?.data_array || []).map(row => ({ + name: row[0], type: (row[1] || 'STRING').toUpperCase(), description: row[2] || '' + })); + + let tagMap = {}; + try { + const tagResult = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: getWarehouseId(), + statement: `SELECT column_name, tag_name, tag_value FROM \`${catalog}\`.information_schema.column_tags + WHERE table_schema = '${schema}' AND table_name = '${table}'`, + wait_timeout: '10s' + }); + if (tagResult.data?.status?.state === 'SUCCEEDED') { + for (const row of (tagResult.data.result?.data_array || [])) { + if (!tagMap[row[0]]) tagMap[row[0]] = {}; + tagMap[row[0]][row[1]] = row[2]; + } + } + } catch (_) {} + + const piiPatterns = /^(ssn|social_security|dob|date_of_birth|birth_date|email|phone|address|bank_account|credit_card|salary|compensation|wage)/i; + const confPatterns = /^(cost_center|approver|budget_code|account_number|internal_id)/i; + + return columns.map(col => { + const tags = tagMap[col.name] || {}; + let sensitivity = (tags.sensitivity_level || tags.sensitivity || '').toUpperCase(); + if (!sensitivity) { + if (piiPatterns.test(col.name)) sensitivity = 'PII'; + else if (confPatterns.test(col.name)) sensitivity = 'CONFIDENTIAL'; + else sensitivity = 'INTERNAL'; + } + const masked = sensitivity === 'PII' || sensitivity === 'CONFIDENTIAL'; + const elevatedPII = sensitivity === 'PII' && piiPatterns.test(col.name) && /ssn|dob|date_of_birth|birth|bank|credit_card/i.test(col.name); + return { ...col, sensitivity, masked, elevatedPII }; + }); + } catch (e) { + console.warn(`[UC] Schema fetch failed for ${ucFullName}:`, e.message); + return null; + } +} + +// ─── UC Catalog Browser (lazy, no SQL warehouse needed) ────────────────────── +export function ucApiRequest(host, token, path) { + return new Promise((resolve, reject) => { + const req = https.request({ + hostname: host, path, method: 'GET', + headers: { 'Authorization': `Bearer ${token}` }, + }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error('Bad UC API response')); } }); + }); + req.on('error', reject); + req.end(); + }); +} + +export async function getUcAuth() { + const host = getDatabricksHost(); + const token = await getWorkspaceOAuthToken(); + if (!host || !token) throw new Error('DATABRICKS_HOST and workspace credentials are required'); + return { host, token }; +} diff --git a/datamarket/db.js b/datamarket/db.js new file mode 100644 index 00000000..7883d8e9 --- /dev/null +++ b/datamarket/db.js @@ -0,0 +1,284 @@ +import pg from 'pg'; +import { + getWorkspaceOAuthToken, + generateProvisionedDbCredential, + resolveAutoscaleLakebaseAuth, +} from './auth.js'; + +const { Pool } = pg; + +// ─── Lakebase connection config ─────────────────────────────────────────────── +// Three deployment modes — detected by which env vars are present: +// +// 1. Marketplace install (recommended): +// app.yaml uses `valueFrom: lakebase-db` → platform injects LAKEBASE_ENDPOINT, +// PGHOST, PGDATABASE, PGPORT, PGSSLMODE. App SP (DATABRICKS_CLIENT_ID/SECRET) +// authenticates via M2M OAuth + /api/2.0/postgres/credentials. +// +// 2. CLI deploy (deploy.sh): +// LAKEBASE_HOST + LAKEBASE_ENDPOINT set explicitly for Autoscaling, or +// LAKEBASE_HOST + LAKEBASE_INSTANCE_NAME for Provisioned instances. +// +// 3. Legacy / local dev: +// DATABRICKS_TOKEN (user OAuth JWT) used directly as Postgres password. +// +// PGHOST / PGDATABASE / PGPORT are the Marketplace-injected equivalents of +// LAKEBASE_HOST / LAKEBASE_DB — checked as fallbacks so one app binary works +// for both paths without modification. +export const LAKEBASE_HOST = process.env.LAKEBASE_HOST || process.env.PGHOST || 'your-project.database.azuredatabricks.net'; +export const LAKEBASE_DB = process.env.LAKEBASE_DB || process.env.PGDATABASE || 'databricks_postgres'; +export const LAKEBASE_SCHEMA = process.env.LAKEBASE_SCHEMA || 'datamarket'; +export const LAKEBASE_INSTANCE_NAME = process.env.LAKEBASE_INSTANCE_NAME || ''; + +export const DEMO_MODE = (process.env.DEMO_MODE || 'true').toLowerCase() === 'true'; +export const SQL_WAREHOUSE_ID = process.env.SQL_WAREHOUSE_ID || ''; +export const RFA_ENABLED = (process.env.RFA_ENABLED || 'false').toLowerCase() === 'true'; + +// ─── App branding (customize via env vars in app.yaml) ──────────────────────── +export const APP_NAME = process.env.APP_NAME || 'DataMarket'; +export const APP_SUBTITLE = process.env.APP_SUBTITLE || 'Data Discovery & Access'; +export const APP_LOGO_URL = process.env.APP_LOGO_URL || '/datamarket-logo.svg'; + +let dbPool = null; +let poolCreatedAt = 0; +const POOL_TTL_MS = 55 * 60 * 1000; // recreate pool every 55 min (token TTL is 1h) + +export async function getPool() { + const now = Date.now(); + // Recreate pool before token expires so in-flight queries aren't dropped + if (dbPool && now < poolCreatedAt + POOL_TTL_MS) return dbPool; + + if (dbPool) { try { await dbPool.end(); } catch (_) {} } + + let pgPassword; + let pgUser; + if (LAKEBASE_INSTANCE_NAME) { + console.log(`[Lakebase] Generating DB credential for provisioned instance "${LAKEBASE_INSTANCE_NAME}"...`); + const oauthToken = await getWorkspaceOAuthToken(); + pgPassword = await generateProvisionedDbCredential(oauthToken); + pgUser = process.env.LAKEBASE_PGUSER || process.env.DATABRICKS_USER; + if (!pgUser) throw new Error('DATABRICKS_USER env var is required'); + console.log('[Lakebase] Creating connection pool (Provisioned)...'); + } else { + const auth = await resolveAutoscaleLakebaseAuth(); + pgPassword = auth.pgPassword; + pgUser = auth.pgUser; + console.log(`[Lakebase] Creating connection pool (${auth.mode})...`); + } + + dbPool = new Pool({ + host: LAKEBASE_HOST, + port: parseInt(process.env.PGPORT || '5432', 10), + database: LAKEBASE_DB, + user: pgUser, + password: pgPassword, + ssl: { rejectUnauthorized: true }, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 8000, + options: `-c search_path=${LAKEBASE_SCHEMA},public`, + }); + + dbPool.on('error', (err) => console.error('[Lakebase] Pool error:', err.message)); + poolCreatedAt = now; + return dbPool; +} + +export async function query(sql, params = []) { + const pool = await getPool(); + return pool.query(sql, params); +} + +export async function closePool() { + if (dbPool) await dbPool.end().catch(() => {}); +} + +// ─── Product column cache ───────────────────────────────────────────────────── +// Cache which optional columns exist — populated once on first products query +let _productCols = null; +export async function getProductCols() { + if (_productCols) return _productCols; + try { + const { rows } = await query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'datamarket' AND table_name = 'data_products' + AND column_name IN ('source_type','product_url','report_url')` + ); + _productCols = new Set(rows.map(r => r.column_name)); + } catch (_) { + _productCols = new Set(); + } + return _productCols; +} +export function resetProductColsCache() { _productCols = null; } + +// ─── Settings cache ─────────────────────────────────────────────────────────── +// In-memory cache so /api/portal/config stays fast (refreshed on PUT). +let settingsCache = null; + +export async function loadSettings() { + try { + const { rows } = await query('SELECT key, value FROM settings'); + settingsCache = Object.fromEntries(rows.map(r => [r.key, r.value])); + } catch (_) { + // Settings table may not exist yet on first boot — that's fine. + settingsCache = {}; + } + return settingsCache; +} + +export function getSetting(key, envFallback) { + if (settingsCache && settingsCache[key] !== undefined && settingsCache[key] !== '') { + return settingsCache[key]; + } + return envFallback; +} + +export function invalidateSettingsCache() { settingsCache = null; } + +// ─── Schema migration — run at startup ─────────────────────────────────────── +// DDL lives in schema/schema.sql (applied by deploy.sh). The app SP has DML grants +// but not table ownership, so avoid ALTER TABLE here — use UPDATE/INSERT only. +export async function runMigrations() { + try { + // ── Add any columns that may be missing from older schemas ──────────────── + // These run as the table owner (via the schema-owner role), not the app SP. + // We use individual try/catch so one failure doesn't abort the rest. + const addColumnIfMissing = async (col, definition) => { + try { + await query(`ALTER TABLE data_products ADD COLUMN IF NOT EXISTS ${col} ${definition}`); + } catch (_) { /* column may already exist or SP lacks DDL — safe to ignore */ } + }; + await addColumnIfMissing('source_type', "VARCHAR(20) DEFAULT 'Databricks'"); + await addColumnIfMissing('product_url', 'TEXT'); + await addColumnIfMissing('report_url', 'TEXT'); + await addColumnIfMissing('data_classification', "VARCHAR(50) DEFAULT 'Internal'"); + + // Mark all existing rows as Published so they stay visible + await query(`UPDATE data_products SET status = 'Published' WHERE status IS NULL`); + // Seed last_refreshed with plausible values based on refresh_frequency + await query(`UPDATE data_products SET last_refreshed = + CASE refresh_frequency + WHEN 'Daily' THEN NOW() - (RANDOM() * INTERVAL '1 day') + WHEN 'Weekly' THEN NOW() - (RANDOM() * INTERVAL '7 days') + WHEN 'Monthly' THEN NOW() - (RANDOM() * INTERVAL '30 days') + ELSE NOW() - (RANDOM() * INTERVAL '365 days') + END + WHERE last_refreshed IS NULL AND refresh_frequency IS NOT NULL`); + await query(`UPDATE data_products SET source_system = 'Databricks' WHERE source_system IS NULL`); + // Always ensure the three core demo users exist — safe upsert, never overwrites existing rows + await query(` + INSERT INTO users (email, display_name, role, department) VALUES + ('analyst@example.org', 'Alex Analyst', 'analyst', 'Finance'), + ('datasteward@example.org', 'Dana Steward', 'admin', 'Data Governance') + ON CONFLICT (email) DO NOTHING + `); + + // Create settings table if it doesn't exist + await query(` + CREATE TABLE IF NOT EXISTS settings ( + key VARCHAR(100) PRIMARY KEY, + value VARCHAR(4096), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + // Create portal_groups table if it doesn't exist (group-based role assignment) + await query(` + CREATE TABLE IF NOT EXISTS portal_groups ( + group_id SERIAL PRIMARY KEY, + group_name VARCHAR(255) NOT NULL, + scim_id VARCHAR(100), + role VARCHAR(50) DEFAULT 'analyst', + department VARCHAR(100) DEFAULT 'General', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(group_name) + ) + `); + + // Migrate legacy role values → analyst | admin + await query(`UPDATE users SET role = 'admin' WHERE role IN ('steward', 'data_steward')`); + await query(`UPDATE users SET role = 'analyst' WHERE role IN ('manager')`); + await query(`UPDATE portal_groups SET role = 'admin' WHERE role IN ('steward', 'data_steward', 'manager')`); + + // Feature requests + votes + await query(` + CREATE TABLE IF NOT EXISTS feature_requests ( + request_id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + description TEXT, + requester_email VARCHAR(255), + requester_name VARCHAR(255), + status VARCHAR(50) DEFAULT 'open', + upvotes INTEGER DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + await query(` + CREATE TABLE IF NOT EXISTS feature_request_votes ( + vote_id SERIAL PRIMARY KEY, + request_id INTEGER REFERENCES feature_requests(request_id) ON DELETE CASCADE, + voter_email VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(request_id, voter_email) + ) + `); + + // When running in production mode, remove any leftover demo placeholder products + // (products seeded with fake uc_full_name like 'your_catalog.your_schema.*') + if (!DEMO_MODE) { + // Delete access_requests first (FK constraint) + await query( + `DELETE FROM access_requests WHERE product_id IN ( + SELECT product_id FROM data_products WHERE uc_full_name LIKE 'your_catalog.your_schema.%' + )` + ); + const { rowCount } = await query( + `DELETE FROM data_products WHERE uc_full_name LIKE 'your_catalog.your_schema.%'` + ); + if (rowCount > 0) console.log(`[Lakebase] Removed ${rowCount} demo placeholder product(s) (DEMO_MODE=false)`); + } + + // Auto-seed demo data products if catalog is empty and DEMO_MODE is on + if (DEMO_MODE) { + const { rows: [{ cnt }] } = await query(`SELECT COUNT(*)::int AS cnt FROM data_products`); + if (cnt === 0) { + console.log('[Lakebase] Empty catalog detected in DEMO_MODE — seeding demo products...'); + await query(` + INSERT INTO data_products (product_ref, uc_full_name, display_name, description, type, domain, tags, source_system, refresh_frequency, owner_email, classification, last_refreshed) VALUES + ('DP-001', 'your_catalog.your_schema.revenue_summary', + 'Revenue Summary', 'Monthly revenue aggregations by business unit and region.', + 'Dataset', 'Finance', ARRAY['revenue','monthly','kpi'], 'ERP', 'Monthly', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '5 days'), + ('DP-002', 'your_catalog.your_schema.customer_360', + 'Customer 360', 'Unified customer profile combining CRM, support, and usage data.', + 'Dataset', 'Operations', ARRAY['customer','crm','unified'], 'CRM', 'Daily', 'datasteward@example.org', 'Confidential', NOW() - INTERVAL '1 day'), + ('DP-003', 'your_catalog.your_schema.vendor_payments', + 'Vendor Payments', 'All vendor payment transactions with contract and PO references.', + 'Dataset', 'Finance', ARRAY['vendor','payments','procurement'], 'AP System', 'Weekly', 'datasteward@example.org', 'Confidential', NOW() - INTERVAL '3 days'), + ('DP-004', NULL, + 'Operational KPI Dashboard', 'Executive dashboard showing service delivery metrics across all departments.', + 'Dashboard', 'Operations', ARRAY['dashboard','kpi','executive'], 'Databricks', 'Daily', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '1 day'), + ('DP-005', 'your_catalog.your_schema.employee_headcount', + 'Employee Headcount', 'Active employee counts by department, location, and classification.', + 'Dataset', 'HR', ARRAY['hr','headcount','workforce'], 'HRIS', 'Monthly', 'datasteward@example.org', 'Confidential', NOW() - INTERVAL '15 days'), + ('DP-006', 'your_catalog.your_schema.service_requests', + 'Service Requests', 'Citizen and internal service requests with status tracking and SLA metrics.', + 'Dataset', 'Operations', ARRAY['service','requests','sla'], 'ServiceNow', 'Daily', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '1 day'), + ('DP-007', NULL, + 'Budget vs. Actuals Report', 'Automated report comparing approved budget to actual expenditure by quarter.', + 'Report', 'Finance', ARRAY['budget','report','quarterly'], 'ERP', 'Quarterly', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '45 days'), + ('DP-008', 'your_catalog.your_schema.it_asset_inventory', + 'IT Asset Inventory', 'Complete inventory of hardware, software, and cloud assets with lifecycle status.', + 'Dataset', 'IT', ARRAY['assets','inventory','it'], 'CMDB', 'Weekly', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '4 days') + ON CONFLICT (product_ref) DO NOTHING + `); + console.log('[Lakebase] Demo products seeded'); + } + } + + console.log('[Lakebase] Migrations applied'); + } catch (e) { + console.warn('[Lakebase] Migration warning (non-fatal):', e.message); + } +} diff --git a/datamarket/deploy.sh b/datamarket/deploy.sh new file mode 100755 index 00000000..309d7906 --- /dev/null +++ b/datamarket/deploy.sh @@ -0,0 +1,927 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# DataMarket deploy.sh +# Deploys DataMarket to your Databricks workspace from scratch. +# Usage: ./deploy.sh [options] +# +# Options: +# --profile PROFILE Databricks CLI profile (default: DEFAULT) +# --admin-email EMAIL Your email — gets admin role on first login (required) +# --lakebase-project NAME Lakebase autoscaling project name (default: datamarket) +# --app-name NAME Databricks App name (default: datamarket) +# --warehouse-id ID SQL Warehouse ID — auto-grants SP 'Can use' permission +# --grant-catalogs true|false Auto-grant SP USE CATALOG/SCHEMA on all UC catalogs (default: true) +# --demo-mode true|false Enable persona switcher (default: false) +# --seed true|false Apply schema/seed.sql after schema init — loads demo data products, +# users, and requests (default: true when --demo-mode true, else false) +# --use-bundle true|false Use Databricks Asset Bundle (DAB) for Lakebase+app deploy (default: false) +# --bundle-target TARGET DAB target to deploy: dev or prod (default: prod) +# --help Show this help +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +# ── Colours ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' +ok() { echo -e "${GREEN}✓${NC} $*"; } +info() { echo -e "${BLUE}▸${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +fail() { echo -e "${RED}✗ ERROR:${NC} $*"; exit 1; } +step() { echo -e "\n${BOLD}${BLUE}[$1/$TOTAL_STEPS]${NC} ${BOLD}$2${NC}"; } + +TOTAL_STEPS=10 + +# ── Defaults ───────────────────────────────────────────────────────────────── +PROFILE="DEFAULT" +ADMIN_EMAIL="" +LAKEBASE_PROJECT="datamarket" +APP_NAME="datamarket" +DEMO_MODE="false" +SEED_DATA="" # empty = auto (true when demo-mode, false otherwise) +WAREHOUSE_ID="" +GRANT_CATALOGS="true" +USE_BUNDLE="false" +BUNDLE_TARGET="prod" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Parse args ──────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case $1 in + --profile) PROFILE="$2"; shift 2 ;; + --admin-email) ADMIN_EMAIL="$2"; shift 2 ;; + --lakebase-project) LAKEBASE_PROJECT="$2"; shift 2 ;; + --app-name) APP_NAME="$2"; shift 2 ;; + --demo-mode) DEMO_MODE="$2"; shift 2 ;; + --seed) SEED_DATA="$2"; shift 2 ;; + --warehouse-id) WAREHOUSE_ID="$2"; shift 2 ;; + --grant-catalogs) GRANT_CATALOGS="$2"; shift 2 ;; + --use-bundle) USE_BUNDLE="$2"; shift 2 ;; + --bundle-target) BUNDLE_TARGET="$2"; shift 2 ;; + --help|-h) + sed -n '/^# /p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) fail "Unknown option: $1. Run with --help for usage." ;; + esac +done + +# ── Banner ──────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}DataMarket — Automated Deploy${NC}" +echo "────────────────────────────────" +echo "" + +# ── Prerequisites ───────────────────────────────────────────────────────────── +step 1 "Checking prerequisites" + +command -v databricks >/dev/null 2>&1 || fail "Databricks CLI not found. Install: brew tap databricks/tap && brew install databricks" +command -v node >/dev/null 2>&1 || fail "Node.js not found. Install: brew install node" +command -v npm >/dev/null 2>&1 || fail "npm not found." +command -v python3 >/dev/null 2>&1 || fail "python3 not found." +PSQL_BIN=$(command -v psql 2>/dev/null || true) +if [[ -z "$PSQL_BIN" ]]; then + warn "psql not found — schema init and SP grants will be skipped." + warn "Install with: brew install postgresql@16" + warn "The app will auto-create tables on first start, but you must grant schema access manually." +fi +ok "All prerequisites met" + +# ── Validate CLI auth ───────────────────────────────────────────────────────── +step 2 "Reading Databricks profile" + +AUTH_JSON=$(databricks auth describe --profile "$PROFILE" --output json 2>/dev/null) \ + || fail "Cannot authenticate with profile '${PROFILE}'. Run: databricks configure --profile ${PROFILE}" + +DATABRICKS_HOST=$(echo "$AUTH_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('details',{}).get('host','') or d.get('host',''))" 2>/dev/null || true) +DATABRICKS_USER=$(echo "$AUTH_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('details',{}).get('user','') or d.get('user',''))" 2>/dev/null || true) + +# Fallback: parse from profile config +if [[ -z "$DATABRICKS_HOST" ]]; then + DATABRICKS_HOST=$(databricks auth describe --profile "$PROFILE" 2>&1 | grep -i "Host:" | head -1 | awk '{print $2}' || true) +fi +if [[ -z "$DATABRICKS_USER" ]]; then + DATABRICKS_USER=$(databricks auth describe --profile "$PROFILE" 2>&1 | grep -i "User:" | head -1 | awk '{print $2}' || true) +fi + +[[ -z "$DATABRICKS_HOST" ]] && fail "Could not detect DATABRICKS_HOST from profile. Set it manually in the generated app.yaml after this script runs." +DATABRICKS_HOST="${DATABRICKS_HOST%/}" # strip trailing slash +ok "Host: $DATABRICKS_HOST" +ok "User: ${DATABRICKS_USER:-}" + +# ── Prompt for required values ──────────────────────────────────────────────── +if [[ -z "$ADMIN_EMAIL" ]]; then + # Auto-detect from profile first + ADMIN_EMAIL="${DATABRICKS_USER:-}" + if [[ -z "$ADMIN_EMAIL" ]]; then + echo "" + read -rp " Your email address (becomes the first admin): " ADMIN_EMAIL + [[ -z "$ADMIN_EMAIL" ]] && fail "ADMIN_EMAIL is required." + else + info "Admin email auto-detected from profile: ${ADMIN_EMAIL}" + fi +fi +ok "Admin: $ADMIN_EMAIL" + +# Workspace path — derive from user email or use default +WS_USER="${DATABRICKS_USER:-$ADMIN_EMAIL}" +WS_USER_PATH="${WS_USER//@/%40}" # URL-encode @ for workspace path display +WORKSPACE_PATH="/Workspace/Users/${WS_USER}/${APP_NAME}" +info "Workspace path: ${WORKSPACE_PATH}" + +# ── Bundle path ─────────────────────────────────────────────────────────────── +# When --use-bundle true, the DAB handles Lakebase provisioning + app deploy. +# deploy.sh then picks up from Step 7 (Lakebase schema grants) onwards. +if [[ "$USE_BUNDLE" == "true" ]]; then + BUNDLE_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" # repo root (3 levels up from src/app) + if [[ ! -f "${BUNDLE_ROOT}/databricks.yml" ]]; then + fail "databricks.yml not found at ${BUNDLE_ROOT}. Run from the repo root or check --use-bundle." + fi + + info "─── Bundle mode (DAB) — steps 3–6 handled by databricks bundle deploy ───" + info "Bundle root: ${BUNDLE_ROOT}" + info "Target: ${BUNDLE_TARGET}" + + # Validate CLI version (postgres_projects needs >= 0.287.0) + CLI_VERSION=$(databricks -v 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + info "Databricks CLI version: ${CLI_VERSION:-unknown}" + + # Build frontend first (DAB doesn't know how to build Node apps) + step 3 "Building frontend" + (cd "$SCRIPT_DIR" && npm install --silent 2>/dev/null && npm run build:local 2>&1 | tail -4) + ok "Build complete" + + # Ensure app.yaml exists (required in source dir before bundle deploy) + step 4 "Checking app.yaml" + if [[ ! -f "${SCRIPT_DIR}/app.yaml" ]]; then + info "app.yaml not found — generating from values provided..." + # Discover Lakebase hostname for app.yaml (needed even in bundle mode) + LAKEBASE_HOST="" + LAKEBASE_CACHE_FILE="${SCRIPT_DIR}/.lakebase-${APP_NAME}.cache" + BRANCH_NAME=$(databricks api get "2.0/postgres/autoscaling/projects/${LAKEBASE_PROJECT}/branches" \ + --profile "$PROFILE" 2>/dev/null \ + | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + branches = d.get('branches', d.get('items', [])) + prod = next((b.get('name','') for b in branches if b.get('name','') == 'production'), '') + first = branches[0].get('name','') if branches else '' + print(prod or first) +except: print('') +" 2>/dev/null || true) + if [[ -n "$BRANCH_NAME" ]]; then + LAKEBASE_ENDPOINT_PATH="projects/${LAKEBASE_PROJECT}/branches/${BRANCH_NAME}/endpoints/primary" + LAKEBASE_HOST=$(databricks api get "2.0/postgres/endpoints/${LAKEBASE_ENDPOINT_PATH}" \ + --profile "$PROFILE" 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('read_write_dns','') or d.get('dns','') or '')" 2>/dev/null || true) + fi + if [[ -z "$LAKEBASE_HOST" && -f "$LAKEBASE_CACHE_FILE" ]]; then + LAKEBASE_HOST=$(cat "$LAKEBASE_CACHE_FILE" 2>/dev/null || true) + fi + if [[ -z "$LAKEBASE_HOST" ]]; then + warn "Lakebase hostname not yet discoverable (project may not exist yet)." + warn "The DAB will provision Lakebase. After 'databricks bundle deploy' completes, re-run this script to finalize app.yaml." + info "Proceeding with bundle deploy — app.yaml will be created after Lakebase is up." + fi + LAKEBASE_ENDPOINT="${LAKEBASE_ENDPOINT_PATH:-projects/${LAKEBASE_PROJECT}/branches/production/endpoints/primary}" + cat > "${SCRIPT_DIR}/app.yaml" << YAML +command: + - "node" + - "app.js" +env: + - name: DATABRICKS_HOST + value: "${DATABRICKS_HOST}" + - name: ADMIN_EMAIL + value: "${ADMIN_EMAIL}" + - name: LAKEBASE_HOST + value: "${LAKEBASE_HOST}" + - name: LAKEBASE_DB + value: "databricks_postgres" + - name: LAKEBASE_SCHEMA + value: "${APP_NAME}" + - name: LAKEBASE_ENDPOINT + value: "${LAKEBASE_ENDPOINT}" + - name: DEMO_MODE + value: "${DEMO_MODE}" +YAML + ok "app.yaml written" + else + ok "app.yaml already exists — using as-is" + fi + + step 5 "Running databricks bundle deploy (Lakebase + App)" + cd "$BUNDLE_ROOT" + databricks bundle deploy -t "$BUNDLE_TARGET" \ + --var "admin_email=${ADMIN_EMAIL}" \ + --var "demo_mode=${DEMO_MODE}" \ + --var "lakebase_project=${LAKEBASE_PROJECT}" \ + --var "app_name=${APP_NAME}" \ + --profile "$PROFILE" 2>&1 | tail -10 + ok "Bundle deployed" + cd "$SCRIPT_DIR" + + # After bundle deploy, update app.yaml with discovered Lakebase hostname if missing + step 6 "Refreshing app.yaml with Lakebase hostname" + LAKEBASE_CACHE_FILE="${SCRIPT_DIR}/.lakebase-${APP_NAME}.cache" + BRANCH_NAME=$(databricks api get "2.0/postgres/autoscaling/projects/${LAKEBASE_PROJECT}/branches" \ + --profile "$PROFILE" 2>/dev/null \ + | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + branches = d.get('branches', d.get('items', [])) + prod = next((b.get('name','') for b in branches if b.get('name','') == 'production'), '') + first = branches[0].get('name','') if branches else '' + print(prod or first) +except: print('') +" 2>/dev/null || true) + if [[ -n "$BRANCH_NAME" ]]; then + LAKEBASE_ENDPOINT="projects/${LAKEBASE_PROJECT}/branches/${BRANCH_NAME}/endpoints/primary" + LAKEBASE_HOST=$(databricks api get "2.0/postgres/endpoints/${LAKEBASE_ENDPOINT}" \ + --profile "$PROFILE" 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('read_write_dns','') or d.get('dns','') or '')" 2>/dev/null || true) + if [[ -n "$LAKEBASE_HOST" ]]; then + echo "$LAKEBASE_HOST" > "$LAKEBASE_CACHE_FILE" + # Patch app.yaml with real hostname + python3 -c " +import re, sys +with open('${SCRIPT_DIR}/app.yaml', 'r') as f: content = f.read() +content = re.sub(r'(name: LAKEBASE_HOST\n\s+value: \")[^\"]*\"', r'\1${LAKEBASE_HOST}\"', content) +with open('${SCRIPT_DIR}/app.yaml', 'w') as f: f.write(content) +print('app.yaml patched with real Lakebase hostname') +" + # Redeploy app with correct Lakebase host + info "Redeploying app with correct Lakebase hostname..." + databricks apps deploy "$APP_NAME" \ + --source-code-path "${WORKSPACE_PATH}" \ + --profile "$PROFILE" 2>&1 | tail -3 || true + ok "app.yaml updated: ${LAKEBASE_HOST}" + fi + fi + + # Skip to grants — app is already deployed via bundle + TOTAL_STEPS=10 + SP_UUID=$(databricks apps get "$APP_NAME" --profile "$PROFILE" --output json 2>/dev/null \ + | python3 -c " +import sys, json +d = json.load(sys.stdin) +candidates = [ + d.get('service_principal_client_id'), + d.get('service_principal', {}).get('client_id'), +] +print(next((c for c in candidates if c), '')) +" 2>/dev/null || true) + + # Jump straight to step 7 (grants) + # shellcheck disable=SC2034 + BUNDLE_DEPLOY_DONE=true +fi + +# ── Standard path: Lakebase detection, app.yaml gen, build, upload, deploy ─── +if [[ "${BUNDLE_DEPLOY_DONE:-false}" != "true" ]]; then + +step 3 "Detecting Lakebase configuration" + +# ── Pre-flight: verify token is fresh before the long Lakebase operation ────── +# Lakebase creation takes 2–3 min. An expired token mid-way leaves the project +# in a broken state. Fail fast here rather than halfway through provisioning. +TOKEN_CHECK=$(databricks auth token --profile "$PROFILE" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) +if [[ -z "$TOKEN_CHECK" ]]; then + fail "$(cat </dev/null \ + | python3 -c " +import sys, json +try: + items = json.load(sys.stdin) + if not isinstance(items, list): items = [] + # resource names look like 'projects/foo/branches/bar' — extract short name + names = [b.get('name','').split('/')[-1] for b in items if b.get('name','')] + prod = next((n for n in names if n == 'production'), '') + print(prod or (names[0] if names else '')) +except: print('') +" 2>/dev/null || true +} + +# ── Helper: get endpoint hostname for a branch ──────────────────────────────── +_get_host() { + local branch="$1" + databricks postgres list-endpoints "projects/${LAKEBASE_PROJECT}/branches/${branch}" \ + --profile "$PROFILE" -o json 2>/dev/null \ + | python3 -c " +import sys, json +try: + items = json.load(sys.stdin) + if not isinstance(items, list): items = [] + for ep in items: + host = ep.get('status', {}).get('hosts', {}).get('host', '') + if host: + print(host) + break +except: print('') +" 2>/dev/null || true +} + +info "Looking up Lakebase project: ${LAKEBASE_PROJECT}" +BRANCH_NAME=$(_get_branch) + +# ── Project doesn't exist yet — create it ───────────────────────────────────── +if [[ -z "$BRANCH_NAME" ]]; then + info "Project '${LAKEBASE_PROJECT}' not found — creating Lakebase Autoscaling project..." + info "(This takes ~2–3 minutes on first deploy — the CLI will wait automatically)" + + CREATE_OUT=$(databricks postgres create-project "$LAKEBASE_PROJECT" \ + --profile "$PROFILE" -o json 2>&1 || true) + + if echo "$CREATE_OUT" | python3 -c "import sys,json; json.load(sys.stdin)" &>/dev/null; then + ok "Project '${LAKEBASE_PROJECT}' created" + else + # May already exist or be in progress — not fatal + warn "create-project output: ${CREATE_OUT:0:200}" + fi + + # Poll for branch (provisioned after project creation) + info "Waiting for Lakebase branch to be ready..." + for i in $(seq 1 36); do + BRANCH_NAME=$(_get_branch) + [[ -n "$BRANCH_NAME" ]] && { ok "Branch ready: ${BRANCH_NAME}"; break; } + echo -n "."; sleep 5 + done + echo "" + if [[ -z "$BRANCH_NAME" ]]; then + warn "Lakebase branch not ready after 3 min — project '${LAKEBASE_PROJECT}' is stuck." + warn "This usually means a previous deploy was interrupted (e.g. auth token expired)." + info "Auto-recovering: deleting stuck project and recreating..." + + DEL_OUT=$(databricks postgres delete-project "projects/${LAKEBASE_PROJECT}" \ + --profile "$PROFILE" 2>&1 || true) + if echo "$DEL_OUT" | grep -qi "error\|INTERNAL\|not found"; then + warn "Could not auto-delete stuck project: ${DEL_OUT:0:150}" + warn "Delete manually: Compute → Lakebase → ${LAKEBASE_PROJECT} → Settings → Delete project" + warn "Then re-run: ./deploy.sh --profile ${PROFILE}" + fail "Lakebase branch unavailable and auto-recovery failed. See above." + fi + ok "Stuck project deleted — recreating..." + sleep 5 + + databricks postgres create-project "$LAKEBASE_PROJECT" \ + --profile "$PROFILE" -o json 2>&1 | head -3 || true + + info "Waiting for Lakebase branch to be ready (attempt 2)..." + for i in $(seq 1 36); do + BRANCH_NAME=$(_get_branch) + [[ -n "$BRANCH_NAME" ]] && { ok "Branch ready: ${BRANCH_NAME}"; break; } + echo -n "."; sleep 5 + done + echo "" + [[ -z "$BRANCH_NAME" ]] && fail "Lakebase branch still unavailable after recreation. Check Compute → Lakebase in the UI." + fi +fi + +# ── Resolve endpoint hostname ───────────────────────────────────────────────── +LAKEBASE_ENDPOINT="projects/${LAKEBASE_PROJECT}/branches/${BRANCH_NAME}/endpoints/primary" +LAKEBASE_HOST=$(_get_host "$BRANCH_NAME") + +# Fall back to cache if API doesn't return a hostname yet (endpoint still provisioning) +if [[ -z "$LAKEBASE_HOST" && -f "$LAKEBASE_CACHE_FILE" ]]; then + LAKEBASE_HOST=$(cat "$LAKEBASE_CACHE_FILE" 2>/dev/null || true) + [[ -n "$LAKEBASE_HOST" ]] && ok "Lakebase hostname loaded from cache." +fi + +# Last resort — ask once, cache the answer +if [[ -z "$LAKEBASE_HOST" ]]; then + warn "Could not resolve Lakebase hostname from API (endpoint may still be provisioning)." + warn "Go to: Compute → Lakebase → ${LAKEBASE_PROJECT} → Overview → Connection details" + echo "" + read -rp " Paste your Lakebase hostname (ep-...): " LAKEBASE_HOST + [[ -z "$LAKEBASE_HOST" ]] && fail "Lakebase hostname is required." +fi + +# Always cache the resolved hostname for future runs +echo "$LAKEBASE_HOST" > "$LAKEBASE_CACHE_FILE" + +ok "Lakebase host: ${LAKEBASE_HOST}" +ok "Lakebase endpoint: ${LAKEBASE_ENDPOINT}" + +# ── Generate app.yaml ───────────────────────────────────────────────────────── +step 4 "Generating app.yaml" + +APP_YAML_PATH="${SCRIPT_DIR}/app.yaml" + +cat > "$APP_YAML_PATH" << YAML +command: + - "node" + - "app.js" +env: + # ── Databricks Identity ──────────────────────────────────────────────────── + - name: DATABRICKS_HOST + value: "${DATABRICKS_HOST}" + + # ── Admin bootstrap ──────────────────────────────────────────────────────── + # On first SSO login the app auto-promotes this email to admin. + - name: ADMIN_EMAIL + value: "${ADMIN_EMAIL}" + + # ── Lakebase Connection ──────────────────────────────────────────────────── + - name: LAKEBASE_HOST + value: "${LAKEBASE_HOST}" + - name: LAKEBASE_DB + value: "databricks_postgres" + - name: LAKEBASE_SCHEMA + value: "${APP_NAME}" + - name: LAKEBASE_ENDPOINT + value: "${LAKEBASE_ENDPOINT}" + + # ── Mode ────────────────────────────────────────────────────────────────── + # false = real SSO identity + UC grants. true = persona switcher (demos only) + - name: DEMO_MODE + value: "${DEMO_MODE}" +YAML + +ok "app.yaml written to ${APP_YAML_PATH}" + +# ── Build frontend ──────────────────────────────────────────────────────────── +step 5 "Building frontend" + +(cd "$SCRIPT_DIR" && npm install --silent 2>/dev/null && npm run build:local 2>&1 | tail -4) +ok "Build complete" + +# ── Build frontend ──────────────────────────────────────────────────────────── +step 6 "Building frontend and uploading to Databricks" + +if command -v node >/dev/null 2>&1 && [[ -f "${SCRIPT_DIR}/vite.config.js" ]]; then + info "Running Vite build..." + (cd "${SCRIPT_DIR}" && npm run build:local 2>&1 | tail -5) \ + && ok "Frontend built" \ + || warn "Vite build failed — uploading existing dist/" +else + info "Node/Vite not found — uploading existing dist/" +fi + +info "Uploading dist/..." +databricks workspace import-dir "${SCRIPT_DIR}/dist" "${WORKSPACE_PATH}/dist" \ + --overwrite --profile "$PROFILE" 2>&1 | tail -2 + +info "Uploading backend modules..." +for f in app.js db.js auth.js databricks.js; do + databricks workspace import "${WORKSPACE_PATH}/${f}" \ + --file "${SCRIPT_DIR}/${f}" --format AUTO --overwrite \ + --profile "$PROFILE" 2>/dev/null +done + +if [[ -d "${SCRIPT_DIR}/routes" ]]; then + databricks workspace import-dir "${SCRIPT_DIR}/routes" "${WORKSPACE_PATH}/routes" \ + --overwrite --profile "$PROFILE" 2>&1 | tail -2 +fi + +if [[ -d "${SCRIPT_DIR}/lib" ]]; then + databricks workspace import-dir "${SCRIPT_DIR}/lib" "${WORKSPACE_PATH}/lib" \ + --overwrite --profile "$PROFILE" 2>&1 | tail -2 +fi + +# Upload remaining config files +for f in package.json manifest.yaml app.yaml; do + [[ -f "${SCRIPT_DIR}/${f}" ]] && \ + databricks workspace import "${WORKSPACE_PATH}/${f}" \ + --file "${SCRIPT_DIR}/${f}" --format AUTO --overwrite \ + --profile "$PROFILE" 2>/dev/null || true +done + +info "Deploying app (this takes ~2 minutes)..." + +# Create the app first if it doesn't exist yet +if ! databricks apps get "$APP_NAME" --profile "$PROFILE" --output json >/dev/null 2>&1; then + info "App does not exist yet — creating..." + databricks apps create "$APP_NAME" --profile "$PROFILE" 2>&1 | tail -3 +fi + +DEPLOY_OUT=$(databricks apps deploy "$APP_NAME" \ + --source-code-path "$WORKSPACE_PATH" \ + --profile "$PROFILE" 2>&1) + +if echo "$DEPLOY_OUT" | grep -q '"state":"SUCCEEDED"'; then + ok "App deployed successfully" +else + warn "Deploy output:" + echo "$DEPLOY_OUT" | tail -10 + fail "Deploy did not reach SUCCEEDED state. Check the output above." +fi + +fi # end standard path (not bundle mode) + +# ── Lakebase schema grants ──────────────────────────────────────────────────── +step 7 "Granting Lakebase schema permissions to the app service principal" + +# Get the SP UUID from the running app +SP_UUID=$(databricks apps get "$APP_NAME" --profile "$PROFILE" --output json 2>/dev/null \ + | python3 -c " +import sys, json +d = json.load(sys.stdin) +# Try several paths where the SP UUID might live +candidates = [ + d.get('service_principal_client_id'), + d.get('service_principal', {}).get('client_id'), + d.get('pending_deployment', {}).get('creator', {}).get('client_id'), +] +print(next((c for c in candidates if c), '')) +" 2>/dev/null || true) + +# Fallback: scrape from app logs +if [[ -z "$SP_UUID" ]]; then + SP_UUID=$(databricks apps logs "$APP_NAME" --profile "$PROFILE" 2>&1 \ + | grep -oE "Apps SP [0-9a-f-]{8}" | head -1 | awk '{print $3}' || true) + # Logs show truncated UUID — try to get full UUID via SCIM if we have a prefix +fi + +if [[ -z "$SP_UUID" ]]; then + warn "Could not auto-detect the app service principal UUID." + warn "Find it in: Apps → ${APP_NAME} → Service Principal" + echo "" + read -rp " Paste the SP client UUID (or press Enter to skip): " SP_UUID +fi + +if [[ -z "$SP_UUID" ]]; then + warn "Skipping Lakebase grants — you will need to run these manually:" + echo "" + echo " GRANT USAGE ON SCHEMA ${APP_NAME} TO \"\";" + echo " GRANT CREATE ON SCHEMA ${APP_NAME} TO \"\";" + echo "" + warn "Until this is done, the app will start but data will not persist." +else + info "Granting schema access to SP: ${SP_UUID}" + + # Generate a short-lived Lakebase token + PG_TOKEN=$(databricks auth token --profile "$PROFILE" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) + + if [[ -z "$PSQL_BIN" ]]; then + warn "psql not available — skipping schema init and grants. Run deploy again after installing psql." + warn " brew install postgresql@16" + elif [[ -z "$PG_TOKEN" ]]; then + warn "Could not generate a Lakebase token. Grants skipped — run manually." + else + # Step 1: Create schema + apply schema.sql (creates all core tables) + SCHEMA_SQL="${SCRIPT_DIR}/../../schema/schema.sql" + PG_CONN="host=${LAKEBASE_HOST} port=5432 dbname=databricks_postgres sslmode=require user=${DATABRICKS_USER:-${ADMIN_EMAIL}}" + + SCHEMA_OUT=$(PGPASSWORD="$PG_TOKEN" psql "$PG_CONN" \ + -c "CREATE SCHEMA IF NOT EXISTS ${APP_NAME}; SET search_path TO ${APP_NAME};" \ + 2>&1 || true) + if echo "$SCHEMA_OUT" | grep -qi "FATAL\|error:"; then + warn "Lakebase connection failed during schema create:" + echo "$SCHEMA_OUT" | grep -i "FATAL\|error:" | head -3 + warn "The app will try to auto-create tables on first start." + warn "If this persists, ensure your CLI profile uses OAuth (not PAT):" + warn " databricks auth login --profile ${PROFILE} --host ${DATABRICKS_HOST}" + fi + + if [[ -f "$SCHEMA_SQL" ]]; then + SQL_OUT=$(PGPASSWORD="$PG_TOKEN" psql "$PG_CONN" \ + -v ON_ERROR_STOP=0 \ + -c "SET search_path TO ${APP_NAME};" \ + -f "$SCHEMA_SQL" \ + 2>&1 || true) + if echo "$SQL_OUT" | grep -qi "FATAL\|error:"; then + warn "schema.sql could not be applied — connection issue (see above)" + else + echo "$SQL_OUT" | grep -v "^$" | grep -v "^NOTICE" | grep -v "already exists" || true + ok "schema.sql applied" + fi + else + warn "schema.sql not found at ${SCHEMA_SQL} — tables will be auto-created by the app on first start" + fi + + # ── Seed data ──────────────────────────────────────────────────────────── + # Auto-seed when demo-mode is on; can be forced with --seed true|false + if [[ -z "$SEED_DATA" ]]; then + [[ "$DEMO_MODE" == "true" ]] && SEED_DATA="true" || SEED_DATA="false" + fi + + SEED_SQL="${SCRIPT_DIR}/../../schema/seed.sql" + if [[ "$SEED_DATA" == "true" ]]; then + if [[ -f "$SEED_SQL" ]]; then + info "Applying seed data (schema/seed.sql)..." + PGPASSWORD="$PG_TOKEN" psql "$PG_CONN" \ + -v ON_ERROR_STOP=0 \ + -c "SET search_path TO ${APP_NAME};" \ + -f "$SEED_SQL" \ + 2>&1 | grep -v "^$" | grep -v "^NOTICE" | grep -v "already exists" || true + ok "Seed data applied — demo products + users loaded" + else + warn "seed.sql not found at ${SEED_SQL} — skipping seed" + fi + else + info "Seed data skipped (production mode). Pass --seed true to load demo data." + fi + + # Step 2: Create the SP OAuth role in Lakebase (required before app can authenticate) + # Lakebase uses LAKEBASE_OAUTH_V1 auth — the SP role must be pre-registered via the API, + # not via psql CREATE ROLE (which creates a native password role that won't accept OAuth tokens). + info "Registering SP as OAuth role in Lakebase..." + EXISTING_LAKEBASE_ROLE=$(databricks postgres list-roles \ + "projects/${LAKEBASE_PROJECT}/branches/${BRANCH_NAME}" \ + --profile "$PROFILE" -o json 2>/dev/null \ + | python3 -c " +import sys, json +try: + roles = json.load(sys.stdin) + match = next((r for r in roles if r.get('status',{}).get('postgres_role','') == '${SP_UUID}'), None) + if match: + method = match.get('status',{}).get('auth_method','') + print(method) + else: + print('') +except: print('') +" 2>/dev/null || true) + + if [[ "$EXISTING_LAKEBASE_ROLE" == "LAKEBASE_OAUTH_V1" ]]; then + ok "SP OAuth role already registered in Lakebase." + else + if [[ -n "$EXISTING_LAKEBASE_ROLE" ]]; then + # Wrong auth method — find and delete the existing role first + WRONG_ROLE_NAME=$(databricks postgres list-roles \ + "projects/${LAKEBASE_PROJECT}/branches/${BRANCH_NAME}" \ + --profile "$PROFILE" -o json 2>/dev/null \ + | python3 -c " +import sys, json +try: + roles = json.load(sys.stdin) + match = next((r for r in roles if r.get('status',{}).get('postgres_role','') == '${SP_UUID}'), None) + print(match.get('name','') if match else '') +except: print('') +" 2>/dev/null || true) + [[ -n "$WRONG_ROLE_NAME" ]] && \ + databricks postgres delete-role "$WRONG_ROLE_NAME" --profile "$PROFILE" 2>/dev/null || true + # Also clean up the manually-created postgres role if it exists + PGPASSWORD="$PG_TOKEN" psql "$PG_CONN" \ + -c "REVOKE ALL ON ALL TABLES IN SCHEMA ${APP_NAME} FROM \"${SP_UUID}\"; \ + REVOKE ALL ON ALL SEQUENCES IN SCHEMA ${APP_NAME} FROM \"${SP_UUID}\"; \ + REVOKE ALL ON SCHEMA ${APP_NAME} FROM \"${SP_UUID}\"; \ + DROP ROLE IF EXISTS \"${SP_UUID}\";" \ + 2>/dev/null || true + fi + + CREATE_ROLE_OUT=$(databricks postgres create-role \ + "projects/${LAKEBASE_PROJECT}/branches/${BRANCH_NAME}" \ + --json "{\"spec\": {\"identity_type\": \"SERVICE_PRINCIPAL\", \"postgres_role\": \"${SP_UUID}\"}}" \ + --profile "$PROFILE" -o json 2>&1 || true) + + if echo "$CREATE_ROLE_OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',{}).get('auth_method',''))" 2>/dev/null | grep -q "LAKEBASE_OAUTH_V1"; then + ok "SP OAuth role created in Lakebase" + else + warn "Could not create SP OAuth role: ${CREATE_ROLE_OUT:0:150}" + warn "App may fall back to demo mode. Re-run deploy to retry." + fi + fi + + # Step 3: Grant the SP schema access + GRANT_OUT=$(PGPASSWORD="$PG_TOKEN" psql \ + "host=${LAKEBASE_HOST} port=5432 dbname=databricks_postgres sslmode=require user=${DATABRICKS_USER:-${ADMIN_EMAIL}}" \ + -c " + GRANT CONNECT ON DATABASE databricks_postgres TO \"${SP_UUID}\"; + GRANT USAGE ON SCHEMA ${APP_NAME} TO \"${SP_UUID}\"; + GRANT CREATE ON SCHEMA ${APP_NAME} TO \"${SP_UUID}\"; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA ${APP_NAME} TO \"${SP_UUID}\"; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ${APP_NAME} TO \"${SP_UUID}\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA ${APP_NAME} GRANT ALL ON TABLES TO \"${SP_UUID}\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA ${APP_NAME} GRANT ALL ON SEQUENCES TO \"${SP_UUID}\"; + " 2>&1 || true) + + if echo "$GRANT_OUT" | grep -qi "FATAL\|error:"; then + warn "SP grants failed:" + echo "$GRANT_OUT" | grep -i "FATAL\|error:" | head -3 + else + ok "Schema grants applied to SP" + fi + + # Step 4: Restart app with role + grants in place + info "Restarting app..." + databricks apps deploy "$APP_NAME" \ + --source-code-path "$WORKSPACE_PATH" \ + --profile "$PROFILE" 2>&1 | tail -3 + ok "App restarted" + fi +fi + +# ── Warehouse SP permission ─────────────────────────────────────────────────── +step 8 "Granting SQL Warehouse 'Can use' to app service principal" + +# Auto-detect warehouse if not provided +if [[ -z "$WAREHOUSE_ID" ]]; then + info "No --warehouse-id provided — auto-detecting..." + WAREHOUSE_ID=$(databricks warehouses list --profile "$PROFILE" -o json 2>/dev/null \ + | python3 -c " +import sys, json + +data = json.load(sys.stdin) +warehouses = data if isinstance(data, list) else data.get('warehouses', data.get('items', [])) + +def score(w): + name = (w.get('name') or '').lower() + state = (w.get('state') or '').upper() + wtype = (w.get('warehouse_type') or '').upper() + s = 0 + if state == 'RUNNING': s += 10 + if 'starter' in name: s += 4 + if 'serverless' in name: s += 3 + if wtype == 'PRO': s += 2 + return s + +best = sorted(warehouses, key=score, reverse=True) +if best: + w = best[0] + print(w.get('id','')) + import sys + print(f' Selected: {w.get(\"name\")} ({w.get(\"warehouse_type\")}, {w.get(\"state\")})', file=sys.stderr) +" 2>/tmp/wh_detect_log.txt || true) + [[ -f /tmp/wh_detect_log.txt ]] && cat /tmp/wh_detect_log.txt >&2 || true + if [[ -n "$WAREHOUSE_ID" ]]; then + ok "Auto-detected warehouse: ${WAREHOUSE_ID}" + info "Pass --warehouse-id ${WAREHOUSE_ID} to skip detection on future runs." + else + warn "Could not auto-detect a SQL Warehouse. UC GRANTs won't execute until one is set." + warn "After deploy: Manage → Settings → SQL Warehouse ID" + fi +fi + +if [[ -z "$WAREHOUSE_ID" ]]; then + : # skip silently — warned above +elif [[ -z "$SP_UUID" ]]; then + warn "SP UUID not detected — skipping warehouse grant. Grant manually in the UI." +else + WAREHOUSE_PERM_PAYLOAD="{\"access_control_list\":[{\"service_principal_name\":\"${SP_UUID}\",\"permission_level\":\"CAN_USE\"}]}" + WAREHOUSE_PERM_RESULT=$(databricks api patch "/api/2.0/permissions/warehouses/${WAREHOUSE_ID}" \ + --profile "$PROFILE" \ + --json "$WAREHOUSE_PERM_PAYLOAD" 2>&1 || echo "error") + + if echo "$WAREHOUSE_PERM_RESULT" | grep -qi "error\|Error\|INTERNAL"; then + warn "Warehouse permission grant returned a warning (may already be set or partial):" + echo "$WAREHOUSE_PERM_RESULT" | head -3 + else + ok "SP '${SP_UUID}' granted CAN_USE on warehouse ${WAREHOUSE_ID}" + fi +fi + +# ── UC Catalog / Schema grants ──────────────────────────────────────────────── +step 9 "Granting SP USE CATALOG + USE SCHEMA on all Unity Catalog catalogs" + +if [[ "$GRANT_CATALOGS" != "true" ]]; then + warn "UC catalog grants skipped (--grant-catalogs false)." +elif [[ -z "$SP_UUID" ]]; then + warn "SP UUID not detected — skipping UC grants. Run the SQL below manually:" + echo " GRANT USE CATALOG ON CATALOG TO \`\`;" +elif [[ -z "$WAREHOUSE_ID" ]]; then + warn "No warehouse ID available — cannot execute UC GRANTs. Pass --warehouse-id to automate." +else + info "Listing UC catalogs visible to this profile..." + + CATALOGS=$(databricks api get "/api/2.1/unity-catalog/catalogs" \ + --profile "$PROFILE" 2>/dev/null \ + | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + cats = [c.get('name','') for c in d.get('catalogs',[]) + if c.get('name','') not in ('system','__databricks_internal','hive_metastore')] + print(' '.join(cats)) +except: print('') +" 2>/dev/null || true) + + if [[ -z "$CATALOGS" ]]; then + warn "No catalogs found or insufficient permissions to list catalogs." + else + info "Found catalogs: ${CATALOGS}" + GRANT_ERRORS=0 + for CATALOG in $CATALOGS; do + # USE CATALOG — required to enter the catalog + GRANT_RESULT=$(databricks api post "/api/2.0/sql/statements" \ + --profile "$PROFILE" \ + --json "{\"warehouse_id\":\"${WAREHOUSE_ID}\",\"statement\":\"GRANT USE CATALOG ON CATALOG \`${CATALOG}\` TO \`${SP_UUID}\`\",\"wait_timeout\":\"10s\"}" \ + 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',{}).get('state','UNKNOWN'))" 2>/dev/null || echo "ERROR") + + # USE SCHEMA ON CATALOG — cascades USE SCHEMA to all current and future schemas. + # Required for the SP to enumerate schemas and tables via the UC REST API. + databricks api post "/api/2.0/sql/statements" \ + --profile "$PROFILE" \ + --json "{\"warehouse_id\":\"${WAREHOUSE_ID}\",\"statement\":\"GRANT USE SCHEMA ON CATALOG \`${CATALOG}\` TO \`${SP_UUID}\`\",\"wait_timeout\":\"10s\"}" \ + 2>/dev/null >/dev/null || true + + # SELECT ON CATALOG — cascades read access to all current and future schemas/tables. + SELECT_RESULT=$(databricks api post "/api/2.0/sql/statements" \ + --profile "$PROFILE" \ + --json "{\"warehouse_id\":\"${WAREHOUSE_ID}\",\"statement\":\"GRANT SELECT ON CATALOG \`${CATALOG}\` TO \`${SP_UUID}\`\",\"wait_timeout\":\"10s\"}" \ + 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',{}).get('state','UNKNOWN'))" 2>/dev/null || echo "ERROR") + + if [[ "$GRANT_RESULT" == "SUCCEEDED" && "$SELECT_RESULT" == "SUCCEEDED" ]]; then + ok " ✓ ${CATALOG} — USE CATALOG + USE SCHEMA + SELECT granted (covers all schemas, now and future)" + elif [[ "$GRANT_RESULT" == "SUCCEEDED" ]]; then + ok " ✓ ${CATALOG} — USE CATALOG granted" + warn " ⚠ ${CATALOG} — SELECT grant failed (catalog may be owned by another user — run manually)" + else + warn " ⚠ ${CATALOG} — grant failed. Use the onboarding wizard to generate the correct SQL." + GRANT_ERRORS=$((GRANT_ERRORS + 1)) + fi + done + [[ $GRANT_ERRORS -eq 0 ]] && ok "UC catalog grants complete" || warn "${GRANT_ERRORS} catalog(s) could not be granted — the onboarding wizard will show the exact SQL to run." + fi +fi + +# ── Resource tagging for spend/consumption observability ───────────────────── +step 10 "Tagging Databricks resources for spend observability" + +TAGS_JSON="{\"app\":\"datamarket\",\"purpose\":\"data_marketplace\",\"environment\":\"${DEMO_MODE_FLAG:-production}\"}" + +# Tag the Databricks App +APP_TAG_RESULT=$(databricks api patch "/api/2.0/apps/${APP_NAME}" \ + --profile "$PROFILE" \ + --json "{\"custom_tags\":${TAGS_JSON}}" 2>&1 || echo "error") +if echo "$APP_TAG_RESULT" | grep -qi "error\|INTERNAL"; then + warn "App tagging skipped (API may not support PATCH custom_tags in this workspace version)" +else + ok "App tagged: app=datamarket, purpose=data_marketplace" +fi + +# Tag the SQL Warehouse +if [[ -n "$WAREHOUSE_ID" ]]; then + WH_TAGS_PAYLOAD="{\"tags\":{\"custom_tags\":[{\"key\":\"app\",\"value\":\"datamarket\"},{\"key\":\"purpose\",\"value\":\"data_marketplace\"},{\"key\":\"environment\",\"value\":\"${DEMO_MODE_FLAG:-production}\"}]}}" + WH_TAG_RESULT=$(databricks api post "/api/2.0/sql/warehouses/${WAREHOUSE_ID}/edit" \ + --profile "$PROFILE" \ + --json "$WH_TAGS_PAYLOAD" 2>&1 || echo "error") + if echo "$WH_TAG_RESULT" | grep -qi "error\|INTERNAL"; then + warn "Warehouse tagging skipped — apply manually in SQL Warehouse settings" + info " Tag key: app, value: datamarket" + else + ok "Warehouse ${WAREHOUSE_ID} tagged: app=datamarket" + fi +else + info "No warehouse ID — warehouse tagging skipped" +fi + +info "Tags flow into system.billing.usage under the custom_tags column." +info "Note: Lakebase custom_tags are UI-only (CLI API does not expose the field yet)." +info " → Workspace → Lakebase → ${APP_NAME} → Settings → Custom tags → add app=datamarket" +info "Note: FMAPI (Ask AI) usage has no taggable resource — filter by sku_name instead." +info "Full DataMarket spend query:" +cat <<'QUERY' + + -- Full DataMarket spend across all resource types + SELECT usage_date, + usage_type, + CASE + WHEN custom_tags['app'] = 'datamarket' THEN 'App / Warehouse' + WHEN sku_name LIKE '%LAKEBASE%' THEN 'Lakebase (Postgres)' + WHEN sku_name LIKE '%FOUNDATION_MODEL%' + OR sku_name LIKE '%LLAMA%' + OR sku_name LIKE '%PREMIUM_SERVING%' THEN 'Ask AI (FMAPI)' + ELSE 'Other' + END AS resource, + SUM(usage_quantity) AS dbus + FROM system.billing.usage + WHERE (custom_tags['app'] = 'datamarket' + OR sku_name LIKE '%LAKEBASE%' + OR sku_name LIKE '%FOUNDATION_MODEL%' + OR sku_name LIKE '%PREMIUM_SERVING%') + GROUP BY 1, 2, 3 + ORDER BY 1 DESC, dbus DESC; + +QUERY + + +APP_URL=$(databricks apps get "$APP_NAME" --profile "$PROFILE" --output json 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('url',''))" 2>/dev/null || true) + +echo "" +echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}${BOLD} DataMarket is live!${NC}" +echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" +if [[ -n "$APP_URL" ]]; then + echo -e " ${BOLD}URL:${NC} $APP_URL" +fi +echo -e " ${BOLD}Admin:${NC} $ADMIN_EMAIL" +echo "" +echo -e " ${YELLOW}Next steps in the app (2 minutes):${NC}" +echo -e " 1. Open the URL and log in" +echo -e " 2. Click Manage → Data Products → Import from UC" +if [[ -z "$WAREHOUSE_ID" ]]; then + echo -e " 3. Click Manage → Settings → set your SQL Warehouse ID" + echo -e " Then: SQL Warehouses → your warehouse → Permissions → add app SP with 'Can use'" +else + echo -e " 3. Warehouse permission already granted ✓" +fi +echo "" diff --git a/datamarket/index.html b/datamarket/index.html new file mode 100644 index 00000000..8bb71cb1 --- /dev/null +++ b/datamarket/index.html @@ -0,0 +1,17 @@ + + + + + + + + DataMarket · Data Discovery & Access + + + + + +
+ + + \ No newline at end of file diff --git a/datamarket/jsconfig.json b/datamarket/jsconfig.json new file mode 100644 index 00000000..01c84ba6 --- /dev/null +++ b/datamarket/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} \ No newline at end of file diff --git a/datamarket/manifest.yaml b/datamarket/manifest.yaml new file mode 100644 index 00000000..74c2bf32 --- /dev/null +++ b/datamarket/manifest.yaml @@ -0,0 +1,77 @@ +# DataMarket — Databricks Marketplace manifest +# ───────────────────────────────────────────────────────────────────────────── +# This file is used when publishing DataMarket through Databricks Marketplace. +# It declares the resources the consumer must provide at install time. +# +# For CLI-based deployment (deploy.sh), use app.yaml.example instead. +# ───────────────────────────────────────────────────────────────────────────── + +command: + - "bash" + - "-c" + - "npm install --include=dev && ([ -d dist ] || npm run build) && node app.js" + +env: + # ── Lakebase (required) ─────────────────────────────────────────────────── + # The consumer selects their Lakebase Autoscaling project at install time. + # The platform automatically injects PGHOST, PGDATABASE, PGPORT, PGSSLMODE. + # LAKEBASE_ENDPOINT is set here so the app can generate short-lived DB + # credentials using the app service principal (M2M OAuth). + - name: LAKEBASE_ENDPOINT + valueFrom: lakebase-db + + # ── SQL Warehouse (optional) ────────────────────────────────────────────── + # Required only if DEMO_MODE=false — used to execute real UC GRANT/REVOKE + # statements when approving access requests. Can be configured post-install + # via Admin → Settings in the app UI. + - name: SQL_WAREHOUSE_ID + valueFrom: sql-warehouse + + # ── Mode ───────────────────────────────────────────────────────────────── + # "false" = production mode (recommended for Marketplace installs) + # • Real SSO identity flows from Databricks Apps headers (Entra ID / Okta) + # • UC GRANT/REVOKE executed via SQL Warehouse when access is approved + # "true" = demo/POC mode + # • Persona switcher visible for demos + # • UC grants generated but not executed + - name: DEMO_MODE + value: "false" + + # ── Lakebase schema ─────────────────────────────────────────────────────── + # The Postgres schema where DataMarket tables are stored. + # Must match the schema used when running the pre-install setup script. + - name: LAKEBASE_SCHEMA + value: "datamarket" + + # ── Branding (configure post-install via Admin → Settings) ─────────────── + # These defaults are used until an admin saves custom values in the app. + # No redeployment needed — Settings are stored in Lakebase and take effect + # immediately. + - name: APP_NAME + value: "DataMarket" + - name: APP_SUBTITLE + value: "Data Discovery & Access" + +resources: + # ── Lakebase Autoscaling (required) ─────────────────────────────────────── + # Consumer must create a Lakebase project and run the pre-install setup + # script (scripts/setup_notebook.py) before installing. + - name: lakebase-db + description: > + Lakebase Autoscaling project for storing DataMarket metadata: data + product catalog, access requests, approvals, audit log, and user + library. Create a project in Compute → Lakebase, then run the + pre-install setup script to initialize the schema. + postgres: + permission: CAN_CONNECT_AND_CREATE + + # ── SQL Warehouse (optional) ────────────────────────────────────────────── + # Required for production-mode UC grant execution. Can be left unbound at + # install and configured later via Admin → Settings. + - name: sql-warehouse + description: > + SQL Warehouse used to execute Unity Catalog GRANT/REVOKE statements + when an access request is approved. Required only when DEMO_MODE=false. + Any running warehouse with CAN_USE permission works. + sql_warehouse: + permission: CAN_USE diff --git a/datamarket/package-lock.json b/datamarket/package-lock.json new file mode 100644 index 00000000..c9991a37 --- /dev/null +++ b/datamarket/package-lock.json @@ -0,0 +1,5097 @@ +{ + "name": "datamarket", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "datamarket", + "version": "1.0.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "compression": "^1.7.4", + "cors": "^2.8.5", + "express": "^4.18.2", + "helmet": "^7.1.0", + "lucide-react": "^0.294.0", + "pg": "^8.19.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.8.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.1.1", + "autoprefixer": "^10.4.16", + "concurrently": "^8.2.2", + "nodemon": "^3.0.1", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^4.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/datamarket/package.json b/datamarket/package.json new file mode 100644 index 00000000..9a8a4b5f --- /dev/null +++ b/datamarket/package.json @@ -0,0 +1,53 @@ +{ + "name": "datamarket", + "version": "1.0.0", + "description": "DataMarket — Self-Service Data Product Marketplace on Databricks", + "main": "app.js", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "start": "node app.js", + "start:dev": "concurrently \"npm run dev\" \"nodemon app.js\"", + "start:prod": "npm run build && node app.js", + "server": "node app.js", + "server:dev": "nodemon app.js", + "build:local": "vite build" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "compression": "^1.7.4", + "cors": "^2.8.5", + "express": "^4.18.2", + "helmet": "^7.1.0", + "lucide-react": "^0.294.0", + "pg": "^8.19.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.8.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.1.1", + "autoprefixer": "^10.4.16", + "concurrently": "^8.2.2", + "nodemon": "^3.0.1", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^4.5.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/datamarket/postcss.config.js b/datamarket/postcss.config.js new file mode 100644 index 00000000..e99ebc2c --- /dev/null +++ b/datamarket/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/datamarket/public/databricks-favicon.svg b/datamarket/public/databricks-favicon.svg new file mode 100644 index 00000000..bdd5c6c1 --- /dev/null +++ b/datamarket/public/databricks-favicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/datamarket/public/databricks-logo.svg b/datamarket/public/databricks-logo.svg new file mode 100644 index 00000000..cd600e48 --- /dev/null +++ b/datamarket/public/databricks-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/datamarket/public/datamarket-logo.svg b/datamarket/public/datamarket-logo.svg new file mode 100644 index 00000000..96def98a --- /dev/null +++ b/datamarket/public/datamarket-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/datamarket/public/favicon.ico b/datamarket/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..50e597badf117c1e994cdc55428240450d800d2b GIT binary patch literal 34494 zcmdU&4X9Stn#XtPXyb^6h&WDo4qit>Ld5YL8b?w@BqTy2BqAcjaY7=Th>)&yycQZE z>cl0w;&ekqLM|dsgwsT0Ce{tdBqBs4jvrV^cuyAIbG_&8J-`3+thGPh_ucP#k6(2+ zJU(l$^?a{qeeAXOc}VTCr^(~l9fr4Or7fYul{Y4d`Mk)x8J`5-LeTu zGIwsc|HmY`JUvMkFZTO;mn6v>OOxbYWI&2UuLq6j>h8_P0~>)?0W@}YL3?L+Z~7GY zG#LK$MapNuPXl<;`U-rDyL+4xL zBix?@(+iT3js~7|nv{5@6Oi#R?Oy_4alaBEo)!SloPIsFj) zE^sg9nP7ZNkAi!-zX!Y<7y`sUhq@22YZ~wtbYD>SATSBo3jCV-$zWoeDjs`)7+Y*I z{!7UCn(|ukIOv|CjNQ7=R(Uynj{0$a4{cBO3T54^Z&XHKx*U8Gx^1*iQfzclN6gaA zVA=MF->YA;1=ik^-3QMhXbu3gjH$&x@O9Jv4z$F_e3Ae3Up5Hr04jOWLPR1Ocou@m zm2{5j??r#F^phLJzZIX+^`y{cvU61<4fV*C|0#WLxi>A+&_*ApZvdwN{IBM3q%$48 z*%;^*fAz-(;00{$2cDyRSDCh58QMwkYz15Y*U_eZNiy`mN%F(zNpk$3lH`B>FG()` zYm)rtH%YSh-;(4%|2s+ki@MLhPm+{R=$e)jY#gm1rPn{%UiT`=(s3uwd_K zU^OvX2aKsS7B{W_xGe%q1_B< z?2BJxl`(I2G8V|qo>cvy`C=JZG@4H~aDN4S4*Z1b6%a19zYS#6?*q?Jr7-vJSbcBy zKKMS-hBoyB{XxIb^kdHSp^|v|~PPhq+fAc7yLh<`h8vFa&%CEDXMA z?Q*}>=JrdzY&gQb`b%+CypUskqBtIfzt`(o|DtZ@SlZMt8XK|SBx?q+4>%$oK(RRj z$R^fd)=G~9b6=|5lRo95a*`NW?kWZ`cIqFEXVwDZ-kWLrqxnAWImD0s8vp49FnOH4 zNSQvg*pq{-ow2@$`NwsfrhNr%@LNBrztnG+XkQDCbCi6N{fb2#htfHl`*$1bF->ig zX``PQ-?paN+Ec1mKBXUs)_qCw(bp&A?SzdXx@plZ~2kcLJ zQsQspI?9(n@6aCS;5ZLPn>)tHk*$-!+koFiMr3OYJ_V3J%!B4*Ixg5B$(mvl8jxwZ zs{B!0wfBg$75|}A{%-;^57?ahIJzq|f!FGwRnCy3imm0p;;!|fmPc*Svv;z+wqo`v zp#4~d2fcHt{}9mJu^+7cS%s!_Pth*5>oN>aFTng@GTuQ>H!uy@Mcoi^0$2=G_*{g` z2<2KNUgWI=*qdtY(fCd;0;_0?b3iRW4RtXD`Ku^!k&xp57w(3WLpS(aoU15>kP z=%q!;pT7BRa{gD}B-XWLDNWSPuAX0{LOM&V*$n0*)&Bfl)Z{#>zA<&jIceh&2AM}Hmy$WsI5 z(*4&ju`^C|Oab0R=e3|08|*nP*pcn!zPmGNol_z6F7&+sW}dWl^DD}lJ1y?ug}`Tk z#_d&THv_gG>|9GTC9*U}tptDV{$}4%ewMnp#%Yd?HfSw=63|@8++h32HQaYH)+)*+ z6SyCsU#)+&?(N`S`xm>Y@8*6FptaQYacZDEo;K!68}sUylhiex5xA7*f1!i^;;opI54O*nQlisysPaMWf{LAyE@IlNa(mdf$?j@=S95<6kX`41 zCn!G*KIL(;J<#*i*>fRS{kH&o3}|Vm^wTyUADJ7C<`S~jQfGY(Muzs0#L?S-q|AOl z)m_hW!{&|VHSXDB}+UJ;lKJ2NqxW&365mpM1>J=qfEv3{mop)Jk{^c>a0b5t?T zcXNMF&gbKi(=NY|F&Wy|fiOOGwv){-wJSGvLWd3M^RZqSy%sYoH^~WSE#aIAnT*Gb zINQFDHCc0iG2XbJ&HZ}-G1v2r_SmV}3E$(PzM?Kq5@v*+>2S0{9kE5^ak5Z@j zsy{UzFNCu4VijQH4a^u~f7N4WPNJpXvQ6Bt0jm#k{Z4EBb=unWOy}ox+RNC*eJ^$# z3GIq6hLMV%J#}>zE`7dI&s)c+SB&wMbEwL^r8B&2CtzoVvY&F)|2(or!?T(?^{3*+c+8##@_Cf;GhqRad8fWc3w$*+bSb@D}XS4xcY&D(oMY_>p`${9BJc;^kVDCF`pIPwh ztW(ceHrDjKwF@Y6h&h9{x2@ggFrB|ta^o@Dq+2mv0(cyKe_lLWc+6?9+F!C`K5a*k zBU!|R=Sj|H8rnZKUA@Kp!@Y88F|jL+UCtMH)&|rs$^~+w{=B@TgTA+Cw6C2vB?m0O zqfg2U$;|UV@(N?`9QfZi8+&Yx+syrl_Y&|hN3t(2_AJNHxc~_I3;%=OsnEWOe$AK4 zfql@ia{CDr2Ob?BUvwr<}MG{{<^3e+Dcq^HJ~d_cI>FpZ+qtZpaSgD4z}iUeak1G zoIn5h#DO1%9$5GDk0%Eg8aQ+N>(-gU&=vyl+idOY4 z>#$q7K+~=TZyS+d7 zl6tM1(VsKiuLPnGQF2*dxvci`eO)=q84<>gV(%XJ+P%D2(leo-J&Xaf)^LvQ`=jU! z`&pgE-0++#e>w&EX0+H;U3+}*q`ll8+PWiM#Mn8Rwr$uvNPFxH?I~N*N4^d)B*XSk z-e>4mJf8)KXUGfB^<&&G0Aehn+{hdKz89K<#_Ri5`J%H1o=Nkw{Fc0`9{N$=6a`AD8GyesnW8gg3kP$!s+-p1Tw ztzNF!h`rrTEm8TOXiDxGd{C?$-f2qp!%rrrFecUvo}YYR_GPMrWI4KDXPR_x0Q#bon^E zjDGUbo-rbiWRY9xH0sGC>qqtZ0cf?Q7o9GnheppT8gJ*IDg3ba7Ws3N@j#<~pr0&< zqWvzd=3i)Mz*Ft(Hv3^{$t}i99Mo&-Lz}`ML8NLZy`tfj<9#CsRSCKGrXP*F>A*@hJCN3y5v7%g(m#%qq&133frx8e{Kf+y?Ixt$(3!EdCX~z;n`d zn9Z`~JM=wEo95igMcoW&_d!$4BmSM*R@(A+Rgno=<(8lSRB}XT&gneb8tu_IdJN#a zHrNw!bN(py%9jvx^%HBJ=c?r9Alrb~0PSrey^m4W&tPv(U*feU;xQGuJK+s;iOEpD zC`Ys>iM*nh59|ff4YXe=^i}f5>FgUo^}YGJ3?9je7jm?3ww(CZXv}ueXX>-1)J1yJ zPrZ1^1D)4Z=1<8~PR*tLgIt#96*lq?v)B_x{h9~w0pz1-*5(bNK-*^Q@cioa?}gPw zy<)H&Xx!Ueq>i!Dyw|0^oBF-rn6K}1pT9qex`XT$(48u1Ehgt^dlG2cM^mT0K>6}` zRr6;K1d7odVsr>x*MFD)LSU$Wm(H`7^)f2kw4I5X3`?x`!#Tcmn*F#s1quBx7l!9J;CtrlqM!nJQ+z8~k{odz;@PxiMtUd5zXexZVNB$D< ziFWj~-v=COpwYbY82lBPGKji4IlbF2TQpy61sdlz@hitS(-vbC@dfHu0m?(8i0{WTEBdavqMSeS zRsKwHvf^LlPNIGXGU%fq*S{$t@AG&4(T=F2(4%JpfBydtEc;~}YY6YMd$RH~rYWTk z`g9$*pyypHjZtQx<9XWG|96l#!zxSvP^TQ%TE?1|=N98b-$FLMN2$mziOGxbOa^u! ztH@O!_peab95?@l@S5pvUcaPIeIOg7?odZR=I0KTe*6^Lh2qPN^0M)T`eDo1GaGmp zSw)Wcw-c4TRo_B0XmotvSduUGjW#fbY~L~$thw9!+2ei=Sx-76_VY#AGMn{pEIRPTxpf{(VouadS$@SO2d? z$0eXV|Lvz<&$pgywfUpHMz$W>0U*Xe`?EdxULIR9#8&Bb1jk z=;c;>AbZAG56yu2YW-#Y*gPPja7k{wZh9H}<^YGV;R9^$FtYc&6j*XBgReA3>Lwu~X~TTx5m$Kjf7^BW|Nj`>33Lgk_nO^P)rZ z-V5}9dEQgJv@g{8drn(ZF3YQw&5vIGo`k>8pzLGA$B^deXQ(TmHC1>^x?0x5ubf?p z41dPW|rl-X&?10xV-<7KRlOJ&L)PHB?9<*fg{NCInlA!NcMrv`^Gjx z+x3muV!M@*JsF)lz~!|?bJZd0=9K6oUj;)m9oP+M?-%Df<@;XZ(>)x2i*~9im-Ok1 z*zX$tMOUl+zn&4_#IE>^7`c=u+P`eVPhzb7jpA^Tg3$J=0vEuE-Ug1N-w=(=+-A4T=7VE&4kmjBT=UHTGPr*x2g*i+{iL zB0k4=3Q>R5*~*S`UAbK5jrGF1Vq3>$e7GCCv=8B|Ub&$>0ehJouY7;hiF`9;n17<* zHw|NRH$E^P!x*)5Dm(wVX*xoD%P|W8`@01xz~6NNt-TJ*B;sKJSc!Rf^Qu>*^eWN^{T9}RUSNx%5U zCJ8r#T`woeH{v@V_=Mr>2M>ww7lCh-@eTBWKN^JZ<0Sb+eBXeBzE4ICOzcPB(V*|J z^i52X!|B8U(>K^PBtGdIdtUnZcuE+rTpynPLi+H06#6nQFl2m774G>Pd}~L~5T8~J z^vPcMxi@*jk}oVfg=3tumpyJ-jH5ozqTN2w64vE^*T0zV+6Z*;ofcHL|6&}CVeC)1 z{hEJTjrh&FX=i4f3)tD_8pYzLwd(e0$IrR={2n;&apHdABy|r0w`@+jJs!5!f?w0{ zTYD(ZO2XRLh%F|Zc~zgyS_}Lusm0%MfZnfN#{aPH3tO3eCGVfgdjii3?V*1S&8_-2 z@|H&r`!&Y(48_?+^f&nLzVlq5^L)LNx4(a3-%qKTYerHP478xxdS(#0DJET z*#GB#g#MO)I`_BVL8y##>gU1p0TAYe$ZI^OsehE%RCsQ{#9P9OjotX>`$Nm=;ER`e zE^qzLIvOMYfECC&?Rik)R}7EP)(iZs;(%T3?|Ht?zq5$*oBGh6hk56nbv%~gVa`aE z_m?dWHcwg}Xng2-VK;5vF>XH<#)dK2xf<|i$T)s&3|rs53r+c)t;tl{Yx|P6srbs8 z5zp7dnD9B`3hkSK+kTddJ(}B=0VgQ?crAR^z20}|onXCha-5ezZqY?v(5?o2b(};NtfeS_)}X3BSg;`s);+xOzVo zu|7ea=78FKqfKX;Z)0EB+r++>JpJ8**25GXsq2vJDBsK456o8#TbW9mY`hP64;dx< zq8$5;r?&ajC^yK|8v7wo_+6AQqH7Ipwet;a6QJ1$zG5=Wr(D)m+I9f>dPsR3K#rB> zggD0h8<)3eo8D+^yr)fZSdNS@!7XgIcLXbFYkQ^%KmBEYcg?=MoKuo%-USuF7njjAZrdf57o%(hvo^Owl8U$ z0w~AATwU?$LL1!&O{a}@#lXH_S^{oqx9Wje0CR4LnPKAX`Fn}BwLtAyX=96IKMZ_T zm2Gy(woicvh*L}3Bo`ZI1NapC-{Nqwjn0+OUH};5oV&M=@nqV!p`RGbU+Kt!{I%as zYd2Qp-y`_;47kmDr#+@UH)w4v>xkvw;p5CUK55-Ol-tL<^Ez79ql3Ateosl8*28Df zslIg^;#}qT8Y`@aZRc#QhaZ|v7B|gLt-l$LwnD3%ejO-sI@ixPv;JGDE#pmFkpsL- zi84om(Hr+)U*Q{mH-P_*^-|H*$;FQ8@V*Ui!~B}(H1ktBzn0&}o7Ou$S2Ww;zxk>0 zR>>##KiE4$|K$%b4|pF4IhWh3dySPf_*FZ`X%Fk6zR`_#M_G;c_HC2ayZCohP2r$T zHZ2Ff074u}^87cH9}=CI!SsK$x%2*q?;c>6_g6{2blTtB*!S8kbI$bIclW$w)NeoK zu`9{(-%5IecFh+a+c=Nd`#k$SfZFf6h`F96G=F^C%y06vhUi-d{LkeNoBz;`12$k6 z?@QtHbui3v7RTSwKC7u?LIrK=pC#~q*2M4rouqx4=qyh5T?%KyrmL(EUH03_`FD9O zRYKD+7aL!+`x@=R50hQ%KlY3Uo-m!t7xP>GhO+-2`Qm7ID`Vi%+~@0IBfow#*8eWC zX{^9Q%=jK%d$>5iRQP?pYo#;RAzRNO+G7JkvzwFruv|sNmxA?BidK-djssWxXrM72Zj}YtUQ`G7gp7%VpD3_(9G$=Q}bqx6KE%R#*M@oZ&3c z_Krc&42||44?=S?_)yjB>)mqEMV>|s?=0HsueQ$( zVeU|@?OU&Wyh}Dff9Y?|%x3TByf5@^hyATV?HGqoIrBKcH#X?V=Qq=*e0u>X|NTbA z4cas}JPqHs&MO(>RX-Sh58O7NDgqmB;4O{+!hhtezx3>R#q~wK(rK^{TstR84ebkn zV=ej3hbSvOgKsOLZPSw~HlG^p#t!^Px33lI z>*Jvu>mnu|oO2YvP2MO<{e3sQo1yu!kcodTTk(7n(0I5LSON|6rTHCcgokL`4Rkjt z2o=sBzIAKnM}~e2)_#*RAIoiI75!hyDa!}-Kfbj8juL&!8GW;+{3+#(m9J2@31~XQ zq0agAF2(ww!V90Br#10KUxMB>$hp|W?{maa+8BfN>o9HZzv!!=e%MROD?b! zx)VT^-@g~zLw$E6U*kPAcLSRN?LDe;{5j$&+UnO9zF#KxueRiOJ9ahdjQ8*q{#!0t z9~`58A<%LhVk3Ur8hrrl{q6B~+j~>-w~c$$U&i<_6F$wWoWF%}k=v9q@94a(ZC|qI zvcD^=^0#^Zb$z+6bI5;!y4rDrd_8yUfR1^%Ja*M@_BY?GbvW z-Yv>@_9MDhazWml2slU~|d4G?D z+|kG*?@RAlqdwmsZ>6pNIo$F(`d`2QZ56|Eo#Kpt52Nc8*!_)T-N)A3(A*=sGT+TA z6W0mI-URsEP#JIbytJ0Krnvz=AJ=YcZU5KViNDIDd0^hbS|5jbDfGGjzaPD{-4eeW z?ZTFBY+*hQbA#E0EpFQv;N`UCws3!t7{vAw`H!A^;NJzl26&zq^U)dVv{$R0BN}bL zS@-BwpDYA3zsk3gZSL!P)XkB;+<&L*$bV#O&-)^>wU6@HD@s-hbV_~V12#*v!+h`>VyV6 zr(wrV>?-yr{yU+y=Ww0KjWi}d|IM&G2g3L;ovg2&f7==5-YCZ3ap+x7-&_X!JfF|m zc4l7tJa9d_(G&Blm=|oGKzEP6Q`P@3QhyG8Jvv+Qpo3iJ_hI~8N4g8}`RYq(*#F*G z{@3d+f0LoOOTY5M%BQjCQQ6pv|3>O06WRBpE9~WRol*ZK+MmJirstm<(c6j_TkZnZ zVap}-HRhh~57W+Ca&!6sS$f{t1F)xQ)Ti&V>^CiL`}&MsQ;5M1FgAo-FpQ1_yH-vnG~lJEPw{nX!E=J_eg*f1OLy_4;qG$vv^?3;&|@TK-VrO{@1 zwFW;0oC8X-qD=jswVfH?^l{c`OQ-kPqcwOLa1wBvZLL(<@}VEUo}{hzS(!G;SPXn- z@hkKD8hnAaRY0wbPVK!_bh3@UUIScozr%R7IZ|=px6@kue}574|Q<+ls6 zj8^^nKsqYzJKV>QAWR(U8tCFj4Y~#<4k77q-{DaylJ3ZmvoRYQJ2a|)QU9WWiGvd* zNoDHB_Djwk+1MXMd$@0l2p9EtZ9$z#x44{9ThO)fj%?!=)Qv*)*g=ysfNlL@UswNLUtBoFe&SIIek=)MO7DOLpI2dOdw}en#=h!zsivH zFWQ2C3c(i2{Pe=uEixO!x9E2qFdQku{Pskbu;d7*A$Hjouh0JAbl=Elu9va353C=9 wSR}cMqx2}Q3N>v6S#c&19{1P}#I8&A=^Z8lNhVT8s2WLK{KJ(lGUWgKKP@q)w*UYD literal 0 HcmV?d00001 diff --git a/datamarket/routes/ask-catalog.js b/datamarket/routes/ask-catalog.js new file mode 100644 index 00000000..2b1d7e1c --- /dev/null +++ b/datamarket/routes/ask-catalog.js @@ -0,0 +1,89 @@ +import { query } from '../db.js'; +import { getUcAuth } from '../databricks.js'; +import { httpsJsonRequest } from '../auth.js'; + +export function registerRoutes(app) { + // ─── Ask Catalog — FMAPI semantic search over product metadata ─────────────── + app.post('/api/portal/ask-catalog', async (req, res) => { + try { + const { question } = req.body; + if (!question?.trim()) return res.status(400).json({ error: 'question required' }); + + // Fetch published products for context + const { rows: products } = await query(` + SELECT product_ref, display_name, description, domain, type, tags, + source_system, classification, uc_full_name, owner_email + FROM data_products + WHERE is_active = TRUE AND COALESCE(status,'Published') = 'Published' + ORDER BY display_name LIMIT 60 + `); + + if (!products.length) return res.json({ matches: [], question, reason: 'no_products' }); + + // Build compact product list for the prompt + const productList = products.map(p => { + const tags = Array.isArray(p.tags) ? p.tags.join(', ') + : typeof p.tags === 'string' ? p.tags.replace(/[{}"]/g, '') : ''; + return `${p.product_ref} | ${p.display_name} | ${p.domain || 'Other'} | ${p.type || 'Dataset'} | ${(p.description || '').substring(0, 120)} | ${tags}`; + }).join('\n'); + + const prompt = `You are a data catalog assistant. A user is searching for data products. +Given the question below and the catalog of available data products, identify the 3-5 most relevant products. +For each match, write one sentence explaining why it is relevant to the user's question. + +User question: "${question}" + +Catalog (format: ref | name | domain | type | description | tags): +${productList} + +Respond with ONLY a valid JSON array, no other text: +[{"ref":"DP-001","name":"Product Name","reason":"One sentence why relevant."}] +If nothing is relevant, return: []`; + + const { host, token } = await getUcAuth(); + const fmResp = await httpsJsonRequest({ + hostname: host.replace(/^https?:\/\//, ''), + path: '/serving-endpoints/databricks-meta-llama-3-3-70b-instruct/invocations', + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ role: 'user', content: prompt }], + max_tokens: 512, + temperature: 0.1 + }), + timeoutMs: 25000 + }); + + const raw = fmResp.data?.choices?.[0]?.message?.content || '[]'; + const jsonMatch = raw.match(/\[[\s\S]*\]/); + let matches = []; + try { matches = jsonMatch ? JSON.parse(jsonMatch[0]) : []; } catch (_) {} + + // Enrich matches with full product row + const enriched = matches + .map(m => { + const p = products.find(p => p.product_ref === m.ref); + if (!p) return null; + return { + ref: m.ref, + name: p.display_name, + reason: m.reason, + domain: p.domain, + type: p.type, + classification: p.classification, + uc_full_name: p.uc_full_name, + source_system: p.source_system, + tags: Array.isArray(p.tags) ? p.tags + : typeof p.tags === 'string' ? p.tags.replace(/[{}"]/g,'').split(',').map(t=>t.trim()).filter(Boolean) + : [], + }; + }) + .filter(Boolean); + + res.json({ matches: enriched, question }); + } catch (e) { + console.error('[ask-catalog]', e.message); + res.status(500).json({ error: e.message }); + } + }); +} diff --git a/datamarket/routes/config.js b/datamarket/routes/config.js new file mode 100644 index 00000000..cc450b96 --- /dev/null +++ b/datamarket/routes/config.js @@ -0,0 +1,247 @@ +import { + query, + loadSettings, + getSetting, + invalidateSettingsCache, + DEMO_MODE, + SQL_WAREHOUSE_ID, + RFA_ENABLED, + APP_NAME, + APP_SUBTITLE, + APP_LOGO_URL, +} from '../db.js'; +import { getUcAuth, ucApiRequest, databricksApi } from '../databricks.js'; + +export function registerRoutes(app) { + // ─── Health ─────────────────────────────────────────────────────────────────── + app.get('/api/health', async (req, res) => { + let dbStatus = 'disconnected'; + try { + await query('SELECT 1'); + dbStatus = 'connected'; + } catch (e) { + dbStatus = `error: ${e.message}`; + } + res.json({ + status: 'healthy', timestamp: new Date().toISOString(), service: 'datamarket', + lakebase: dbStatus, demo_mode: DEMO_MODE, rfa_enabled: RFA_ENABLED, + uc_grants_enabled: !DEMO_MODE && !!SQL_WAREHOUSE_ID + }); + }); + + // ─── App Config (branding + mode) ──────────────────────────────────────────── + app.get('/api/portal/config', async (req, res) => { + await loadSettings(); + res.json({ + appName: getSetting('app_name', APP_NAME), + appSubtitle:getSetting('app_subtitle', APP_SUBTITLE), + appLogoUrl: getSetting('app_logo_url', APP_LOGO_URL), + demoMode: DEMO_MODE, + sqlWarehouseId: getSetting('sql_warehouse_id', SQL_WAREHOUSE_ID), + askAiEnabled: getSetting('ask_ai_enabled', 'true') !== 'false', + insightsEnabled: getSetting('insights_enabled', 'true') !== 'false', + featureRequestsEnabled: getSetting('feature_requests_enabled', 'false') === 'true', + contributeUrl: getSetting('contribute_url', ''), + searchChips: (() => { + const raw = getSetting('search_chips', ''); + if (raw) { try { return JSON.parse(raw); } catch (_) {} } + return []; + })(), + rfaEnabled: getSetting('rfa_enabled', String(RFA_ENABLED)) === 'true', + setupComplete: getSetting('setup_complete', '') === 'true', + autoDiscoverEnabled: getSetting('auto_discover_enabled', 'false') === 'true', + autoDiscoverPrefix: getSetting('auto_discover_prefix', ''), + databricksHost: process.env.DATABRICKS_HOST || '', + navLinks: (() => { + const raw = getSetting('nav_links', ''); + if (raw) { try { return JSON.parse(raw); } catch (_) {} } + return [ + { label: 'About', visible: true }, + { label: 'FAQ', visible: true }, + { label: 'Contact', visible: true }, + ]; + })(), + aboutText: getSetting('about_text', ''), + contactName: getSetting('contact_name', ''), + contactEmail: getSetting('contact_email', process.env.ADMIN_EMAIL || process.env.DATABRICKS_USER || ''), + contactNote: getSetting('contact_note', ''), + faqItems: (() => { + const raw = getSetting('faq_items', ''); + if (raw) { try { return JSON.parse(raw); } catch (_) {} } + return []; + })(), + }); + }); + + // ─── Portal Settings (admin CRUD) ──────────────────────────────────────────── + app.get('/api/portal/settings', async (req, res) => { + try { + const s = await loadSettings(); + res.json(s); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.put('/api/portal/settings', async (req, res) => { + try { + const updates = req.body; // { key: value, ... } + if (!updates || typeof updates !== 'object') return res.status(400).json({ error: 'Body must be a JSON object of {key: value}' }); + for (const [key, value] of Object.entries(updates)) { + await query( + `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, value ?? ''] + ); + } + invalidateSettingsCache(); + await loadSettings(); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── UC Access Check — catalog visibility for the app SP ───────────────────── + // Used by the onboarding wizard to surface grant gaps and generate fix SQL. + app.get('/api/portal/admin/uc-access-check', async (req, res) => { + try { + const spId = process.env.DATABRICKS_CLIENT_ID || ''; + const databricksHost = (process.env.DATABRICKS_HOST || '').replace(/\/$/, ''); + + const { host, token } = await getUcAuth(); + const catalogData = await ucApiRequest(host, token, '/api/2.1/unity-catalog/catalogs'); + const catalogs = (catalogData.catalogs || []) + .filter(c => !['system', '__databricks_internal', 'hive_metastore'].includes(c.name)); + + // For each catalog, check schemas and whether the SP can list tables in them. + const results = await Promise.all(catalogs.map(async (cat) => { + let schemas = []; + // canListSchemas = true means the API call succeeded (even if 0 user schemas returned) + // This distinguishes "no access" from "accessible but empty catalog" + let canListSchemas = false; + try { + const d = await ucApiRequest(host, token, + `/api/2.1/unity-catalog/schemas?catalog_name=${encodeURIComponent(cat.name)}`); + schemas = (d.schemas || []).filter(s => s.name !== 'information_schema'); + canListSchemas = true; + } catch { /* no access at catalog level */ } + + // Check if tables are visible in each schema + const schemaDetails = await Promise.all(schemas.map(async (sch) => { + try { + const td = await ucApiRequest(host, token, + `/api/2.1/unity-catalog/tables?catalog_name=${encodeURIComponent(cat.name)}&schema_name=${encodeURIComponent(sch.name)}&omit_columns=true&max_results=1`); + return { name: sch.name, tablesVisible: true, tableCount: (td.tables || []).length }; + } catch { + return { name: sch.name, tablesVisible: false, tableCount: 0 }; + } + })); + + const schemasNeedingGrant = schemaDetails.filter(s => !s.tablesVisible); + // Accessible if we can list schemas (even if catalog is empty) AND all visible schemas allow table listing + const accessible = canListSchemas && schemasNeedingGrant.length === 0; + return { name: cat.name, accessible, canListSchemas, schemas: schemaDetails, schemasNeedingGrant }; + })); + + // Generate catalog-level grants — USE CATALOG + SELECT ON CATALOG covers all schemas and tables, + // including any added in the future. No per-schema grants needed. + const grantLines = []; + for (const cat of results) { + if (!cat.accessible && spId) { + // 3 grants per catalog — all cascade to current and future schemas/tables: + // USE CATALOG: enter the catalog + // USE SCHEMA: enumerate schemas and tables via REST API (required for Import modal) + // SELECT: read table data (required for import and preview) + grantLines.push( + `GRANT USE CATALOG ON CATALOG \`${cat.name}\` TO \`${spId}\`;`, + `GRANT USE SCHEMA ON CATALOG \`${cat.name}\` TO \`${spId}\`;`, + `GRANT SELECT ON CATALOG \`${cat.name}\` TO \`${spId}\`;` + ); + } + } + + const needsGrant = results.filter(c => !c.accessible); + const grantSql = grantLines.join('\n'); + + const sqlEditorUrl = databricksHost ? `${databricksHost}/sql/editor` : ''; + + res.json({ + spId, + catalogs: results, + needsGrant: needsGrant.map(c => c.name), + grantSql, + sqlEditorUrl, + allAccessible: needsGrant.length === 0, + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── Auto-run UC grants for the SP across all inaccessible catalogs ────────── + app.post('/api/portal/admin/uc-run-grants', async (req, res) => { + try { + const spId = process.env.DATABRICKS_CLIENT_ID || ''; + if (!spId) return res.status(400).json({ error: 'DATABRICKS_CLIENT_ID not set — cannot determine SP identity.' }); + + const warehouseId = getSetting('sql_warehouse_id', SQL_WAREHOUSE_ID); + if (!warehouseId) return res.status(400).json({ error: 'No SQL Warehouse configured. Complete step 1 of the wizard first.' }); + + // Optionally scope to specific catalogs; default to all non-system catalogs + const { catalogs: requestedCatalogs } = req.body || {}; + + const { host, token } = await getUcAuth(); + const catalogData = await ucApiRequest(host, token, '/api/2.1/unity-catalog/catalogs'); + const allCatalogs = (catalogData.catalogs || []) + .map(c => c.name) + .filter(n => !['system', '__databricks_internal', 'hive_metastore'].includes(n)); + + const targets = requestedCatalogs?.length + ? allCatalogs.filter(n => requestedCatalogs.includes(n)) + : allCatalogs; + + const results = []; + for (const catalog of targets) { + const grants = [ + `GRANT USE CATALOG ON CATALOG \`${catalog}\` TO \`${spId}\``, + `GRANT USE SCHEMA ON CATALOG \`${catalog}\` TO \`${spId}\``, + `GRANT SELECT ON CATALOG \`${catalog}\` TO \`${spId}\``, + ]; + let success = true; + const errors = []; + for (const sql of grants) { + try { + const resp = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: warehouseId, + statement: sql, + wait_timeout: '15s', + }); + const state = resp.data?.status?.state; + if (state !== 'SUCCEEDED') { + const msg = resp.data?.status?.error?.message || state || 'UNKNOWN'; + errors.push(msg); + success = false; + } + } catch (e) { + errors.push(e.message); + success = false; + } + } + results.push({ catalog, success, errors }); + } + + res.json({ results, allSucceeded: results.every(r => r.success) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── Legacy KPI stubs (kept for backward compat) ───────────────────────────── + app.get('/api/kpis', (_, res) => res.json({ + total_revenue: { value: '$2.4M', trend: { direction: 'up', value: '+12%' } }, + total_customers: { value: '15,234', trend: { direction: 'up', value: '+8%' } }, + avg_order_value: { value: '$156', trend: { direction: 'up', value: '+5%' } }, + conversion_rate: { value: '3.2%', trend: { direction: 'down', value: '-2%' } } + })); +} diff --git a/datamarket/routes/demo.js b/datamarket/routes/demo.js new file mode 100644 index 00000000..5d5709c7 --- /dev/null +++ b/datamarket/routes/demo.js @@ -0,0 +1,123 @@ +import { query, DEMO_MODE } from '../db.js'; + +export function registerRoutes(app) { + // ─── Demo Seed ──────────────────────────────────────────────────────────────── + app.post('/api/portal/demo-seed', async (req, res) => { + if (!DEMO_MODE) { + return res.status(403).json({ error: 'Demo seed is disabled in production mode.' }); + } + try { + await query(` + INSERT INTO users (email, display_name, role, department) VALUES + ('analyst@example.org', 'Alex Analyst', 'analyst', 'Finance'), + ('datasteward@example.org', 'Dana Steward', 'admin', 'Data Governance') + ON CONFLICT (email) DO NOTHING + `); + + await query(` + INSERT INTO data_products (product_ref, uc_full_name, display_name, description, type, domain, tags, source_system, refresh_frequency, owner_email, classification, last_refreshed) VALUES + ('DP-001', 'your_catalog.your_schema.revenue_summary', + 'Revenue Summary', 'Monthly revenue aggregations by business unit and region.', + 'Dataset', 'Finance', ARRAY['revenue','monthly','kpi'], 'ERP', 'Monthly', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '5 days'), + ('DP-002', 'your_catalog.your_schema.customer_360', + 'Customer 360', 'Unified customer profile combining CRM, support, and usage data.', + 'Dataset', 'Operations', ARRAY['customer','crm','unified'], 'CRM', 'Daily', 'datasteward@example.org', 'Confidential', NOW() - INTERVAL '1 day'), + ('DP-003', 'your_catalog.your_schema.vendor_payments', + 'Vendor Payments', 'All vendor payment transactions with contract and PO references.', + 'Dataset', 'Finance', ARRAY['vendor','payments','procurement'], 'AP System', 'Weekly', 'datasteward@example.org', 'Confidential', NOW() - INTERVAL '3 days'), + ('DP-004', NULL, + 'Operational KPI Dashboard', 'Executive dashboard showing service delivery metrics across all departments.', + 'Dashboard', 'Operations', ARRAY['dashboard','kpi','executive'], 'Databricks', 'Daily', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '1 day'), + ('DP-005', 'your_catalog.your_schema.employee_headcount', + 'Employee Headcount', 'Active employee counts by department, location, and classification.', + 'Dataset', 'HR', ARRAY['hr','headcount','workforce'], 'HRIS', 'Monthly', 'datasteward@example.org', 'Confidential', NOW() - INTERVAL '15 days'), + ('DP-006', 'your_catalog.your_schema.service_requests', + 'Service Requests', 'Citizen and internal service requests with status tracking and SLA metrics.', + 'Dataset', 'Operations', ARRAY['service','requests','sla'], 'ServiceNow', 'Daily', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '1 day'), + ('DP-007', NULL, + 'Budget vs. Actuals Report', 'Automated report comparing approved budget to actual expenditure by quarter.', + 'Report', 'Finance', ARRAY['budget','report','quarterly'], 'ERP', 'Quarterly', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '45 days'), + ('DP-008', 'your_catalog.your_schema.it_asset_inventory', + 'IT Asset Inventory', 'Complete inventory of hardware, software, and cloud assets with lifecycle status.', + 'Dataset', 'IT', ARRAY['assets','inventory','it'], 'CMDB', 'Weekly', 'datasteward@example.org', 'Internal', NOW() - INTERVAL '4 days') + ON CONFLICT (product_ref) DO NOTHING + `); + + await query(` + INSERT INTO access_requests (request_ref, product_id, requester_id, requester_team, business_reason, access_level, status) + SELECT 'REQ-001', + (SELECT product_id FROM data_products WHERE product_ref = 'DP-002'), + (SELECT user_id FROM users WHERE email = 'analyst@example.org'), + 'Finance', + 'Need customer data for quarterly churn analysis report.', + 'Read Only', 'Pending' + WHERE NOT EXISTS (SELECT 1 FROM access_requests WHERE request_ref = 'REQ-001') + `); + + await query(` + INSERT INTO audit_log (event_type, actor_email, target_name, metadata) VALUES + ('REQUEST_SUBMITTED', 'analyst@example.org', 'REQ-001', + '{"productRef":"DP-002","reason":"Quarterly churn analysis"}') + ON CONFLICT DO NOTHING + `); + + const { rows: counts } = await query(` + SELECT + (SELECT COUNT(*) FROM data_products) AS products, + (SELECT COUNT(*) FROM users) AS users, + (SELECT COUNT(*) FROM access_requests) AS requests + `); + console.log('[demo-seed] Seeded:', counts[0]); + res.json({ success: true, counts: counts[0] }); + } catch (e) { + console.error('[demo-seed]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Demo Reset (admin only) ─────────────────────────────────────────────── + app.post('/api/portal/demo-reset', async (req, res) => { + if (!DEMO_MODE) { + return res.status(403).json({ error: 'Demo reset is disabled in production mode (DEMO_MODE=false).' }); + } + try { + const SEEDED_REFS = ['DP-001','DP-002','DP-003','DP-004','DP-005','DP-006', + 'DP-007','DP-008','DP-009','DP-010','DP-011','DP-012']; + + // Clear request history, audit trail, and personal libraries — NOT users or published products + await query(`DELETE FROM access_requests`); + await query(`DELETE FROM audit_log`); + await query(`DELETE FROM user_library`); + + // Remove any pending/rejected products that were added during demos (not seeded refs) + await query( + `DELETE FROM data_products + WHERE COALESCE(status,'Published') IN ('Pending','Rejected') + AND product_ref != ALL($1::text[])`, + [SEEDED_REFS] + ); + + // Re-ensure the three seed users always exist (safe upsert — never deletes anyone) + await query(` + INSERT INTO users (email, display_name, role, department) VALUES + ('analyst@example.org', 'Alex Analyst', 'analyst', 'Finance'), + ('datasteward@example.org', 'Dana Steward', 'admin', 'Data Governance') + ON CONFLICT (email) DO NOTHING + `); + + const { rows: counts } = await query(` + SELECT + (SELECT COUNT(*) FROM access_requests) AS requests, + (SELECT COUNT(*) FROM audit_log) AS audit, + (SELECT COUNT(*) FROM user_library) AS library, + (SELECT COUNT(*) FROM data_products) AS products, + (SELECT COUNT(*) FROM users) AS users + `); + console.log('[demo-reset] Demo data cleared:', counts[0]); + res.json({ success: true, counts: counts[0] }); + } catch (e) { + console.error('[demo-reset]', e.message); + res.status(500).json({ error: e.message }); + } + }); +} diff --git a/datamarket/routes/feature-requests.js b/datamarket/routes/feature-requests.js new file mode 100644 index 00000000..0bf669eb --- /dev/null +++ b/datamarket/routes/feature-requests.js @@ -0,0 +1,92 @@ +import { query, getPool } from '../db.js'; + +// ─── Caller identity helpers (SSO header or request body fallback) ──────────── +export function callerEmail(req) { + return req.headers['x-forwarded-email'] || req.headers['x-forwarded-user'] || req.body?.requester_email || 'anonymous'; +} +export function callerName(req) { + const email = callerEmail(req); + return req.body?.requester_name || email.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + +export function registerRoutes(app) { + // ─── Feature Requests (Loop 1 — data demand signal) ───────────────────────── + app.get('/api/portal/feature-requests', async (req, res) => { + try { + const email = callerEmail(req); + const { rows } = await query(` + SELECT fr.*, + COALESCE(v.voter_emails, ARRAY[]::text[]) AS voter_emails, + (fr.requester_email = $1 OR $1 = ANY(COALESCE(v.voter_emails, ARRAY[]::text[]))) AS user_voted + FROM feature_requests fr + LEFT JOIN ( + SELECT request_id, array_agg(voter_email) AS voter_emails + FROM feature_request_votes GROUP BY request_id + ) v ON v.request_id = fr.request_id + ORDER BY fr.upvotes DESC, fr.created_at DESC + `, [email]); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + app.post('/api/portal/feature-requests', async (req, res) => { + try { + const { title, description } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'title required' }); + const email = callerEmail(req); + const name = callerName(req); + const { rows: [fr] } = await query(` + INSERT INTO feature_requests (title, description, requester_email, requester_name, upvotes) + VALUES ($1, $2, $3, $4, 1) RETURNING * + `, [title.trim(), description?.trim() || null, email, name]); + // Auto-vote by requester + await query(` + INSERT INTO feature_request_votes (request_id, voter_email) VALUES ($1, $2) ON CONFLICT DO NOTHING + `, [fr.request_id, email]); + res.json(fr); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + app.post('/api/portal/feature-requests/:id/vote', async (req, res) => { + try { + const id = parseInt(req.params.id); + const email = callerEmail(req); + const pool = await getPool(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: existing } = await client.query( + `SELECT vote_id FROM feature_request_votes WHERE request_id=$1 AND voter_email=$2`, [id, email]); + let voted; + if (existing.length) { + await client.query(`DELETE FROM feature_request_votes WHERE request_id=$1 AND voter_email=$2`, [id, email]); + await client.query(`UPDATE feature_requests SET upvotes = GREATEST(0, upvotes-1), updated_at=NOW() WHERE request_id=$1`, [id]); + voted = false; + } else { + await client.query(`INSERT INTO feature_request_votes (request_id, voter_email) VALUES ($1, $2)`, [id, email]); + await client.query(`UPDATE feature_requests SET upvotes = upvotes+1, updated_at=NOW() WHERE request_id=$1`, [id]); + voted = true; + } + await client.query('COMMIT'); + return res.json({ voted }); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + app.put('/api/portal/feature-requests/:id/status', async (req, res) => { + try { + const { status } = req.body; + const allowed = ['open', 'on_roadmap', 'done', 'declined']; + if (!allowed.includes(status)) return res.status(400).json({ error: 'invalid status' }); + const { rows: [fr] } = await query( + `UPDATE feature_requests SET status=$1, updated_at=NOW() WHERE request_id=$2 RETURNING *`, + [status, parseInt(req.params.id)]); + res.json(fr); + } catch (e) { res.status(500).json({ error: e.message }); } + }); +} diff --git a/datamarket/routes/insights.js b/datamarket/routes/insights.js new file mode 100644 index 00000000..0eba3f7d --- /dev/null +++ b/datamarket/routes/insights.js @@ -0,0 +1,4 @@ +// Placeholder for future /api/portal/insights* routes + +export function registerRoutes(app) { // eslint-disable-line no-unused-vars +} diff --git a/datamarket/routes/products.js b/datamarket/routes/products.js new file mode 100644 index 00000000..c8eb80af --- /dev/null +++ b/datamarket/routes/products.js @@ -0,0 +1,793 @@ +import { + query, + getProductCols, + resetProductColsCache, + loadSettings, + getSetting, +} from '../db.js'; +import { + fetchUcSchema, + ucApiRequest, + getUcAuth, + databricksApi, +} from '../databricks.js'; + +// ─── Simple TTL cache for schema + preview (avoids hammering UC on every page load) ── +const _cache = new Map(); +function cacheGet(key) { + const entry = _cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { _cache.delete(key); return null; } + return entry.value; +} +function cacheSet(key, value, ttlMs) { + _cache.set(key, { value, expiresAt: Date.now() + ttlMs }); +} +function cacheClear(prefix = '') { + if (!prefix) { _cache.clear(); return; } + for (const k of _cache.keys()) { if (k.startsWith(prefix)) _cache.delete(k); } +} + +// ─── Background auto-discover (runs once after startup if enabled) ──────────── +export async function maybeAutoDiscover() { + try { + await loadSettings(); + if (getSetting('auto_discover_enabled', 'false') !== 'true') return; + const prefix = getSetting('auto_discover_prefix', ''); + if (!prefix) return; + const parts = prefix.split('.'); + if (parts.length < 2) return; + const [catalog, schema] = parts; + const { host, token } = await getUcAuth(); + const listData = await ucApiRequest(host, token, + `/api/2.0/unity-catalog/tables?catalog_name=${encodeURIComponent(catalog)}&schema_name=${encodeURIComponent(schema)}&max_results=200`); + const ucTables = (listData?.tables || []).filter(t => + parts[2] ? t.name?.toLowerCase().startsWith(parts[2].toLowerCase()) : true); + const { rows: existing } = await query(`SELECT uc_full_name FROM data_products WHERE uc_full_name IS NOT NULL`); + const existingSet = new Set(existing.map(r => r.uc_full_name.toLowerCase())); + const newTables = ucTables.filter(t => !existingSet.has(`${catalog}.${schema}.${t.name}`.toLowerCase())); + if (!newTables.length) return; + const { rows: [maxRow] } = await query(`SELECT MAX(CAST(SUBSTRING(product_ref FROM 4) AS INTEGER)) AS max_id FROM data_products`); + let nextId = (maxRow.max_id || 0) + 1; + for (const t of newTables) { + const fullName = `${catalog}.${schema}.${t.name}`; + const ref = `DP-${String(nextId++).padStart(3, '0')}`; + const displayName = t.name.replace(/_/g, ' ').replace(/\bgold\b/i, '').trim().replace(/\b\w/g, c => c.toUpperCase()); + await query( + `INSERT INTO data_products (product_ref, display_name, description, type, domain, tags, source_system, refresh_frequency, owner_email, classification, uc_full_name, is_active, status, last_refreshed) + VALUES ($1,$2,$3,'Dataset','Other','{}','Unity Catalog','Daily','','Internal',$4,FALSE,'Draft',NOW()) + ON CONFLICT (product_ref) DO NOTHING`, + [ref, displayName, `Auto-discovered from Unity Catalog: ${fullName}`, fullName]); + } + console.log(`[auto-discover] Drafted ${newTables.length} new table(s) from ${prefix}`); + } catch (e) { + console.warn('[auto-discover] Non-fatal:', e.message); + } +} + +export function registerRoutes(app) { + // ─── Data Products ──────────────────────────────────────────────────────────── + app.get('/api/portal/products', async (req, res) => { + try { + const { domain, type, q, includeAll } = req.query; + const cols = await getProductCols(); + + // Build SELECT — only include optional columns if they exist in this schema + const optionalCols = [ + cols.has('source_type') ? "COALESCE(source_type, source_system, 'Databricks') AS source_type" + : "COALESCE(source_system,'Databricks') AS source_type", + cols.has('product_url') ? "COALESCE(product_url,'') AS product_url" : "'' AS product_url", + cols.has('report_url') ? "COALESCE(report_url,'') AS report_url" : "'' AS report_url", + ].join(', '); + + let sql = `SELECT product_id, product_ref, uc_full_name, display_name, description, type, domain, + tags, source_system, refresh_frequency, owner_email, classification, is_active, + COALESCE(status,'Published') AS status, created_at, updated_at, last_refreshed, + ${optionalCols} + FROM data_products WHERE (is_active = TRUE OR COALESCE(status,'Published') = 'Pending')`; + + if (!includeAll) sql = sql.replace( + "(is_active = TRUE OR COALESCE(status,'Published') = 'Pending')", + "is_active = TRUE AND COALESCE(status,'Published') = 'Published'" + ); + // When includeAll=true (admin view), remove all status/active filters — show everything + if (includeAll) sql = sql.replace( + "WHERE (is_active = TRUE OR COALESCE(status,'Published') = 'Pending')", + 'WHERE TRUE' + ); + const params = []; + if (domain) { params.push(domain); sql += ` AND domain = $${params.length}`; } + if (type) { params.push(type); sql += ` AND type = $${params.length}`; } + if (q) { params.push(`%${q}%`); sql += ` AND (display_name ILIKE $${params.length} OR description ILIKE $${params.length})`; } + sql += ' ORDER BY display_name'; + + const { rows } = await query(sql, params); + res.json(rows); + } catch (e) { + console.error('[/api/portal/products]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Products debug ──────────────────────────────────────────────────────────── + app.get('/api/portal/products/debug', async (req, res) => { + try { + const cols = await getProductCols(); + const { rows: countRows } = await query('SELECT COUNT(*) as total FROM data_products'); + const { rows: sample } = await query( + `SELECT product_ref, display_name, is_active, status FROM data_products LIMIT 5` + ); + res.json({ + detected_optional_cols: [...cols], + total_products: countRows[0]?.total, + sample, + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.post('/api/portal/products', async (req, res) => { + try { + const { name, description, type, source, tags, refreshFrequency, + ownerEmail, classification, ucFullName, domain, hasPII, submittedBy, productUrl } = req.body; + if (!name || !description) return res.status(400).json({ error: 'name and description are required' }); + + // Generate a sequential ref after current max + const { rows: [maxRow] } = await query( + `SELECT MAX(CAST(SUBSTRING(product_ref FROM 4) AS INTEGER)) AS max_id FROM data_products` + ); + const nextId = (maxRow.max_id || 12) + 1; + const productRef = `DP-${String(nextId).padStart(3, '0')}`; + + const tagsArr = Array.isArray(tags) ? tags : (tags ? tags.split(',').map(t => t.trim()) : []); + + const { rows: [product] } = await query( + `INSERT INTO data_products + (product_ref, display_name, description, type, source_system, tags, + refresh_frequency, owner_email, classification, uc_full_name, domain, is_active, status, product_url) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,FALSE,'Pending',$12) + RETURNING *`, + [productRef, name, description, type || 'Dashboard', source || 'Other', + tagsArr, refreshFrequency || 'Daily', + ownerEmail || submittedBy || 'unknown@example.org', + classification || 'Internal', ucFullName || '', domain || source || 'Other', + productUrl || null] + ); + + // Best-effort audit log (non-fatal if table doesn't exist) + try { + await query( + `INSERT INTO audit_log (event_type, actor_email, target_name, metadata) + VALUES ('PRODUCT_SUBMITTED', $1, $2, $3)`, + [submittedBy || ownerEmail || 'unknown', productRef, JSON.stringify({ name, productRef })] + ); + } catch (_) {} + + res.status(201).json(product); + } catch (e) { + console.error('[POST /api/portal/products]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Get pending products (for admin review) ────────────────────────────────── + app.get('/api/portal/products/pending', async (req, res) => { + try { + const { rows } = await query( + `SELECT * FROM data_products WHERE is_active = FALSE AND status = 'Pending' ORDER BY created_at DESC` + ); + res.json(rows); + } catch (e) { + console.error('[/api/portal/products/pending]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Publish product (admin approves, makes it live in catalog) ─────────────── + app.put('/api/portal/products/:ref/publish', async (req, res) => { + try { + const { ref } = req.params; + const { reviewerEmail } = req.body; + const { rows: [product] } = await query( + `UPDATE data_products SET is_active = TRUE, status = 'Published', updated_at = NOW() + WHERE product_ref = $1 RETURNING *`, + [ref] + ); + if (!product) return res.status(404).json({ error: 'Product not found' }); + + try { + await query( + `INSERT INTO audit_log (event_type, actor_email, target_name, metadata) + VALUES ('PRODUCT_PUBLISHED', $1, $2, $3)`, + [reviewerEmail || 'admin', ref, JSON.stringify({ ref, name: product.display_name })] + ); + } catch (_) {} + res.json(product); + } catch (e) { + console.error('[PUT /api/portal/products/:ref/publish]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Reject product registration ────────────────────────────────────────────── + app.put('/api/portal/products/:ref/reject', async (req, res) => { + try { + const { ref } = req.params; + const { reviewerEmail, reason } = req.body; + const { rows: [product] } = await query( + `UPDATE data_products SET status = 'Rejected', updated_at = NOW() + WHERE product_ref = $1 RETURNING *`, + [ref] + ); + if (!product) return res.status(404).json({ error: 'Product not found' }); + + try { + await query( + `INSERT INTO audit_log (event_type, actor_email, target_name, metadata) + VALUES ('PRODUCT_REJECTED', $1, $2, $3)`, + [reviewerEmail || 'admin', ref, JSON.stringify({ ref, reason })] + ); + } catch (_) {} + res.json(product); + } catch (e) { + console.error('[PUT /api/portal/products/:ref/reject]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── UC Schema — live column metadata with synthetic fallback ──────────────── + app.get('/api/portal/products/:ref/schema', async (req, res) => { + try { + const { ref } = req.params; + + // Serve from cache if available (5 min TTL) + const cacheKey = `schema:${ref}`; + const cached = cacheGet(cacheKey); + if (cached) return res.json(cached); + + const { rows: [product] } = await query('SELECT uc_full_name, domain FROM data_products WHERE product_ref = $1', [ref]); + if (!product) return res.status(404).json({ error: 'Product not found' }); + + if (product.uc_full_name) { + // ── Step 1: UC REST API first — fast (~300ms), no warehouse needed ────── + let restColumns = []; + let tableComment = ''; + try { + const result = await databricksApi('GET', `/api/2.1/unity-catalog/tables/${product.uc_full_name}`); + restColumns = result.data?.columns || []; + tableComment = result.data?.comment || ''; + } catch (e) { + console.warn('[schema] UC REST API failed:', e.message); + } + + if (restColumns.length > 0) { + const piiPatterns = /^(ssn|social_security|dob|date_of_birth|birth_date|email|phone|address|bank_account|credit_card|salary|compensation|wage)/i; + const confPatterns = /^(cost_center|approver|budget_code|account_number|internal_id)/i; + const columns = restColumns.map(col => { + const name = col.name; + const type = (col.type_text || col.type_name || 'STRING').toUpperCase(); + const description = col.comment || ''; + let sensitivity = 'INTERNAL'; + if (piiPatterns.test(name)) sensitivity = 'PII'; + else if (confPatterns.test(name)) sensitivity = 'CONFIDENTIAL'; + const masked = sensitivity === 'PII' || sensitivity === 'CONFIDENTIAL'; + const elevatedPII = sensitivity === 'PII' && /ssn|dob|date_of_birth|birth|bank|credit_card/i.test(name); + return { name, type, description, sensitivity, masked, elevatedPII }; + }); + + // ── Step 2: Optionally enrich with UC column tags if warehouse is ready ─ + // Only attempt if warehouse is configured — runs async enrichment, doesn't + // block the response. For now return REST result immediately; tag enrichment + // is a future enhancement (requires warehouse to be already warm). + const warehouseId = getSetting('sql_warehouse_id', process.env.SQL_WAREHOUSE_ID || ''); + if (warehouseId) { + // Try warehouse enrichment with a short timeout — don't block if cold + const liveSchema = await Promise.race([ + fetchUcSchema(product.uc_full_name), + new Promise(resolve => setTimeout(() => resolve(null), 3000)), + ]); + if (liveSchema) { + const payload = { source: 'unity_catalog', uc_full_name: product.uc_full_name, columns: liveSchema, table_comment: tableComment }; + cacheSet(cacheKey, payload, 5 * 60 * 1000); + return res.json(payload); + } + } + + const payload = { source: 'unity_catalog_rest', uc_full_name: product.uc_full_name, columns, table_comment: tableComment }; + cacheSet(cacheKey, payload, 5 * 60 * 1000); + return res.json(payload); + } + } + // Fall back to signal that frontend should use its synthetic schemas + res.json({ source: 'synthetic', domain: product.domain, uc_full_name: product.uc_full_name || null }); + } catch (e) { + console.error('[/api/portal/products/:ref/schema]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── UC Sample Data — live row preview ──────────────────────────────────────── + app.get('/api/portal/products/:ref/preview', async (req, res) => { + try { + const { ref } = req.params; + + // Serve from cache if available (10 min TTL) + const cacheKey = `preview:${ref}`; + const cached = cacheGet(cacheKey); + if (cached) return res.json(cached); + + // Reload settings so a newly configured warehouse ID is picked up without restart + await loadSettings(); + const warehouseId = getSetting('sql_warehouse_id', process.env.SQL_WAREHOUSE_ID || ''); + const { rows: [product] } = await query('SELECT uc_full_name FROM data_products WHERE product_ref = $1', [ref]); + if (!product) return res.status(404).json({ error: 'Product not found' }); + if (!product.uc_full_name) return res.json({ source: 'synthetic', rows: [], columns: [] }); + if (!warehouseId) return res.json({ source: 'no_warehouse', rows: [], columns: [] }); + + const result = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: warehouseId, + statement: `SELECT * FROM ${product.uc_full_name} LIMIT 5`, + wait_timeout: '15s' + }); + + if (result.data?.status?.state !== 'SUCCEEDED') { + return res.json({ source: 'error', rows: [], columns: [], error: result.data?.status?.error?.message }); + } + + const schema = result.data.manifest?.schema?.columns || []; + const columns = schema.map(c => c.name); + const rows = result.data.result?.data_array || []; + const payload = { source: 'unity_catalog', columns, rows }; + cacheSet(cacheKey, payload, 10 * 60 * 1000); + res.json(payload); + } catch (e) { + console.error('[/api/portal/products/:ref/preview]', e.message); + res.status(500).json({ source: 'error', rows: [], columns: [], error: e.message }); + } + }); + + // ─── Admin: Product inline update ───────────────────────────────────────────── + app.delete('/api/portal/products/:ref', async (req, res) => { + try { + const { ref } = req.params; + // Delete dependent rows first, then the product + await query(`DELETE FROM access_requests WHERE product_id IN (SELECT product_id FROM data_products WHERE product_ref = $1)`, [ref]); + await query(`DELETE FROM audit_log WHERE target_name = $1`, [ref]); + const { rows: [deleted] } = await query( + `DELETE FROM data_products WHERE product_ref = $1 RETURNING product_ref, display_name`, [ref]); + if (!deleted) return res.status(404).json({ error: 'Product not found' }); + cacheClear(`schema:${ref}`); + cacheClear(`preview:${ref}`); + res.json({ deleted: true, ref: deleted.product_ref, name: deleted.display_name }); + } catch (e) { + console.error('[DELETE /api/portal/products/:ref]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + app.put('/api/portal/products/:ref', async (req, res) => { + try { + const { ref } = req.params; + const { + uc_full_name, source_type, refresh_frequency, report_url, domain, + description, display_name, owner_email, data_classification, tags + } = req.body; + const sets = []; + const params = []; + if (uc_full_name !== undefined) { params.push(uc_full_name); sets.push(`uc_full_name = $${params.length}`); } + if (source_type !== undefined) { params.push(source_type); sets.push(`source_system = $${params.length}`); } + if (refresh_frequency !== undefined) { params.push(refresh_frequency); sets.push(`refresh_frequency = $${params.length}`); } + if (report_url !== undefined) { params.push(report_url); sets.push(`report_url = $${params.length}`); } + if (domain !== undefined) { params.push(domain); sets.push(`domain = $${params.length}`); } + if (description !== undefined) { params.push(description); sets.push(`description = $${params.length}`); } + if (display_name !== undefined) { params.push(display_name); sets.push(`display_name = $${params.length}`); } + if (owner_email !== undefined) { params.push(owner_email); sets.push(`owner_email = $${params.length}`); } + if (data_classification !== undefined) { params.push(data_classification); sets.push(`data_classification = $${params.length}`); } + if (tags !== undefined) { params.push(tags); sets.push(`tags = $${params.length}`); } + if (sets.length === 0) return res.status(400).json({ error: 'Nothing to update' }); + sets.push('updated_at = NOW()'); + params.push(ref); + const { rows: [product] } = await query( + `UPDATE data_products SET ${sets.join(', ')} WHERE product_ref = $${params.length} RETURNING *`, params); + if (!product) return res.status(404).json({ error: 'Product not found' }); + res.json(product); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── Admin: UC Catalog Browser ──────────────────────────────────────────────── + // Returns already-registered UC table names so the frontend can mark them. + app.get('/api/portal/admin/uc-registered', async (req, res) => { + try { + const { rows } = await query( + `SELECT uc_full_name FROM data_products WHERE uc_full_name IS NOT NULL AND uc_full_name != ''`); + res.json({ names: rows.map(r => r.uc_full_name) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // Local-dev proxy: forwards UC API calls as the SP (used when databricksHost isn't + // available in the browser, e.g. running locally). Not used in Databricks Apps. + app.get('/api/portal/admin/uc-proxy', async (req, res) => { + try { + const path = req.query.path; + if (!path || !path.startsWith('/api/2.1/unity-catalog/')) { + return res.status(400).json({ error: 'Invalid path' }); + } + const { host, token } = await getUcAuth(); + const data = await ucApiRequest(host, token, path); + res.json(data); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.get('/api/portal/admin/uc-catalogs', async (req, res) => { + try { + const { host, token } = await getUcAuth(); + const data = await ucApiRequest(host, token, '/api/2.1/unity-catalog/catalogs'); + const catalogs = (data.catalogs || []).map(c => ({ name: c.name, comment: c.comment })); + res.json({ catalogs }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.get('/api/portal/admin/uc-schemas', async (req, res) => { + try { + const { catalog } = req.query; + if (!catalog) return res.status(400).json({ error: 'catalog param required' }); + const { host, token } = await getUcAuth(); + const data = await ucApiRequest(host, token, + `/api/2.1/unity-catalog/schemas?catalog_name=${encodeURIComponent(catalog)}`); + const schemas = (data.schemas || []) + .filter(s => s.name !== 'information_schema') + .map(s => ({ name: s.name, full_name: s.full_name, comment: s.comment })); + res.json({ schemas }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.get('/api/portal/admin/uc-tables-browse', async (req, res) => { + try { + const { catalog, schema } = req.query; + if (!catalog || !schema) return res.status(400).json({ error: 'catalog and schema params required' }); + + const { rows: existing } = await query( + `SELECT uc_full_name FROM data_products WHERE uc_full_name IS NOT NULL AND uc_full_name != ''`); + const registered = new Set(existing.map(r => r.uc_full_name)); + + // Internal Databricks system table prefixes to exclude from the Import modal + const INTERNAL_TABLE_PREFIXES = ['__materialization_mat_', '__apply_changes_', 'event_log_', '__dlt_']; + const isInternalTable = name => INTERNAL_TABLE_PREFIXES.some(p => name.startsWith(p)); + + let tables = []; + + const warehouseId = getSetting('sql_warehouse_id', ''); + if (warehouseId) { + // Primary: query information_schema.tables — works with USE SCHEMA + USE CATALOG only + try { + const stmt = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: warehouseId, + statement: `SELECT table_name, table_type FROM \`${catalog}\`.information_schema.tables WHERE table_schema = '${schema}' ORDER BY table_name`, + wait_timeout: '15s', + }); + if (stmt.data?.status?.state === 'SUCCEEDED') { + const rows = stmt.data.result?.data_array || []; + // rows: [table_name, table_type] + tables = rows + .filter(r => !isInternalTable(r[0])) + .map(r => { + const tName = r[0]; + const full = `${catalog}.${schema}.${tName}`; + return { full_name: full, table_name: tName, schema_name: schema, catalog_name: catalog, table_type: r[1], registered: registered.has(full) }; + }); + } + } catch (e) { + console.warn('[uc-tables-browse] information_schema query failed:', e.message); + } + + // Secondary: SHOW TABLES (requires SELECT on tables) + if (tables.length === 0) { + try { + const stmt = await databricksApi('POST', '/api/2.0/sql/statements', { + warehouse_id: warehouseId, + statement: `SHOW TABLES IN \`${catalog}\`.\`${schema}\``, + wait_timeout: '15s', + }); + if (stmt.data?.status?.state === 'SUCCEEDED') { + const rows = stmt.data.result?.data_array || []; + tables = rows + .filter(r => !isInternalTable(r[1] || r[0])) + .map(r => { + const tName = r[1] || r[0]; + const full = `${catalog}.${schema}.${tName}`; + return { full_name: full, table_name: tName, schema_name: schema, catalog_name: catalog, registered: registered.has(full) }; + }); + } + } catch (e) { + console.warn('[uc-tables-browse] SHOW TABLES failed:', e.message); + } + } + } + + // Final fallback: UC REST API + if (tables.length === 0) { + try { + const { host, token } = await getUcAuth(); + const data = await ucApiRequest(host, token, + `/api/2.1/unity-catalog/tables?catalog_name=${encodeURIComponent(catalog)}&schema_name=${encodeURIComponent(schema)}`); + tables = (data.tables || []) + .filter(t => !isInternalTable(t.name)) + .map(t => ({ + full_name: t.full_name, table_name: t.name, + schema_name: schema, catalog_name: catalog, + table_type: t.table_type, comment: t.comment, + registered: registered.has(t.full_name), + })); + } catch (_) {} + } + + // If still empty, guide the user to grant at catalog level — covers all schemas/tables now and future + let needsGrant = false; + let grantSql = ''; + let sqlEditorUrl = ''; + if (tables.length === 0) { + const spId = process.env.DATABRICKS_CLIENT_ID || ''; + if (spId) { + needsGrant = true; + const databricksHost = (process.env.DATABRICKS_HOST || '').replace(/\/$/, ''); + grantSql = `GRANT USE CATALOG ON CATALOG \`${catalog}\` TO \`${spId}\`;\nGRANT USE SCHEMA ON CATALOG \`${catalog}\` TO \`${spId}\`;\nGRANT SELECT ON CATALOG \`${catalog}\` TO \`${spId}\`;`; + sqlEditorUrl = databricksHost ? `${databricksHost}/sql/editor` : ''; + } + } + + res.json({ tables, needsGrant, grantSql, sqlEditorUrl }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── Admin: UC Table Discovery ──────────────────────────────────────────────── + app.get('/api/portal/admin/uc-tables', async (req, res) => { + try { + // Get already-registered UC table names + const { rows: existing } = await query( + `SELECT uc_full_name FROM data_products WHERE uc_full_name IS NOT NULL AND uc_full_name != ''`); + const registered = new Set(existing.map(r => r.uc_full_name)); + + // If a warehouse is configured, always query real UC tables (even in demo mode) + if (SQL_WAREHOUSE_ID) { + const schema = await fetchUcSchema('information_schema.tables'); + if (schema) return res.json({ source: 'unity_catalog', tables: schema.filter(t => !registered.has(t.full_name)) }); + } + + // No warehouse configured — return generic placeholder tables so the UI isn't empty + const demoTables = [ + { full_name: 'your_catalog.your_schema.sales_transactions', table_name: 'sales_transactions', schema_name: 'your_schema', catalog_name: 'your_catalog' }, + { full_name: 'your_catalog.your_schema.customer_profiles', table_name: 'customer_profiles', schema_name: 'your_schema', catalog_name: 'your_catalog' }, + { full_name: 'your_catalog.your_schema.product_inventory', table_name: 'product_inventory', schema_name: 'your_schema', catalog_name: 'your_catalog' }, + { full_name: 'your_catalog.your_schema.employee_records', table_name: 'employee_records', schema_name: 'your_schema', catalog_name: 'your_catalog' }, + { full_name: 'your_catalog.your_schema.financial_ledger', table_name: 'financial_ledger', schema_name: 'your_schema', catalog_name: 'your_catalog' }, + ]; + res.json({ source: 'demo_placeholder', tables: demoTables.filter(t => !registered.has(t.full_name)), registered: existing.map(r => r.uc_full_name) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.post('/api/portal/admin/import-uc', async (req, res) => { + try { + const { tables } = req.body; + if (!Array.isArray(tables) || tables.length === 0) return res.status(400).json({ error: 'tables array required' }); + + const { rows: [maxRow] } = await query( + `SELECT MAX(CAST(SUBSTRING(product_ref FROM 4) AS INTEGER)) AS max_id FROM data_products`); + let nextId = (maxRow.max_id || 16) + 1; + + // Domain inference from catalog/schema name — broadens left-nav filter options + const inferDomain = (catalogName = '', schemaName = '') => { + const s = `${catalogName} ${schemaName}`.toLowerCase(); + if (/finance|budget|payroll|revenue|billing|accounting|expenditure/.test(s)) return 'Finance'; + if (/hr|human.resource|employee|headcount|workforce|hris/.test(s)) return 'HR'; + if (/health|clinical|patient|medical|pharma/.test(s)) return 'Healthcare'; + if (/fire|police|ems|safety|emergency|incident|911/.test(s)) return 'Public Safety'; + if (/sales|customer|crm|marketing|retail|ecommerce/.test(s)) return 'Sales & Marketing'; + if (/geo|gis|spatial|map|location|address/.test(s)) return 'Geospatial'; + if (/weather|forecast|climate|accuweather/.test(s)) return 'Weather'; + if (/tax|property|assessment|parcel/.test(s)) return 'Property Tax'; + if (/supply|inventory|logistics|warehouse|procurement/.test(s)) return 'Operations'; + if (/nyctaxi|transit|transport|trip|vehicle/.test(s)) return 'Transportation'; + if (/bakehouse|restaurant|food|hospitality|hotel|wander/.test(s)) return 'Hospitality'; + if (/audit|compliance|risk|governance/.test(s)) return 'Compliance'; + return 'Other'; + }; + + const { host, token } = await getUcAuth(); + // Clear any cached synthetic schema for tables about to be imported + cacheClear('schema:'); + + const imported = []; + + for (const t of tables) { + const ref = `DP-${String(nextId++).padStart(3, '0')}`; + const parts = (t.full_name || '').split('.'); + const catalogName = parts[0] || ''; + const schemaName = parts[1] || ''; + const tableName = parts[2] || t.table_name || ''; + + const displayName = (tableName || t.full_name.split('.').pop()) + .replace(/_/g, ' ').replace(/\bgold\b/i, '').trim() + .replace(/\b\w/g, c => c.toUpperCase()); + + // ── Enrich from UC REST API ────────────────────────────────────────── + let ucComment = ''; + let ucTags = []; + let ucOwner = ''; + let ucUpdatedAt = null; + let hasPii = false; + let hasConf = false; + try { + const ucMeta = await ucApiRequest(host, token, + `/api/2.1/unity-catalog/tables/${encodeURIComponent(t.full_name)}`); + ucComment = ucMeta?.comment || ''; + ucOwner = ucMeta?.owner || ''; + ucUpdatedAt = ucMeta?.updated_at ? new Date(ucMeta.updated_at).toISOString() : null; + + // UC tags are a key-value map — use the keys as tags (values often empty) + const rawTags = ucMeta?.tags || {}; + ucTags = Object.keys(rawTags).filter(k => k && k.trim()); + + // Infer sensitivity from column names using same patterns as schema panel + const piiPatterns = /^(ssn|social_security|dob|date_of_birth|birth_date|email|phone|address|bank_account|credit_card|salary|compensation|wage)/i; + const confPatterns = /^(cost_center|approver|budget_code|account_number|internal_id)/i; + const cols = ucMeta?.columns || []; + hasPii = cols.some(c => piiPatterns.test(c.name || '')); + hasConf = !hasPii && cols.some(c => confPatterns.test(c.name || '')); + } catch (_) { /* non-fatal — proceed without enrichment */ } + + // Build final tag array: domain + sensitivity + existing UC tags + // Intentionally excludes catalog/schema names (already structured fields) + // and 'UC Import' (source method, not a discovery attribute) + const domain = t.domain || inferDomain(catalogName, schemaName); + const tagSet = new Set(); + if (domain && domain !== 'Other') tagSet.add(domain); + if (hasPii) tagSet.add('Contains PII'); + if (hasConf) tagSet.add('Confidential'); + ucTags.forEach(tag => tagSet.add(tag)); + if (tagSet.size === 0) tagSet.add('Dataset'); + const finalTags = `{${[...tagSet].map(tag => `"${tag.replace(/"/g, '')}"`).join(',')}}`; + + const description = ucComment || t.description || `Imported from Unity Catalog: ${t.full_name}`; + const ownerEmail = ucOwner || t.owner_email || 'datasteward@example.org'; + + const { rows: [product] } = await query( + `INSERT INTO data_products + (product_ref, display_name, description, type, domain, tags, source_system, + refresh_frequency, owner_email, classification, uc_full_name, is_active, status, + last_refreshed) + VALUES ($1, $2, $3, 'Dataset', $4, $5, 'Unity Catalog', 'Daily', + $6, 'Internal', $7, TRUE, 'Published', COALESCE($8, NOW())) + ON CONFLICT (product_ref) DO NOTHING RETURNING *`, + [ref, t.display_name || displayName, description, + domain, finalTags, + ownerEmail, t.full_name, ucUpdatedAt]); + if (product) imported.push(product); + } + res.json({ imported: imported.length, products: imported }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── Sync UC metadata (last_refreshed + mark unavailable) ──────────────────── + // Fetches updated_at from UC for each product that has a uc_full_name. + // Also marks products whose UC table no longer exists as status='Unavailable'. + app.post('/api/portal/admin/sync-uc-metadata', async (req, res) => { + try { + const { host, token } = await getUcAuth(); + const { rows: products } = await query( + `SELECT product_id, product_ref, uc_full_name FROM data_products + WHERE uc_full_name IS NOT NULL AND uc_full_name NOT LIKE 'your_catalog.%'` + ); + if (!products.length) return res.json({ synced: 0, unavailable: 0 }); + + let synced = 0, unavailable = 0; + for (const p of products) { + try { + const ucMeta = await ucApiRequest(host, token, + `/api/2.0/unity-catalog/tables/${encodeURIComponent(p.uc_full_name)}`); + + if (ucMeta?.error_code === 'TABLE_DOES_NOT_EXIST' || ucMeta?.error_code === 'NOT_FOUND') { + await query( + `UPDATE data_products SET status = 'Unavailable', updated_at = NOW() WHERE product_id = $1`, + [p.product_id]); + unavailable++; + } else { + // updated_at from UC is epoch ms + const updatedAt = ucMeta?.updated_at + ? new Date(ucMeta.updated_at).toISOString() + : new Date().toISOString(); + await query( + `UPDATE data_products SET last_refreshed = $1, updated_at = NOW() WHERE product_id = $2`, + [updatedAt, p.product_id]); + synced++; + } + } catch (_) { /* skip individual failures */ } + } + // Bust product column cache so next fetch rebuilds + resetProductColsCache(); + console.log(`[sync-uc] synced=${synced} unavailable=${unavailable}`); + res.json({ synced, unavailable, total: products.length }); + } catch (e) { + console.error('[sync-uc-metadata]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Auto-discover new UC tables ───────────────────────────────────────────── + // Scans a catalog/schema prefix from settings (auto_discover_prefix) and imports + // any tables not yet in data_products as status='Draft' for admin review. + app.post('/api/portal/admin/discover-uc', async (req, res) => { + try { + const prefix = getSetting('auto_discover_prefix', '') || req.body?.prefix || ''; + if (!prefix) return res.status(400).json({ error: 'auto_discover_prefix not configured in Settings' }); + + const parts = prefix.split('.'); + if (parts.length < 2) return res.status(400).json({ error: 'prefix must be catalog.schema or catalog.schema.prefix' }); + const [catalog, schema] = parts; + + const { host, token } = await getUcAuth(); + const listData = await ucApiRequest(host, token, + `/api/2.0/unity-catalog/tables?catalog_name=${encodeURIComponent(catalog)}&schema_name=${encodeURIComponent(schema)}&max_results=200`); + + const ucTables = (listData?.tables || []).filter(t => { + if (parts[2]) return t.name?.toLowerCase().startsWith(parts[2].toLowerCase()); + return true; + }); + + // Find which uc_full_names are already in data_products + const { rows: existing } = await query( + `SELECT uc_full_name FROM data_products WHERE uc_full_name IS NOT NULL`); + const existingSet = new Set(existing.map(r => r.uc_full_name.toLowerCase())); + + const newTables = ucTables.filter(t => + !existingSet.has(`${catalog}.${schema}.${t.name}`.toLowerCase())); + + if (!newTables.length) return res.json({ discovered: 0, message: 'No new tables found' }); + + // Get next product_ref + const { rows: [maxRow] } = await query( + `SELECT MAX(CAST(SUBSTRING(product_ref FROM 4) AS INTEGER)) AS max_id FROM data_products`); + let nextId = (maxRow.max_id || 0) + 1; + + const drafted = []; + for (const t of newTables) { + const fullName = `${catalog}.${schema}.${t.name}`; + const ref = `DP-${String(nextId++).padStart(3, '0')}`; + const displayName = t.name.replace(/_/g, ' ').replace(/\bgold\b/i, '').trim() + .replace(/\b\w/g, c => c.toUpperCase()); + const { rows: [product] } = await query( + `INSERT INTO data_products + (product_ref, display_name, description, type, domain, tags, source_system, + refresh_frequency, owner_email, classification, uc_full_name, is_active, status, last_refreshed) + VALUES ($1, $2, $3, 'Dataset', 'Other', '{}', 'Unity Catalog', 'Daily', + '', 'Internal', $4, FALSE, 'Draft', NOW()) + ON CONFLICT (product_ref) DO NOTHING RETURNING *`, + [ref, displayName, + `Auto-discovered from Unity Catalog: ${fullName}`, fullName]); + if (product) drafted.push(product); + } + + console.log(`[discover-uc] ${drafted.length} new tables drafted from ${prefix}`); + res.json({ discovered: drafted.length, products: drafted }); + } catch (e) { + console.error('[discover-uc]', e.message); + res.status(500).json({ error: e.message }); + } + }); +} diff --git a/datamarket/routes/requests.js b/datamarket/routes/requests.js new file mode 100644 index 00000000..b9b7de7f --- /dev/null +++ b/datamarket/routes/requests.js @@ -0,0 +1,318 @@ +import { query } from '../db.js'; +import { rfaNotify, executeUcStatement } from '../databricks.js'; + +export function registerRoutes(app) { + // ─── Access Requests ────────────────────────────────────────────────────────── + app.get('/api/portal/requests', async (req, res) => { + try { + const { email, status } = req.query; + let sql = `SELECT ar.request_id, ar.request_ref, ar.status, ar.business_reason, ar.access_level, + ar.requested_at, ar.resolved_at, ar.expires_at, ar.denial_reason, ar.uc_grant_sql, + dp.product_ref, dp.display_name AS product_name, dp.domain, dp.type AS product_type, + u.email AS requester_email, u.display_name AS requester_name, u.department AS requester_team + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id`; + const params = []; + const where = []; + if (email) { params.push(email); where.push(`u.email = $${params.length}`); } + if (status) { params.push(status); where.push(`ar.status = $${params.length}`); } + if (where.length) sql += ' WHERE ' + where.join(' AND '); + sql += ' ORDER BY ar.requested_at DESC'; + const { rows } = await query(sql, params); + res.json(rows); + } catch (e) { + console.error('[/api/portal/requests]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + app.get('/api/portal/requests/pending', async (req, res) => { + try { + const { rows } = await query(` + SELECT ar.request_id, ar.request_ref, ar.status, ar.business_reason, ar.access_level, + ar.requested_at, dp.product_ref, dp.display_name AS product_name, dp.domain, + dp.uc_full_name, u.email AS requester_email, u.display_name AS requester_name, + u.department AS requester_team + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id + WHERE ar.status = 'Pending' + ORDER BY ar.requested_at DESC`); + res.json(rows); + } catch (e) { + console.error('[/api/portal/requests/pending]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + app.post('/api/portal/requests', async (req, res) => { + try { + const { productRef, requesterEmail, team, reason, accessLevel = 'Read Only' } = req.body; + if (!productRef || !requesterEmail || !reason) { + return res.status(400).json({ error: 'productRef, requesterEmail, and reason are required' }); + } + + const { rows: [product] } = await query('SELECT product_id FROM data_products WHERE product_ref = $1', [productRef]); + if (!product) return res.status(404).json({ error: `Product ${productRef} not found` }); + + // Upsert user — creates a row if this email hasn't been seen before + const { rows: [user] } = await query(` + INSERT INTO users (email, display_name, role, department) + VALUES ($1, $2, 'analyst', 'General') + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email + RETURNING user_id, department`, + [requesterEmail, requesterEmail.split('@')[0].replace('.', ' ').replace(/\b\w/g, c => c.toUpperCase())] + ); + + const { rows: [{ count }] } = await query('SELECT COUNT(*) FROM access_requests', []); + const ref = `REQ-${String(parseInt(count) + 1).padStart(3, '0')}`; + + // Default 90-day expiry from approval date (set on approve, not submit) + const { rows: [newReq] } = await query(` + INSERT INTO access_requests (request_ref, product_id, requester_id, requester_team, business_reason, access_level, status) + VALUES ($1, $2, $3, $4, $5, $6, 'Pending') + RETURNING *`, [ref, product.product_id, user.user_id, team || user.department, reason, accessLevel]); + + await query(`INSERT INTO audit_log (event_type, actor_email, target_type, target_id, target_name, metadata) + VALUES ('REQUEST_SUBMITTED', $1, 'access_request', $2, $3, $4)`, + [requesterEmail, newReq.request_id, ref, JSON.stringify({ productRef, reason })]); + + // Fire RFA notification (best-effort, non-blocking) + const { rows: [prod] } = await query('SELECT uc_full_name FROM data_products WHERE product_ref = $1', [productRef]); + const rfaResult = await rfaNotify(prod?.uc_full_name, requesterEmail, reason); + + res.json({ ...newReq, request_ref: ref, rfa_notified: !!rfaResult }); + } catch (e) { + console.error('[POST /api/portal/requests]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + app.put('/api/portal/requests/:id/approve', async (req, res) => { + try { + const { id } = req.params; + const { adminEmail = 'datasteward@example.org' } = req.body; + + // id can be a UUID or a request_ref like "REQ-001" — avoid type mismatch + const isUUID = /^[0-9a-f-]{36}$/i.test(id); + const { rows: [req_] } = await query(` + SELECT ar.*, dp.uc_full_name, dp.display_name AS product_name, u.email AS requester_email + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id + WHERE ${isUUID ? 'ar.request_id = $1::uuid' : 'ar.request_ref = $1'}`, [id]); + if (!req_) return res.status(404).json({ error: 'Request not found' }); + + const ucGrantSql = req_.uc_full_name + ? `GRANT SELECT ON ${req_.uc_full_name} TO \`${req_.requester_email}\`;` + : `-- No UC table linked for ${req_.product_name} (set uc_full_name on the product to enable automatic grants)`; + + const { rows: [adminUser] } = await query('SELECT user_id FROM users WHERE email = $1', [adminEmail]); + + await query(`UPDATE access_requests SET status = 'Approved', resolved_at = NOW(), + resolved_by = $1, uc_grant_issued = TRUE, uc_grant_sql = $2, updated_at = NOW(), + expires_at = NOW() + INTERVAL '90 days' + WHERE request_ref = $3`, + [adminUser?.user_id || null, ucGrantSql, req_.request_ref]); + + // Execute the real GRANT in UC (skipped in demo mode) + const grantResult = await executeUcStatement(ucGrantSql); + + try { + await query(`INSERT INTO audit_log (event_type, actor_email, target_type, target_id, target_name, metadata) + VALUES ('REQUEST_APPROVED', $1, 'access_request', $2, $3, $4)`, + [adminEmail, req_.request_id, req_.request_ref, JSON.stringify({ ucGrantSql, uc_executed: grantResult.executed })]); + } catch (_) {} + + res.json({ status: 'Approved', uc_grant_sql: ucGrantSql, uc_executed: grantResult.executed }); + } catch (e) { + console.error('[PUT approve]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + app.put('/api/portal/requests/:id/deny', async (req, res) => { + try { + const { id } = req.params; + const { adminEmail = 'datasteward@example.org', reason = '' } = req.body; + + const isUUID = /^[0-9a-f-]{36}$/i.test(id); + const { rows: [req_] } = await query( + `SELECT request_id, request_ref FROM access_requests WHERE ${isUUID ? 'request_id = $1::uuid' : 'request_ref = $1'}`, [id]); + if (!req_) return res.status(404).json({ error: 'Request not found' }); + + const { rows: [adminUser] } = await query('SELECT user_id FROM users WHERE email = $1', [adminEmail]); + + await query(`UPDATE access_requests SET status = 'Denied', resolved_at = NOW(), + resolved_by = $1, denial_reason = $2, updated_at = NOW() + WHERE request_ref = $3`, + [adminUser?.user_id || null, reason, req_.request_ref]); + + try { + await query(`INSERT INTO audit_log (event_type, actor_email, target_type, target_id, target_name, metadata) + VALUES ('REQUEST_DENIED', $1, 'access_request', $2, $3, $4)`, + [adminEmail, req_.request_id, req_.request_ref, JSON.stringify({ reason })]); + } catch (_) {} + + res.json({ status: 'Denied' }); + } catch (e) { + console.error('[PUT deny]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Revoke access ──────────────────────────────────────────────────────────── + app.put('/api/portal/requests/:id/revoke', async (req, res) => { + try { + const { id } = req.params; + const { adminEmail = 'datasteward@example.org', reason = 'Access revoked by administrator' } = req.body; + const isUUID = /^[0-9a-f-]{36}$/i.test(id); + const { rows: [req_] } = await query( + `SELECT request_id, request_ref, uc_grant_sql FROM access_requests + WHERE ${isUUID ? 'request_id = $1::uuid' : 'request_ref = $1'}`, [id]); + if (!req_) return res.status(404).json({ error: 'Request not found' }); + + // Generate REVOKE SQL mirroring the original GRANT + const revokeSql = req_.uc_grant_sql + ? req_.uc_grant_sql.replace(/^GRANT/, 'REVOKE').replace(/ TO /, ' FROM ') + : `-- No UC table linked; manual revocation required`; + + await query(`UPDATE access_requests SET status = 'Revoked', resolved_at = NOW(), + denial_reason = $1, uc_grant_sql = $2, updated_at = NOW() + WHERE request_ref = $3`, + [reason, revokeSql, req_.request_ref]); + + // Execute the real REVOKE in UC (skipped in demo mode) + const revokeResult = await executeUcStatement(revokeSql); + + try { + await query(`INSERT INTO audit_log (event_type, actor_email, target_name, metadata) + VALUES ('ACCESS_REVOKED', $1, $2, $3)`, + [adminEmail, req_.request_ref, JSON.stringify({ reason, revokeSql, uc_executed: revokeResult.executed })]); + } catch (_) {} + + res.json({ status: 'Revoked', revoke_sql: revokeSql, uc_executed: revokeResult.executed }); + } catch (e) { + console.error('[PUT revoke]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Granted access for a product (admin revoke UI) ─────────────────────────── + app.get('/api/portal/products/:ref/granted-access', async (req, res) => { + try { + const { ref } = req.params; + const { rows } = await query(` + SELECT ar.request_ref, ar.requested_at, ar.resolved_at, ar.expires_at, + u.email AS requester_email, u.display_name AS requester_name + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id + WHERE dp.product_ref = $1 AND ar.status = 'Approved' + ORDER BY ar.resolved_at DESC`, [ref]); + res.json(rows); + } catch (e) { + console.error('[GET granted-access]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Get notifications for a user ───────────────────────────────────────────── + app.get('/api/portal/notifications', async (req, res) => { + try { + const { email } = req.query; + if (!email) return res.json([]); + const { rows } = await query(` + SELECT ar.request_ref, ar.status, ar.resolved_at, ar.expires_at, ar.denial_reason, + dp.display_name AS product_name, dp.product_ref + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id + WHERE u.email = $1 + AND ar.status IN ('Approved','Denied','Revoked') + AND ar.resolved_at > NOW() - INTERVAL '7 days' + ORDER BY ar.resolved_at DESC + LIMIT 10`, [email]); + // Also include requests expiring within 14 days + const { rows: expiring } = await query(` + SELECT ar.request_ref, 'Expiring' AS status, ar.expires_at, ar.resolved_at, + dp.display_name AS product_name, dp.product_ref + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id + WHERE u.email = $1 + AND ar.status = 'Approved' + AND ar.expires_at < NOW() + INTERVAL '14 days' + ORDER BY ar.expires_at ASC + LIMIT 5`, [email]); + res.json([...expiring, ...rows]); + } catch (e) { + console.error('[/api/portal/notifications]', e.message); + res.json([]); + } + }); + + // ─── Nudge Approver ─────────────────────────────────────────────────────────── + app.post('/api/portal/requests/:id/nudge', async (req, res) => { + try { + const { id } = req.params; + const { requesterEmail, productName } = req.body; + await query(` + INSERT INTO audit_log (event_type, actor_email, target_name, metadata) + VALUES ('NUDGE_SENT', $1, $2, $3) + `, [ + requesterEmail || 'unknown', + id, + JSON.stringify({ productName, message: 'Requester sent a reminder to approver via DataMarket Assistant' }) + ]); + res.json({ success: true, message: 'Reminder logged and approver notified' }); + } catch (e) { + console.error('[POST nudge]', e.message); + res.json({ success: true }); // best-effort — don't fail the UI + } + }); + + // ─── User Library ───────────────────────────────────────────────────────────── + app.get('/api/portal/library', async (req, res) => { + try { + const { email } = req.query; + if (!email) return res.status(400).json({ error: 'email required' }); + + // Approved requests + pinned library entries + const { rows } = await query(` + SELECT dp.product_ref, dp.display_name AS product_name, dp.domain, dp.type AS product_type, + dp.classification, dp.refresh_frequency, dp.source_system, dp.uc_full_name, + 'approved' AS source, ar.resolved_at AS added_at, ar.request_ref + FROM access_requests ar + JOIN data_products dp ON dp.product_id = ar.product_id + JOIN users u ON u.user_id = ar.requester_id + WHERE u.email = $1 AND ar.status = 'Approved' + UNION ALL + SELECT dp.product_ref, dp.display_name, dp.domain, dp.type, + dp.classification, dp.refresh_frequency, dp.source_system, dp.uc_full_name, + 'library' AS source, ul.added_at, NULL AS request_ref + FROM user_library ul + JOIN data_products dp ON dp.product_id = ul.product_id + JOIN users u ON u.user_id = ul.user_id + WHERE u.email = $1 + ORDER BY added_at DESC`, [email]); + + res.json(rows); + } catch (e) { + console.error('[/api/portal/library]', e.message); + res.status(500).json({ error: e.message }); + } + }); + + // ─── Audit Log ──────────────────────────────────────────────────────────────── + app.get('/api/portal/audit', async (req, res) => { + try { + const { rows } = await query( + 'SELECT * FROM audit_log ORDER BY occurred_at DESC LIMIT 50', []); + res.json(rows); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); +} diff --git a/datamarket/routes/users.js b/datamarket/routes/users.js new file mode 100644 index 00000000..a2959fd7 --- /dev/null +++ b/datamarket/routes/users.js @@ -0,0 +1,220 @@ +import { query, DEMO_MODE } from '../db.js'; +import { ucApiRequest, getUcAuth } from '../databricks.js'; +import { normalizeRole } from '../lib/roles.js'; + +function callerEmail(req) { + return req.headers['x-forwarded-email'] || req.headers['x-forwarded-user'] || req.body?.requester_email || 'anonymous'; +} + +export function registerRoutes(app) { + // ─── Identity — resolve SSO user or return demo mode personas ──────────────── + app.get('/api/portal/identity', async (req, res) => { + if (DEMO_MODE) return res.json({ mode: 'demo' }); + + const email = callerEmail(req) === 'anonymous' ? '' : callerEmail(req); + if (!email) return res.json({ mode: 'demo', reason: 'no_sso_header' }); + + const ADMIN_EMAILS = (process.env.ADMIN_EMAIL || process.env.DATABRICKS_USER || '') + .split(',').map(e => e.trim().toLowerCase()).filter(Boolean); + const isHardcodedAdmin = ADMIN_EMAILS.includes(email.toLowerCase()); + + try { + const resolveScim = async () => { + try { + const { host, token } = await getUcAuth(); + const filter = encodeURIComponent(`userName eq "${email}"`); + const scimData = await ucApiRequest(host, token, + `/api/2.0/preview/scim/v2/Users?filter=${filter}&count=1&attributes=groups,displayName,name`); + const scimUser = (scimData.Resources || [])[0]; + const displayName = scimUser?.displayName + || [scimUser?.name?.givenName, scimUser?.name?.familyName].filter(Boolean).join(' ') + || null; + const groupNames = (scimUser?.groups || []).map(g => g.display).filter(Boolean); + let groupRole = null; + if (groupNames.length) { + const { rows: matched } = await query( + `SELECT role FROM portal_groups WHERE group_name = ANY($1) ORDER BY + CASE role WHEN 'admin' THEN 1 WHEN 'steward' THEN 2 ELSE 3 END LIMIT 1`, + [groupNames] + ); + groupRole = matched[0]?.role ? normalizeRole(matched[0].role) : null; + } + return { displayName, groupRole }; + } catch (_) { return { displayName: null, groupRole: null }; } + }; + + const { rows: [user] } = await query( + `SELECT user_id, email, display_name, role, department FROM users WHERE email = $1`, [email]); + + if (user) { + if (isHardcodedAdmin && user.role !== 'admin') { + await query(`UPDATE users SET role = 'admin' WHERE email = $1`, [email]); + user.role = 'admin'; + console.info(`[identity] Promoted ${email} to admin (ADMIN_EMAIL match)`); + } else if (!isHardcodedAdmin) { + const { groupRole } = await resolveScim(); + if (groupRole && user.role !== groupRole) { + await query(`UPDATE users SET role = $1 WHERE email = $2`, [groupRole, email]); + user.role = groupRole; + console.info(`[identity] ${email} → role '${groupRole}' via group sync`); + } + } + if (!user.display_name || user.display_name === user.email || user.display_name.includes('@')) { + const { displayName: scimName } = await resolveScim(); + if (scimName) { + await query(`UPDATE users SET display_name = $1 WHERE email = $2`, [scimName, email]); + user.display_name = scimName; + } + } + user.role = normalizeRole(user.role); + return res.json({ mode: 'sso', user }); + } + + const { displayName: scimDisplayName, groupRole } = await resolveScim(); + let role = 'analyst'; + if (isHardcodedAdmin) { + role = 'admin'; + } else if (groupRole) { + role = groupRole; + console.info(`[identity] ${email} → role '${role}' via group`); + } + + const displayName = scimDisplayName + || email.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const { rows: [newUser] } = await query( + `INSERT INTO users (email, display_name, role, department) + VALUES ($1, $2, $3, 'General') RETURNING *`, [email, displayName, normalizeRole(role)]); + console.info(`[identity] Auto-registered ${email} as ${normalizeRole(role)} (name: ${displayName})`); + return res.json({ mode: 'sso', user: { ...newUser, role: normalizeRole(newUser.role) }, new_user: true }); + } catch (e) { + console.warn('[/api/portal/identity]', e.message); + return res.json({ mode: 'demo', reason: e.message }); + } + }); + + // ─── Admin: Users ───────────────────────────────────────────────────────────── + app.get('/api/portal/admin/users', async (req, res) => { + try { + const { rows } = await query( + `SELECT user_id, email, display_name, role, department, created_at FROM users ORDER BY display_name`); + res.json(rows.map(u => ({ ...u, role: normalizeRole(u.role) }))); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.post('/api/portal/admin/users', async (req, res) => { + try { + const { email, display_name, role, department } = req.body; + if (!email) return res.status(400).json({ error: 'email is required' }); + const normalizedRole = normalizeRole(role); + const { rows: [user] } = await query( + `INSERT INTO users (email, display_name, role, department) + VALUES ($1, $2, $3, $4) + ON CONFLICT (email) DO UPDATE + SET display_name = EXCLUDED.display_name, + role = EXCLUDED.role, + department = EXCLUDED.department + RETURNING *`, + [email.trim().toLowerCase(), display_name || '', normalizedRole, department || ''] + ); + res.status(201).json({ ...user, role: normalizeRole(user.role) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + app.put('/api/portal/admin/users/:id', async (req, res) => { + try { + const { id } = req.params; + const { role, department, display_name } = req.body; + const sets = []; + const params = []; + if (role) { params.push(normalizeRole(role)); sets.push(`role = $${params.length}`); } + if (department) { params.push(department); sets.push(`department = $${params.length}`); } + if (display_name) { params.push(display_name); sets.push(`display_name = $${params.length}`); } + if (sets.length === 0) return res.status(400).json({ error: 'Nothing to update' }); + params.push(id); + const { rows: [user] } = await query( + `UPDATE users SET ${sets.join(', ')} WHERE user_id = $${params.length}::uuid RETURNING *`, params); + if (!user) return res.status(404).json({ error: 'User not found' }); + res.json({ ...user, role: normalizeRole(user.role) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + // ─── SCIM user search — proxies Databricks workspace SCIM API ──────────────── + app.get('/api/portal/admin/scim-search', async (req, res) => { + try { + const { q = '' } = req.query; + if (!q || q.length < 2) return res.json([]); + + const { host, token } = await getUcAuth(); + const filter = encodeURIComponent(`displayName co "${q}" or userName co "${q}"`); + const scimData = await ucApiRequest(host, token, + `/api/2.0/preview/scim/v2/Users?filter=${filter}&count=10&attributes=displayName,userName,emails`); + + const users = (scimData.Resources || []).map(u => ({ + display_name: u.displayName || u.userName, + email: (u.emails?.find(e => e.primary)?.value) || u.userName, + })).filter(u => u.email); + + res.json(users); + } catch (e) { + console.warn('[SCIM search]', e.message); + res.json([]); + } + }); + + // ─── SCIM Group search ──────────────────────────────────────────────────────── + app.get('/api/portal/admin/scim-groups-search', async (req, res) => { + try { + const { q = '' } = req.query; + if (!q || q.length < 1) return res.json([]); + const { host, token } = await getUcAuth(); + const filter = encodeURIComponent(`displayName co "${q}"`); + const scimData = await ucApiRequest(host, token, + `/api/2.0/preview/scim/v2/Groups?filter=${filter}&count=20&attributes=displayName,id,members`); + const groups = (scimData.Resources || []).map(g => ({ + scim_id: g.id, + group_name: g.displayName, + member_count: (g.members || []).length, + })); + res.json(groups); + } catch (e) { + console.warn('[SCIM groups search]', e.message); + res.json([]); + } + }); + + // ─── Portal Groups CRUD ─────────────────────────────────────────────────────── + app.get('/api/portal/admin/groups', async (req, res) => { + try { + const { rows } = await query(`SELECT * FROM portal_groups ORDER BY group_name`); + res.json(rows.map(g => ({ ...g, role: normalizeRole(g.role) }))); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + app.post('/api/portal/admin/groups', async (req, res) => { + try { + const { group_name, scim_id, role, department } = req.body; + if (!group_name) return res.status(400).json({ error: 'group_name required' }); + const { rows: [g] } = await query( + `INSERT INTO portal_groups (group_name, scim_id, role, department) + VALUES ($1, $2, $3, $4) + ON CONFLICT (group_name) DO UPDATE SET role=$3, department=$4, scim_id=$2 + RETURNING *`, + [group_name, scim_id || null, normalizeRole(role), department || 'General'] + ); + res.json({ ...g, role: normalizeRole(g.role) }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + app.delete('/api/portal/admin/groups/:id', async (req, res) => { + try { + await query(`DELETE FROM portal_groups WHERE group_id = $1`, [req.params.id]); + res.json({ ok: true }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); +} diff --git a/datamarket/src/App.jsx b/datamarket/src/App.jsx new file mode 100644 index 00000000..f189ce0a --- /dev/null +++ b/datamarket/src/App.jsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react' +import { PersonaProvider } from './context/PersonaContext' +import { AppConfigProvider } from './context/AppConfigContext' +import { DataMarketLayout } from './components/layout/DataMarketLayout' +import { FirstRunWizard } from './components/FirstRunWizard' +import { DataMarketHomePage } from './pages/DataMarketHomePage' +import { DataMarketCatalogPage } from './pages/DataMarketCatalogPage' +import { DataMarketProductDetailPage } from './pages/DataMarketProductDetailPage' +import { DataMarketLibraryPage } from './pages/DataMarketLibraryPage' +import { DataMarketRegisterPage } from './pages/DataMarketRegisterPage' +import { DataMarketInsightsPage } from './pages/DataMarketInsightsPage' +import { AIExplorerPage } from './pages/AIExplorerPage' +import { AboutPage } from './pages/AboutPage' +import { FAQPage } from './pages/FAQPage' +import { ContactPage } from './pages/ContactPage' +import { FeatureRequestPage } from './pages/FeatureRequestPage' +import { useAppConfig } from './context/AppConfigContext' +import { usePersona } from './context/PersonaContext' + +function AppInner() { + const [currentPage, setCurrentPage] = useState('home') + const [pageProps, setPageProps] = useState({}) + const [selectedProduct, setSelectedProduct] = useState(null) + const [wizardDismissed, setWizardDismissed] = useState(false) + + const { setupComplete } = useAppConfig() + const { isAdmin } = usePersona() + + const showWizard = !setupComplete && isAdmin && !wizardDismissed + + const navigate = (page, props = {}) => { + setCurrentPage(page) + setPageProps(props) + setSelectedProduct(null) + window.scrollTo(0, 0) + } + + const openProduct = (product) => { + setSelectedProduct(product) + setCurrentPage('detail') + window.scrollTo(0, 0) + } + + const renderPage = () => { + if (currentPage === 'detail' && selectedProduct) { + return navigate('discover')} onNavigate={navigate} /> + } + switch (currentPage) { + case 'home': return + // Discover (catalog) + case 'discover': + case 'data': + case 'catalog': return + // Ask AI (FMAPI) + case 'ask-ai': + case 'ai-explorer': return + // Insights (dashboard gallery) + case 'insights': return + // My Data / Manage — unified for both regular users and admin + case 'my-access': + case 'library': + case 'my-library': return + case 'admin': return + case 'register': return + case 'about': return + case 'faq': return + case 'contact': return + case 'feature-requests': return + default: return + } + } + + return ( + + {renderPage()} + {showWizard && setWizardDismissed(true)} />} + + ) +} + +function App() { + return ( + + + + + + ) +} + +export default App diff --git a/datamarket/src/components/DataMarketAssistant.jsx b/datamarket/src/components/DataMarketAssistant.jsx new file mode 100644 index 00000000..1f77cda8 --- /dev/null +++ b/datamarket/src/components/DataMarketAssistant.jsx @@ -0,0 +1,498 @@ +import React, { useState, useRef, useEffect } from 'react' +import { MessageCircle, X, Send, Sparkles, CheckCircle, Clock, AlertCircle, Bell, ChevronRight, Database, RotateCcw } from 'lucide-react' +import { usePersona } from '../context/PersonaContext' + +const DataMarket_BLUE = '#003865' + +const allProducts = [ + { id: 1, ref: 'DP-001', name: 'Budget Expenditure Report', category: 'Budget', owner: 'james.park', steward: 'Sarah Johnson' }, + { id: 2, ref: 'DP-002', name: 'Employee Metrics Dashboard', category: 'HRIS', owner: 'sarah.kim', steward: 'Sarah Johnson' }, + { id: 3, ref: 'DP-003', name: 'Property Tax Report 2024', category: 'Property Tax', owner: 'robert.lee', steward: 'Sarah Johnson' }, + { id: 4, ref: 'DP-004', name: 'Census 2023 Dataset', category: 'Demographics', owner: 'diana.torres', steward: 'Sarah Johnson' }, + { id: 5, ref: 'DP-005', name: 'Service Ticket Tracking Report', category: 'Accounting', owner: 'michael.chang', steward: 'Sarah Johnson' }, + { id: 6, ref: 'DP-006', name: 'Essential Service Usage Report', category: 'Health Services', owner: 'angela.wright', steward: 'Sarah Johnson' }, + { id: 7, ref: 'DP-007', name: 'Payroll Dashboard', category: 'Payroll', owner: 'james.park', steward: 'Sarah Johnson' }, + { id: 8, ref: 'DP-008', name: 'Property Tax Dashboard', category: 'Property Tax', owner: 'robert.lee', steward: 'Sarah Johnson' }, + { id: 9, ref: 'DP-009', name: 'Population by Age 2020 Dataset', category: 'Demographics', owner: 'diana.torres', steward: 'Sarah Johnson' }, + { id: 10, ref: 'DP-010', name: 'Enterprise Budget Analytics Dashboard', category: 'Budget', owner: 'john.doe', steward: 'Sarah Johnson' }, + { id: 11, ref: 'DP-011', name: 'Audit Finding Tracker', category: 'Audit', owner: 'david.nguyen', steward: 'Sarah Johnson' }, + { id: 12, ref: 'DP-012', name: 'GIS Infrastructure Map', category: 'GIS', owner: 'john.doe', steward: 'Sarah Johnson' }, +] + +const statusIcon = (status) => { + if (status === 'Approved') return + if (status === 'Denied') return + return +} + +const statusColor = (status) => { + if (status === 'Approved') return 'text-green-700 bg-green-50 border-green-200' + if (status === 'Denied') return 'text-red-700 bg-red-50 border-red-200' + return 'text-amber-700 bg-amber-50 border-amber-200' +} + +function buildResponse(input, persona, myRequests, pendingRequests, hasAccess, apiAvailable, onNudge) { + const q = input.toLowerCase() + + // ── Admin: approvals queue (must come before status check) ─────────────── + if (/approval|waiting on me|pending approval|queue|review|need.*approve/.test(q)) { + if (!isAdmin) { + return { + text: `Only Data Stewards can view the approval queue. You can check the status of your own requests instead.`, + chips: ['Check my request status'] + } + } + if (pendingRequests.length === 0) { + return { + text: `No pending approvals right now — you're all caught up! All submitted requests have been resolved.`, + actions: [{ label: 'Open Admin Panel', page: 'admin' }] + } + } + return { + text: `You have ${pendingRequests.length} request${pendingRequests.length > 1 ? 's' : ''} waiting for your review:`, + requests: pendingRequests.slice(0, 4), + followUp: `Head to the Admin panel to approve or deny with a single click.`, + actions: [{ label: 'Open Admin Panel', page: 'admin' }] + } + } + + // ── Status of my requests ───────────────────────────────────────────────── + if (/status|check.*request|my request|pending|approved|denied/.test(q)) { + if (myRequests.length === 0) { + return { + text: `You have no open access requests, ${persona.name}. Head to the Data Catalog to find datasets and request access.`, + actions: [{ label: 'Browse Catalog', page: 'discover' }] + } + } + const pending = myRequests.filter(r => r.status === 'Pending') + const approved = myRequests.filter(r => r.status === 'Approved') + const denied = myRequests.filter(r => r.status === 'Denied') + return { + text: `Here's your request summary, ${persona.name}:`, + requests: myRequests.slice(0, 4), + followUp: pending.length > 0 + ? `You have ${pending.length} pending request${pending.length > 1 ? 's' : ''}. Want me to send a nudge to the approver?` + : approved.length > 0 + ? `${approved.length} request${approved.length > 1 ? 's have' : ' has'} been approved — check your Library.` + : null, + nudgeable: pending.length > 0 ? pending[0] : null, + onNudge + } + } + + // ── Nudge approver ──────────────────────────────────────────────────────── + if (/nudge|remind|follow.?up|ping|chase|hasn.?t|haven.?t heard/.test(q)) { + const pending = myRequests.filter(r => r.status === 'Pending') + if (pending.length === 0) { + return { text: `No pending requests to follow up on right now. All your requests have been resolved.` } + } + return { + text: `I can send a reminder to the Data Steward for your pending request${pending.length > 1 ? 's' : ''}:`, + nudgeable: pending[0], + onNudge, + followUp: 'Click the button below to notify the approver.' + } + } + + // ── What can I access ───────────────────────────────────────────────────── + if (/what.*access|my.*data|can.*access|have access|library|approved/.test(q)) { + const accessible = allProducts.filter(p => hasAccess(p.ref)) + if (accessible.length === 0) { + return { + text: `You don't have approved access to any datasets yet, ${persona.name}. Browse the catalog to find what you need and submit a request.`, + actions: [{ label: 'Browse Catalog', page: 'discover' }] + } + } + return { + text: `You currently have access to ${accessible.length} data product${accessible.length > 1 ? 's' : ''}:`, + products: accessible.slice(0, 5), + followUp: accessible.length > 5 ? `...and ${accessible.length - 5} more. Check your Library for the full list.` : null, + actions: [{ label: 'View My Data', page: 'my-access' }] + } + } + + // ── Who approves / owns what ────────────────────────────────────────────── + if (/who.*approve|who.*own|data steward|data owner|approver|contact/.test(q)) { + return { + text: `All access requests in DataMarket are reviewed by the Data Steward team. For specific datasets:`, + products: allProducts.slice(0, 4).map(p => ({ ...p, showOwner: true })), + followUp: 'Submit a request from any product page and the steward is notified automatically.' + } + } + + // ── Find/discover data ──────────────────────────────────────────────────── + if (/find|search|look for|do we have|is there|data about|dataset/.test(q)) { + const keywords = q.replace(/find|search|look for|do we have|is there|data about|dataset|any/g, '').trim().split(/\s+/).filter(w => w.length > 2) + const matches = allProducts.filter(p => + keywords.some(k => + p.name.toLowerCase().includes(k) || + p.category.toLowerCase().includes(k) + ) + ) + if (matches.length === 0) { + return { + text: `I didn't find an exact match for that topic. Browse the full catalog — it has 12 data products across Budget, HR, Property Tax, Demographics, and more.`, + actions: [{ label: 'Browse Catalog', page: 'discover' }] + } + } + return { + text: `Found ${matches.length} data product${matches.length > 1 ? 's' : ''} matching your query:`, + products: matches.slice(0, 4), + actions: matches.length > 0 && !hasAccess(matches[0].ref) + ? [{ label: `Request Access to ${matches[0].name.split(' ').slice(0, 3).join(' ')}...`, page: 'discover' }] + : [{ label: 'View in Catalog', page: 'discover' }] + } + } + + // ── How to request access ───────────────────────────────────────────────── + if (/how.*request|request.*access|get access|apply|submit/.test(q)) { + return { + text: `Requesting access is simple:`, + steps: [ + 'Go to the Data Catalog (click "Data" in the nav)', + 'Click any data product to open its detail page', + 'Click "Request Access" and fill in your business justification', + 'The Data Steward is notified and typically responds within 1-2 business days', + 'Once approved, the dataset appears in your Library' + ], + actions: [{ label: 'Go to Catalog', page: 'discover' }] + } + } + + // ── Greetings ───────────────────────────────────────────────────────────── + if (/^(hi|hello|hey|help|what can you|what do you)/.test(q)) { + const pending = myRequests.filter(r => r.status === 'Pending') + if (isAdmin) { + return { + text: `Hi ${persona.name}! As Data Steward I can help you:`, + chips: ['Approvals waiting on me', 'Show all pending requests', 'Who has access to what?', 'How does approval work?'] + } + } + return { + text: `Hi ${persona.name}! I'm your DataMarket assistant. I can help you with:`, + chips: ['Check my request status', 'What data can I access?', 'Find budget data', 'How do I request access?', pending.length > 0 ? 'Nudge my approver' : 'Who owns payroll data?'] + } + } + + // ── Default ─────────────────────────────────────────────────────────────── + return { + text: `I can help you with access requests, data discovery, and navigating DataMarket. Try asking:`, + chips: ['Check my request status', 'What data can I access?', 'Find payroll data', 'Who approves requests?'] + } +} + +function Message({ msg, onChipClick, onPageNavigate }) { + return ( +
+ {msg.role === 'assistant' && ( +
+ +
+ )} +
+ {msg.role === 'user' ? ( +
+ {msg.content} +
+ ) : ( +
+
+ {msg.content} +
+ + {/* Request status list */} + {msg.requests && ( +
+ {msg.requests.map((r, i) => ( +
+ {statusIcon(r.status)} +
+

{r.product_name || r.product_ref}

+

{r.status} · {r.requested_at ? new Date(r.requested_at).toLocaleDateString() : 'Recently'}

+
+
+ ))} +
+ )} + + {/* Product list */} + {msg.products && ( +
+ {msg.products.map((p, i) => ( +
+ +
+

{p.name}

+ {p.showOwner + ?

Owner: {p.owner} · Steward: {p.steward}

+ :

{p.category}

+ } +
+
+ ))} +
+ )} + + {/* Steps list */} + {msg.steps && ( +
+ {msg.steps.map((s, i) => ( +
+ {i + 1} + {s} +
+ ))} +
+ )} + + {/* Follow-up text */} + {msg.followUp && ( +
+ {msg.followUp} +
+ )} + + {/* Nudge button */} + {msg.nudgeable && msg.onNudge && ( + + )} + + {/* Action buttons */} + {msg.actions && ( +
+ {msg.actions.map((a, i) => ( + + ))} +
+ )} + + {/* Quick chips */} + {msg.chips && ( +
+ {msg.chips.map((c, i) => ( + + ))} +
+ )} +
+ )} +
+
+ ) +} + +export function DataMarketAssistant({ onNavigate }) { + const [open, setOpen] = useState(false) + const [input, setInput] = useState('') + const [messages, setMessages] = useState([]) + const [nudgedRefs, setNudgedRefs] = useState(new Set()) + const [unread, setUnread] = useState(0) + const bottomRef = useRef(null) + const inputRef = useRef(null) + const { persona, myRequests, pendingRequests, hasAccess, apiAvailable, isAdmin } = usePersona() + + const buildGreeting = (p, myReqs, pendingReqs) => { + if (p.id === 'admin') { + return pendingReqs.length > 0 + ? { + role: 'assistant', + content: `Hi ${p.name}! There are ${pendingReqs.length} access request${pendingReqs.length > 1 ? 's' : ''} waiting for your review.`, + chips: ['Approvals waiting on me', 'Who has access to what?', 'Show all pending requests'] + } + : { + role: 'assistant', + content: `Hi ${p.name}! No pending approvals right now. Everything's up to date.`, + chips: ['Who has access to what?', 'Show all pending requests', 'How does approval work?'] + } + } + const pending = myReqs.filter(r => r.status === 'Pending') + return pending.length > 0 + ? { + role: 'assistant', + content: `Hi ${p.name}! You have ${pending.length} pending access request${pending.length > 1 ? 's' : ''}. Want me to check the status or send a reminder?`, + chips: ['Check my request status', 'Nudge my approver', 'What data can I access?'] + } + : { + role: 'assistant', + content: `Hi ${p.name}! I can help with data access, request status, or finding the right dataset.`, + chips: ['Check my request status', 'What data can I access?', 'Find budget data', 'How do I request access?'] + } + } + + const resetChat = () => { + setNudgedRefs(new Set()) + setMessages([buildGreeting(persona, myRequests, pendingRequests)]) + setTimeout(() => inputRef.current?.focus(), 100) + } + + // Fire greeting whenever open becomes true OR persona changes while open + useEffect(() => { + if (open) { + setUnread(0) + if (messages.length === 0) { + setMessages([buildGreeting(persona, myRequests, pendingRequests)]) + } + setTimeout(() => inputRef.current?.focus(), 100) + } + }, [open]) + + // Persona switch — always reset and re-greet + useEffect(() => { + setNudgedRefs(new Set()) + const greeting = buildGreeting(persona, myRequests, pendingRequests) + if (open) { + setMessages([greeting]) + } else { + setMessages([]) + setUnread(1) + } + }, [persona.id]) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const handleNudge = async (request) => { + const ref = request.request_ref || request.id + if (nudgedRefs.has(ref)) { + addAssistantMessage({ content: `I already sent a reminder for that request. Give the approver a little more time — they've been notified.` }) + return + } + try { + await fetch(`/api/portal/requests/${ref}/nudge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requesterEmail: persona.email, productName: request.product_name }) + }) + } catch (e) { /* silent — nudge is best-effort */ } + setNudgedRefs(prev => new Set([...prev, ref])) + addAssistantMessage({ + content: `✓ Reminder sent to the Data Steward for "${request.product_name || request.product_ref}". They've been notified that your request is waiting. You'll typically hear back within 1 business day.` + }) + } + + const addAssistantMessage = (msg) => { + setMessages(prev => [...prev, { role: 'assistant', ...msg }]) + } + + const sendMessage = (text) => { + const userText = text || input.trim() + if (!userText) return + setInput('') + setMessages(prev => [...prev, { role: 'user', content: userText }]) + + setTimeout(() => { + const response = buildResponse(userText, persona, myRequests, pendingRequests, hasAccess, apiAvailable, handleNudge) + addAssistantMessage(response) + }, 400) + } + + return ( + <> + {/* Floating bubble */} + {!open && ( + + )} + + {/* Chat panel */} + {open && ( +
+ {/* Header */} +
+
+
+ +
+
+

DataMarket Assistant

+

Powered by Databricks

+
+
+
+ + +
+
+ + {/* Messages */} +
+ {messages.map((msg, i) => ( + { onNavigate(page); setOpen(false) }} + /> + ))} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendMessage()} + placeholder="Ask about your data access..." + className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50" + /> + +
+
+
+ )} + + ) +} diff --git a/datamarket/src/components/DatabricksCard.jsx b/datamarket/src/components/DatabricksCard.jsx new file mode 100644 index 00000000..050c1a45 --- /dev/null +++ b/datamarket/src/components/DatabricksCard.jsx @@ -0,0 +1,81 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { TrendingUp, TrendingDown } from 'lucide-react' + +export function DatabricksCard({ + title, + description, + value, + trend, + icon: Icon, + className = "", + variant = 'default', + ...props +}) { + const cardVariants = { + default: 'border-border', + accent: 'border-databricks-blue bg-gradient-to-br from-blue-50 to-transparent', + success: 'border-databricks-emerald bg-gradient-to-br from-emerald-50 to-transparent', + warning: 'border-databricks-amber bg-gradient-to-br from-amber-50 to-transparent', + info: 'border-databricks-cyan bg-gradient-to-br from-cyan-50 to-transparent' + } + + return ( + + + + {title} + + {Icon && ( +
+ +
+ )} +
+ +
+ {value} +
+ {description && ( +

+ {description} +

+ )} + {trend && ( +
+ + {trend.direction === 'up' ? ( + + ) : ( + + )} + {trend.value} + + vs last period +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/datamarket/src/components/DatabricksChart.jsx b/datamarket/src/components/DatabricksChart.jsx new file mode 100644 index 00000000..3cec8db8 --- /dev/null +++ b/datamarket/src/components/DatabricksChart.jsx @@ -0,0 +1,149 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + LineChart, Line, AreaChart, Area, BarChart, Bar, + XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer +} from 'recharts' +import { TrendingUp, BarChart3, LineChart as LineChartIcon } from 'lucide-react' + +const DATABRICKS_COLORS = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#06b6d4', '#ec4899'] + +export function DatabricksChart({ + type = 'line', + data = [], + xKey, + yKeys = [], + title, + subtitle, + className = "", + height = 400, + showLegend = true, + showGrid = true, + ...props +}) { + const ChartComponent = { + line: LineChart, + area: AreaChart, + bar: BarChart + }[type] + + const getChartIcon = () => { + switch (type) { + case 'bar': return BarChart3 + case 'area': return TrendingUp + default: return LineChartIcon + } + } + + const ChartIcon = getChartIcon() + + const CustomTooltip = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( + + +

{label}

+
+ {payload.map((entry, index) => ( +
+
+
+ {entry.name} +
+ + {entry.value.toLocaleString()} + +
+ ))} +
+ + + ) + } + return null + } + + return ( + + +
+ + + {title} + + {subtitle && ( +

{subtitle}

+ )} +
+ + {data.length} {data.length === 1 ? 'point' : 'points'} + +
+ + + + {showGrid && ( + + )} + + { + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M` + if (value >= 1000) return `${(value / 1000).toFixed(0)}K` + return value.toLocaleString() + }} + width={60} + /> + } /> + {showLegend && } + {yKeys.map((key, index) => { + const color = DATABRICKS_COLORS[index % DATABRICKS_COLORS.length] + const commonProps = { + key, + dataKey: key, + stroke: color, + strokeWidth: 2, + name: key.charAt(0).toUpperCase() + key.slice(1) + } + + if (type === 'area') { + return + } else if (type === 'bar') { + return + } else { + return + } + })} + + + +
+ ) +} \ No newline at end of file diff --git a/datamarket/src/components/DatabricksLogo.jsx b/datamarket/src/components/DatabricksLogo.jsx new file mode 100644 index 00000000..2dec227f --- /dev/null +++ b/datamarket/src/components/DatabricksLogo.jsx @@ -0,0 +1,36 @@ +import React from 'react' + +export function DatabricksLogo({ variant = 'full', size = 'md' }) { + const sizeClasses = { + sm: 'text-lg', + md: 'text-xl', + lg: 'text-2xl' + } + + if (variant === 'compact') { + return ( +
+ DNA +
+ ) + } + + return ( +
+
+
+ + + +
+
+
+ DNA + Portal +
+

DataMarket

+
+
+
+ ) +} diff --git a/datamarket/src/components/FirstRunWizard.jsx b/datamarket/src/components/FirstRunWizard.jsx new file mode 100644 index 00000000..265c8dbd --- /dev/null +++ b/datamarket/src/components/FirstRunWizard.jsx @@ -0,0 +1,442 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { Database, Warehouse, CheckCircle2, ArrowRight, X, Sparkles, Package, + ShieldCheck, AlertTriangle, RefreshCw, Copy, ExternalLink } from 'lucide-react' +import { useAppConfig } from '@/context/AppConfigContext' +import { ImportUCModal } from '@/components/ImportUCModal' + +const BLUE = '#003865' + +const STEPS = [ + { id: 'warehouse', short: 'SQL Warehouse', icon: Warehouse }, + { id: 'catalogs', short: 'Catalog access', icon: ShieldCheck }, + { id: 'import', short: 'Import data', icon: Package }, + { id: 'done', short: 'You\'re live', icon: CheckCircle2 }, +] + +export function FirstRunWizard({ onDismiss }) { + const { appName, refreshConfig } = useAppConfig() + const [step, setStep] = useState(0) + + // Step 0 — Warehouse + const [warehouseId, setWarehouseId] = useState('') + const [warehousePreFilled, setPreFilled] = useState(false) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState('') + + // Step 1 — Catalog access + const [accessCheck, setAccessCheck] = useState(null) + const [accessLoading, setAccessLoading] = useState(false) + const [granting, setGranting] = useState(false) + const [grantResults, setGrantResults] = useState(null) // null | { allSucceeded, results[] } + const [copied, setCopied] = useState(false) + + // Step 2 — Import + const [showImport, setShowImport] = useState(false) + const [imported, setImported] = useState(false) + + // Pre-fill warehouse ID from settings + useEffect(() => { + fetch('/api/portal/settings') + .then(r => r.ok ? r.json() : null) + .then(data => { + const id = data?.sql_warehouse_id || '' + if (id) { setWarehouseId(id); setPreFilled(true) } + }) + .catch(() => {}) + }, []) + + // Load access check whenever we enter step 1 + const runAccessCheck = useCallback(async () => { + setAccessLoading(true) + try { + const d = await fetch('/api/portal/admin/uc-access-check').then(r => r.json()) + setAccessCheck(d) + } catch (e) { + setAccessCheck({ error: e.message }) + } finally { + setAccessLoading(false) + } + }, []) + + useEffect(() => { + if (step === 1) { setGrantResults(null); runAccessCheck() } + }, [step, runAccessCheck]) + + const runGrants = useCallback(async () => { + setGranting(true) + setGrantResults(null) + try { + // Only grant for catalogs that aren't already accessible + const needsCatalogs = accessCheck?.needsGrant || [] + const d = await fetch('/api/portal/admin/uc-run-grants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catalogs: needsCatalogs }), + }).then(r => r.json()) + setGrantResults(d) + // Re-check access after granting so the list updates + if (d.allSucceeded) await runAccessCheck() + } catch (e) { + setGrantResults({ error: e.message }) + } finally { + setGranting(false) + } + }, [accessCheck, runAccessCheck]) + + const saveWarehouse = async () => { + if (!warehouseId.trim()) { setSaveError('Paste your warehouse ID first.'); return } + setSaving(true); setSaveError('') + try { + const r = await fetch('/api/portal/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql_warehouse_id: warehouseId.trim() }), + }) + if (!r.ok) throw new Error(await r.text()) + refreshConfig() + setStep(1) + } catch (e) { setSaveError(e.message) } + finally { setSaving(false) } + } + + const markComplete = async () => { + await fetch('/api/portal/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ setup_complete: 'true' }), + }).catch(() => {}) + refreshConfig() + onDismiss() + } + + const copyGrantSql = () => { + navigator.clipboard.writeText(accessCheck?.grantSql || '') + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const progressPct = (step / (STEPS.length - 1)) * 100 + + return ( + <> +
+
+ + {/* Header */} +
+
+
+
+ +
+
+

Set up {appName}

+

4 quick steps — takes about 2 minutes

+
+
+ +
+
+
+
+
+ {STEPS.map((s, i) => ( + + {i + 1}. {s.short} + + ))} +
+
+ + {/* Step content */} +
+ + {/* ── Step 1: SQL Warehouse ── */} + {step === 0 && ( +
+
+
+ +
+
+

Connect a SQL Warehouse

+

Required so the app can execute Unity Catalog grants when you approve access requests.

+
+
+
+ + {warehousePreFilled && ( +
+ +

Auto-detected by deploy script — verify below or continue.

+
+ )} + { setWarehouseId(e.target.value); setPreFilled(false) }} + onKeyDown={e => e.key === 'Enter' && saveWarehouse()} + placeholder="e.g. abc1234def567890" + autoFocus={!warehousePreFilled} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + {!warehousePreFilled && ( +

+ Find it in SQL Warehouses → your warehouse → Connection Details → HTTP Path — it's the last segment after /sql/1.0/warehouses/ +

+ )} +
+ {saveError &&

{saveError}

} +
+ + +
+
+ )} + + {/* ── Step 2: Catalog access ── */} + {step === 1 && ( +
+
+
+ +
+
+

Grant catalog access to the app

+

+ The app runs as a service principal. It needs USE SCHEMA on each schema to browse and import tables. +

+
+
+ + {/* Loading */} + {accessLoading && ( +
+ Checking catalog access… +
+ )} + + {/* Error */} + {!accessLoading && accessCheck?.error && ( +
+ Could not check access: {accessCheck.error} +
+ )} + + {/* Results */} + {!accessLoading && accessCheck && !accessCheck.error && ( + <> + {/* Catalog list */} +
+ {accessCheck.catalogs.map(cat => ( +
+
+ + {cat.name} + {cat.accessible + ? Accessible + : cat.canListSchemas + ? (cat.schemasNeedingGrant?.length > 0 + ? Tables need grant + : Accessible) + : No access + } +
+ {/* Per-schema breakdown when schemas are visible but tables aren't */} + {!cat.accessible && cat.canListSchemas && cat.schemasNeedingGrant?.length > 0 && ( +
+ {cat.schemasNeedingGrant.map(s => ( +
+ + {cat.name}.{s.name} + — needs USE SCHEMA +
+ ))} +
+ )} +
+ ))} +
+ + {/* All good */} + {accessCheck.allAccessible && ( +
+ +

All catalogs accessible — you're good to go!

+
+ )} + + {/* Grant section */} + {!accessCheck.allAccessible && accessCheck.grantSql && ( +
+ {/* Auto-grant button */} +
+
+

Grant access automatically

+

+ Runs USE CATALOG + USE SCHEMA + SELECT on each catalog — cascades to all schemas and future tables. +

+
+ {/* Grant results */} + {grantResults && !grantResults.error && ( +
+ {grantResults.results?.map(r => ( +
+ {r.success + ? + : } + {r.catalog} + {!r.success && — {r.errors?.[0]}} +
+ ))} +
+ )} + {grantResults?.error && ( +

{grantResults.error}

+ )} + +
+ + {/* Manual fallback */} +
+ + Or run manually in SQL Editor + +
+
+
+                                {accessCheck.grantSql}
+                              
+ +
+
+ {accessCheck.sqlEditorUrl && ( + + Open SQL Editor + + )} + +
+
+
+
+ )} + + )} + +
+ +
+
+ )} + + {/* ── Step 3: Import ── */} + {step === 2 && ( +
+
+
+ +
+
+

Import your first data products

+

Pull tables from Unity Catalog into the catalog. Users can't discover anything until you import.

+
+
+ {imported ? ( +
+ +

Tables imported. Ready to go!

+
+ ) : ( + + )} +
+ {imported && ( + + )} + +
+
+ )} + + {/* ── Step 4: Done ── */} + {step === 3 && ( +
+
+ +
+
+

You're all set!

+

+ {appName} is configured and ready. Invite your team and start approving access requests. +

+
+
+

What's next

+ {[ + 'Share this URL with your data consumers', + 'Add data stewards in Manage → Users', + 'Customise the portal name and logo in Settings', + ].map(t => ( +
+ + {t} +
+ ))} +
+ +
+ )} +
+
+
+ + {showImport && ( + setShowImport(false)} + onImported={() => { setShowImport(false); setImported(true) }} + /> + )} + + ) +} diff --git a/datamarket/src/components/ImportUCModal.jsx b/datamarket/src/components/ImportUCModal.jsx new file mode 100644 index 00000000..b3eb9ab1 --- /dev/null +++ b/datamarket/src/components/ImportUCModal.jsx @@ -0,0 +1,429 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Upload, X, Check, Database, FolderOpen, Folder, ChevronRight, ChevronDown, Loader2, Search, Table2, ShieldAlert, Copy, ExternalLink, CheckCircle2 } from 'lucide-react' + +const BLUE = '#003865' + +// ─── Indeterminate checkbox ──────────────────────────────────────────────────── +function Checkbox({ checked, indeterminate, disabled, onChange, className = '' }) { + const ref = useRef(null) + useEffect(() => { if (ref.current) ref.current.indeterminate = !!indeterminate }, [indeterminate]) + return ( + + ) +} + +// ─── Main modal ─────────────────────────────────────────────────────────────── +export function ImportUCModal({ onClose, onImported }) { + const [catalogs, setCatalogs] = useState([]) + const [schemas, setSchemas] = useState({}) + const [tables, setTables] = useState({}) + const [tablesMeta, setTablesMeta] = useState({}) // { [catName.schName]: { needsGrant, grantSql, sqlEditorUrl } } + const [selected, setSelected] = useState(new Set()) + const [search, setSearch] = useState('') + const [initLoad, setInitLoad] = useState(true) + const [importing, setImporting] = useState(false) + const [result, setResult] = useState(null) + const [initError, setInitError] = useState(null) + const [copiedKey, setCopiedKey] = useState(null) + + // Load catalog list on mount + useEffect(() => { + fetch('/api/portal/admin/uc-catalogs') + .then(r => r.json()) + .then(d => { + if (d.error) throw new Error(d.error) + setCatalogs((d.catalogs || []).map(c => ({ + name: c.name, comment: c.comment, + expanded: false, loading: false, schemasLoaded: false, + }))) + }) + .catch(e => setInitError(e.message)) + .finally(() => setInitLoad(false)) + }, []) + + // ── Catalog toggle ─────────────────────────────────────────────────────────── + const toggleCatalog = async (name) => { + const cat = catalogs.find(c => c.name === name) + if (!cat) return + if (!cat.schemasLoaded && !cat.expanded) { + setCatalogs(p => p.map(c => c.name === name ? { ...c, loading: true } : c)) + try { + const d = await fetch(`/api/portal/admin/uc-schemas?catalog=${encodeURIComponent(name)}`).then(r => r.json()) + if (d.error) throw new Error(d.error) + setSchemas(p => ({ + ...p, + [name]: (d.schemas || []).map(s => ({ + name: s.name, expanded: false, loading: false, tablesLoaded: false, + })) + })) + setCatalogs(p => p.map(c => c.name === name ? { ...c, schemasLoaded: true, loading: false, expanded: true } : c)) + } catch { + setCatalogs(p => p.map(c => c.name === name ? { ...c, loading: false } : c)) + } + } else { + setCatalogs(p => p.map(c => c.name === name ? { ...c, expanded: !c.expanded } : c)) + } + } + + // ── Schema toggle ──────────────────────────────────────────────────────────── + const toggleSchema = async (catName, schName) => { + const sch = (schemas[catName] || []).find(s => s.name === schName) + if (!sch) return + const key = `${catName}.${schName}` + if (!sch.tablesLoaded && !sch.expanded) { + setSchemas(p => ({ ...p, [catName]: p[catName].map(s => s.name === schName ? { ...s, loading: true } : s) })) + try { + const d = await fetch( + `/api/portal/admin/uc-tables-browse?catalog=${encodeURIComponent(catName)}&schema=${encodeURIComponent(schName)}` + ).then(r => r.json()) + if (d.error) throw new Error(d.error) + setTables(p => ({ ...p, [key]: d.tables || [] })) + if (d.needsGrant) { + setTablesMeta(p => ({ ...p, [key]: { needsGrant: true, grantSql: d.grantSql, sqlEditorUrl: d.sqlEditorUrl } })) + } + setSchemas(p => ({ + ...p, + [catName]: p[catName].map(s => s.name === schName ? { ...s, tablesLoaded: true, loading: false, expanded: true } : s) + })) + } catch { + setSchemas(p => ({ ...p, [catName]: p[catName].map(s => s.name === schName ? { ...s, loading: false } : s) })) + } + } else { + setSchemas(p => ({ ...p, [catName]: p[catName].map(s => s.name === schName ? { ...s, expanded: !s.expanded } : s) })) + } + } + + // ── Selection helpers ──────────────────────────────────────────────────────── + const toggleTable = (fullName) => + setSelected(p => { const n = new Set(p); n.has(fullName) ? n.delete(fullName) : n.add(fullName); return n }) + + const schemaSelectable = (catName, schName) => + (tables[`${catName}.${schName}`] || []).filter(t => !t.registered) + + const schemaCheckState = (catName, schName) => { + const ts = schemaSelectable(catName, schName) + if (!ts.length) return 'none' + const sel = ts.filter(t => selected.has(t.full_name)).length + return sel === 0 ? 'unchecked' : sel === ts.length ? 'checked' : 'indeterminate' + } + + const toggleSchemaSelect = (catName, schName) => { + const ts = schemaSelectable(catName, schName) + const all = ts.every(t => selected.has(t.full_name)) + setSelected(p => { const n = new Set(p); ts.forEach(t => all ? n.delete(t.full_name) : n.add(t.full_name)); return n }) + } + + const catalogSelectable = (catName) => + (schemas[catName] || []).flatMap(s => schemaSelectable(catName, s.name)) + + const catalogCheckState = (catName) => { + const ts = catalogSelectable(catName) + if (!ts.length) return 'none' + const sel = ts.filter(t => selected.has(t.full_name)).length + return sel === 0 ? 'unchecked' : sel === ts.length ? 'checked' : 'indeterminate' + } + + const toggleCatalogSelect = (catName) => { + const ts = catalogSelectable(catName) + const all = ts.every(t => selected.has(t.full_name)) + setSelected(p => { const n = new Set(p); ts.forEach(t => all ? n.delete(t.full_name) : n.add(t.full_name)); return n }) + } + + // ── Search filter ───────────────────────────────────────────────────────────── + const matches = (s) => !search || s.toLowerCase().includes(search.toLowerCase()) + + // ── Import ──────────────────────────────────────────────────────────────────── + const handleImport = async () => { + setImporting(true) + try { + const allTables = Object.values(tables).flat() + const toImport = allTables.filter(t => selected.has(t.full_name)) + const r = await fetch('/api/portal/admin/import-uc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: toImport }), + }) + const data = await r.json() + setResult(data) + if (data.imported > 0) onImported() + } catch (e) { + setResult({ error: e.message }) + } + setImporting(false) + } + + // ── Result screen ───────────────────────────────────────────────────────────── + if (result) return ( +
+
+
+ +
+

+ {result.error ? 'Import failed' : `${result.imported} Table${result.imported !== 1 ? 's' : ''} Imported`} +

+

+ {result.error || 'Data products created from Unity Catalog and published to the catalog.'} +

+ +
+
+ ) + + // ── Main modal ──────────────────────────────────────────────────────────────── + return ( +
+
+ + {/* Header */} +
+
+

+ + Import from Unity Catalog +

+

+ Browse catalogs → schemas → tables. Select tables to register as data products. +

+
+ +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter loaded tables by name…" + className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-100 bg-gray-50" + /> +
+
+ + {/* Tree body */} +
+ {initLoad ? ( +
+ + Loading catalogs… +
+ ) : initError ? ( +
+ +

Could not load Unity Catalog

+

{initError}

+

Ensure DATABRICKS_TOKEN is set in app.yaml

+
+ ) : catalogs.length === 0 ? ( +
+ + No catalogs found +
+ ) : ( +
+ {catalogs.map(cat => { + const catState = catalogCheckState(cat.name) + return ( +
+ {/* ── Catalog row ── */} +
+ + {catState !== 'none' && ( + toggleCatalogSelect(cat.name)} + /> + )} +
+ + {/* ── Schemas ── */} + {cat.expanded && ( +
+ {(schemas[cat.name] || []).length === 0 && !cat.loading && ( +
No schemas
+ )} + {(schemas[cat.name] || []) + .filter(sch => { + if (!search) return true + const key = `${cat.name}.${sch.name}` + return matches(sch.name) || (tables[key] || []).some(t => matches(t.table_name)) + }) + .map(sch => { + const schKey = `${cat.name}.${sch.name}` + const schTables = tables[schKey] || [] + const unregistered = schTables.filter(t => !t.registered) + const schState = schemaCheckState(cat.name, sch.name) + const visibleTables = search ? schTables.filter(t => matches(t.table_name)) : schTables + + return ( +
+ {/* ── Schema row ── */} +
+ + {schState !== 'none' && ( + toggleSchemaSelect(cat.name, sch.name)} + /> + )} +
+ + {/* ── Tables ── */} + {sch.expanded && ( +
+ {schTables.length === 0 && !sch.loading && (() => { + const meta = tablesMeta[`${cat.name}.${sch.name}`] + if (meta?.needsGrant) { + return ( +
+
+ + App service principal needs SELECT ON CATALOG to see tables here. Run once — covers all schemas. +
+
+ {meta.grantSql} + +
+ {meta.sqlEditorUrl && ( + + Open SQL Editor + + )} + +
+ ) + } + return
No tables
+ })()} + {visibleTables.map(t => ( + + ))} +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+ )} +
+ + {/* Footer */} +
+ + {selected.size > 0 ? `${selected.size} table${selected.size !== 1 ? 's' : ''} selected` : 'Expand catalogs to browse tables'} + + + +
+
+
+ ) +} diff --git a/datamarket/src/components/layout/DataMarketLayout.jsx b/datamarket/src/components/layout/DataMarketLayout.jsx new file mode 100644 index 00000000..142c9401 --- /dev/null +++ b/datamarket/src/components/layout/DataMarketLayout.jsx @@ -0,0 +1,326 @@ +import React, { useState } from 'react' +import { Search, Menu, X, ChevronDown, ShieldCheck, Bell, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-react' +import { usePersona, personas } from '../../context/PersonaContext' +import { useAppConfig } from '../../context/AppConfigContext' +import { DataMarketAssistant } from '../DataMarketAssistant' + +const DataMarket_BLUE = '#003865' + +const personaBadgeColors = { + richard: 'bg-blue-500', + james: 'bg-emerald-500', + admin: 'bg-purple-600' +} + +const notifIcon = (status) => { + if (status === 'Approved') return + if (status === 'Denied' || status === 'Revoked') return + if (status === 'Expiring') return + return +} + +const notifText = (n) => { + if (n.status === 'Approved') return `Access to ${n.product_name} approved` + if (n.status === 'Denied') return `Access to ${n.product_name} denied` + if (n.status === 'Revoked') return `Access to ${n.product_name} revoked` + if (n.status === 'Expiring') return `${n.product_name} access expires soon` + return n.product_name +} + +export function DataMarketLayout({ currentPage, onNavigate, children }) { + const [mobileOpen, setMobileOpen] = useState(false) + const [userMenuOpen, setUserMenuOpen] = useState(false) + const [personaMenuOpen, setPersonaMenuOpen] = useState(false) + const [notifOpen, setNotifOpen] = useState(false) + const { persona, currentPersona, setCurrentPersona, pendingRequests, notifications, unreadNotificationCount, isAdmin, demoMode, identityLoading } = usePersona() + const { appName, appSubtitle, appLogoUrl, navLinks, askAiEnabled, insightsEnabled, featureRequestsEnabled } = useAppConfig() + + const personaColor = isAdmin ? '#7C3AED' : currentPersona === 'james' ? '#059669' : '#3B82F6' + const avatarBadgeClass = isAdmin ? 'bg-purple-600' : currentPersona === 'james' ? 'bg-emerald-500' : 'bg-blue-500' + + const navItems = [ + { id: 'home', label: 'Home' }, + { id: 'discover', label: 'Discover' }, + ...(askAiEnabled ? [{ id: 'ask-ai', label: 'Ask AI' }] : []), + ...(insightsEnabled ? [{ id: 'insights', label: 'Insights' }] : []), + ...(featureRequestsEnabled ? [{ id: 'feature-requests', label: 'Requests' }] : []), + { id: 'my-access', label: isAdmin ? 'Manage' : 'My Data' }, + ] + + const isActive = (id) => { + if (id === 'home') return currentPage === 'home' + if (id === 'discover') return ['discover', 'data', 'catalog', 'detail'].includes(currentPage) + if (id === 'ask-ai') return ['ask-ai', 'ai-explorer'].includes(currentPage) + if (id === 'insights') return currentPage === 'insights' + if (id === 'feature-requests') return currentPage === 'feature-requests' + if (id === 'my-access') return ['my-access', 'library', 'my-library', 'register', 'admin'].includes(currentPage) + return false + } + + const switchPersona = (id) => { + setCurrentPersona(id) + setPersonaMenuOpen(false) + setUserMenuOpen(false) + onNavigate('home') + } + + return ( +
+ {/* Demo Banner — only shown in demo mode */} + {demoMode && ( +
+ + Demo mode — viewing as {persona.name} ({persona.role}, {persona.department}) + {!isAdmin && ' · '} + {isAdmin && pendingRequests.length > 0 && ( + + )} +
+ )} + + {/* Top Nav */} +
+
+
+ {/* Logo */} + + + {/* Desktop Nav */} + + + {/* Right side */} +
+ {/* Notifications bell */} +
+ + + {notifOpen && ( +
+

+ {isAdmin ? 'Pending Approvals' : 'Notifications'} +

+ {isAdmin ? ( + pendingRequests.length === 0 ? ( +

No pending approvals

+ ) : ( + <> + {pendingRequests.slice(0, 5).map(r => ( + + ))} +
+ +
+ + ) + ) : ( + notifications.length === 0 ? ( +

No recent notifications

+ ) : ( + notifications.slice(0, 6).map((n, i) => ( +
+ {notifIcon(n.status)} +
+

{notifText(n)}

+ {n.status === 'Approved' && n.expires_at && ( +

+ Expires {new Date(n.expires_at).toLocaleDateString()} +

+ )} + {(n.status === 'Denied' || n.status === 'Revoked') && n.denial_reason && ( +

{n.denial_reason}

+ )} +
+
+ )) + ) + )} +
+ )} +
+ + {/* Search */} +
+ + { if (e.key === 'Enter' && e.target.value) onNavigate('discover') }} + /> +
+ + {/* Persona / User menu */} +
+ + + {userMenuOpen && ( +
+ {/* Current user info */} +
+
+
+ {persona.avatar} +
+
+

{persona.name}

+

{persona.email}

+

{persona.role}

+
+
+
+ + {/* Quick links */} +
+ + {isAdmin && ( + + )} +
+ + {/* Persona switcher — demo mode only */} + {demoMode && ( +
+

+ Switch Demo Persona +

+ {Object.values(personas).map(p => ( + + ))} +
+ )} +
+ )} +
+ + {/* Mobile menu */} + +
+
+
+ + {/* Mobile Nav */} + {mobileOpen && ( +
+ {navItems.map(item => ( + + ))} + {/* Mobile: pending badge shown inline on "Manage" item via navItems loop above */} +
+ )} +
+ +
+ {children} +
+ +
+
+
+ {appLogoUrl && {appName}} + {appName} · {appSubtitle} +
+ {navLinks?.some(l => l.visible) && ( + + )} +
+
+ + {(userMenuOpen || personaMenuOpen || mobileOpen || notifOpen) && ( +
{ setUserMenuOpen(false); setPersonaMenuOpen(false); setMobileOpen(false); setNotifOpen(false) }} /> + )} + + +
+ ) +} diff --git a/datamarket/src/components/ui/badge.jsx b/datamarket/src/components/ui/badge.jsx new file mode 100644 index 00000000..deae51c4 --- /dev/null +++ b/datamarket/src/components/ui/badge.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + ...props +}) { + return (
); +} + +export { Badge, badgeVariants } diff --git a/datamarket/src/components/ui/button.jsx b/datamarket/src/components/ui/button.jsx new file mode 100644 index 00000000..b4cbcfec --- /dev/null +++ b/datamarket/src/components/ui/button.jsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + () + ); +}) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/datamarket/src/components/ui/card.jsx b/datamarket/src/components/ui/card.jsx new file mode 100644 index 00000000..c0ede2ef --- /dev/null +++ b/datamarket/src/components/ui/card.jsx @@ -0,0 +1,50 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/datamarket/src/components/ui/dialog.jsx b/datamarket/src/components/ui/dialog.jsx new file mode 100644 index 00000000..b674d5d1 --- /dev/null +++ b/datamarket/src/components/ui/dialog.jsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/datamarket/src/components/ui/dropdown-menu.jsx b/datamarket/src/components/ui/dropdown-menu.jsx new file mode 100644 index 00000000..90673d1c --- /dev/null +++ b/datamarket/src/components/ui/dropdown-menu.jsx @@ -0,0 +1,155 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}) => { + return ( + () + ); +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/datamarket/src/components/ui/navigation-menu.jsx b/datamarket/src/components/ui/navigation-menu.jsx new file mode 100644 index 00000000..1a1c83d9 --- /dev/null +++ b/datamarket/src/components/ui/navigation-menu.jsx @@ -0,0 +1,104 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => ( + + {children} + + +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => ( + +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent" +) + +const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => ( + +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => ( +
+ +
+)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => ( + +
+ +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/datamarket/src/components/ui/separator.jsx b/datamarket/src/components/ui/separator.jsx new file mode 100644 index 00000000..c40b8883 --- /dev/null +++ b/datamarket/src/components/ui/separator.jsx @@ -0,0 +1,23 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef(( + { className, orientation = "horizontal", decorative = true, ...props }, + ref +) => ( + +)) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/datamarket/src/components/ui/sheet.jsx b/datamarket/src/components/ui/sheet.jsx new file mode 100644 index 00000000..1066ba39 --- /dev/null +++ b/datamarket/src/components/ui/sheet.jsx @@ -0,0 +1,108 @@ +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva } from "class-variance-authority"; +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/datamarket/src/config/navigation.js b/datamarket/src/config/navigation.js new file mode 100644 index 00000000..8716873b --- /dev/null +++ b/datamarket/src/config/navigation.js @@ -0,0 +1,138 @@ +import { + BarChart3, + Database, + ShieldAlert, + DollarSign, + ArrowLeftRight, + Bot, + FileText, + KeyRound, + Settings +} from 'lucide-react' + +export const primaryNavigation = [ + { + id: 'overview', + name: 'Overview', + href: 'overview', + icon: BarChart3, + badge: null, + description: 'DNA Portal dashboard with key metrics', + disabled: false + }, + { + id: 'data-products', + name: 'Data Products', + href: 'data-products', + icon: Database, + badge: '10', + description: 'Browse and discover enterprise data products', + disabled: false + }, + { + id: 'vendor-analytics', + name: 'Vendor Analytics', + href: 'vendor-analytics', + icon: ShieldAlert, + badge: '5 flags', + description: 'Vendor payments, risk scoring, and fraud detection', + disabled: false + }, + { + id: 'budget-finance', + name: 'Budget & Finance', + href: 'budget-finance', + icon: DollarSign, + badge: null, + description: 'Departmental budgets and variance analysis', + disabled: false + }, + { + id: 'internal-billing', + name: 'Internal Billing', + href: 'internal-billing', + icon: ArrowLeftRight, + badge: '2', + description: 'Inter-departmental charges and anomaly detection', + disabled: false + }, + { + id: 'ai-explorer', + name: 'AI Explorer', + href: 'ai-explorer', + icon: Bot, + badge: 'AI', + description: 'Natural language data exploration powered by Databricks Foundation Models', + disabled: false + }, + { + id: 'documents', + name: 'Documents', + href: 'documents', + icon: FileText, + badge: null, + description: 'Data dictionaries, training materials, and guides', + disabled: false + } +] + +export const secondaryNavigation = [ + { + id: 'access-requests', + name: 'Access Requests', + href: 'access-requests', + icon: KeyRound, + badge: null, + description: 'Request access to data products', + disabled: false + }, + { + id: 'admin', + name: 'Admin', + href: 'admin', + icon: Settings, + badge: null, + description: 'Portal administration and access controls', + disabled: false, + permissions: ['admin'] + } +] + +export const allNavigation = [...primaryNavigation, ...secondaryNavigation] + +export const getNavigationItem = (id) => { + return allNavigation.find(item => item.id === id) +} + +export const isNavigationItemActive = (currentPage, itemHref) => { + const normalizedCurrent = currentPage?.replace('#', '') || 'overview' + const normalizedHref = itemHref?.replace('#', '') || 'overview' + return normalizedCurrent === normalizedHref +} + +export const filterNavigationByPermissions = (items, userPermissions = []) => { + return items.filter(item => { + if (!item.permissions || item.permissions.length === 0) return true + return item.permissions.some(permission => userPermissions.includes(permission)) + }) +} + +export const getNavigationWithBadges = () => { + return allNavigation.filter(item => item.badge !== null) +} + +export const mobileNavConfig = { + primaryNavigation, + secondaryNavigation, + showBadges: true, + showDescriptions: false, + minTouchTarget: '44px' +} + +export const desktopSidebarConfig = { + primaryNavigation, + secondaryNavigation, + showBadges: true, + showDescriptions: true, + collapsible: true +} diff --git a/datamarket/src/context/AppConfigContext.jsx b/datamarket/src/context/AppConfigContext.jsx new file mode 100644 index 00000000..ce3c1cf0 --- /dev/null +++ b/datamarket/src/context/AppConfigContext.jsx @@ -0,0 +1,54 @@ +import { createContext, useContext, useEffect, useState, useCallback } from 'react' + +const defaults = { + appName: 'DataMarket', + appSubtitle: 'Data Discovery & Access', + appLogoUrl: '', + demoMode: true, + sqlWarehouseId:'', + rfaEnabled: false, + setupComplete: false, + autoDiscoverEnabled: false, + autoDiscoverPrefix: '', + databricksHost: '', + askAiEnabled: true, + insightsEnabled: true, + featureRequestsEnabled: false, + contributeUrl: '', + searchChips: [], + navLinks: [ + { label: 'About', visible: true }, + { label: 'FAQ', visible: true }, + { label: 'Contact', visible: true }, + ], + aboutText: '', + contactName: '', + contactEmail: '', + contactNote: '', + faqItems: [], +} + +const AppConfigContext = createContext({ ...defaults, refreshConfig: () => {} }) + +export function AppConfigProvider({ children }) { + const [config, setConfig] = useState(defaults) + + const refreshConfig = useCallback(() => { + fetch('/api/portal/config') + .then(r => r.json()) + .then(data => setConfig({ ...defaults, ...data })) + .catch(() => { /* keep defaults */ }) + }, []) + + useEffect(() => { refreshConfig() }, [refreshConfig]) + + return ( + + {children} + + ) +} + +export function useAppConfig() { + return useContext(AppConfigContext) +} diff --git a/datamarket/src/context/PersonaContext.jsx b/datamarket/src/context/PersonaContext.jsx new file mode 100644 index 00000000..056a9534 --- /dev/null +++ b/datamarket/src/context/PersonaContext.jsx @@ -0,0 +1,357 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react' +import { isAdminRole, normalizeRole, roleDisplayName } from '../lib/roles' + +export const personas = { + richard: { + id: 'richard', + name: 'Richard', + fullName: 'Richard Chen', + email: 'richard.chen@example.org', + role: 'Data Analyst', + department: 'Finance & Accounting', + avatar: 'RC', + color: '#3B82F6', + approvedProductRefs: ['DP-001', 'DP-007'], + description: 'Analyst — limited access, can browse and request' + }, + james: { + id: 'james', + name: 'James', + fullName: 'James Park', + email: 'james.park@example.org', + role: 'Finance Manager', + department: 'Budget & Finance', + avatar: 'JP', + color: '#10B981', + approvedProductRefs: ['DP-001', 'DP-002', 'DP-003', 'DP-007', 'DP-010'], + description: 'Finance Manager — pre-approved for financial data (demo only)' + }, + admin: { + id: 'admin', + name: 'Admin', + fullName: 'Data Steward', + email: 'datasteward@example.org', + role: 'Data Steward', + department: 'Data Platform Team', + avatar: 'DS', + color: '#8B5CF6', + approvedProductRefs: 'all', + description: 'Data Steward — full access + approval authority' + } +} + +const PersonaContext = createContext(null) + +function toProductRef(productRef) { + return typeof productRef === 'number' + ? `DP-${String(productRef).padStart(3, '0')}` + : productRef +} + +export function PersonaProvider({ children }) { + const [currentPersona, setCurrentPersona] = useState('richard') + const [requests, setRequests] = useState([]) + const [products, setProducts] = useState([]) + const [library, setLibrary] = useState([]) + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(false) + const [apiAvailable, setApiAvailable] = useState(false) + const [demoMode, setDemoMode] = useState(null) // null = identity not yet resolved + const [ssoUser, setSsoUser] = useState(null) + const [rfaEnabled, setRfaEnabled] = useState(false) + const [ucGrantsEnabled, setUcGrantsEnabled] = useState(false) + + const identityLoading = demoMode === null + + const ssoIsAdmin = ssoUser && !demoMode && isAdminRole(ssoUser.role) + + const persona = ssoUser && !demoMode ? { + id: ssoIsAdmin ? 'admin' : 'sso', + name: ssoUser.display_name?.split(' ')[0] || 'User', + fullName: ssoUser.display_name || ssoUser.email, + email: ssoUser.email, + role: roleDisplayName(ssoUser.role), + department: ssoUser.department || 'General', + avatar: (ssoUser.display_name || ssoUser.email).split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase(), + color: ssoIsAdmin ? '#8B5CF6' : '#3B82F6', + approvedProductRefs: ssoIsAdmin ? 'all' : [], + description: ssoIsAdmin ? 'Data Steward — authenticated via SSO' : 'Data Analyst — authenticated via SSO' + } : personas[currentPersona] + + const isAdmin = persona.id === 'admin' || ssoIsAdmin + + // ── Check API availability and identity mode ─────────────────────────────── + useEffect(() => { + fetch('/api/health') + .then(r => r.json()) + .then(data => { + const lakebaseOk = data.lakebase === 'connected' + setApiAvailable(lakebaseOk) + setDemoMode(data.demo_mode !== false) + setRfaEnabled(!!data.rfa_enabled) + setUcGrantsEnabled(!!data.uc_grants_enabled) + if (!lakebaseOk) console.info('[PersonaContext] Lakebase not connected, using demo data') + + if (data.demo_mode === false) { + fetch('/api/portal/identity') + .then(r => r.json()) + .then(id => { + if (id.mode === 'sso' && id.user) { + setSsoUser({ ...id.user, role: normalizeRole(id.user.role) }) + } + }) + .catch(() => {}) + } + }) + .catch(() => setApiAvailable(false)) + }, []) + + // ── Load from API or fall back to local seed ────────────────────────────── + const loadProducts = useCallback(async () => { + if (!apiAvailable) return + try { + const r = await fetch('/api/portal/products') + if (r.ok) setProducts(await r.json()) + } catch (e) { console.warn('products load failed', e) } + }, [apiAvailable]) + + const loadRequests = useCallback(async () => { + if (!apiAvailable) return + try { + const r = await fetch('/api/portal/requests') + if (r.ok) setRequests(await r.json()) + } catch (e) { console.warn('requests load failed', e) } + }, [apiAvailable]) + + const loadLibrary = useCallback(async () => { + if (!apiAvailable) return + try { + const r = await fetch(`/api/portal/library?email=${encodeURIComponent(persona.email)}`) + if (r.ok) setLibrary(await r.json()) + } catch (e) { console.warn('library load failed', e) } + }, [apiAvailable, persona.email]) + + const loadNotifications = useCallback(async () => { + if (!apiAvailable || isAdmin) return + try { + const r = await fetch(`/api/portal/notifications?email=${encodeURIComponent(persona.email)}`) + if (r.ok) setNotifications(await r.json()) + } catch (e) { console.warn('notifications load failed', e) } + }, [apiAvailable, persona.email, isAdmin]) + + useEffect(() => { + if (apiAvailable) { + loadProducts() + loadRequests() + loadLibrary() + loadNotifications() + } + }, [apiAvailable, loadProducts, loadRequests, loadLibrary, loadNotifications]) + + useEffect(() => { + if (apiAvailable) { + loadLibrary() + loadNotifications() + } + }, [currentPersona, apiAvailable, loadLibrary, loadNotifications]) + + // ── Mutations ───────────────────────────────────────────────────────────── + const submitRequest = async (product, form) => { + const productRef = product.product_ref || `DP-${String(product.id).padStart(3, '0')}` + if (apiAvailable) { + try { + const r = await fetch('/api/portal/requests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productRef, + requesterEmail: persona.email, + team: form.team || persona.department, + reason: form.reason, + accessLevel: 'Read Only' + }) + }) + if (r.ok) { + await loadRequests() + return await r.json() + } + } catch (e) { console.warn('submitRequest API failed, falling back', e) } + } + const newReq = { + request_ref: `REQ-${String(requests.length + 1).padStart(3, '0')}`, + product_ref: productRef, + product_name: product.display_name || product.name, + requester_email: persona.email, + requester_name: persona.fullName, + requester_team: form.team || persona.department, + business_reason: form.reason, + status: 'Pending', + requested_at: new Date().toISOString(), + access_level: 'Read Only', + _local: true + } + setRequests(prev => [newReq, ...prev]) + return newReq + } + + const approveRequest = async (reqRef) => { + if (apiAvailable) { + try { + await fetch(`/api/portal/requests/${reqRef}/approve`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adminEmail: persona.email }) + }) + await loadRequests() + return + } catch (e) { console.warn('approveRequest API failed', e) } + } + setRequests(prev => prev.map(r => + (r.request_ref === reqRef || r.id === reqRef) + ? { ...r, status: 'Approved', resolved_at: new Date().toISOString() } + : r + )) + } + + const denyRequest = async (reqRef, reason = '') => { + if (apiAvailable) { + try { + await fetch(`/api/portal/requests/${reqRef}/deny`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adminEmail: persona.email, reason }) + }) + await loadRequests() + return + } catch (e) { console.warn('denyRequest API failed', e) } + } + setRequests(prev => prev.map(r => + (r.request_ref === reqRef || r.id === reqRef) + ? { ...r, status: 'Denied', resolved_at: new Date().toISOString(), denial_reason: reason } + : r + )) + } + + const revokeRequest = async (reqRef, reason = '') => { + if (apiAvailable) { + try { + await fetch(`/api/portal/requests/${reqRef}/revoke`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adminEmail: persona.email, reason }) + }) + await loadRequests() + await loadLibrary() + await loadNotifications() + return + } catch (e) { console.warn('revokeRequest API failed', e) } + } + setRequests(prev => prev.map(r => + (r.request_ref === reqRef || r.id === reqRef) + ? { ...r, status: 'Revoked', resolved_at: new Date().toISOString(), denial_reason: reason } + : r + )) + } + + const myProductRequests = useCallback((productRef) => { + const refStr = toProductRef(productRef) + return requests.filter(r => { + const matchesProduct = r.product_ref === refStr || r.productRef === refStr + const matchesUser = r.requester_email === persona.email || r.requestedBy === currentPersona + return matchesProduct && matchesUser + }).sort((a, b) => new Date(b.resolved_at || b.requested_at) - new Date(a.resolved_at || a.requested_at)) + }, [requests, persona.email, currentPersona]) + + const hasApprovedAccess = useCallback((productRef) => { + const refStr = toProductRef(productRef) + if (persona.approvedProductRefs === 'all') return false + if (persona.approvedProductRefs.includes(refStr)) return true + return myProductRequests(productRef).some(r => { + if (r.status !== 'Approved') return false + if (r.expires_at && new Date(r.expires_at) < new Date()) return false + return true + }) + }, [persona, myProductRequests]) + + const getProductAccessState = useCallback((productRef) => { + const refStr = toProductRef(productRef) + const mine = myProductRequests(productRef) + const approved = mine.find(r => { + if (r.status !== 'Approved') return false + if (r.expires_at && new Date(r.expires_at) < new Date()) return false + return true + }) + if (approved) { + return { state: 'granted', requestRef: approved.request_ref || approved.id, canQuery: true } + } + const revoked = mine.find(r => r.status === 'Revoked') + if (revoked) { + return { + state: 'revoked', + requestRef: revoked.request_ref || revoked.id, + reason: revoked.denial_reason || revoked.denyReason, + canQuery: false, + } + } + const pending = mine.find(r => r.status === 'Pending') + if (pending) { + return { state: 'pending', requestRef: pending.request_ref || pending.id, canQuery: false } + } + if (isAdmin) { + return { state: 'admin', canQuery: true } + } + return { state: 'none', canQuery: false } + }, [myProductRequests, isAdmin]) + + const hasAccess = useCallback((productRef) => { + const access = getProductAccessState(productRef) + return access.canQuery + }, [getProductAccessState]) + + const unreadNotificationCount = useMemo(() => notifications.length, [notifications]) + + const myRequests = requests.filter(r => + r.requester_email === persona.email || r.requestedBy === currentPersona) + const pendingRequests = requests.filter(r => r.status === 'Pending') + + return ( + {}, + persona, + isAdmin, + requests, + myRequests, + pendingRequests, + products, + library, + notifications, + unreadNotificationCount, + loading, + identityLoading, + apiAvailable, + demoMode, + rfaEnabled, + ucGrantsEnabled, + ssoUser, + submitRequest, + approveRequest, + denyRequest, + revokeRequest, + hasAccess, + hasApprovedAccess, + getProductAccessState, + refreshRequests: loadRequests, + refreshLibrary: loadLibrary, + refreshNotifications: loadNotifications + }}> + {children} + + ) +} + +export function usePersona() { + const ctx = useContext(PersonaContext) + if (!ctx) throw new Error('usePersona must be used within PersonaProvider') + return ctx +} diff --git a/datamarket/src/index.css b/datamarket/src/index.css new file mode 100644 index 00000000..4ebce20e --- /dev/null +++ b/datamarket/src/index.css @@ -0,0 +1,167 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@import './styles/responsive.css'; +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 217.2 91.2% 59.8%; /* databricks-blue */ + --primary-foreground: 222.2 84% 4.9%; + + --secondary: 215.4 16.3% 46.9%; /* databricks-slate */ + --secondary-foreground: 222.2 84% 4.9%; + + --muted: 210 40% 98%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 188.7 91.4% 42.2%; /* databricks-cyan */ + --accent-foreground: 222.2 84% 4.9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 217.2 91.2% 59.8%; /* databricks-blue */ + + --chart-1: 217.2 91.2% 59.8%; /* blue */ + --chart-2: 158.1 64.4% 51.6%; /* emerald */ + --chart-3: 248.7 83.5% 67.1%; /* purple */ + --chart-4: 36.7 100% 48%; /* amber */ + --chart-5: 188.7 91.4% 42.2%; /* cyan */ + --chart-6: 330.4 81.2% 60.4%; /* pink */ + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 84% 4.9%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 188.7 91.4% 42.2%; + --accent-foreground: 222.2 84% 4.9%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 217.2 91.2% 59.8%; + + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 250 70% 60%; + --chart-4: 40 70% 50%; + --chart-5: 190 70% 50%; + --chart-6: 330 70% 50%; + } +} + +@layer base { + * { + @apply border-border; + } + html { + height: 100%; + } + body { + @apply bg-background text-foreground; + font-family: 'Inter', system-ui, sans-serif; + height: 100%; + } + #root { + height: 100%; + } +} + + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + + + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/datamarket/src/main.jsx b/datamarket/src/main.jsx new file mode 100644 index 00000000..0291fe5b --- /dev/null +++ b/datamarket/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) \ No newline at end of file diff --git a/datamarket/src/pages/AIExplorerPage.jsx b/datamarket/src/pages/AIExplorerPage.jsx new file mode 100644 index 00000000..ca7de3f8 --- /dev/null +++ b/datamarket/src/pages/AIExplorerPage.jsx @@ -0,0 +1,365 @@ +import React, { useState, useRef, useEffect } from 'react' +import { Bot, Send, Sparkles, Database, BarChart3, FileText, RotateCcw, Search, ExternalLink, Tag, ArrowRight, BookOpen, ClipboardCheck, Zap, ChevronRight } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { useAppConfig } from '@/context/AppConfigContext' + +const TYPE_ICONS = { Dashboard: BarChart3, Report: FileText, Dataset: Database } +const DataMarket_BLUE = '#003865' + +const DOMAIN_COLORS = { + Finance: { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' }, + HR: { bg: 'bg-violet-50', text: 'text-violet-700', border: 'border-violet-200' }, + Operations: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' }, + 'Public Safety': { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' }, + Technology: { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' }, + Analytics: { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200' }, + Other: { bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-200' }, +} +function domainColor(d) { return DOMAIN_COLORS[d] || DOMAIN_COLORS.Other } + +const HOW_IT_WORKS = [ + { + icon: Search, + step: '1. Describe what you need', + detail: 'Type a business question, topic, or use case in plain English.', + }, + { + icon: BookOpen, + step: '2. AI finds matching data', + detail: 'Databricks AI scans your catalog and explains why each product fits.', + }, + { + icon: ClipboardCheck, + step: '3. Request access', + detail: 'Open a product and click "Request Access" — a steward reviews and approves.', + }, + { + icon: Zap, + step: '4. Query immediately', + detail: 'Once approved, open the table in UC Explorer or copy a ready-to-run SQL snippet.', + }, +] + +export function AIExplorerPage({ initialQuestion = '', onNavigate, onOpenProduct }) { + const { demoMode } = useAppConfig() + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const [domains, setDomains] = useState([]) + const bottomRef = useRef(null) + const inputRef = useRef(null) + const initialSent = useRef(false) + + // Load real domains from catalog on mount + useEffect(() => { + fetch('/api/portal/products?includeAll=false') + .then(r => r.json()) + .then(products => { + if (!Array.isArray(products)) return + const counts = {} + products.forEach(p => { + const d = p.domain || 'Other' + counts[d] = (counts[d] || 0) + 1 + }) + setDomains(Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 7) + .map(([name, count]) => ({ name, count })) + ) + }) + .catch(() => {}) + }, []) + + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, loading]) + + const sendQuestion = async (question) => { + if (!question.trim() || loading) return + const q = question.trim() + setInput('') + setMessages(prev => [...prev, { role: 'user', content: q }]) + setLoading(true) + + try { + const r = await fetch('/api/portal/ask-catalog', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question: q }) + }) + const data = await r.json() + if (!r.ok) throw new Error(data.error || 'API error') + + if (data.matches?.length > 0) { + setMessages(prev => [...prev, { role: 'assistant', type: 'products', matches: data.matches, question: q }]) + } else { + setMessages(prev => [...prev, { + role: 'assistant', type: 'empty', question: q, + content: `No data products matched "${q}". Try rephrasing or browse the full catalog.` + }]) + } + } catch (e) { + setMessages(prev => [...prev, { + role: 'assistant', type: 'error', + content: `Catalog search is unavailable right now. ${e.message}` + }]) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (initialQuestion && !initialSent.current) { + initialSent.current = true + setTimeout(() => sendQuestion(initialQuestion), 200) + } + }, [initialQuestion]) + + const openProduct = async (ref, name) => { + try { + const res = await fetch('/api/portal/products?includeAll=true') + const products = await res.json() + const p = products.find(x => x.product_ref === ref || x.display_name === name) + if (p && onOpenProduct) { + onOpenProduct({ + id: p.product_id, product_ref: p.product_ref, ref: p.product_ref, + name: p.display_name, description: p.description, type: p.type, + source: p.source_system, tags: Array.isArray(p.tags) ? p.tags : [], + refreshFrequency: p.refresh_frequency, owner: p.owner_email, + classification: p.classification, uc_full_name: p.uc_full_name, ucFullName: p.uc_full_name, + }) + return + } + } catch (_) {} + onNavigate?.('discover', { search: name }) + } + + return ( +
+
+
+ +
+
+

AI Explorer

+

Find data products using natural language — powered by Databricks Foundation Models

+
+
+ +
+ + {/* ── Chat panel ── */} +
+ + + + {messages.length === 0 && !loading && ( +
+
+ +
+

What data are you looking for?

+

Describe your use case or ask about a topic. I'll find the most relevant data products in your catalog.

+ {domains.length > 0 && ( +
+ {domains.map(d => { + const c = domainColor(d.name) + return ( + + ) + })} +
+ )} +
+ )} + + {messages.map((msg, i) => ( +
+ {msg.role === 'user' ? ( +
+ {msg.content} +
+ ) : ( +
+
+ + Catalog AI +
+ + {msg.type === 'products' && ( + <> +

+ Found {msg.matches.length} data product{msg.matches.length > 1 ? 's' : ''} relevant to "{msg.question}": +

+
+ {msg.matches.map((m, mi) => { + const Icon = TYPE_ICONS[m.type] || Database + const c = domainColor(m.domain) + return ( +
+
+
+
+ +
+
+
+ {m.name} + {m.domain} + {m.type} +
+

{m.reason}

+ {m.tags?.length > 0 && ( +
+ {m.tags.slice(0, 4).map(t => ( + + {t} + + ))} +
+ )} +
+
+ +
+
+ ) + })} +
+ + + )} + + {(msg.type === 'empty' || msg.type === 'error') && ( +
+

{msg.content}

+ {msg.type === 'empty' && ( + + )} +
+ )} +
+ )} +
+ ))} + + {loading && ( +
+
+
+ {[0, 150, 300].map(d => ( +
+ ))} +
+ Searching catalog... +
+
+ )} +
+ + +
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendQuestion(input)} + /> + + {messages.length > 0 && ( + + )} +
+
+ +
+ + {/* ── Sidebar ── */} +
+ + {/* Browse by domain */} + {domains.length > 0 && ( + + + Browse by domain + + + {domains.map(d => { + const c = domainColor(d.name) + return ( + + ) + })} + + + )} + + {/* How it works */} + + + How it works + + + {HOW_IT_WORKS.map((step, i) => ( +
+
+ +
+
+

{step.step}

+

{step.detail}

+
+
+ ))} +
+
+ + {/* Tip */} + + +

Tip

+

+ The more specific your question, the better. Try including the use case: + "revenue data for Q4 budget forecasting" rather than just + "revenue". +

+
+
+ +
+
+
+ ) +} diff --git a/datamarket/src/pages/AboutPage.jsx b/datamarket/src/pages/AboutPage.jsx new file mode 100644 index 00000000..2864a61e --- /dev/null +++ b/datamarket/src/pages/AboutPage.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import { Database, ShieldCheck, Users, Zap, ArrowRight, ExternalLink, MessageSquarePlus } from 'lucide-react' +import { useAppConfig } from '@/context/AppConfigContext' + +const DataMarket_BLUE = '#003865' + +const FEATURES = [ + { icon: Database, title: 'Unified data catalog', desc: 'Browse and search all certified data products across your organization in one place.' }, + { icon: ShieldCheck, title: 'Governed access', desc: 'Request access to datasets. Stewards review and approve — Unity Catalog permissions are set automatically.' }, + { icon: Users, title: 'Role-based experience', desc: 'Analysts discover and request. Managers review team usage. Stewards publish and approve. Admins configure everything.' }, + { icon: Zap, title: 'AI-powered discovery', desc: 'Describe what you need in plain English. Databricks Foundation Models find the most relevant data products instantly.' }, +] + +export function AboutPage({ onNavigate }) { + const { appName, appSubtitle, appLogoUrl, aboutText, contributeUrl } = useAppConfig() + + return ( +
+ + {/* Hero */} +
+ {appLogoUrl + ? {appName} + : ( +
+ +
+ ) + } +
+

{appName}

+

{appSubtitle}

+ + Powered by Databricks + +
+
+ + {/* About text — admin-configured or sensible default */} +
+ {aboutText + ? aboutText.split('\n').map((line, i) =>

{line}

) + : ( + <> +

+ {appName} is a self-service data portal built on Databricks. + It gives every team in the organisation a single place to discover what data exists, understand what it means, + and request access — without filing a ticket or waiting for an engineer. +

+

+ All data products listed here are certified and managed by your data stewards. + Access is governed through Unity Catalog: when a steward approves your request, + permissions are granted automatically and you can query the table immediately. +

+

+ Use the Discover tab to browse the catalog, Ask AI to search + in plain English, and My Data to track your requests and approved datasets. +

+ + ) + } +
+ + {/* Feature grid */} +
+

What you can do

+
+ {FEATURES.map(f => ( +
+
+ +
+
+

{f.title}

+

{f.desc}

+
+
+ ))} +
+
+ + {/* CTA */} +
+ + + {contributeUrl && ( + + + Suggest a feature + + + )} +
+ +
+ ) +} diff --git a/datamarket/src/pages/ContactPage.jsx b/datamarket/src/pages/ContactPage.jsx new file mode 100644 index 00000000..292fbc45 --- /dev/null +++ b/datamarket/src/pages/ContactPage.jsx @@ -0,0 +1,84 @@ +import React from 'react' +import { Mail, MessageSquare, HelpCircle, ExternalLink } from 'lucide-react' +import { useAppConfig } from '@/context/AppConfigContext' + +const DataMarket_BLUE = '#003865' + +export function ContactPage({ onNavigate }) { + const { appName, contactName, contactEmail, contactNote } = useAppConfig() + + const name = contactName || 'Your Data Team' + const email = contactEmail || '' + + return ( +
+ +
+
+ +
+
+

Contact

+

Get in touch with the {appName} team

+
+
+ + {/* Contact card */} +
+
+
+ {name.charAt(0).toUpperCase()} +
+
+

{name}

+

{appName} administrator

+
+
+ + {email && ( + + + {email} + + + )} + + {contactNote && ( +

+ {contactNote} +

+ )} + + {!email && !contactNote && ( +

+ Contact details not yet configured. An admin can set them under Manage → Settings. +

+ )} +
+ + {/* Quick links */} +
+

Quick links

+ + +
+ +
+ ) +} diff --git a/datamarket/src/pages/DataMarketAdminPage.jsx b/datamarket/src/pages/DataMarketAdminPage.jsx new file mode 100644 index 00000000..4542df7f --- /dev/null +++ b/datamarket/src/pages/DataMarketAdminPage.jsx @@ -0,0 +1,571 @@ +import React, { useState, useEffect } from 'react' +import { ShieldCheck, CheckCircle2, XCircle, Clock, Terminal, ChevronDown, ChevronUp, User, Calendar, Database, Package, Eye, RotateCcw, AlertTriangle } from 'lucide-react' +import { usePersona } from '../context/PersonaContext' +import { useAppConfig } from '../context/AppConfigContext' + +const DataMarket_BLUE = '#003865' + +function UCGrantPanel({ request, action }) { + const [open, setOpen] = useState(false) + + const grantSQL = request.ucGrantSql || `-- Unity Catalog RBAC — executed automatically on approval +GRANT SELECT ON TABLE .. + TO \`${request.requesterEmail}\`; + +-- Verify the grant was applied +SHOW GRANTS ON TABLE ..;` + + const revokeSQL = `-- Unity Catalog RBAC — executed automatically on denial +REVOKE SELECT ON TABLE .. + FROM \`${request.requesterEmail}\`;` + + // Pending requests always preview the GRANT that will be executed on approval + const sql = action === 'Denied' || action === 'Revoked' ? revokeSQL : grantSQL + + return ( +
+ + {open && ( +
+
+
+
+
+ Unity Catalog — your_catalog +
+
{sql}
+
+ )} +
+ ) +} + +// Normalize API (snake_case) and local (camelCase) request shapes +function norm(r) { + return { + id: r.request_ref || r.id, + productName: r.product_name || r.productName, + requesterEmail:r.requester_email || r.requesterEmail, + requesterName: r.requester_name || r.requesterName, + requesterTeam: r.requester_team || r.requesterTeam, + reason: r.business_reason || r.reason, + requestedAt: r.requested_at || r.requestedAt, + status: r.status, + accessLevel: r.access_level || r.accessLevel || 'Read Only', + denyReason: r.denial_reason || r.denyReason, + ucGrantSql: r.uc_grant_sql || r.ucGrantSql, + ucFullName: r.uc_full_name, + expiresAt: r.expires_at || r.expiresAt, + } +} + +export function DataMarketAdminPage({ embedded = false }) { + const { requests, approveRequest, denyRequest, revokeRequest, currentPersona, isAdmin } = usePersona() + const { demoMode } = useAppConfig() + const [filter, setFilter] = useState(embedded ? 'Approved' : 'Pending') + const [activeView, setActiveView] = useState('access') // 'access' | 'products' + const [denyModal, setDenyModal] = useState(null) + const [denyReason, setDenyReason] = useState('') + const [revokeModal, setRevokeModal] = useState(null) + const [revokeReason, setRevokeReason] = useState('') + const [justActed, setJustActed] = useState({}) + const [pendingProducts, setPendingProducts] = useState([]) + const [resetState, setResetState] = useState('idle') // 'idle' | 'confirm' | 'loading' | 'done' + const [productActed, setProductActed] = useState({}) + const normalizedReqs = requests.map(norm) + + useEffect(() => { + fetch('/api/portal/products?includeAll=true') + .then(r => r.json()) + .then(rows => { + if (Array.isArray(rows)) { + setPendingProducts(rows.filter(p => p.status === 'Pending' || p.is_active === false)) + } + }) + .catch(() => {}) + }, []) + + const handlePublish = async (productRef, productName) => { + try { + await fetch(`/api/portal/products/${productRef}/publish`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reviewerEmail: 'datasteward@example.org' }) + }) + setProductActed(prev => ({ ...prev, [productRef]: 'published' })) + setPendingProducts(prev => prev.filter(p => p.product_ref !== productRef)) + } catch (e) { console.error(e) } + } + + const handleRejectProduct = async (productRef) => { + try { + await fetch(`/api/portal/products/${productRef}/reject`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reviewerEmail: 'datasteward@example.org', reason: 'Does not meet governance standards' }) + }) + setProductActed(prev => ({ ...prev, [productRef]: 'rejected' })) + setPendingProducts(prev => prev.filter(p => p.product_ref !== productRef)) + } catch (e) { console.error(e) } + } + + if (!isAdmin) { + return ( +
+ +

Access Restricted

+

This page is only accessible to Data Stewards and Admins.

+
+ ) + } + + const filtered = normalizedReqs.filter(r => filter === 'All' || r.status === filter) + const counts = { + Pending: normalizedReqs.filter(r => r.status === 'Pending').length, + Approved: normalizedReqs.filter(r => r.status === 'Approved').length, + Denied: normalizedReqs.filter(r => r.status === 'Denied').length, + } + + const handleApprove = (reqId) => { + approveRequest(reqId) + setJustActed(prev => ({ ...prev, [reqId]: 'approved' })) + } + + const handleDeny = () => { + denyRequest(denyModal, denyReason) + setJustActed(prev => ({ ...prev, [denyModal]: 'denied' })) + setDenyModal(null) + setDenyReason('') + } + + const handleRevoke = () => { + revokeRequest(revokeModal, revokeReason) + setJustActed(prev => ({ ...prev, [revokeModal]: 'revoked' })) + setRevokeModal(null) + setRevokeReason('') + } + + const timeAgo = (iso) => { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.floor(hrs / 24)}d ago` + } + + const wrapperClass = embedded + ? 'w-full' + : 'max-w-7xl mx-auto px-4 sm:px-6 py-8' + + return ( +
+ {!embedded &&
+
+

+ + {activeView === 'access' ? 'Approval Queue' : 'Product Registration Queue'} +

+

+ {activeView === 'access' + ? 'Review and action data access requests — approvals automatically issue Unity Catalog grants' + : 'Review and publish new data products submitted by producers'} +

+
+ {/* View toggle + Demo Reset */} +
+ + + + {/* Demo Reset — only shown when DEMO_MODE=true */} + {demoMode &&
+ {resetState === 'idle' && ( + + )} + {resetState === 'confirm' && ( +
+ + Clear all demo data? + + +
+ )} + {resetState === 'loading' && ( + + Clearing... + + )} + {resetState === 'done' && ( + + Reset done + + )} +
} +
+
} + + {/* View toggle shown in embedded mode as compact pills */} + {embedded && ( +
+ + +
+ )} + + {/* ── Product Registration Queue ─────────────────────────────────────────── */} + {activeView === 'products' && ( +
+ {pendingProducts.length === 0 && Object.keys(productActed).length === 0 && ( +
+ +

No products pending review.

+

Products submitted via Register Product will appear here.

+
+ )} + {Object.entries(productActed).map(([ref, action]) => ( +
+
+ {action === 'published' ? : } + {ref} — {action === 'published' ? 'Published to catalog ✓' : 'Rejected'} +
+
+ ))} + {pendingProducts.map(p => { + const tags = Array.isArray(p.tags) ? p.tags + : typeof p.tags === 'string' ? (p.tags.startsWith('[') ? JSON.parse(p.tags) : p.tags.split(',')) : [] + return ( +
+
+
+
+ {p.product_ref} + Pending Review + {p.domain || p.source_system} +
+
+ +

{p.display_name}

+ {p.type} +
+
+ {p.owner_email} + {p.classification} · {p.refresh_frequency} +
+
+

Description

+ {p.description} +
+ {tags.length > 0 && ( +
+ {tags.map(t => {t})} +
+ )} +
+
+ + +
+
+
+ ) + })} +
+ )} + + {/* ── Access Request Queue ───────────────────────────────────────────────── */} + {activeView === 'access' && (<> + + {/* Stats */} +
+ {[ + { label: 'Pending', count: counts.Pending, color: 'amber', icon: Clock }, + { label: 'Approved', count: counts.Approved, color: 'emerald', icon: CheckCircle2 }, + { label: 'Denied', count: counts.Denied, color: 'red', icon: XCircle }, + ].map(({ label, count, color, icon: Icon }) => ( + + ))} +
+ + {/* Filter tabs */} +
+ {['Pending', 'Approved', 'Denied', 'All'].map(tab => ( + + ))} +
+ + {/* Request cards */} +
+ {filtered.length === 0 && ( +
+ +

No {filter.toLowerCase()} requests.

+
+ )} + + {filtered.map(req => { + const acted = justActed[req.id] + return ( +
+
+
+ {/* Header */} +
+ {req.id} + + {req.status} + + {timeAgo(req.requestedAt)} +
+ + {/* Product */} +
+ +

{req.productName}

+ {req.accessLevel} +
+ + {/* Requester */} +
+ + + {req.requesterName} · {req.requesterEmail} + + + + {req.requesterTeam} + +
+ + {/* Reason */} +
+

Business Justification

+ {req.reason} +
+ + {req.expiresAt && req.status === 'Approved' && ( +
+ + Access expires {new Date(req.expiresAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+ )} + {req.denyReason && ( +
+

{req.status === 'Revoked' ? 'Revocation Reason' : 'Denial Reason'}

+ {req.denyReason} +
+ )} + + +
+ + {/* Action buttons */} + {req.status === 'Pending' && ( +
+ + +
+ )} + + {req.status !== 'Pending' && ( +
+
+ {req.status === 'Approved' ? : } + {req.status} +
+ {req.status === 'Approved' && ( + + )} +
+ )} +
+
+ ) + })} +
+ ) } + + {/* Deny modal */} + {denyModal && ( +
+
+

Deny Access Request

+

+ Provide a reason for denying this request. The requester will be notified. +

+