feat(appliance): initial release of MERICA standalone appliance v1.0.0
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# FRONTEND CLIENT: REACT.JS PIPELINE BUILDER UI
|
||||
## DLH Data Lakehouse Interactive Web UI Generator
|
||||
|
||||
---
|
||||
|
||||
## 1. Description
|
||||
|
||||
Folder ini memuat aplikasi **React.js Client** yang menyajikan Form Builder interaktif berbasis Master Data Registries dari FastAPI Backend.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cara Menjalankan React Frontend
|
||||
|
||||
```bash
|
||||
cd experimental/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- **Frontend App**: `http://localhost:5173` (Vite)
|
||||
- **TypeScript Contract**: `src/types/pipeline.ts`
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MERICA Data Lakehouse - Pipeline Studio</title>
|
||||
<!-- Google Fonts: Inter & JetBrains Mono for Data-Dense Dashboard Typography -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- Runtime Environment Injection (MERICA Standalone ) -->
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+4793
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "dlh-pipeline-ui-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"js-yaml": "^5.4.2",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"reactflow": "^11.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Runtime configuration injected by MERICA entrypoint
|
||||
window.__MERICA_ENV__ = window.__MERICA_ENV__ || {
|
||||
API_BASE: '/api/v1',
|
||||
OPENMETADATA_HOST: 'http://localhost:8585',
|
||||
AIRFLOW_WEB_URL: 'http://localhost:8080',
|
||||
MINIO_CONSOLE_URL: 'http://localhost:9001',
|
||||
SITOMAT_URL: 'http://localhost:8000',
|
||||
APP_VERSION: '1.0.0',
|
||||
ENVIRONMENT: 'development'
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import AppRouter from './router/AppRouter';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppRouter />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import App from './App';
|
||||
|
||||
describe('Web UI Pipeline Studio - Airflow 3 Versioning & Canvas Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.setItem('dlh_studio_user', JSON.stringify({
|
||||
email: 'admin@dlh.go.id',
|
||||
role: 'admin',
|
||||
name: 'DLH Admin'
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn().mockImplementation((url, options) => {
|
||||
if (url.includes('/connections')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
connections: [
|
||||
{ conn_id: 'conn_postgres_source_db', conn_type: 'postgres' },
|
||||
{ conn_id: 'conn_spku_api_service', conn_type: 'http' },
|
||||
{ conn_id: 'conn_minio_s3_dlh', conn_type: 'aws' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes('/deploy')) {
|
||||
const body = JSON.parse(options.body);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: 'success',
|
||||
pipeline_id: body.pipeline_id,
|
||||
filename: `${body.pipeline_id}.yaml`,
|
||||
message: `Pipeline '${body.pipeline_id}' successfully generated & saved`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes('/publish')) {
|
||||
const body = JSON.parse(options.body);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: 'published',
|
||||
pipeline_id: body.pipeline_id,
|
||||
version: body.version_tag || 'v1.1.0',
|
||||
bundle_name: body.bundle_name || 'dlh-core-ingestion',
|
||||
message: 'Version published successfully',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes('/versions')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
pipeline_id: 'test_pipeline',
|
||||
current_version: 'v1.0.0',
|
||||
bundle_name: 'dlh-core-ingestion',
|
||||
versions: [
|
||||
{
|
||||
version: 'v1.0.0',
|
||||
pipeline_id: 'test_pipeline',
|
||||
bundle_name: 'dlh-core-ingestion',
|
||||
changelog: 'Initial baseline deployment',
|
||||
published_at: '2026-09-28T12:00:00Z',
|
||||
nodes_count: 3,
|
||||
edges_count: 2,
|
||||
}
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes('/pipelines')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ pipelines: [] }),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
});
|
||||
});
|
||||
|
||||
it('1. Render Shell with MERICA Brand, Sidebar Navigation, DAG Bundle, and Role pill', async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText(/^MERICA$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Studio/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Admin \(Full Access\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByTitle(/AIRFLOW 3 DAG BUNDLE/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/dlh-core-ingestion/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('2. Open and interact with Publish Version Modal via More menu', async () => {
|
||||
render(<App />);
|
||||
|
||||
const moreBtn = screen.getByLabelText(/More actions/i);
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
const publishBtn = screen.getByText(/Publish Version/i);
|
||||
fireEvent.click(publishBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Publish Version Snapshot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Airflow 3 GitDAGBundle Version Pinning/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('3. Open and interact with Time Machine Modal via More menu', async () => {
|
||||
render(<App />);
|
||||
|
||||
const moreBtn = screen.getByLabelText(/More actions/i);
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
const historyBtn = screen.getByText(/Time Machine/i);
|
||||
fireEvent.click(historyBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Time Machine: Version History/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('4. Switch Dark and Light Mode theme state via More menu', async () => {
|
||||
render(<App />);
|
||||
|
||||
const moreBtn = screen.getByLabelText(/More actions/i);
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
const themeToggleBtn = screen.getByTitle(/Switch to Light Mode/i);
|
||||
fireEvent.click(themeToggleBtn);
|
||||
|
||||
// Reopen menu to verify theme state toggle
|
||||
fireEvent.click(moreBtn);
|
||||
expect(screen.getByText(/Dark mode/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. Validate spec and status message overlay', async () => {
|
||||
render(<App />);
|
||||
|
||||
const validateBtn = screen.getByText(/Validate/i);
|
||||
fireEvent.click(validateBtn);
|
||||
|
||||
expect(screen.getByText(/Canvas is empty\. Add components to validate\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('6. Render Admin Settings button and navigate to Settings page', async () => {
|
||||
render(<App />);
|
||||
|
||||
const settingsBtn = screen.getByTitle(/Akses Pengaturan Dynamic RBAC/i);
|
||||
expect(settingsBtn).toBeInTheDocument();
|
||||
fireEvent.click(settingsBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Pengaturan Hak Akses & Dynamic RBAC/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Editor \(Data Engineer\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Viewer \(Pimpinan & Auditor\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import HeaderBar from './components/HeaderBar';
|
||||
|
||||
describe('accessibility.test.jsx - Keyboard Navigation, Shortcuts, and Theme Modes', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('1. Toggles theme between dark and light mode and sets root attributes', () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(
|
||||
<HashRouter>
|
||||
<HeaderBar
|
||||
theme="dark"
|
||||
onToggleTheme={onToggleTheme}
|
||||
pipelineId="test_pipe"
|
||||
/>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
const moreBtn = screen.getByLabelText(/More actions/i);
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
const themeBtn = screen.getByTitle(/Switch to Light Mode/i);
|
||||
expect(themeBtn).toBeInTheDocument();
|
||||
fireEvent.click(themeBtn);
|
||||
|
||||
expect(onToggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('2. Shows keyboard shortcut badges in More menu and accessible labels', () => {
|
||||
const onUndo = vi.fn();
|
||||
const onRedo = vi.fn();
|
||||
|
||||
render(
|
||||
<HashRouter>
|
||||
<HeaderBar
|
||||
theme="dark"
|
||||
canUndo={true}
|
||||
canRedo={true}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
pipelineId="test_pipe"
|
||||
/>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
const moreBtn = screen.getByLabelText(/More actions/i);
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
expect(screen.getByText('Ctrl+Z')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ctrl+Y')).toBeInTheDocument();
|
||||
|
||||
const undoBtn = screen.getByRole('button', { name: /Undo/i });
|
||||
fireEvent.click(undoBtn);
|
||||
expect(onUndo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('3. Renders accessible aria labels and titles for all interactive buttons', () => {
|
||||
render(
|
||||
<HashRouter>
|
||||
<HeaderBar
|
||||
theme="dark"
|
||||
pipelineId="test_pipe"
|
||||
schedule="@hourly"
|
||||
/>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Pipeline name')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Pipeline schedule')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('More actions')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Deploy DAG/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Validate/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import React, { useState } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { yaml as yamlLang } from '@codemirror/lang-yaml';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FileCode2,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function BottomDrawer({
|
||||
isOpen = false,
|
||||
onToggle,
|
||||
activeTab = 'yaml', // 'yaml' | 'problems'
|
||||
onChangeTab,
|
||||
yamlContent = '',
|
||||
problems = [],
|
||||
nodeCount = 0,
|
||||
edgeCount = 0,
|
||||
theme = 'dark',
|
||||
onSelectNode,
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const errors = problems.filter((p) => p.severity === 'error');
|
||||
const warnings = problems.filter((p) => p.severity === 'warning');
|
||||
|
||||
const handleCopyYaml = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!yamlContent) return;
|
||||
navigator.clipboard.writeText(yamlContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleTabClick = (tab, e) => {
|
||||
e.stopPropagation();
|
||||
if (onChangeTab) {
|
||||
onChangeTab(tab);
|
||||
}
|
||||
if (!isOpen && onToggle) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProblemNode = (nodeId, e) => {
|
||||
e.stopPropagation();
|
||||
if (nodeId && onSelectNode) {
|
||||
onSelectNode(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="drawer"
|
||||
className={isOpen ? 'open' : ''}
|
||||
style={{
|
||||
flex: 'none',
|
||||
background: 'var(--surface)',
|
||||
borderTop: '1px solid var(--line)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 15,
|
||||
transition: 'height 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{/* 36px BOTTOM BAR (#dbar per design-spec.md) */}
|
||||
<div
|
||||
id="dbar"
|
||||
onClick={onToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
height: '36px',
|
||||
padding: '0 14px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: '12.5px',
|
||||
}}
|
||||
>
|
||||
{/* EXPAND / COLLAPSE CHEVRON */}
|
||||
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--muted)' }}>
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
|
||||
{/* TAB 1: YAML PREVIEW */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleTabClick('yaml', e)}
|
||||
className={`chip ${activeTab === 'yaml' ? 'acc' : ''}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '2px 8px',
|
||||
fontSize: '12px',
|
||||
border: activeTab === 'yaml' ? '1px solid var(--acc)' : '1px solid transparent',
|
||||
}}
|
||||
aria-label="YAML preview tab"
|
||||
>
|
||||
<FileCode2 size={13} />
|
||||
<b>YAML preview</b>
|
||||
</button>
|
||||
|
||||
{/* TAB 2: PROBLEMS LIST */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleTabClick('problems', e)}
|
||||
className={`chip ${activeTab === 'problems' ? 'acc' : ''}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '2px 8px',
|
||||
fontSize: '12px',
|
||||
border: activeTab === 'problems' ? '1px solid var(--acc)' : '1px solid transparent',
|
||||
color: errors.length > 0 ? 'var(--err)' : (warnings.length > 0 ? '#f59e0b' : 'var(--muted)'),
|
||||
}}
|
||||
aria-label="Problems list tab"
|
||||
>
|
||||
{errors.length > 0 ? (
|
||||
<AlertCircle size={13} color="var(--err)" />
|
||||
) : warnings.length > 0 ? (
|
||||
<AlertTriangle size={13} color="#f59e0b" />
|
||||
) : (
|
||||
<CheckCircle2 size={13} color="var(--ok)" />
|
||||
)}
|
||||
<span>Problems</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 5px',
|
||||
borderRadius: '99px',
|
||||
fontSize: '11px',
|
||||
background: errors.length > 0 ? 'rgba(210,63,63,0.15)' : (warnings.length > 0 ? 'rgba(245,158,11,0.15)' : 'rgba(31,157,99,0.15)'),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{problems.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* RIGHT METRIC INFO */}
|
||||
<span className="m" id="cnt" style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: '11.5px' }}>
|
||||
{nodeCount} nodes · {edgeCount} dependencies
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* EXPANDED CONTENT BODY (#ybody) */}
|
||||
{isOpen && (
|
||||
<div
|
||||
id="ybody"
|
||||
style={{
|
||||
height: '200px',
|
||||
overflow: 'hidden',
|
||||
borderTop: '1px solid var(--line)',
|
||||
background: 'var(--bg)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* TAB 1: YAML VIEW */}
|
||||
{activeTab === 'yaml' && (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 12px',
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<span>Generated Apache Airflow 3 DAG Specification</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleCopyYaml}
|
||||
style={{ padding: '2px 8px', fontSize: '11.5px', gap: '4px' }}
|
||||
title="Copy YAML to clipboard"
|
||||
>
|
||||
{copied ? <Check size={12} color="var(--ok)" /> : <Copy size={12} />}
|
||||
<span>{copied ? 'Copied!' : 'Copy YAML'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<CodeMirror
|
||||
value={yamlContent}
|
||||
height="100%"
|
||||
extensions={[yamlLang()]}
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: PROBLEMS VIEW */}
|
||||
{activeTab === 'problems' && (
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '10px 14px' }}>
|
||||
{problems.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
height: '100%',
|
||||
color: 'var(--ok)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
<span>No problems detected. Workflow topology and node configurations are valid.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{problems.map((problem) => {
|
||||
const isErr = problem.severity === 'error';
|
||||
return (
|
||||
<div
|
||||
key={problem.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--surface)',
|
||||
border: `1px solid ${isErr ? 'rgba(210,63,63,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||||
fontSize: '12.5px',
|
||||
}}
|
||||
>
|
||||
{/* SEVERITY ICON */}
|
||||
<div style={{ flex: 'none', display: 'flex', alignItems: 'center' }}>
|
||||
{isErr ? (
|
||||
<AlertCircle size={15} color="var(--err)" />
|
||||
) : (
|
||||
<AlertTriangle size={15} color="#f59e0b" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PROBLEM TARGET */}
|
||||
{problem.nodeTitle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSelectProblemNode(problem.nodeId, e)}
|
||||
className="chip"
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
fontSize: '11px',
|
||||
cursor: problem.nodeId ? 'pointer' : 'default',
|
||||
background: 'var(--bg)',
|
||||
}}
|
||||
title={problem.nodeId ? 'Click to select and configure this task' : ''}
|
||||
>
|
||||
<span>{problem.nodeTitle}</span>
|
||||
{problem.nodeId && <ExternalLink size={10} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* CATEGORY */}
|
||||
<span style={{ fontSize: '11px', color: 'var(--muted)', fontWeight: 500 }}>
|
||||
[{problem.category}]
|
||||
</span>
|
||||
|
||||
{/* MESSAGE */}
|
||||
<span style={{ flex: 1, color: 'var(--text)' }}>
|
||||
{problem.message}
|
||||
</span>
|
||||
|
||||
{/* QUICK ACTION */}
|
||||
{problem.nodeId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={(e) => handleSelectProblemNode(problem.nodeId, e)}
|
||||
style={{ padding: '2px 8px', fontSize: '11px' }}
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import BottomDrawer from './BottomDrawer';
|
||||
|
||||
describe('BottomDrawer.jsx', () => {
|
||||
it('renders collapsed state showing node and dependency counts', () => {
|
||||
render(
|
||||
<BottomDrawer
|
||||
isOpen={false}
|
||||
nodeCount={5}
|
||||
edgeCount={4}
|
||||
problems={[]}
|
||||
yamlContent="dag_id: test\n"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('YAML preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 nodes · 4 dependencies')).toBeInTheDocument();
|
||||
expect(screen.getByText('Problems')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers onToggle when header bar is clicked', () => {
|
||||
const handleToggle = vi.fn();
|
||||
render(
|
||||
<BottomDrawer
|
||||
isOpen={false}
|
||||
onToggle={handleToggle}
|
||||
nodeCount={2}
|
||||
edgeCount={1}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /YAML preview tab/i }));
|
||||
});
|
||||
|
||||
it('renders empty problems state when no problems exist', () => {
|
||||
render(
|
||||
<BottomDrawer
|
||||
isOpen={true}
|
||||
activeTab="problems"
|
||||
nodeCount={3}
|
||||
edgeCount={2}
|
||||
problems={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No problems detected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders problem list and allows clicking to configure node', () => {
|
||||
const handleSelectNode = vi.fn();
|
||||
const mockProblems = [
|
||||
{
|
||||
id: 'p-1',
|
||||
nodeId: 'node-db',
|
||||
nodeTitle: 'Source DB',
|
||||
category: 'Configuration',
|
||||
severity: 'error',
|
||||
message: 'Missing mandatory field "table"',
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<BottomDrawer
|
||||
isOpen={true}
|
||||
activeTab="problems"
|
||||
nodeCount={3}
|
||||
edgeCount={2}
|
||||
problems={mockProblems}
|
||||
onSelectNode={handleSelectNode}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Source DB')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Missing mandatory field "table"/i)).toBeInTheDocument();
|
||||
|
||||
const configBtn = screen.getByRole('button', { name: /Configure/i });
|
||||
fireEvent.click(configBtn);
|
||||
expect(handleSelectNode).toHaveBeenCalledWith('node-db');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* ContextMenu Component (Langkah 6)
|
||||
* Renders fixed context menu at (x, y) with viewport bounds checking,
|
||||
* keyboard shortcut badges (<kbd>), danger styles, and outside-click dismissal.
|
||||
*/
|
||||
export default function ContextMenu({
|
||||
isOpen,
|
||||
position = { x: 0, y: 0 },
|
||||
items = [],
|
||||
onClose,
|
||||
}) {
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePointerDown = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', handlePointerDown, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !items || items.length === 0) return null;
|
||||
|
||||
// Viewport bounds checking (menu width ~244px, height estimated by item count)
|
||||
const menuWidth = 244;
|
||||
const estimatedHeight = items.length * 34 + 16;
|
||||
const clampedX = Math.max(8, Math.min(position.x, window.innerWidth - menuWidth - 8));
|
||||
const clampedY = Math.max(8, Math.min(position.y, window.innerHeight - estimatedHeight - 8));
|
||||
|
||||
return (
|
||||
<div
|
||||
id="ctx"
|
||||
ref={menuRef}
|
||||
className="pop open"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${clampedX}px`,
|
||||
top: `${clampedY}px`,
|
||||
width: `${menuWidth}px`,
|
||||
zIndex: 120,
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Context menu"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item === '-' || item.type === 'divider') {
|
||||
return <hr key={`hr-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.hidden) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key || `item-${index}`}
|
||||
type="button"
|
||||
className={`it ${item.danger ? 'danger' : ''}`}
|
||||
data-k={item.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && <kbd>{item.shortcut}</kbd>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import ContextMenu from './ContextMenu';
|
||||
|
||||
describe('ContextMenu Component (Langkah 6)', () => {
|
||||
it('1. Does not render when isOpen is false', () => {
|
||||
const { container } = render(
|
||||
<ContextMenu
|
||||
isOpen={false}
|
||||
position={{ x: 100, y: 100 }}
|
||||
items={[{ key: 'open', label: 'Open settings' }]}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('2. Renders node context menu items with labels and keyboard badges', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpen = vi.fn();
|
||||
const onToggle = vi.fn();
|
||||
const onDup = vi.fn();
|
||||
const onTidy = vi.fn();
|
||||
const onDel = vi.fn();
|
||||
|
||||
const items = [
|
||||
{ key: 'open', label: 'Open settings', shortcut: 'Enter', onClick: onOpen },
|
||||
{ key: 'rename', label: 'Rename', shortcut: 'Space' },
|
||||
'-',
|
||||
{ key: 'toggle', label: 'Disable', shortcut: 'D', onClick: onToggle },
|
||||
{ key: 'dup', label: 'Duplicate', shortcut: 'Ctrl+D', onClick: onDup },
|
||||
'-',
|
||||
{ key: 'tidy', label: 'Tidy up workflow', shortcut: 'Shift+Alt+T', onClick: onTidy },
|
||||
'-',
|
||||
{ key: 'del', label: 'Delete', shortcut: 'Del', danger: true, onClick: onDel },
|
||||
];
|
||||
|
||||
render(
|
||||
<ContextMenu
|
||||
isOpen={true}
|
||||
position={{ x: 150, y: 200 }}
|
||||
items={items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify all action buttons and shortcut badges
|
||||
expect(screen.getByText('Open settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rename')).toBeInTheDocument();
|
||||
expect(screen.getByText('Space')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disable')).toBeInTheDocument();
|
||||
expect(screen.getByText('D')).toBeInTheDocument();
|
||||
expect(screen.getByText('Duplicate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ctrl+D')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tidy up workflow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shift+Alt+T')).toBeInTheDocument();
|
||||
|
||||
const delBtn = screen.getByText('Delete').closest('button');
|
||||
expect(delBtn).toHaveClass('danger');
|
||||
expect(screen.getByText('Del')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('3. Triggers item onClick and closes menu on item click', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpen = vi.fn();
|
||||
|
||||
const items = [
|
||||
{ key: 'open', label: 'Open settings', shortcut: 'Enter', onClick: onOpen },
|
||||
];
|
||||
|
||||
render(
|
||||
<ContextMenu
|
||||
isOpen={true}
|
||||
position={{ x: 100, y: 100 }}
|
||||
items={items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Open settings'));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('4. Renders canvas pane context menu items correctly', () => {
|
||||
const onAdd = vi.fn();
|
||||
const onNote = vi.fn();
|
||||
const onTidy = vi.fn();
|
||||
const onFit = vi.fn();
|
||||
|
||||
const canvasItems = [
|
||||
{ key: 'add', label: 'Add component', onClick: onAdd },
|
||||
{ key: 'note', label: 'Add note', shortcut: 'Shift+S', onClick: onNote },
|
||||
{ key: 'tidy', label: 'Tidy up workflow', shortcut: 'Shift+Alt+T', onClick: onTidy },
|
||||
{ key: 'fit', label: 'Fit to screen', onClick: onFit },
|
||||
];
|
||||
|
||||
render(
|
||||
<ContextMenu
|
||||
isOpen={true}
|
||||
position={{ x: 200, y: 300 }}
|
||||
items={canvasItems}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add component')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add note')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shift+S')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tidy up workflow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fit to screen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. Closes context menu when Escape is pressed', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<ContextMenu
|
||||
isOpen={true}
|
||||
position={{ x: 100, y: 100 }}
|
||||
items={[{ key: 'open', label: 'Open' }]}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('6. Closes context menu when clicking outside', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="outside-element">Outside</div>
|
||||
<ContextMenu
|
||||
isOpen={true}
|
||||
position={{ x: 100, y: 100 }}
|
||||
items={[{ key: 'open', label: 'Open' }]}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId('outside-element'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from 'reactflow';
|
||||
import {
|
||||
Database,
|
||||
FileText,
|
||||
FolderArchive,
|
||||
Globe,
|
||||
Filter,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Layers,
|
||||
Terminal,
|
||||
Code,
|
||||
ShieldCheck,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
import { isSourceNode } from '../utils/layout';
|
||||
import { AIRFLOW_WEB_URL } from '../utils/env';
|
||||
|
||||
const iconMap = {
|
||||
// Database / Ingestion
|
||||
source_db: Database,
|
||||
db: Database,
|
||||
source_gws: FileText,
|
||||
gws: FileText,
|
||||
source_api: Globe,
|
||||
source_rest: Globe,
|
||||
rest: Globe,
|
||||
source_file: FolderArchive,
|
||||
file: FolderArchive,
|
||||
|
||||
// Transformation
|
||||
pandas_cleansing: Filter,
|
||||
pandas_clean: Filter,
|
||||
pandas: Filter,
|
||||
dbt_transform: Layers,
|
||||
dbt: Layers,
|
||||
duckdb_transform: Database,
|
||||
duckdb: Database,
|
||||
pyiceberg_transform: Cpu,
|
||||
pyiceberg: Cpu,
|
||||
pyspark_transform: Terminal,
|
||||
pyspark: Terminal,
|
||||
bash_operator: Terminal,
|
||||
bash: Terminal,
|
||||
python_operator: Code,
|
||||
python: Code,
|
||||
|
||||
// Storage
|
||||
minio_storage: HardDrive,
|
||||
minio_target: HardDrive,
|
||||
minio: HardDrive,
|
||||
trino_storage: Cpu,
|
||||
trino_target: Cpu,
|
||||
trino: Cpu,
|
||||
|
||||
// Quality & Notify
|
||||
ge_quality: ShieldCheck,
|
||||
ge: ShieldCheck,
|
||||
sitomat_callback: Radio,
|
||||
sitomat: Radio,
|
||||
cb: Radio,
|
||||
};
|
||||
|
||||
function CustomNode({ id, data = {}, selected }) {
|
||||
const nodeType = data.type || 'source_db';
|
||||
const IconComponent = iconMap[nodeType] || Database;
|
||||
const isSource = isSourceNode(nodeType) || data.isSource;
|
||||
const isDisabled = data.enabled === false || data.disabled === true;
|
||||
const hasAirflow = data.hasAirflow ?? Boolean(AIRFLOW_WEB_URL && AIRFLOW_WEB_URL.trim() !== '');
|
||||
|
||||
// Real status determination
|
||||
const status = data.status || (data.hasError ? 'error' : data.configured === false ? 'warning' : 'ok');
|
||||
let statusClass = 'ok';
|
||||
let statusTitle = 'Configured & Valid';
|
||||
if (status === 'error' || status === 'err') {
|
||||
statusClass = 'err';
|
||||
statusTitle = data.errorMessage || 'Configuration error or missing mandatory fields';
|
||||
} else if (status === 'warning' || status === 'warn') {
|
||||
statusClass = 'warn';
|
||||
statusTitle = data.errorMessage || 'Configuration incomplete';
|
||||
}
|
||||
|
||||
const handleDelete = (e) => {
|
||||
e.stopPropagation();
|
||||
if (data.onDeleteNode) {
|
||||
data.onDeleteNode(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDisable = (e) => {
|
||||
e.stopPropagation();
|
||||
if (data.onToggleDisable) {
|
||||
data.onToggleDisable(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = (e) => {
|
||||
e.stopPropagation();
|
||||
if (data.onRunNode) {
|
||||
data.onRunNode(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (e) => {
|
||||
e.stopPropagation();
|
||||
if (data.onOpenMenu) {
|
||||
data.onOpenMenu(id, e);
|
||||
}
|
||||
};
|
||||
|
||||
const displayTitle = data.label || data.title || nodeType;
|
||||
const displaySubtitle = isDisabled
|
||||
? `${data.subtitle || nodeType} (Disabled)`
|
||||
: data.subtitle || nodeType;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`node ${isSource ? 'src' : ''} ${selected ? 'sel' : ''} ${isDisabled ? 'off' : ''}`}
|
||||
data-id={id}
|
||||
>
|
||||
{/* TARGET HANDLE (INPUT PORT - LEFT) - OMITTED FOR SOURCE NODES */}
|
||||
{!isSource && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable={!isDisabled}
|
||||
className="port in"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CENTER LINE ICON */}
|
||||
<div className="ic">
|
||||
<IconComponent size={42} strokeWidth={1.5} color="var(--acc)" />
|
||||
</div>
|
||||
|
||||
{/* REAL STATUS INDICATOR DOT */}
|
||||
<i
|
||||
className={`dot ${statusClass}`}
|
||||
title={statusTitle}
|
||||
aria-label={statusTitle}
|
||||
/>
|
||||
|
||||
{/* SOURCE HANDLE (OUTPUT PORT - RIGHT) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
isConnectable={!isDisabled}
|
||||
className="port out"
|
||||
/>
|
||||
|
||||
{/* MINI TOOLBAR (APPEARS ON HOVER OR SELECTION) */}
|
||||
<div className="ntb">
|
||||
{hasAirflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRun}
|
||||
title="Run this task"
|
||||
aria-label="Run this task"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleDisable}
|
||||
title={isDisabled ? 'Enable task' : 'Disable task'}
|
||||
aria-label={isDisabled ? 'Enable task' : 'Disable task'}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
title="Delete component"
|
||||
aria-label="Delete component"
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMore}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LABEL & SUBTITLE PLACED BELOW THE TILE (13px & 12px) */}
|
||||
<div className="t">
|
||||
<b title={displayTitle}>{displayTitle}</b>
|
||||
<span title={displaySubtitle}>{displaySubtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CustomNode);
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import CustomNode from './CustomNode';
|
||||
|
||||
// Mock reactflow components used by CustomNode
|
||||
vi.mock('reactflow', () => ({
|
||||
Handle: ({ type, position, className }) => (
|
||||
<div data-testid={`handle-${type}-${position}`} className={className} />
|
||||
),
|
||||
Position: {
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('CustomNode - 96x96 Tile, Source & Ports, Status Dot (Langkah 4)', () => {
|
||||
it('1. Renders source node with rounded left end (.src) and no input target handle', () => {
|
||||
const { container } = render(
|
||||
<CustomNode
|
||||
id="node_db_1"
|
||||
data={{
|
||||
type: 'source_db',
|
||||
label: 'Bronze Database',
|
||||
subtitle: 'Postgres CDC',
|
||||
}}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nodeEl = container.querySelector('.node');
|
||||
expect(nodeEl).toHaveClass('src');
|
||||
expect(screen.queryByTestId('handle-target-left')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('handle-source-right')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bronze Database')).toBeInTheDocument();
|
||||
expect(screen.getByText('Postgres CDC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('2. Renders non-source node with standard border radius and both input & output ports', () => {
|
||||
const { container } = render(
|
||||
<CustomNode
|
||||
id="node_dbt_1"
|
||||
data={{
|
||||
type: 'dbt_transform',
|
||||
label: 'dbt Silver',
|
||||
subtitle: 'Cleanse',
|
||||
}}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nodeEl = container.querySelector('.node');
|
||||
expect(nodeEl).not.toHaveClass('src');
|
||||
expect(screen.getByTestId('handle-target-left')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('handle-source-right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('3. Renders selected state with .sel class', () => {
|
||||
const { container } = render(
|
||||
<CustomNode
|
||||
id="node_1"
|
||||
data={{ type: 'pandas_cleansing', label: 'Pandas Cleanse' }}
|
||||
selected={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const nodeEl = container.querySelector('.node');
|
||||
expect(nodeEl).toHaveClass('sel');
|
||||
});
|
||||
|
||||
it('4. Renders disabled state with .off class and (Disabled) text', () => {
|
||||
const { container } = render(
|
||||
<CustomNode
|
||||
id="node_off"
|
||||
data={{
|
||||
type: 'ge_quality',
|
||||
label: 'Quality Gate',
|
||||
subtitle: 'Expectations',
|
||||
enabled: false,
|
||||
}}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nodeEl = container.querySelector('.node');
|
||||
expect(nodeEl).toHaveClass('off');
|
||||
expect(screen.getByText(/Expectations \(Disabled\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. Renders real status indicator dot based on node configuration', () => {
|
||||
const { container: okContainer } = render(
|
||||
<CustomNode
|
||||
id="n_ok"
|
||||
data={{ type: 'source_db', label: 'DB', configured: true }}
|
||||
/>
|
||||
);
|
||||
expect(okContainer.querySelector('.dot.ok')).toBeInTheDocument();
|
||||
|
||||
const { container: warnContainer } = render(
|
||||
<CustomNode
|
||||
id="n_warn"
|
||||
data={{ type: 'source_db', label: 'DB', configured: false }}
|
||||
/>
|
||||
);
|
||||
expect(warnContainer.querySelector('.dot.warn')).toBeInTheDocument();
|
||||
|
||||
const { container: errContainer } = render(
|
||||
<CustomNode
|
||||
id="n_err"
|
||||
data={{ type: 'source_db', label: 'DB', status: 'error', errorMessage: 'Missing DB connection' }}
|
||||
/>
|
||||
);
|
||||
expect(errContainer.querySelector('.dot.err')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('6. Triggers node callbacks on mini toolbar buttons', () => {
|
||||
const onDelete = vi.fn();
|
||||
const onToggle = vi.fn();
|
||||
const onRun = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomNode
|
||||
id="test_node"
|
||||
data={{
|
||||
type: 'source_db',
|
||||
label: 'Test Node',
|
||||
onDeleteNode: onDelete,
|
||||
onToggleDisable: onToggle,
|
||||
onRunNode: onRun,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Run this task'));
|
||||
expect(onRun).toHaveBeenCalledWith('test_node');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Disable task'));
|
||||
expect(onToggle).toHaveBeenCalledWith('test_node');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete component'));
|
||||
expect(onDelete).toHaveBeenCalledWith('test_node');
|
||||
});
|
||||
|
||||
it('7. Triggers onOpenMenu when clicking More actions button on mini toolbar', () => {
|
||||
const onOpenMenu = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomNode
|
||||
id="test_node_menu"
|
||||
data={{
|
||||
type: 'source_db',
|
||||
label: 'Test Node',
|
||||
onOpenMenu,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('More actions'));
|
||||
expect(onOpenMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('8. Hides Run button on mini toolbar when Airflow integration is disabled (hasAirflow: false)', () => {
|
||||
render(
|
||||
<CustomNode
|
||||
id="test_node_no_airflow"
|
||||
data={{
|
||||
type: 'source_db',
|
||||
label: 'Test Node',
|
||||
hasAirflow: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTitle('Run this task')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, BookOpen, Search, FileText, ChevronRight, HelpCircle, Layers, CheckCircle, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function DocsViewerModal({ isOpen, onClose }) {
|
||||
const [docContent, setDocContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [docList, setDocList] = useState([]);
|
||||
const [selectedDoc, setSelectedDoc] = useState('16_user_guide_component_library_merica_studio.md');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchDocList();
|
||||
fetchDocContent(selectedDoc);
|
||||
}
|
||||
}, [isOpen, selectedDoc]);
|
||||
|
||||
const fetchDocList = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/docs');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDocList(data.docs || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch doc list:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocContent = async (filename) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/docs/${filename}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDocContent(data.content || '');
|
||||
} else {
|
||||
setDocContent('### Failed to load document content.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch doc:', err);
|
||||
setDocContent('### Error connecting to documentation endpoint.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Render basic markdown into styled JSX blocks
|
||||
const renderMarkdown = (text) => {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
const elements = [];
|
||||
let inCodeBlock = false;
|
||||
let codeContent = [];
|
||||
let codeLang = '';
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
// Code block start / end
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`code-${idx}`}
|
||||
style={{
|
||||
background: '#0D0F14',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#38BDF8',
|
||||
overflowX: 'auto',
|
||||
margin: '12px 0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{codeContent.join('\n')}
|
||||
</div>
|
||||
);
|
||||
codeContent = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
codeLang = line.trim().replace('```', '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeContent.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Headings
|
||||
if (line.startsWith('# ')) {
|
||||
elements.push(
|
||||
<h1 key={idx} style={{ fontSize: '22px', fontWeight: '800', color: '#F5F7FA', margin: '20px 0 10px 0', letterSpacing: '-0.4px' }}>
|
||||
{line.replace('# ', '')}
|
||||
</h1>
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
elements.push(
|
||||
<h2 key={idx} style={{ fontSize: '17px', fontWeight: '700', color: '#8B7CFF', margin: '18px 0 8px 0', borderBottom: '1px solid rgba(255,255,255,0.08)', paddingBottom: '6px' }}>
|
||||
{line.replace('## ', '')}
|
||||
</h2>
|
||||
);
|
||||
} else if (line.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h3 key={idx} style={{ fontSize: '14px', fontWeight: '600', color: '#38BDF8', margin: '14px 0 6px 0' }}>
|
||||
{line.replace('### ', '')}
|
||||
</h3>
|
||||
);
|
||||
} else if (line.startsWith('> ')) {
|
||||
elements.push(
|
||||
<blockquote
|
||||
key={idx}
|
||||
style={{
|
||||
borderLeft: '4px solid #8B7CFF',
|
||||
background: 'rgba(139, 124, 255, 0.08)',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '0 8px 8px 0',
|
||||
color: '#F5F7FA',
|
||||
fontSize: '13px',
|
||||
margin: '10px 0',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{line.replace('> ', '').replace(/\*\*(.*?)\*\*/g, '$1')}
|
||||
</blockquote>
|
||||
);
|
||||
} else if (line.startsWith('* ') || line.startsWith('- ')) {
|
||||
const bulletText = line.substring(2);
|
||||
elements.push(
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', fontSize: '13px', color: '#A5AAB8', margin: '4px 0', paddingLeft: '8px' }}>
|
||||
<span style={{ color: '#8B7CFF', marginTop: '2px' }}>•</span>
|
||||
<span>{parseInlineFormatting(bulletText)}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (line.trim() === '---') {
|
||||
elements.push(<hr key={idx} style={{ borderColor: 'rgba(255,255,255,0.08)', margin: '16px 0' }} />);
|
||||
} else if (line.trim().length > 0) {
|
||||
elements.push(
|
||||
<p key={idx} style={{ fontSize: '13px', color: '#A5AAB8', lineHeight: '1.6', margin: '6px 0' }}>
|
||||
{parseInlineFormatting(line)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
const parseInlineFormatting = (str) => {
|
||||
// Basic inline code & bold parsing
|
||||
const parts = str.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return (
|
||||
<code key={i} style={{ background: 'rgba(255,255,255,0.1)', color: '#F472B6', padding: '2px 6px', borderRadius: '4px', fontSize: '11px', fontFamily: 'monospace' }}>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} style={{ color: '#F5F7FA', fontWeight: '700' }}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(8, 9, 13, 0.85)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1050,
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '1080px',
|
||||
height: '85vh',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
|
||||
borderRadius: 'var(--radius-2xl, 24px)',
|
||||
boxShadow: '0 25px 70px rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* HEADER BAR */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ padding: '8px', borderRadius: '10px', background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)' }}>
|
||||
<BookOpen size={20} color="#ffffff" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '700', margin: 0, color: '#F5F7FA' }}>
|
||||
MERICA Studio Documentation & User Guide
|
||||
</h2>
|
||||
<p style={{ fontSize: '12px', color: '#707686', margin: 0 }}>
|
||||
Interactive Component Library & Technical Markdown Documentation Viewer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#707686',
|
||||
cursor: 'pointer',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MAIN BODY: SIDEBAR + MARKDOWN CONTENT */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* SIDEBAR DOCUMENT SELECTOR */}
|
||||
<div
|
||||
style={{
|
||||
width: '280px',
|
||||
borderRight: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
background: 'var(--color-sidebar-background, #0A0B10)',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '11px', fontWeight: '700', color: '#707686', letterSpacing: '0.5px' }}>
|
||||
AVAILABLE DOCUMENTS
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto' }}>
|
||||
{docList.map((doc) => {
|
||||
const isActive = selectedDoc === doc;
|
||||
const docLabel = doc.includes('16_')
|
||||
? '📖 User Guide & MERICA Widget Library'
|
||||
: doc.includes('15_')
|
||||
? '📐 Enhancement Design Specification'
|
||||
: doc;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={doc}
|
||||
onClick={() => setSelectedDoc(doc)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: isActive ? '1px solid #8B7CFF' : '1px solid transparent',
|
||||
background: isActive ? 'rgba(139, 124, 255, 0.12)' : 'transparent',
|
||||
color: isActive ? '#ffffff' : '#A5AAB8',
|
||||
fontSize: '12px',
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<FileText size={14} color={isActive ? '#8B7CFF' : '#707686'} />
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{docLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'auto', padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div style={{ fontSize: '11px', fontWeight: '700', color: '#8B7CFF', marginBottom: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Sparkles size={12} /> MERICA Engine
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#707686', margin: 0, lineHeight: 1.4 }}>
|
||||
Wrapper Apache Airflow untuk eksekusi pipeline terkelola, terintegrasi, dan cloud-ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MARKDOWN VIEWER PANEL */}
|
||||
<div style={{ flex: 1, padding: '24px 32px', overflowY: 'auto', background: 'var(--color-surface, #0E1016)' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#707686', fontSize: '13px' }}>
|
||||
Loading documentation...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxWidth: '820px', margin: '0 auto' }}>
|
||||
{renderMarkdown(docContent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Rocket,
|
||||
MoreHorizontal,
|
||||
Sliders,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
History,
|
||||
Tag,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
UserCheck,
|
||||
Eye,
|
||||
Settings,
|
||||
Undo,
|
||||
Redo,
|
||||
} from 'lucide-react';
|
||||
import { OPENMETADATA_HOST, AIRFLOW_WEB_URL } from '../utils/env';
|
||||
|
||||
export default function HeaderBar({
|
||||
theme = 'dark',
|
||||
onToggleTheme,
|
||||
userRole = 'admin',
|
||||
currentUser = null,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
pipelineId = 'example_pipeline',
|
||||
setPipelineId,
|
||||
bundleName = 'dlh-core-ingestion',
|
||||
setBundleName,
|
||||
pipelineVersion = 'v1.0.0',
|
||||
description,
|
||||
setDescription,
|
||||
schedule = '@hourly',
|
||||
setSchedule,
|
||||
onNewPipeline,
|
||||
onOpenPipelines,
|
||||
onOpenDocs,
|
||||
onSaveDraft,
|
||||
onValidate,
|
||||
onDeploy,
|
||||
onOpenPublish,
|
||||
onOpenTimeMachine,
|
||||
onOpenPipelineSettings,
|
||||
isDirty = false,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) {
|
||||
const isViewer = userRole === 'viewer';
|
||||
const isAdmin = userRole === 'admin' || currentUser?.is_admin;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const moreRef = useRef(null);
|
||||
|
||||
// Close more menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (moreRef.current && !moreRef.current.contains(e.target)) {
|
||||
setIsMoreOpen(false);
|
||||
}
|
||||
};
|
||||
if (isMoreOpen) {
|
||||
document.addEventListener('pointerdown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||
}, [isMoreOpen]);
|
||||
|
||||
const handlePipelineSettingsClick = () => {
|
||||
setIsMoreOpen(false);
|
||||
if (onOpenPipelineSettings) {
|
||||
onOpenPipelineSettings();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
height: '48px',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '0 14px',
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
color: 'var(--text)',
|
||||
boxSizing: 'border-box',
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
{/* EDITABLE PIPELINE NAME */}
|
||||
<input
|
||||
id="pname"
|
||||
value={pipelineId}
|
||||
onChange={(e) => setPipelineId && setPipelineId(e.target.value)}
|
||||
aria-label="Pipeline name"
|
||||
placeholder="pipeline_name"
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '13.5px',
|
||||
color: 'var(--text)',
|
||||
padding: '4px 6px',
|
||||
borderRadius: '6px',
|
||||
minWidth: 0,
|
||||
width: 'min(280px, 24vw)',
|
||||
textOverflow: 'ellipsis',
|
||||
outline: 'none',
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
onFocus={(e) => (e.target.style.background = 'var(--bg)')}
|
||||
onBlur={(e) => (e.target.style.background = 'none')}
|
||||
/>
|
||||
|
||||
{/* SCHEDULE CHIP */}
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
id="sched"
|
||||
onClick={handlePipelineSettingsClick}
|
||||
title="Pipeline schedule · Click to configure"
|
||||
aria-label="Pipeline schedule"
|
||||
>
|
||||
<span>⏱️</span>
|
||||
<span>{schedule || '@hourly'}</span>
|
||||
</button>
|
||||
|
||||
{/* DAG BUNDLE CHIP */}
|
||||
{bundleName && (
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
title={`AIRFLOW 3 DAG BUNDLE: ${bundleName}`}
|
||||
onClick={handlePipelineSettingsClick}
|
||||
>
|
||||
<span>📦</span>
|
||||
<span>{bundleName}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* UNSAVED CHANGES INDICATOR (design-spec.md & Langkah 8) */}
|
||||
{isDirty && (
|
||||
<span
|
||||
className="chip"
|
||||
id="unsaved-badge"
|
||||
style={{
|
||||
borderColor: '#f59e0b',
|
||||
color: '#f59e0b',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
fontSize: '11px',
|
||||
padding: '2px 7px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title="You have unsaved changes in this pipeline draft"
|
||||
>
|
||||
<span style={{ fontSize: '9px', lineHeight: 1 }}>●</span>
|
||||
<span>Unsaved changes</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* ROLE BADGE */}
|
||||
<span
|
||||
className={`chip ${isViewer ? '' : 'okc'}`}
|
||||
title={`Role: ${userRole === 'admin' ? 'Admin (Full Access)' : (userRole === 'editor' ? 'Data Engineer (Full Access)' : 'Viewer (Read-Only)')}`}
|
||||
style={{ fontSize: '11.5px', padding: '1px 8px' }}
|
||||
>
|
||||
{isViewer ? <Eye size={12} /> : <UserCheck size={12} />}
|
||||
<span>{isViewer ? 'Viewer (Read-Only)' : (userRole === 'admin' ? 'Admin (Full Access)' : 'Data Engineer (Full Access)')}</span>
|
||||
</span>
|
||||
|
||||
{/* SPACER */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* ACTION BUTTONS (CLEAN & MINIMALIST ACCORDING TO SPEC) */}
|
||||
|
||||
{/* SAVE DRAFT BUTTON (SECONDARY) */}
|
||||
{!isViewer && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
id="save"
|
||||
onClick={onSaveDraft}
|
||||
title="Save draft topology (JSON)"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span>Save draft</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* VALIDATE BUTTON (SECONDARY) */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
id="val"
|
||||
onClick={onValidate}
|
||||
title="Validate workflow graph and configs"
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
<span>Validate</span>
|
||||
</button>
|
||||
|
||||
{/* DEPLOY DAG BUTTON (SATU-SATUNYA TOMBOL UTAMA) */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn pri"
|
||||
id="deploy"
|
||||
onClick={onDeploy}
|
||||
disabled={isViewer}
|
||||
title={isViewer ? 'Viewer role cannot deploy DAG' : 'Deploy DAG to Airflow 3 Engine'}
|
||||
>
|
||||
<Rocket size={14} />
|
||||
<span>Deploy DAG</span>
|
||||
</button>
|
||||
|
||||
{/* MORE MENU BUTTON (⋯) */}
|
||||
<div style={{ position: 'relative' }} ref={moreRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
id="mb"
|
||||
onClick={() => setIsMoreOpen(!isMoreOpen)}
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
style={{ width: '32px', padding: 0 }}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
|
||||
{/* MORE POPUP MENU */}
|
||||
{isMoreOpen && (
|
||||
<div
|
||||
className="pop"
|
||||
id="more"
|
||||
style={{
|
||||
top: '40px',
|
||||
right: '0',
|
||||
width: '210px',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onOpenPipelineSettings) onOpenPipelineSettings();
|
||||
}}
|
||||
>
|
||||
<Sliders size={14} />
|
||||
<span>Pipeline settings</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onOpenPipelines) onOpenPipelines();
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<span>Load pipeline</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onNewPipeline) onNewPipeline();
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>New canvas</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onOpenTimeMachine) onOpenTimeMachine();
|
||||
}}
|
||||
>
|
||||
<History size={14} />
|
||||
<span>Time Machine</span>
|
||||
</button>
|
||||
|
||||
{!isViewer && onOpenPublish && (
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
onOpenPublish();
|
||||
}}
|
||||
>
|
||||
<Tag size={14} />
|
||||
<span>Publish Version</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onUndo) onUndo();
|
||||
}}
|
||||
disabled={!canUndo}
|
||||
style={{ opacity: canUndo ? 1 : 0.45 }}
|
||||
>
|
||||
<Undo size={14} />
|
||||
<span style={{ flex: 1 }}>Undo</span>
|
||||
<kbd style={{ fontSize: '10px', color: 'var(--muted)' }}>Ctrl+Z</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onRedo) onRedo();
|
||||
}}
|
||||
disabled={!canRedo}
|
||||
style={{ opacity: canRedo ? 1 : 0.45 }}
|
||||
>
|
||||
<Redo size={14} />
|
||||
<span style={{ flex: 1 }}>Redo</span>
|
||||
<kbd style={{ fontSize: '10px', color: 'var(--muted)' }}>Ctrl+Y</kbd>
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
{AIRFLOW_WEB_URL && (
|
||||
<a
|
||||
href={AIRFLOW_WEB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="it"
|
||||
onClick={() => setIsMoreOpen(false)}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>Open Airflow</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{OPENMETADATA_HOST && (
|
||||
<a
|
||||
href={OPENMETADATA_HOST}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="it"
|
||||
onClick={() => setIsMoreOpen(false)}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>Open OpenMetadata</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onOpenDocs) onOpenDocs();
|
||||
else navigate('/docs');
|
||||
}}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span>Docs</span>
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* THEME TOGGLE */}
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onToggleTheme) onToggleTheme();
|
||||
}}
|
||||
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={14} color="#fbbf24" /> : <Moon size={14} color="#8b7cff" />}
|
||||
<span>{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
|
||||
</button>
|
||||
|
||||
{/* SETTINGS (ADMIN ONLY) */}
|
||||
{isAdmin && onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>Access Control (RBAC)</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* SIGN OUT */}
|
||||
<button
|
||||
type="button"
|
||||
className="it danger"
|
||||
onClick={() => {
|
||||
setIsMoreOpen(false);
|
||||
if (onLogout) onLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, ShieldCheck, UserCheck, Eye, Lock, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function LoginModal({ isOpen, onLogin }) {
|
||||
const [email, setEmail] = useState('admin@dlh.go.id');
|
||||
const [password, setPassword] = useState('admin123');
|
||||
const [selectedRole, setSelectedRole] = useState('admin');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleQuickFill = (role) => {
|
||||
setSelectedRole(role);
|
||||
if (role === 'admin') {
|
||||
setEmail('admin@dlh.go.id');
|
||||
setPassword('admin123');
|
||||
} else {
|
||||
setEmail('viewer@dlh.go.id');
|
||||
setPassword('viewer123');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onLogin({
|
||||
email,
|
||||
role: selectedRole,
|
||||
name: selectedRole === 'admin' ? 'Administrator (DLH Lead)' : 'Guest Inspector (Viewer)',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(8, 9, 13, 0.85)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '480px',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
|
||||
borderRadius: 'var(--radius-2xl, 24px)',
|
||||
boxShadow: '0 25px 70px rgba(0,0,0,0.5)',
|
||||
padding: '32px',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
{/* BRAND HEADER */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 14px auto',
|
||||
boxShadow: '0 8px 24px rgba(139, 124, 255, 0.35)',
|
||||
}}
|
||||
>
|
||||
<Layers size={28} color="#ffffff" />
|
||||
</div>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '700', margin: '0 0 6px 0', letterSpacing: '-0.3px' }}>
|
||||
DLH Low-Code Studio Access
|
||||
</h2>
|
||||
<p style={{ fontSize: '13px', color: 'var(--color-text-muted, #707686)', margin: 0 }}>
|
||||
Select user role or quick-autofill credentials to sign in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QUICK AUTOFILL ROLE SELECTOR BUTTONS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickFill('admin')}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
border: selectedRole === 'admin' ? '2px solid #8B7CFF' : '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
background: selectedRole === 'admin' ? 'rgba(139, 124, 255, 0.12)' : 'var(--color-surface-raised, #12141B)',
|
||||
color: selectedRole === 'admin' ? '#ffffff' : 'var(--color-text-muted, #707686)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<UserCheck size={20} color={selectedRole === 'admin' ? '#8B7CFF' : '#707686'} />
|
||||
<span style={{ fontSize: '12px', fontWeight: '700' }}>👑 Admin Role</span>
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>Full Edit & Deploy</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickFill('viewer')}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
border: selectedRole === 'viewer' ? '2px solid #38bdf8' : '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
background: selectedRole === 'viewer' ? 'rgba(56, 189, 248, 0.12)' : 'var(--color-surface-raised, #12141B)',
|
||||
color: selectedRole === 'viewer' ? '#ffffff' : 'var(--color-text-muted, #707686)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Eye size={20} color={selectedRole === 'viewer' ? '#38bdf8' : '#707686'} />
|
||||
<span style={{ fontSize: '12px', fontWeight: '700' }}>👁️ Viewer Role</span>
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>Read-Only Canvas</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LOGIN FORM */}
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
|
||||
User Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '10px',
|
||||
background: selectedRole === 'admin'
|
||||
? 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)'
|
||||
: 'linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '6px',
|
||||
boxShadow: '0 4px 16px rgba(139, 124, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
Enter Studio Workspace as {selectedRole.toUpperCase()} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database, FileText, FolderArchive, Globe, Filter, HardDrive, Cpu, Layers, Terminal, Code, ShieldCheck, Radio, Search, GripVertical } from 'lucide-react';
|
||||
|
||||
const PALETTE_ITEMS = [
|
||||
{
|
||||
type: 'source_db',
|
||||
label: 'Source Database',
|
||||
subtitle: 'Postgres / MySQL / Oracle',
|
||||
icon: Database,
|
||||
color: '#60a5fa',
|
||||
bgColor: 'rgba(59, 130, 246, 0.12)',
|
||||
category: 'Ingestion',
|
||||
},
|
||||
{
|
||||
type: 'source_gws',
|
||||
label: 'Source GWS',
|
||||
subtitle: 'Google Sheets & Drive PDF',
|
||||
icon: FileText,
|
||||
color: '#34d399',
|
||||
bgColor: 'rgba(16, 185, 129, 0.12)',
|
||||
category: 'Ingestion',
|
||||
},
|
||||
{
|
||||
type: 'source_api',
|
||||
label: 'Source REST API',
|
||||
subtitle: 'REST Services & Webhooks',
|
||||
icon: Globe,
|
||||
color: '#a78bfa',
|
||||
bgColor: 'rgba(167, 139, 250, 0.12)',
|
||||
category: 'Ingestion',
|
||||
},
|
||||
{
|
||||
type: 'source_api_dlt',
|
||||
label: 'Source REST API (dlt)',
|
||||
subtitle: 'Auto Un-nesting & Pagination',
|
||||
icon: Globe,
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.12)',
|
||||
category: 'Ingestion',
|
||||
},
|
||||
{
|
||||
type: 'source_file',
|
||||
label: 'File / Document Ingestion',
|
||||
subtitle: 'Upload CSV, Parquet, PDF',
|
||||
icon: FolderArchive,
|
||||
color: '#38bdf8',
|
||||
bgColor: 'rgba(14, 165, 233, 0.12)',
|
||||
category: 'Ingestion',
|
||||
},
|
||||
{
|
||||
type: 'pandas_cleansing',
|
||||
label: 'Pandas Cleansing',
|
||||
subtitle: 'Deduplicate, null fill, cast',
|
||||
icon: Filter,
|
||||
color: '#f472b6',
|
||||
bgColor: 'rgba(236, 72, 153, 0.12)',
|
||||
category: 'Transformation',
|
||||
},
|
||||
{
|
||||
type: 'minio_storage',
|
||||
label: 'MinIO S3 Bucket Target',
|
||||
subtitle: 'Parquet, Delta Lake Files',
|
||||
icon: HardDrive,
|
||||
color: '#fbbf24',
|
||||
bgColor: 'rgba(245, 158, 11, 0.12)',
|
||||
category: 'Storage',
|
||||
},
|
||||
{
|
||||
type: 'trino_storage',
|
||||
label: 'Trino Iceberg Table Target',
|
||||
subtitle: 'Apache Iceberg Catalog',
|
||||
icon: Cpu,
|
||||
color: '#f43f5e',
|
||||
bgColor: 'rgba(244, 63, 94, 0.12)',
|
||||
category: 'Storage',
|
||||
},
|
||||
{
|
||||
type: 'dbt_transform',
|
||||
label: 'dbt Trino Iceberg Model',
|
||||
subtitle: 'Unified SQL Models (Silver/Gold)',
|
||||
icon: Layers,
|
||||
color: '#c084fc',
|
||||
bgColor: 'rgba(168, 85, 247, 0.12)',
|
||||
category: 'Transformation',
|
||||
},
|
||||
{
|
||||
type: 'duckdb_transform',
|
||||
label: 'DuckDB Iceberg Transform',
|
||||
subtitle: 'In-process SQL, MinIO HTTPFS (<512MB)',
|
||||
icon: Database,
|
||||
color: '#eab308',
|
||||
bgColor: 'rgba(234, 179, 8, 0.12)',
|
||||
category: 'Transformation',
|
||||
},
|
||||
{
|
||||
type: 'pyiceberg_transform',
|
||||
label: 'PyIceberg Transform',
|
||||
subtitle: 'Native Arrow, ACID Snapshots',
|
||||
icon: Cpu,
|
||||
color: '#38bdf8',
|
||||
bgColor: 'rgba(56, 189, 248, 0.12)',
|
||||
category: 'Transformation',
|
||||
},
|
||||
{
|
||||
type: 'pyspark_transform',
|
||||
label: 'PySpark Iceberg Transform',
|
||||
subtitle: 'Distributed Big Data Scale Engine',
|
||||
icon: Terminal,
|
||||
color: '#f97316',
|
||||
bgColor: 'rgba(249, 115, 22, 0.12)',
|
||||
category: 'Transformation',
|
||||
},
|
||||
{
|
||||
type: 'bash_operator',
|
||||
label: 'Bash Operator',
|
||||
subtitle: 'Curl, Sling CDC, Shell',
|
||||
icon: Terminal,
|
||||
color: '#4ade80',
|
||||
bgColor: 'rgba(34, 197, 94, 0.12)',
|
||||
category: 'Operators',
|
||||
},
|
||||
{
|
||||
type: 'python_operator',
|
||||
label: 'Python Operator',
|
||||
subtitle: 'REST Fetcher, Custom Py',
|
||||
icon: Code,
|
||||
color: '#22d3ee',
|
||||
bgColor: 'rgba(6, 182, 212, 0.12)',
|
||||
category: 'Operators',
|
||||
},
|
||||
{
|
||||
type: 'ge_quality',
|
||||
label: 'GE Quality Test Suite',
|
||||
subtitle: 'Nulls, Uniqueness, Schema',
|
||||
icon: ShieldCheck,
|
||||
color: '#8b7cff',
|
||||
bgColor: 'rgba(139, 124, 255, 0.12)',
|
||||
category: 'Quality',
|
||||
},
|
||||
{
|
||||
type: 'sitomat_callback',
|
||||
label: 'Sitomat F16 Callback',
|
||||
subtitle: 'Dataset Versioning & Governance',
|
||||
icon: Radio,
|
||||
color: '#2dd4bf',
|
||||
bgColor: 'rgba(20, 184, 166, 0.12)',
|
||||
category: 'Governance',
|
||||
},
|
||||
];
|
||||
|
||||
export default function NodePalette() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'Ingestion', 'Transformation', 'Storage', 'Operators', 'Quality', 'Governance'];
|
||||
|
||||
const filteredItems = PALETTE_ITEMS.filter((item) => {
|
||||
const matchesSearch =
|
||||
item.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.subtitle.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'All' || item.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const onDragStart = (event, nodeType, itemData) => {
|
||||
const payload = JSON.stringify(itemData);
|
||||
event.dataTransfer.setData('application/reactflow', payload);
|
||||
event.dataTransfer.setData('application/json', payload);
|
||||
event.dataTransfer.setData('text/plain', nodeType);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: '280px',
|
||||
background: 'var(--color-sidebar-background, #0A0B10)',
|
||||
borderRight: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{/* PALETTE HEADER */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<h2 style={{ fontSize: '15px', fontWeight: '600', margin: 0, color: 'var(--color-text-primary, #F5F7FA)' }}>
|
||||
Component Palette
|
||||
</h2>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: '4px 0 12px 0' }}>
|
||||
Drag & drop onto canvas
|
||||
</p>
|
||||
|
||||
{/* SEARCH INPUT */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={14} style={{ position: 'absolute', left: '10px', top: '10px', color: 'var(--color-text-muted, #707686)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search components..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px 8px 30px',
|
||||
borderRadius: 'var(--radius-sm, 8px)',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
fontSize: '12px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CATEGORY TABS */}
|
||||
<div style={{ display: 'flex', gap: '4px', overflowX: 'auto', marginTop: '10px', paddingBottom: '4px' }}>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
background: selectedCategory === cat ? 'var(--color-violet, #8B7CFF)' : 'var(--color-surface, #0E1016)',
|
||||
color: selectedCategory === cat ? '#ffffff' : 'var(--color-text-muted, #707686)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ITEM LIST */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.type, item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 'var(--radius-md, 12px)',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
cursor: 'grab',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-surface-hover, #171A22)';
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-strong, rgba(255,255,255,0.14))';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-surface, #0E1016)';
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-subtle, rgba(255,255,255,0.06))';
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} color="var(--color-text-disabled, #4B5060)" />
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: item.bgColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={16} color={item.color} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useState, memo, useCallback, useEffect } from 'react';
|
||||
import { NodeResizer } from 'reactflow';
|
||||
|
||||
/**
|
||||
* NoteNode Component (Langkah 7)
|
||||
* Rendered behind nodes and edges on the canvas.
|
||||
* Supports:
|
||||
* - Double-click to edit (1st line bold title, remaining lines body text)
|
||||
* - Corner and border resizing with min size 140x80
|
||||
* - 5 color themes (c0 to c4) cycled via ◐ button
|
||||
* - Delete via × button or Delete key
|
||||
* - Does not contain handles / ports (purely decorative annotation)
|
||||
*/
|
||||
function NoteNode({ id, data = {}, selected }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const colorIndex = data.c ?? 0;
|
||||
const rawText = data.text || 'Note';
|
||||
const lines = rawText.split('\n');
|
||||
const title = lines[0] || 'Note';
|
||||
const body = lines.slice(1).join('\n');
|
||||
|
||||
const handleDoubleClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleFinishEdit = useCallback(
|
||||
(val) => {
|
||||
setIsEditing(false);
|
||||
const trimmed = val.trim();
|
||||
if (data.onUpdateText) {
|
||||
data.onUpdateText(id, trimmed || 'Note');
|
||||
}
|
||||
},
|
||||
[id, data]
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (data.onChangeColor) {
|
||||
data.onChangeColor(id);
|
||||
}
|
||||
},
|
||||
[id, data]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (data.onDeleteNote) {
|
||||
data.onDeleteNote(id);
|
||||
}
|
||||
},
|
||||
[id, data]
|
||||
);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(_, { width, height }) => {
|
||||
if (data.onResizeNote) {
|
||||
data.onResizeNote(id, { width, height });
|
||||
}
|
||||
},
|
||||
[id, data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note c${colorIndex % 5} ${selected ? 'sel' : ''}`}
|
||||
data-id={id}
|
||||
style={{
|
||||
width: data.w ? `${data.w}px` : '100%',
|
||||
height: data.h ? `${data.h}px` : '100%',
|
||||
minWidth: '140px',
|
||||
minHeight: '80px',
|
||||
}}
|
||||
>
|
||||
<NodeResizer
|
||||
minWidth={140}
|
||||
minHeight={80}
|
||||
isVisible={selected}
|
||||
lineClassName="note-resizer-line"
|
||||
handleClassName="note-resizer-handle"
|
||||
onResize={handleResize}
|
||||
/>
|
||||
|
||||
{/* Note Tools (◐ color cycle, × delete) */}
|
||||
<div className="ntools">
|
||||
<button
|
||||
type="button"
|
||||
data-a="color"
|
||||
onClick={handleColorChange}
|
||||
aria-label="Change color"
|
||||
title="Change color"
|
||||
>
|
||||
◐
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-a="del"
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete note"
|
||||
title="Delete note"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Note Content (Title on 1st line, content below) */}
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="ne"
|
||||
aria-label="Note text"
|
||||
defaultValue={rawText}
|
||||
autoFocus
|
||||
onBlur={(e) => handleFinishEdit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleFinishEdit(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="nb" onDoubleClick={handleDoubleClick}>
|
||||
<b>{title}</b>
|
||||
{body && <p>{body}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative corner resize handle indicator */}
|
||||
<i className="rs" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NoteNode);
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import NoteNode from './NoteNode';
|
||||
|
||||
// Mock reactflow components used by NoteNode
|
||||
vi.mock('reactflow', () => ({
|
||||
NodeResizer: ({ onResize, isVisible }) => (
|
||||
<div
|
||||
data-testid="mock-node-resizer"
|
||||
data-visible={isVisible}
|
||||
onClick={() => onResize && onResize(null, { width: 320, height: 220 })}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('NoteNode Component (Langkah 7: Note Layer)', () => {
|
||||
it('1. Renders note with title on 1st line and body text below', () => {
|
||||
const { container } = render(
|
||||
<NoteNode
|
||||
id="note-1"
|
||||
data={{
|
||||
text: 'Bronze Landing Layer\nContains raw ingested tables from Postgres and GWS.',
|
||||
c: 0,
|
||||
w: 300,
|
||||
h: 200,
|
||||
}}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteEl = container.querySelector('.note');
|
||||
expect(noteEl).toBeInTheDocument();
|
||||
expect(noteEl).toHaveClass('c0');
|
||||
expect(screen.getByText('Bronze Landing Layer')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Contains raw ingested tables from Postgres and GWS.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('2. Renders with correct color class corresponding to 5 colors (c0 - c4)', () => {
|
||||
const { container: c1Container } = render(
|
||||
<NoteNode id="note-c1" data={{ text: 'Green Note', c: 1 }} selected={false} />
|
||||
);
|
||||
expect(c1Container.querySelector('.note')).toHaveClass('c1');
|
||||
|
||||
const { container: c3Container } = render(
|
||||
<NoteNode id="note-c3" data={{ text: 'Red Note', c: 3 }} selected={false} />
|
||||
);
|
||||
expect(c3Container.querySelector('.note')).toHaveClass('c3');
|
||||
});
|
||||
|
||||
it('3. Renders selected state with .sel class and shows NodeResizer', () => {
|
||||
const { container } = render(
|
||||
<NoteNode id="note-sel" data={{ text: 'Selected Note', c: 2 }} selected={true} />
|
||||
);
|
||||
const noteEl = container.querySelector('.note');
|
||||
expect(noteEl).toHaveClass('sel');
|
||||
|
||||
const resizer = screen.getByTestId('mock-node-resizer');
|
||||
expect(resizer.getAttribute('data-visible')).toBe('true');
|
||||
});
|
||||
|
||||
it('4. Enters edit mode on double-click and updates text on blur', () => {
|
||||
const onUpdateText = vi.fn();
|
||||
render(
|
||||
<NoteNode
|
||||
id="note-edit"
|
||||
data={{
|
||||
text: 'Initial Title\nInitial Body',
|
||||
c: 0,
|
||||
onUpdateText,
|
||||
}}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteBody = screen.getByText('Initial Title').closest('.nb');
|
||||
fireEvent.doubleClick(noteBody);
|
||||
|
||||
const textarea = screen.getByLabelText('Note text');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea.value).toBe('Initial Title\nInitial Body');
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'Updated Title\nUpdated Body' } });
|
||||
fireEvent.blur(textarea);
|
||||
|
||||
expect(onUpdateText).toHaveBeenCalledWith('note-edit', 'Updated Title\nUpdated Body');
|
||||
});
|
||||
|
||||
it('5. Finishes edit mode when Escape is pressed', () => {
|
||||
const onUpdateText = vi.fn();
|
||||
render(
|
||||
<NoteNode
|
||||
id="note-esc"
|
||||
data={{
|
||||
text: 'Title to edit',
|
||||
c: 0,
|
||||
onUpdateText,
|
||||
}}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteBody = screen.getByText('Title to edit').closest('.nb');
|
||||
fireEvent.doubleClick(noteBody);
|
||||
|
||||
const textarea = screen.getByLabelText('Note text');
|
||||
fireEvent.change(textarea, { target: { value: 'Escaped Title' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
|
||||
expect(onUpdateText).toHaveBeenCalledWith('note-esc', 'Escaped Title');
|
||||
});
|
||||
|
||||
it('6. Triggers onChangeColor when ◐ button is clicked', () => {
|
||||
const onChangeColor = vi.fn();
|
||||
render(
|
||||
<NoteNode
|
||||
id="note-color"
|
||||
data={{
|
||||
text: 'Color Note',
|
||||
c: 0,
|
||||
onChangeColor,
|
||||
}}
|
||||
selected={true}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Change color'));
|
||||
expect(onChangeColor).toHaveBeenCalledWith('note-color');
|
||||
});
|
||||
|
||||
it('7. Triggers onDeleteNote when × button is clicked', () => {
|
||||
const onDeleteNote = vi.fn();
|
||||
render(
|
||||
<NoteNode
|
||||
id="note-del"
|
||||
data={{
|
||||
text: 'Delete Note',
|
||||
c: 0,
|
||||
onDeleteNote,
|
||||
}}
|
||||
selected={true}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete note'));
|
||||
expect(onDeleteNote).toHaveBeenCalledWith('note-del');
|
||||
});
|
||||
|
||||
it('8. Triggers onResizeNote when resizer updates dimensions', () => {
|
||||
const onResizeNote = vi.fn();
|
||||
render(
|
||||
<NoteNode
|
||||
id="note-resize"
|
||||
data={{
|
||||
text: 'Resize Note',
|
||||
c: 0,
|
||||
onResizeNote,
|
||||
}}
|
||||
selected={true}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-node-resizer'));
|
||||
expect(onResizeNote).toHaveBeenCalledWith('note-resize', { width: 320, height: 220 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Search, Database, Layers, Play, Trash2, Download, CheckCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
const FALLBACK_TEMPLATES = [
|
||||
{
|
||||
id: "spku_monitoring_pipeline",
|
||||
description: "Ingest SPKU Stasiun Postgres DB -> Pandas Cleansing -> MinIO S3 Bronze -> dbt Silver -> GE Quality Suite",
|
||||
schedule: "0 */1 * * *",
|
||||
status: "deployed",
|
||||
nodes: [
|
||||
{ id: "node-1", type: "customNode", position: { x: 80, y: 160 }, data: { label: "Source Postgres SPKU", type: "source_db", configured: true, config: { conn_id: "conn_postgres_source_db", table_name: "public.stasiun_spku" } } },
|
||||
{ id: "node-2", type: "customNode", position: { x: 340, y: 160 }, data: { label: "Pandas Cleansing", type: "pandas_cleansing", configured: true, config: { null_strategy: "drop_row", drop_duplicates: true } } },
|
||||
{ id: "node-3", type: "customNode", position: { x: 600, y: 160 }, data: { label: "MinIO S3 Bronze Bucket", type: "minio_storage", configured: true, config: { bucket_name: "dlh-bronze", file_format: "parquet" } } },
|
||||
{ id: "node-4", type: "customNode", position: { x: 860, y: 160 }, data: { label: "dbt Silver Model", type: "dbt_transform", configured: true, config: { dbt_layer: "silver", dbt_model: "stg_spku_realtime" } } },
|
||||
{ id: "node-5", type: "customNode", position: { x: 1120, y: 160 }, data: { label: "GE Quality Test Suite", type: "ge_quality", configured: true, config: { test_null_check: true, test_uniqueness: true } } }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1-2", source: "node-1", target: "node-2" },
|
||||
{ id: "e2-3", source: "node-2", target: "node-3" },
|
||||
{ id: "e3-4", source: "node-3", target: "node-4" },
|
||||
{ id: "e4-5", source: "node-4", target: "node-5" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "gws_waste_ingestion",
|
||||
description: "Source GWS Spreadsheet -> Pandas Cleansing -> Trino Iceberg Storage Target -> dbt Gold Mart",
|
||||
schedule: "0 0 * * *",
|
||||
status: "deployed",
|
||||
nodes: [
|
||||
{ id: "node-10", type: "customNode", position: { x: 80, y: 160 }, data: { label: "GWS Waste Reports", type: "source_gws", configured: true, config: { spreadsheet_id: "1OSRugXZvfjoY9M0yySKT-F3L4LTZXa40J0knU3_4Hik", sheet_range: "Sheet1" } } },
|
||||
{ id: "node-11", type: "customNode", position: { x: 340, y: 160 }, data: { label: "Normalize Headers", type: "pandas_cleansing", configured: true, config: { null_strategy: "zero", drop_duplicates: true } } },
|
||||
{ id: "node-12", type: "customNode", position: { x: 600, y: 160 }, data: { label: "Trino Iceberg Target", type: "trino_storage", configured: true, config: { trino_catalog: "iceberg", trino_schema: "silver", iceberg_table: "waste_reports_iceberg" } } },
|
||||
{ id: "node-13", type: "customNode", position: { x: 860, y: 160 }, data: { label: "dbt Gold Mart", type: "dbt_transform", configured: true, config: { dbt_layer: "gold", dbt_model: "fct_waste_daily_mart" } } }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e10-11", source: "node-10", target: "node-11" },
|
||||
{ id: "e11-12", source: "node-11", target: "node-12" },
|
||||
{ id: "e12-13", source: "node-12", target: "node-13" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function PipelineLoadModal({ isOpen, onClose, onLoadPipeline }) {
|
||||
const [pipelines, setPipelines] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchPipelines();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchPipelines = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/pipelines');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPipelines(data.pipelines && data.pipelines.length > 0 ? data.pipelines : FALLBACK_TEMPLATES);
|
||||
} else {
|
||||
setPipelines(FALLBACK_TEMPLATES);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pipelines:', err);
|
||||
setPipelines(FALLBACK_TEMPLATES);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePipeline = async (id, e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(`Are you sure you want to delete pipeline ${id}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/pipelines/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
setPipelines((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filteredPipelines = pipelines.filter((p) => {
|
||||
const matchesSearch =
|
||||
p.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(p.description && p.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const pStatus = p.status || (p.is_draft ? 'draft' : 'deployed');
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'deployed' && pStatus === 'deployed') ||
|
||||
(activeTab === 'draft' && pStatus === 'draft');
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(8, 9, 13, 0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '840px',
|
||||
maxHeight: '85vh',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
|
||||
borderRadius: 'var(--radius-2xl, 24px)',
|
||||
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* MODAL HEADER */}
|
||||
<div
|
||||
style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '700', margin: 0 }}>📂 Load Existing Pipeline</h2>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: '4px 0 0 0' }}>
|
||||
Select a stored Lakehouse pipeline configuration to load into your canvas.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SEARCH BAR & FILTERS */}
|
||||
<div style={{ padding: '16px 24px', borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: '12px', top: '12px', color: 'var(--color-text-muted, #707686)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pipelines by ID or description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px 10px 38px',
|
||||
borderRadius: 'var(--radius-md, 12px)',
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{[
|
||||
{ id: 'all', label: 'All Pipelines' },
|
||||
{ id: 'deployed', label: '🚀 Airflow Deployed' },
|
||||
{ id: 'draft', label: '📝 Saved Drafts' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: activeTab === tab.id ? 'var(--color-violet, #8B7CFF)' : 'var(--color-surface-raised, #12141B)',
|
||||
color: activeTab === tab.id ? '#ffffff' : 'var(--color-text-muted, #707686)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PIPELINE CARDS GRID */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '24px' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px', color: '#8b7cff' }}>
|
||||
<RefreshCw size={24} className="animate-spin" />
|
||||
</div>
|
||||
) : filteredPipelines.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted, #707686)' }}>
|
||||
No pipelines found.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
{filteredPipelines.map((p) => {
|
||||
const isDraft = p.status === 'draft' || p.is_draft;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
onLoadPipeline(p);
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: 'var(--radius-xl, 20px)',
|
||||
padding: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-violet, #8B7CFF)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-default, rgba(255,255,255,0.09))';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: '700',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
background: isDraft ? 'rgba(251, 191, 36, 0.15)' : 'rgba(74, 222, 128, 0.15)',
|
||||
color: isDraft ? '#fbbf24' : '#4ade80',
|
||||
border: `1px solid ${isDraft ? 'rgba(251, 191, 36, 0.3)' : 'rgba(74, 222, 128, 0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{isDraft ? '📝 Draft' : '🚀 Deployed'}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: '600',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(139, 124, 255, 0.12)',
|
||||
color: '#8b7cff',
|
||||
border: '1px solid rgba(139, 124, 255, 0.22)',
|
||||
}}
|
||||
>
|
||||
{p.nodes ? `${p.nodes.length} Components` : 'Pipeline'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDeletePipeline(p.id, e)}
|
||||
title="Delete Pipeline"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#f87171',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ fontSize: '15px', fontWeight: '600', margin: '0 0 4px 0' }}>{p.id}</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: '0 0 12px 0' }}>
|
||||
{p.description || 'No description provided'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '12px', borderTop: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #707686)' }}>
|
||||
Schedule: {p.schedule || '@hourly'}
|
||||
</span>
|
||||
<button
|
||||
style={{
|
||||
background: 'var(--color-violet, #8B7CFF)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
Load Canvas <Play size={10} fill="#ffffff" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Tag, GitCommit, CheckCircle, X, ShieldCheck } from 'lucide-react';
|
||||
|
||||
export default function PublishVersionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
pipelineId,
|
||||
currentVersion = 'v1.0.0',
|
||||
bundleName = 'dlh-core-ingestion',
|
||||
onPublish
|
||||
}) {
|
||||
const [versionTag, setVersionTag] = useState('');
|
||||
const [changelog, setChangelog] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Auto-suggest next version
|
||||
const suggestedVersion = (() => {
|
||||
try {
|
||||
const match = currentVersion.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (match) {
|
||||
return `v${match[1]}.${match[2]}.${parseInt(match[3], 10) + 1}`;
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return 'v1.1.0';
|
||||
})();
|
||||
|
||||
const activeTag = versionTag.trim() || suggestedVersion;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onPublish(activeTag, changelog);
|
||||
onClose();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(8, 9, 13, 0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '520px',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
|
||||
borderRadius: 'var(--radius-2xl, 24px)',
|
||||
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
|
||||
padding: '28px',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* CLOSE BUTTON */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* HEADER */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
|
||||
padding: '10px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
boxShadow: '0 4px 14px rgba(16,185,129,0.3)',
|
||||
}}
|
||||
>
|
||||
<Tag size={22} color="#ffffff" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '700', margin: 0 }}>
|
||||
Publish Version Snapshot
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)' }}>
|
||||
Airflow 3 GitDAGBundle Version Pinning
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BUNDLE CONTEXT BADGE */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--color-text-muted, #707686)' }}>Pipeline Target:</span>
|
||||
<span style={{ fontWeight: '600' }}>{pipelineId}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--color-text-muted, #707686)' }}>DAG Bundle:</span>
|
||||
<span style={{ color: 'var(--color-violet, #8B7CFF)', fontWeight: '600' }}>{bundleName}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--color-text-muted, #707686)' }}>Current Active:</span>
|
||||
<span style={{ color: '#fbbf24', fontWeight: '600' }}>{currentVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FORM */}
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
|
||||
Semantic Version Tag (Suggested: {suggestedVersion})
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={suggestedVersion}
|
||||
value={versionTag}
|
||||
onChange={(e) => setVersionTag(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
|
||||
Changelog / Release Notes
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="e.g. Added sliding window 3h and dbt Silver transformation model"
|
||||
value={changelog}
|
||||
onChange={(e) => setChangelog(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SUBMIT BUTTON */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 16px rgba(16,185,129,0.3)',
|
||||
}}
|
||||
>
|
||||
<GitCommit size={16} />
|
||||
{isSubmitting ? 'Publishing...' : `Confirm & Publish ${activeTag}`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import { EdgeLabelRenderer, BaseEdge } from 'reactflow';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function RemovableEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
style = {},
|
||||
markerEnd,
|
||||
data,
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Cubic Bezier with horizontal control points max(50, |dx|/2)
|
||||
const dx = Math.max(50, Math.abs(targetX - sourceX) / 2);
|
||||
const edgePath = `M${sourceX},${sourceY} C${sourceX + dx},${sourceY} ${targetX - dx},${targetY} ${targetX},${targetY}`;
|
||||
|
||||
const midX = (sourceX + targetX) / 2;
|
||||
const midY = (sourceY + targetY) / 2;
|
||||
|
||||
const handleDelete = (evt) => {
|
||||
if (evt) evt.stopPropagation();
|
||||
if (data && data.onDeleteEdge) {
|
||||
data.onDeleteEdge(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = (evt) => {
|
||||
evt.stopPropagation();
|
||||
handleDelete(evt);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* VISIBLE BEZIER EDGE */}
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
...style,
|
||||
stroke: isHovered ? 'var(--acc)' : style.stroke || 'var(--muted)',
|
||||
strokeWidth: isHovered ? 2.5 : style.strokeWidth || 2,
|
||||
transition: 'stroke 0.15s ease, stroke-width 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* INVISIBLE WIDE HIT AREA FOR HOVER AND DOUBLE CLICK TO DELETE */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={22}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
|
||||
{/* OPTIONAL HOVER DELETE BUTTON IN CENTER */}
|
||||
{isHovered && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${midX}px,${midY}px)`,
|
||||
fontSize: 12,
|
||||
pointerEvents: 'all',
|
||||
zIndex: 10,
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
title="Click or double-click line to delete connection"
|
||||
aria-label="Delete connection"
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--muted)',
|
||||
border: '1px solid var(--line)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#ffffff';
|
||||
e.currentTarget.style.background = 'var(--err)';
|
||||
e.currentTarget.style.borderColor = 'var(--err)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--muted)';
|
||||
e.currentTarget.style.background = 'var(--surface)';
|
||||
e.currentTarget.style.borderColor = 'var(--line)';
|
||||
}}
|
||||
>
|
||||
<X size={10} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import RightDrawer from './RightDrawer';
|
||||
|
||||
describe('RightDrawer - Component Palette, Node Config, Pipeline Settings (Langkah 5)', () => {
|
||||
it('1. Renders nothing when mode is null', () => {
|
||||
const { container } = render(
|
||||
<RightDrawer mode={null} onClose={vi.fn()} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
describe('Palette Mode', () => {
|
||||
it('2. Renders "What happens next?", search bar, and 5 category groups by default', () => {
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="palette"
|
||||
onClose={vi.fn()}
|
||||
onSelectComponent={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('What happens next?')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Search components/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Ingestion')).toBeInTheDocument();
|
||||
expect(screen.getByText('Transformation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Quality')).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notify')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('3. Selecting a category displays its components in rows with back button', () => {
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="palette"
|
||||
onClose={vi.fn()}
|
||||
onSelectComponent={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Ingestion'));
|
||||
|
||||
// Category view title and back button
|
||||
expect(screen.getByText('Ingestion')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Back to categories')).toBeInTheDocument();
|
||||
expect(screen.getByText('Source Database')).toBeInTheDocument();
|
||||
expect(screen.getByText('Source GWS')).toBeInTheDocument();
|
||||
expect(screen.getByText('Source REST API')).toBeInTheDocument();
|
||||
|
||||
// Click back returns to root categories
|
||||
fireEvent.click(screen.getByLabelText('Back to categories'));
|
||||
expect(screen.getByText('What happens next?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('4. Searching filters components across all categories', () => {
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="palette"
|
||||
onClose={vi.fn()}
|
||||
onSelectComponent={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search components/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'iceberg' } });
|
||||
|
||||
expect(screen.getByText('Trino Iceberg Table Target')).toBeInTheDocument();
|
||||
expect(screen.getByText('PyIceberg Transform')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistent_component_xyz' } });
|
||||
expect(screen.getByText(/No component matches your search/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. Clicking a component triggers onSelectComponent and closes drawer', () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="palette"
|
||||
onClose={onClose}
|
||||
onSelectComponent={onSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Quality'));
|
||||
fireEvent.click(screen.getByText('GE Quality Check'));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'ge_quality', label: 'GE Quality Check' })
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Configuration Mode', () => {
|
||||
it('6. Renders node fields, Task ID (readonly), and calls onUpdateNode on field change', () => {
|
||||
const onUpdateNode = vi.fn();
|
||||
const onDeleteNode = vi.fn();
|
||||
const selectedNode = {
|
||||
id: 'node-db-1',
|
||||
data: {
|
||||
type: 'source_db',
|
||||
label: 'Primary Postgres DB',
|
||||
config: { conn: 'Postgres', table: 'permits' },
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="node"
|
||||
selectedNode={selectedNode}
|
||||
onUpdateNode={onUpdateNode}
|
||||
onDeleteNode={onDeleteNode}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Node title
|
||||
const titleInput = screen.getByDisplayValue('Primary Postgres DB');
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
|
||||
// Change title
|
||||
fireEvent.change(titleInput, { target: { value: 'Renamed DB' } });
|
||||
expect(onUpdateNode).toHaveBeenCalledWith(
|
||||
'node-db-1',
|
||||
expect.objectContaining({ label: 'Renamed DB' })
|
||||
);
|
||||
|
||||
// Readonly Task ID
|
||||
const taskIdInput = screen.getByDisplayValue('node-db-1');
|
||||
expect(taskIdInput).toHaveAttribute('readonly');
|
||||
|
||||
// Table field
|
||||
const tableInput = screen.getByDisplayValue('permits');
|
||||
fireEvent.change(tableInput, { target: { value: 'new_permits_tbl' } });
|
||||
expect(onUpdateNode).toHaveBeenCalledWith(
|
||||
'node-db-1',
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ table: 'new_permits_tbl' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('7. Deletes node and closes drawer on clicking Delete node', () => {
|
||||
const onDeleteNode = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const selectedNode = {
|
||||
id: 'node-to-delete',
|
||||
data: { type: 'ge_quality', label: 'Quality Check' },
|
||||
};
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="node"
|
||||
selectedNode={selectedNode}
|
||||
onDeleteNode={onDeleteNode}
|
||||
onClose={onClose}
|
||||
onUpdateNode={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Delete node'));
|
||||
expect(onDeleteNode).toHaveBeenCalledWith('node-to-delete');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pipeline Settings Mode', () => {
|
||||
it('8. Renders slug, DAG bundle, description, and schedule controls', () => {
|
||||
const setPipelineId = vi.fn();
|
||||
const setBundleName = vi.fn();
|
||||
const setDescription = vi.fn();
|
||||
const setSchedule = vi.fn();
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="pipeline"
|
||||
onClose={vi.fn()}
|
||||
pipelineSettings={{
|
||||
pipelineId: 'lakehouse_pipeline',
|
||||
setPipelineId,
|
||||
bundleName: 'dlh-core-ingestion',
|
||||
setBundleName,
|
||||
description: 'Core ingestion DAG',
|
||||
setDescription,
|
||||
schedule: '@hourly',
|
||||
setSchedule,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Pipeline settings')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('lakehouse_pipeline')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('dlh-core-ingestion')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Core ingestion DAG')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('@hourly')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('lakehouse_pipeline'), {
|
||||
target: { value: 'new_slug' },
|
||||
});
|
||||
expect(setPipelineId).toHaveBeenCalledWith('new_slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard & Dismiss Interactions', () => {
|
||||
it('9. Closes on Escape key press or close button click', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="palette"
|
||||
onClose={onClose}
|
||||
onSelectComponent={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query Variations & Rich Configurations', () => {
|
||||
it('10. Renders dbt transform SQL query textarea and updates on change', () => {
|
||||
const onUpdateNode = vi.fn();
|
||||
const selectedNode = {
|
||||
id: 'node-dbt-1',
|
||||
data: {
|
||||
type: 'dbt_transform',
|
||||
label: 'dbt Silver Cleanse',
|
||||
config: { dbt_layer: 'silver' },
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="node"
|
||||
selectedNode={selectedNode}
|
||||
onUpdateNode={onUpdateNode}
|
||||
onDeleteNode={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('dbt Target Layer')).toBeInTheDocument();
|
||||
expect(screen.getByText('dbt SQL Model Transformation Query')).toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/SELECT ... FROM/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'SELECT id, status FROM {{ ref("bronze") }}' } });
|
||||
expect(onUpdateNode).toHaveBeenCalledWith(
|
||||
'node-dbt-1',
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sql_query_preview: 'SELECT id, status FROM {{ ref("bronze") }}',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('11. Renders DuckDB and PySpark SQL transformation query textareas', () => {
|
||||
const onUpdateNode = vi.fn();
|
||||
const selectedDuck = {
|
||||
id: 'node-duck-1',
|
||||
data: {
|
||||
type: 'duckdb_transform',
|
||||
label: 'DuckDB Dedupe',
|
||||
config: { layer: 'silver', query: 'SELECT * FROM source_parquet' },
|
||||
},
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<RightDrawer
|
||||
mode="node"
|
||||
selectedNode={selectedDuck}
|
||||
onUpdateNode={onUpdateNode}
|
||||
onDeleteNode={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('DuckDB SQL Query (Optional Override)')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('SELECT * FROM source_parquet')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
const selectedSpark = {
|
||||
id: 'node-spark-1',
|
||||
data: {
|
||||
type: 'pyspark_transform',
|
||||
label: 'Spark Large Scale',
|
||||
config: { sql_query: 'SELECT count(*) FROM source_view' },
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="node"
|
||||
selectedNode={selectedSpark}
|
||||
onUpdateNode={onUpdateNode}
|
||||
onDeleteNode={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('PySpark SQL / Transformation Query')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('SELECT count(*) FROM source_view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('12. Renders Great Expectations SQL assertion textarea for unexpected count', () => {
|
||||
const onUpdateNode = vi.fn();
|
||||
const selectedGE = {
|
||||
id: 'node-ge-1',
|
||||
data: {
|
||||
type: 'ge_quality',
|
||||
label: 'GE Quality Assertion',
|
||||
config: {
|
||||
ge_scope_level: 'sql',
|
||||
sql_assertion: 'SELECT count(*) FROM silver.table WHERE status NOT IN ("OK")',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<RightDrawer
|
||||
mode="node"
|
||||
selectedNode={selectedGE}
|
||||
onUpdateNode={onUpdateNode}
|
||||
onDeleteNode={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('⚡ Custom SQL Assertion (Unexpected Rows)')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue('SELECT count(*) FROM silver.table WHERE status NOT IN ("OK")')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
FolderOpen,
|
||||
Boxes,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
User,
|
||||
Shield,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Sidebar({ onToast, onBeforeNavigate }) {
|
||||
const { currentUser, userRole, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Collapsed state persisted in localStorage
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('merica_sidebar_collapsed') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef(null);
|
||||
|
||||
// Auto-collapse on small screens (< 820px)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 820) {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Close user menu on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target)) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
if (isUserMenuOpen) {
|
||||
document.addEventListener('pointerdown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
const nextState = !isCollapsed;
|
||||
setIsCollapsed(nextState);
|
||||
try {
|
||||
localStorage.setItem('merica_sidebar_collapsed', nextState ? '1' : '0');
|
||||
} catch (e) {
|
||||
console.warn('Could not save sidebar preference:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNav = (path) => {
|
||||
if (onBeforeNavigate) {
|
||||
const allowed = onBeforeNavigate(path);
|
||||
if (!allowed) return;
|
||||
}
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const handleTemplatesClick = () => {
|
||||
if (onToast) {
|
||||
onToast('Templates are coming soon in next milestone');
|
||||
}
|
||||
};
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const isStudio = currentPath === '/studio' || currentPath.startsWith('/studio/');
|
||||
const isOverview = currentPath === '/overview' || currentPath === '/';
|
||||
const isAllPipelines = currentPath === '/pipelines';
|
||||
const isSettings = currentPath === '/settings';
|
||||
|
||||
const userInitial = (currentUser?.name || currentUser?.username || 'U')[0].toUpperCase();
|
||||
const authProviderLabel = currentUser?.auth_provider === 'keycloak' ? 'Keycloak SSO' : 'Local Admin';
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: isCollapsed ? '52px' : '208px',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
background: 'var(--surface)',
|
||||
borderRight: '1px solid var(--line)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: isCollapsed ? '10px 6px' : '10px 8px',
|
||||
gap: '4px',
|
||||
transition: 'width 0.15s ease',
|
||||
position: 'relative',
|
||||
zIndex: 30,
|
||||
userSelect: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* BRAND HEADER */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '8px',
|
||||
padding: isCollapsed ? '6px 0 14px 0' : '6px 8px 14px 8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleNav('/studio')}
|
||||
title="Managed Engine for Robust Ingestion & Cloud Airflow"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '7px',
|
||||
background: 'var(--acc)',
|
||||
color: '#ffffff',
|
||||
fontWeight: 700,
|
||||
fontSize: '13px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: '15px', color: 'var(--text)', letterSpacing: '-0.3px' }}>
|
||||
MERICA
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NAVIGATION ITEMS */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '2px', flex: 1 }}>
|
||||
{/* OVERVIEW */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNav('/overview')}
|
||||
title="Overview"
|
||||
aria-label="Overview"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: isCollapsed ? '8px 0' : '8px 10px',
|
||||
border: 0,
|
||||
background: isOverview ? 'var(--bg)' : 'transparent',
|
||||
color: isOverview ? 'var(--text)' : 'var(--muted)',
|
||||
fontWeight: isOverview ? 600 : 500,
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<LayoutDashboard size={17} color={isOverview ? 'var(--acc)' : 'currentColor'} />
|
||||
</span>
|
||||
{!isCollapsed && <span style={{ fontSize: '13px' }}>Overview</span>}
|
||||
</button>
|
||||
|
||||
{/* STUDIO */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNav('/studio')}
|
||||
title="Studio"
|
||||
aria-label="Studio"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: isCollapsed ? '8px 0' : '8px 10px',
|
||||
border: 0,
|
||||
background: isStudio ? 'var(--bg)' : 'transparent',
|
||||
color: isStudio ? 'var(--text)' : 'var(--muted)',
|
||||
fontWeight: isStudio ? 600 : 500,
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<Layers size={17} color={isStudio ? 'var(--acc)' : 'currentColor'} />
|
||||
</span>
|
||||
{!isCollapsed && <span style={{ fontSize: '13px' }}>Studio</span>}
|
||||
</button>
|
||||
|
||||
{/* ALL PIPELINES (ADMIN ONLY) */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNav('/pipelines')}
|
||||
title="All pipelines (Admin only)"
|
||||
aria-label="All pipelines"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: isCollapsed ? '8px 0' : '8px 10px',
|
||||
border: 0,
|
||||
background: isAllPipelines ? 'var(--bg)' : 'transparent',
|
||||
color: isAllPipelines ? 'var(--text)' : 'var(--muted)',
|
||||
fontWeight: isAllPipelines ? 600 : 500,
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<FolderOpen size={17} color={isAllPipelines ? 'var(--acc)' : 'currentColor'} />
|
||||
</span>
|
||||
{!isCollapsed && <span style={{ fontSize: '13px' }}>All pipelines</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* TEMPLATES */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTemplatesClick}
|
||||
title="Templates"
|
||||
aria-label="Templates"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: isCollapsed ? '8px 0' : '8px 10px',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
fontWeight: 500,
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<Boxes size={17} />
|
||||
</span>
|
||||
{!isCollapsed && <span style={{ fontSize: '13px' }}>Templates</span>}
|
||||
</button>
|
||||
|
||||
{/* SPACER */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* SETTINGS */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNav('/settings')}
|
||||
title="Akses Pengaturan Dynamic RBAC · Settings"
|
||||
aria-label="Settings"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: isCollapsed ? '8px 0' : '8px 10px',
|
||||
border: 0,
|
||||
background: isSettings ? 'var(--bg)' : 'transparent',
|
||||
color: isSettings ? 'var(--text)' : 'var(--muted)',
|
||||
fontWeight: isSettings ? 600 : 500,
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<Settings size={17} color={isSettings ? 'var(--acc)' : 'currentColor'} />
|
||||
</span>
|
||||
{!isCollapsed && <span style={{ fontSize: '13px' }}>Settings</span>}
|
||||
</button>
|
||||
|
||||
{/* COLLAPSE / EXPAND TOGGLE */}
|
||||
<button
|
||||
type="button"
|
||||
id="sbt"
|
||||
onClick={toggleCollapse}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: isCollapsed ? '8px 0' : '8px 10px',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
{isCollapsed ? <ChevronRight size={17} /> : <ChevronLeft size={17} />}
|
||||
</span>
|
||||
{!isCollapsed && <span style={{ fontSize: '13px' }}>Collapse</span>}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* USER CARD AT BOTTOM */}
|
||||
<div style={{ position: 'relative' }} ref={userMenuRef}>
|
||||
<div
|
||||
id="usr"
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
title="Account and sign out"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
gap: '10px',
|
||||
padding: '8px',
|
||||
borderTop: '1px solid var(--line)',
|
||||
marginTop: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '7px',
|
||||
background: isUserMenuOpen ? 'var(--bg)' : 'transparent',
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accsoft)',
|
||||
color: 'var(--acc)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{userInitial}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '12.5px',
|
||||
color: 'var(--text)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{currentUser?.name || currentUser?.username || 'User'}
|
||||
</div>
|
||||
<small
|
||||
style={{
|
||||
display: 'block',
|
||||
color: 'var(--muted)',
|
||||
fontSize: '11px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{currentUser?.capability || userRole}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* USER POPOVER MENU */}
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
className="pop"
|
||||
style={{
|
||||
bottom: '48px',
|
||||
left: isCollapsed ? '54px' : '4px',
|
||||
width: '200px',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px 10px', fontSize: '12px', color: 'var(--muted)' }}>
|
||||
Signed in via <strong>{authProviderLabel}</strong>
|
||||
</div>
|
||||
<div style={{ padding: '0 10px 8px 10px', fontSize: '11.5px', color: 'var(--muted)' }}>
|
||||
Role: <span style={{ color: 'var(--text)', fontWeight: 600 }}>{userRole}</span>
|
||||
</div>
|
||||
<hr />
|
||||
<button
|
||||
type="button"
|
||||
className="it danger"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
logout();
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { History, RotateCcw, X, GitCommit, CheckCircle, Clock } from 'lucide-react';
|
||||
|
||||
export default function TimeMachineModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
pipelineId,
|
||||
currentVersion = 'v1.0.0',
|
||||
onRevert
|
||||
}) {
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [revertingTag, setRevertingTag] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && pipelineId) {
|
||||
setLoading(true);
|
||||
fetch(`/api/v1/pipelines/${pipelineId}/versions`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch versions');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setVersions(data.versions || []);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback if empty or offline
|
||||
setVersions([
|
||||
{
|
||||
version: currentVersion || 'v1.0.0',
|
||||
pipeline_id: pipelineId,
|
||||
bundle_name: 'dlh-core-ingestion',
|
||||
changelog: 'Initial baseline deployment',
|
||||
published_at: new Date().toISOString(),
|
||||
nodes_count: 5,
|
||||
edges_count: 4,
|
||||
}
|
||||
]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, pipelineId, currentVersion]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleRevertClick = async (tag) => {
|
||||
setRevertingTag(tag);
|
||||
try {
|
||||
await onRevert(tag);
|
||||
onClose();
|
||||
} finally {
|
||||
setRevertingTag(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(8, 9, 13, 0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '680px',
|
||||
maxHeight: '85vh',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
|
||||
borderRadius: 'var(--radius-2xl, 24px)',
|
||||
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
|
||||
padding: '28px',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* CLOSE BUTTON */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* HEADER */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
|
||||
padding: '10px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
boxShadow: '0 4px 14px rgba(139,124,255,0.3)',
|
||||
}}
|
||||
>
|
||||
<History size={22} color="#ffffff" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '700', margin: 0 }}>
|
||||
Time Machine: Version History
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)' }}>
|
||||
Historical DAG Snapshots for Pipeline: <strong style={{ color: '#F5F7FA' }}>{pipelineId}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VERSION LIST */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
paddingRight: '6px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: '30px', textAlign: 'center', color: 'var(--color-text-muted, #707686)' }}>
|
||||
Loading version history...
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div style={{ padding: '30px', textAlign: 'center', color: 'var(--color-text-muted, #707686)' }}>
|
||||
No historical versions recorded yet. Publish a version to create immutable snapshots.
|
||||
</div>
|
||||
) : (
|
||||
versions.map((item, idx) => {
|
||||
const isCurrent = item.version === currentVersion;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
background: isCurrent ? 'rgba(139, 124, 255, 0.08)' : 'var(--color-surface-raised, #12141B)',
|
||||
border: `1px solid ${isCurrent ? 'var(--color-violet, #8B7CFF)' : 'var(--color-border-default, rgba(255,255,255,0.09))'}`,
|
||||
borderRadius: '14px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
background: isCurrent ? 'var(--color-violet, #8B7CFF)' : 'var(--color-surface, #0E1016)',
|
||||
color: '#ffffff',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '700',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
{item.version}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
background: 'rgba(74, 222, 128, 0.15)',
|
||||
color: '#4ade80',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={11} /> Active in Canvas
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #707686)' }}>
|
||||
Bundle: {item.bundle_name || 'dlh-core-ingestion'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, #A5AAB8)', marginTop: '2px' }}>
|
||||
{item.changelog || 'No changelog description provided.'}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '11px', color: 'var(--color-text-muted, #707686)', marginTop: '4px' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Clock size={11} /> {item.published_at ? new Date(item.published_at).toLocaleString() : 'N/A'}
|
||||
</span>
|
||||
<span>• {item.nodes_count || 0} Components</span>
|
||||
<span>• {item.edges_count || 0} Dependencies</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => handleRevertClick(item.version)}
|
||||
disabled={revertingTag === item.version}
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
{revertingTag === item.version ? 'Reverting...' : 'Revert Canvas'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import { Plus, FolderOpen, Layers, Sparkles, X } from 'lucide-react';
|
||||
|
||||
export default function WelcomeModal({ isOpen, onClose, onCreateBlank, onLoadExisting }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(8, 9, 13, 0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
|
||||
borderRadius: 'var(--radius-2xl, 24px)',
|
||||
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
|
||||
padding: '32px',
|
||||
position: 'relative',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* CLOSE BUTTON */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* HERO ICON */}
|
||||
<div
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '20px',
|
||||
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 20px auto',
|
||||
boxShadow: '0 10px 30px rgba(139, 124, 255, 0.35)',
|
||||
}}
|
||||
>
|
||||
<Layers size={32} color="#ffffff" />
|
||||
</div>
|
||||
|
||||
<h2 style={{ fontSize: '24px', fontWeight: '700', margin: '0 0 8px 0', letterSpacing: '-0.5px' }}>
|
||||
Welcome to DLH Low-Code Studio
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: 'var(--color-text-secondary, #A5AAB8)', margin: '0 0 28px 0', lineHeight: 1.5 }}>
|
||||
Declarative Data Lakehouse Pipeline Canvas. Choose how you want to begin building your Airflow 3.0 workflow.
|
||||
</p>
|
||||
|
||||
{/* 2 MAIN CARDS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
{/* OPTION 1: BLANK CANVAS */}
|
||||
<div
|
||||
onClick={() => {
|
||||
onCreateBlank();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: 'var(--radius-xl, 20px)',
|
||||
padding: '24px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-violet, #8B7CFF)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-default, rgba(255,255,255,0.09))';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(139, 124, 255, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<Plus size={22} color="#8b7cff" />
|
||||
</div>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: '600', margin: '0 0 4px 0' }}>Create New Pipeline</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: 0 }}>
|
||||
Start with a clean blank canvas and drag components
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OPTION 2: LOAD EXISTING */}
|
||||
<div
|
||||
onClick={() => {
|
||||
onLoadExisting();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: 'var(--radius-xl, 20px)',
|
||||
padding: '24px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-violet, #8B7CFF)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-default, rgba(255,255,255,0.09))';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(96, 165, 250, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={22} color="#60a5fa" />
|
||||
</div>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: '600', margin: '0 0 4px 0' }}>Load Existing Pipeline</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: 0 }}>
|
||||
Select from stored database pipelines or templates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { API_BASE, KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID } from '../utils/env';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '../utils/pkce';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [token, setToken] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('merica_token') || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [currentUser, setCurrentUser] = useState(() => {
|
||||
try {
|
||||
const savedUser = localStorage.getItem('merica_user') || localStorage.getItem('dlh_studio_user');
|
||||
return savedUser ? JSON.parse(savedUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Sync session on mount with /api/v1/auth/me if token exists
|
||||
useEffect(() => {
|
||||
const verifySession = async () => {
|
||||
const storedToken = localStorage.getItem('merica_token');
|
||||
if (!storedToken) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${storedToken}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
if (user && (user.username || user.capability || user.id)) {
|
||||
setCurrentUser(user);
|
||||
localStorage.setItem('merica_user', JSON.stringify(user));
|
||||
localStorage.setItem('dlh_studio_user', JSON.stringify(user));
|
||||
}
|
||||
} else if (res.status === 401) {
|
||||
// Token expired or invalid
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Session verification error (backend might be offline or starting up):', err);
|
||||
}
|
||||
};
|
||||
|
||||
verifySession();
|
||||
}, []);
|
||||
|
||||
const loginLocal = async (username, password) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || 'Username atau password tidak valid');
|
||||
}
|
||||
|
||||
setToken(data.access_token);
|
||||
setCurrentUser(data.user);
|
||||
localStorage.setItem('merica_token', data.access_token);
|
||||
localStorage.setItem('merica_user', JSON.stringify(data.user));
|
||||
localStorage.setItem('dlh_studio_user', JSON.stringify(data.user));
|
||||
|
||||
return { success: true, user: data.user };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initiateKeycloakSSO = async () => {
|
||||
try {
|
||||
const redirectUri = window.location.origin + window.location.pathname + '#/auth/callback';
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('merica_pkce_verifier', verifier);
|
||||
} catch (e) {
|
||||
console.warn('Could not store PKCE verifier in sessionStorage:', e);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: KEYCLOAK_CLIENT_ID,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authUrl = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
window.location.href = authUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to initiate Keycloak SSO PKCE:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSOCallback = async (code, redirectUri) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const effectiveRedirectUri = redirectUri || (window.location.origin + window.location.pathname + '#/auth/callback');
|
||||
let codeVerifier = null;
|
||||
try {
|
||||
codeVerifier = sessionStorage.getItem('merica_pkce_verifier');
|
||||
sessionStorage.removeItem('merica_pkce_verifier');
|
||||
} catch (e) {
|
||||
console.warn('Could not read PKCE verifier from sessionStorage:', e);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
code,
|
||||
redirect_uri: effectiveRedirectUri,
|
||||
};
|
||||
if (codeVerifier) {
|
||||
payload.code_verifier = codeVerifier;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/auth/sso/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || 'Gagal autentikasi SSO Keycloak');
|
||||
}
|
||||
|
||||
setToken(data.access_token);
|
||||
setCurrentUser(data.user);
|
||||
localStorage.setItem('merica_token', data.access_token);
|
||||
localStorage.setItem('merica_user', JSON.stringify(data.user));
|
||||
localStorage.setItem('dlh_studio_user', JSON.stringify(data.user));
|
||||
|
||||
return { success: true, user: data.user };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Compatibility helper for legacy tests / mock callers
|
||||
const login = (user) => {
|
||||
setCurrentUser(user);
|
||||
try {
|
||||
localStorage.setItem('merica_user', JSON.stringify(user));
|
||||
localStorage.setItem('dlh_studio_user', JSON.stringify(user));
|
||||
} catch (e) {
|
||||
console.error('LocalStorage write error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const isKeycloakUser = currentUser?.auth_provider === 'keycloak';
|
||||
setToken(null);
|
||||
setCurrentUser(null);
|
||||
try {
|
||||
localStorage.removeItem('merica_token');
|
||||
localStorage.removeItem('merica_user');
|
||||
localStorage.removeItem('dlh_studio_user');
|
||||
sessionStorage.removeItem('merica_pkce_verifier');
|
||||
} catch (e) {
|
||||
console.error('LocalStorage remove error:', e);
|
||||
}
|
||||
|
||||
if (isKeycloakUser && KEYCLOAK_URL && KEYCLOAK_REALM) {
|
||||
const redirectUri = window.location.origin + window.location.pathname;
|
||||
const logoutUrl = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout?client_id=${KEYCLOAK_CLIENT_ID}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`;
|
||||
window.location.href = logoutUrl;
|
||||
}
|
||||
};
|
||||
|
||||
// Derive standardized capabilities
|
||||
const isAdmin = !!(
|
||||
currentUser?.is_admin ||
|
||||
currentUser?.capability === 'admin' ||
|
||||
currentUser?.role === 'admin'
|
||||
);
|
||||
|
||||
const canEdit = !!(
|
||||
isAdmin ||
|
||||
currentUser?.can_edit ||
|
||||
currentUser?.capability === 'editor' ||
|
||||
currentUser?.role === 'editor'
|
||||
);
|
||||
|
||||
const isViewer = !canEdit;
|
||||
|
||||
const userRole = isAdmin ? 'admin' : (canEdit ? 'editor' : 'viewer');
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
currentUser,
|
||||
userRole,
|
||||
token,
|
||||
isAdmin,
|
||||
canEdit,
|
||||
isViewer,
|
||||
isAuthenticated: !!currentUser,
|
||||
loading,
|
||||
login,
|
||||
loginLocal,
|
||||
initiateKeycloakSSO,
|
||||
handleSSOCallback,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# 01. Pengenalan MERICA Data Lakehouse Studio
|
||||
|
||||
**Tagline**:
|
||||
> **MERICA: wrapper Apache Airflow untuk eksekusi pipeline yang terkelola, terintegrasi, dan cloud-ready.**
|
||||
|
||||
---
|
||||
|
||||
## What is MERICA?
|
||||
**MERICA** (*Managed Engine for Robust Ingestion & Cloud Airflow*) adalah layer abstraksi dan *wrapper* enterprise berbasis **Apache Airflow 3.0**.
|
||||
|
||||
MERICA menyederhanakan pembuatan, pengujian, deployment, dan pemantauan pipeline data modern berbasis **Data Lakehouse** (MinIO S3, Trino Iceberg, Postgres CDC, dbt, dan Great Expectations) tanpa mewajibkan pengguna menulis kode Python DAG manual dari nol.
|
||||
|
||||
---
|
||||
|
||||
## Keunggulan Utama MERICA Studio:
|
||||
1. **Declarative YAML Engine**: Menyusun pipeline data dengan spesifikasi deklaratif YAML yang *human-readable*.
|
||||
2. **Visual Low-Code Canvas**: Perancangan DAG visual dengan fitur *Drag & Drop*, *Auto-Layout*, dan *Animated Flowing Connections*.
|
||||
3. **Medallion Architecture Native**: Dukungan terintegrasi untuk layer **Bronze** (Ingestion), **Silver** (Cleansing/dbt), dan **Gold** (Data Marts).
|
||||
4. **Data Governance & RBAC**: Pembatasan hak akses berbasis peran (👑 Admin vs 👁️ Viewer) serta pengamanan kredensial multi-tier.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 02. Panduan Penggunaan UI Canvas Studio
|
||||
|
||||
Panduan langkah demi langkah menggunakan antarmuka kanvas visual MERICA Studio:
|
||||
|
||||
---
|
||||
|
||||
## Alur Kerja Utama (Step-by-Step Workflow)
|
||||
|
||||
1. **Autentikasi & Pemilihan Role**:
|
||||
- Pilih role pada halaman login: **👑 Admin** (Akses Penuh Edit & Deploy) atau **👁️ Viewer** (Mode Read-Only).
|
||||
2. **Inisialisasi Canvas**:
|
||||
- Klik `New Canvas` di toolbar header atau pilih `Load Pipeline` untuk memuat pipeline terdaftar dari Airflow.
|
||||
3. **Tarik & Lepas Komponen (Drag & Drop)**:
|
||||
- Tarik simpul dari **Component Palette** sebelah kiri ke area kanvas.
|
||||
4. **Hubungkan Alur Data (Connecting Nodes)**:
|
||||
- Tarik garis dari handle output simpul sumber (misal: *Source Database*) ke handle input simpul target (misal: *MinIO Storage*).
|
||||
- Line akan otomatis mengalir dengan animasi *flowing dash offset*.
|
||||
5. **Konfigurasi Node di Inspector Panel**:
|
||||
- Klik simpul pada kanvas untuk mengedit parameter di **Inspector Panel** sebelah kanan.
|
||||
6. **Inspeksi YAML Preview**:
|
||||
- Periksa sintaksis deklaratif Airflow YAML 3.0 pada panel bawah **CodeMirror Editor**.
|
||||
7. **Simpan Draft & Deploy DAG**:
|
||||
- Klik `💾 Save Draft` untuk menyimpan progres tanpa deploy.
|
||||
- Klik `🚀 Deploy DAG` untuk mengunggah spesifikasi ke **MERICA Airflow Engine**.
|
||||
@@ -0,0 +1,29 @@
|
||||
# 03. Katalog Komponen: Ingestion Layer (Bronze)
|
||||
|
||||
Komponen ingesti bertugas mengambil data mentah dari berbagai sumber data (Database, Sheets, REST API, File) ke Data Lakehouse.
|
||||
|
||||
---
|
||||
|
||||
### 3.1. Source Database (`source_db`) 🗄️
|
||||
* **Fungsi**: Ekstraksi data dari Relational Database (Postgres, MySQL, Oracle, MSSQL).
|
||||
* **Mode Koneksi**:
|
||||
1. **Registered Airflow Connection**: Memilih Connection ID terdaftar (e.g. `conn_postgres_source_db`).
|
||||
2. **Custom Manual Credentials**: Input `host`, `port`, `database`, `schema`, `user`, `password`.
|
||||
|
||||
### 3.2. Google Workspace Sheets Ingestion (`source_gws`) 📄
|
||||
* **Fungsi**: Extractor otomatis dokumen Google Sheets ke Bronze Lakehouse.
|
||||
* **Parameter**:
|
||||
- `spreadsheet_id`: ID unik spreadsheet dari URL.
|
||||
- `sheet_range`: Range sheet (e.g. `Sheet1!A1:Z1000`).
|
||||
- `gws_conn_id`: Airflow Connection (`conn_gws_google_api`).
|
||||
|
||||
### 3.3. REST API Services Ingestion (`source_api`) 🌐
|
||||
* **Fungsi**: Fetching data dari REST API Endpoint secara berkala.
|
||||
* **Parameter**:
|
||||
- `endpoint_url`: URL target API.
|
||||
- `http_method`: `GET` / `POST`.
|
||||
- `json_root_path`: JSON Pointer path array data (e.g. `data.items`).
|
||||
|
||||
### 3.4. File & Document Ingestion (`source_file`) 📁
|
||||
* **Fungsi**: Ingest file lokal/remote (CSV, Parquet, JSON, PDF).
|
||||
* **Fitur**: Direct UI Drag-and-Drop file upload ke landing bucket MinIO (`dlh-landing/uploads/`).
|
||||
@@ -0,0 +1,21 @@
|
||||
# 04. Katalog Komponen: Transformation Layer (Silver & Gold)
|
||||
|
||||
Komponen transformasi bertugas membersihkan, menormalkan, dan mengagregasi data dari Bronze ke Silver dan Gold Layer.
|
||||
|
||||
---
|
||||
|
||||
### 4.1. Pandas Data Cleansing (`pandas_cleansing`) 🧪
|
||||
* **Fungsi**: Pembersihan dan manipulasi dataframe Python secara cepat.
|
||||
* **Parameter**:
|
||||
- `null_strategy`: `drop_row`, `fill_zero`, `fill_mean`, `keep`.
|
||||
- `drop_duplicates`: Checkbox hapus duplikat.
|
||||
- `column_renames`: Pemetaan nama kolom JSON (e.g. `{"tgl": "date_created"}`).
|
||||
- `custom_script`: Kode snippet Python custom transformation.
|
||||
|
||||
### 4.2. dbt Model Transformation (`dbt_transform`) 🧱
|
||||
* **Fungsi**: Pengelolaan transformasi data berlapis (Medallion Layer) menggunakan dbt (data build tool).
|
||||
* **Parameter**:
|
||||
- `dbt_layer`: Target layer (`Silver Staging` / `Gold Data Mart`).
|
||||
- `dbt_model`: Nama tag/model dbt (e.g. `stg_spku_realtime`, `fct_waste_daily_mart`).
|
||||
- `target_catalog`: Pilihan Trino Catalog (`iceberg`, `nessie`, `polaris`, `lakehouse`).
|
||||
- `sql_query_preview`: Editor SQL CodeMirror terintegrasi dengan fitur auto-sync berkas `.sql` fisik ke direktori dbt.
|
||||
@@ -0,0 +1,20 @@
|
||||
# 05. Katalog Komponen: Storage Targets
|
||||
|
||||
Komponen target penyimpanan menentukan lokasi dan format akhir data di Data Lakehouse.
|
||||
|
||||
---
|
||||
|
||||
### 5.1. MinIO S3 Storage Target (`minio_storage`) 💽
|
||||
* **Fungsi**: Target penyimpanan Object Storage S3 kompatibel (MinIO).
|
||||
* **Parameter**:
|
||||
- `bucket_name`: Nama bucket target (`dlh-landing`, `dlh-bronze`, `dlh-silver`).
|
||||
- `file_format`: `parquet`, `orc`, `json`, `csv`.
|
||||
- `compression`: Kompresi (`snappy`, `gzip`, `none`).
|
||||
|
||||
### 5.2. Trino / Iceberg Storage Target (`trino_storage`) ⚡
|
||||
* **Fungsi**: Target penyimpanan tabel Lakehouse berperforma tinggi berbasis Apache Iceberg via Trino query engine.
|
||||
* **Parameter**:
|
||||
- `trino_catalog`: Katalog engine (`iceberg`, `nessie`, `polaris`).
|
||||
- `trino_schema`: Target Schema (`bronze`, `silver`, `gold`).
|
||||
- `iceberg_table`: Nama tabel target Apache Iceberg.
|
||||
- `partition_spec`: Spesifikasi partisi (e.g. `day(created_at)`).
|
||||
@@ -0,0 +1,18 @@
|
||||
# 06. Katalog Komponen: Operators & Quality Suite
|
||||
|
||||
Komponen eksekusi umum dan penjaminan kualitas data (*Data Quality Assertion*).
|
||||
|
||||
---
|
||||
|
||||
### 6.1. Bash Operator (`bash_operator`) 💻
|
||||
* **Fungsi**: Eksekusi perintah CLI / Shell scripts di dalam Airflow worker.
|
||||
* **Penggunaan Popular**: Eksekusi Sling CDC (`sling run`), dbt CLI build (`dbt run`).
|
||||
|
||||
### 6.2. Python Operator (`python_operator`) 🐍
|
||||
* **Fungsi**: Eksekusi fungsi Python kustom dengan penukaran data XCom.
|
||||
|
||||
### 6.3. Great Expectations Quality Suite (`ge_quality`) 🛡️
|
||||
* **Fungsi**: Pengujian dan penjaminan kualitas data secara otomatis.
|
||||
* **Tipe Pengujian**:
|
||||
- `Table-Level`: Row count between, column set matching.
|
||||
- `Column-Level`: Null check (`not_null`), Uniqueness check (`unique`), Min/Max range check.
|
||||
@@ -0,0 +1,17 @@
|
||||
# 07. FAQ & Troubleshooting Penggunaan Studio Canvas
|
||||
|
||||
Jawaban atas pertanyaan umum saat merancang pipeline di MERICA Studio Canvas:
|
||||
|
||||
---
|
||||
|
||||
### Q1: Mengapa tombol "Deploy DAG" saya tidak aktif (*disabled*)?
|
||||
* **Jawaban**: Anda kemungkinan masuk dalam mode **👁️ Viewer (Read-Only)**. Pindah role ke **👑 Admin** di halaman login untuk mengaktifkan fitur edit dan deploy DAG.
|
||||
|
||||
### Q2: Bagaimana cara menghapus garis koneksi (*edge*) antar node?
|
||||
* **Jawaban**: Arahkan kursor (*hover*) ke atas garis koneksi. Tombol ikon silang `(X)` berwarna merah akan muncul di tengah garis, klik ikon tersebut untuk menghapus koneksi.
|
||||
|
||||
### Q3: Apakah perubahan saya akan hilang jika browser di-refresh?
|
||||
* **Jawaban**: Tidak. Sesi otentikasi dan role Anda disimpan pada `localStorage` (`dlh_studio_user`). Selain itu, gunakan tombol `💾 Save Draft` secara berkala untuk menyimpan progres graf kanvas Anda ke server.
|
||||
|
||||
### Q4: Apakah saya perlu mengunggah berkas SQL manual untuk dbt node?
|
||||
* **Jawaban**: Tidak perlu. Backend MERICA Studio akan secara otomatis men-sync berkas fisik `.sql` ke direktori dbt `models/silver/` atau `models/gold/` saat Anda mengeklik tombol `🚀 Deploy DAG`.
|
||||
@@ -0,0 +1,190 @@
|
||||
# MERICA Data Lakehouse Studio: User Guide & Widget Component Library
|
||||
|
||||
**Document ID**: `DLH-DOC-016`
|
||||
**Tagline**:
|
||||
> **MERICA: wrapper Apache Airflow untuk eksekusi pipeline yang terkelola, terintegrasi, dan cloud-ready.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary & 1-Page FAQ
|
||||
|
||||
### What is MERICA?
|
||||
**MERICA** (*Managed Engine for Robust Ingestion & Cloud Airflow*) adalah layer abstraksi dan *wrapper* enterprise berbasis **Apache Airflow 3.0**. MERICA menyederhanakan pembuatan, pengujian, deployment, dan pemantauan pipeline data modern berbasis **Data Lakehouse** (MinIO S3, Trino Iceberg, Postgres CDC, dbt, dan Great Expectations) tanpa mewajibkan pengguna menulis kode Python DAG manual dari nol.
|
||||
|
||||
---
|
||||
|
||||
### Frequently Asked Questions (FAQ)
|
||||
|
||||
#### Q1: Mengapa kita memerlukan MERICA di atas Apache Airflow?
|
||||
* **Jawaban**: Penulisan DAG Airflow secara manual rentan terhadap *boilerplate code*, kesalahan sintaksis Python, dan kesulitan standardisasi antar tim. MERICA memberikan format **Declarative YAML** dan **Visual Low-Code Canvas** yang secara otomatis men-generate DAG Airflow 3.0 yang *production-ready*, hemat memori, dan mematuhi *best practices* cloud-native.
|
||||
|
||||
#### Q2: Bagaimana MERICA mengelola target penyimpanan Data Lakehouse?
|
||||
* **Jawaban**: MERICA mendukung **Medallion Architecture** secara *native*:
|
||||
- **Bronze Layer**: Data mentah dari Postgres (via Sling CDC), REST API, File Upload, atau GWS Sheets disimpan ke MinIO S3 (`dlh-bronze`) atau langsung menjadi tabel **Apache Iceberg via Trino** (`iceberg.bronze.<table_name>`).
|
||||
- **Silver Layer**: Data dibersihkan menggunakan **Pandas Cleansing** atau **dbt Silver Staging Models**.
|
||||
- **Gold Layer**: Data Mart siap analisis yang diorkestrasi via **dbt Gold Marts** dan divalidasi oleh **Great Expectations Quality Suite**.
|
||||
|
||||
#### Q3: Bagaimana pengamanan kredensial dan hak akses role (Admin vs Viewer)?
|
||||
* **Jawaban**: MERICA menyediakan **Role-Based Access Control (RBAC)**:
|
||||
- **👑 Admin Role**: Memiliki hak akses penuh untuk membuat canvas, mengedit parameter node, menyimpan draft, menghapus pipeline, dan melakukan *Deploy Airflow DAG*.
|
||||
- **👁️ Viewer Role**: Mode *Read-Only* yang aman untuk inspeksi topologi canvas dan preview sintaks YAML tanpa risiko mengubah konfigurasi production.
|
||||
- **Credential Safety**: Kredensial database kustom di-enkripsi dan disinkronisasikan langsung ke Airflow MetaDB secara aman.
|
||||
|
||||
#### Q4: Apakah MERICA dapat dijalankan di lingkungan Cloud Container / Kubernetes?
|
||||
* **Jawaban**: **Ya.** MERICA bersifat *cloud-ready*. Seluruh DAG dan konfigurasi yang dihasilkan bersifat deklaratif sehingga dapat langsung di-mount ke Pod Airflow Kubernetes Executor, Docker Swarm, maupun Standalone Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## 2. In-App Component Widget Library & Setup Guide
|
||||
|
||||
Setiap komponen di canvas DLH Low-Code Studio dirancang modular. Berikut adalah panduan lengkap cara konfigurasi tiap simpul (node):
|
||||
|
||||
---
|
||||
|
||||
### 2.1. Source Database (`source_db`) 🗄️
|
||||
* **Fungsi**: Ekstraksi data dari Relational Database Management System (RDBMS).
|
||||
* **Mode Koneksi**:
|
||||
1. **Registered Airflow Connection**: Memilih `conn_id` yang sudah terdaftar di Airflow (e.g. `conn_postgres_source_db`).
|
||||
2. **Custom Manual Credentials (`custom`)**: Memasukkan parameter koneksi kustom secara manual:
|
||||
- `db_driver`: `postgres`, `mysql`, `oracle`, `mssql`, `clickhouse`.
|
||||
- `host`: Host / IP Server Database.
|
||||
- `port`: Port (e.g. `5432`, `3306`).
|
||||
- `database`: Nama database target.
|
||||
- `schema`: Schema database (e.g. `public`, `bronze_raw`).
|
||||
- `user` & `password`: Kredensial login.
|
||||
* **Target Storage Integration**:
|
||||
- `MinIO S3 Bucket` (`dlh-bronze`, format Parquet).
|
||||
- `Direct Bronze Table via Trino` (`iceberg.bronze.<table_name>`).
|
||||
|
||||
---
|
||||
|
||||
### 2.2. Google Workspace Sheets Ingestion (`source_gws`) 📄
|
||||
* **Fungsi**: Ekstraksi spreadsheet Google Sheets secara otomatis ke Lakehouse.
|
||||
* **Tata Kelola & Penyimpanan Kredensial Service Account Key**:
|
||||
1. **Airflow Connection (`conn_gws_google_api`) [Rekomendasi Utama]**:
|
||||
Kredensial Service Account JSON disimpan pada bidang *Extra JSON* Airflow Connection:
|
||||
- `{"keyfile_json": "{...}"}` (Kredensial JSON mentah)
|
||||
- `{"key_path": "/opt/airflow/keys/gws_service_account.json"}` (Path file kredensial)
|
||||
2. **Airflow Variable (`gws_service_account_json`)**:
|
||||
Kredensial disimpan dalam Airflow Variable Secrets Store dan dipanggil via `Variable.get('gws_service_account_json')`.
|
||||
* **Parameter Form**:
|
||||
- `spreadsheet_id`: ID unik dokumen Google Sheets (dapat diambil dari URL Google Sheets).
|
||||
- `sheet_range`: Nama sheet dan range sel (e.g. `Sheet1!A1:Z1000`).
|
||||
- `gws_conn_id`: Connection ID (`conn_gws_google_api`).
|
||||
|
||||
---
|
||||
|
||||
### 2.3. REST API Services Ingestion (`source_api`) 🌐
|
||||
* **Fungsi**: Mengunduh data dari REST API Endpoint secara realtime/periodic.
|
||||
* **Parameter Form**:
|
||||
- `endpoint_url`: URL API Target (e.g. `https://api.spku.go.id/v1/air-quality`).
|
||||
- `http_method`: Method HTTP (`GET`, `POST`).
|
||||
- `auth_token`: Bearer Token / API Key (opsional).
|
||||
- `params`: Query string / JSON payload.
|
||||
- `json_root_path`: JSON Pointer path untuk mengekstrak array data (e.g. `data.records`).
|
||||
|
||||
---
|
||||
|
||||
### 2.4. File & Document Ingestion (`source_file`) 📁
|
||||
* **Fungsi**: Ingest file dokumen lokal atau remote (CSV, Parquet, JSON, PDF).
|
||||
* **Mode Upload/Scan**:
|
||||
1. **Direct UI File Upload**: *Drag & Drop* file langsung dari UI Studio. File diunggah ke MinIO Landing bucket (`dlh-landing/raw_uploads/`).
|
||||
2. **Remote SFTP / S3 Path**: Pemindaian lokasi file jarak jauh (e.g. `s3://landing-bucket/daily_reports/*.csv`).
|
||||
* **Format Extractor**: `CSV Parser`, `Parquet Engine`, `JSON Lines`, `PDF Unstructured Extractor`.
|
||||
|
||||
---
|
||||
|
||||
### 2.5. Pandas Data Cleansing (`pandas_cleansing`) 🧪
|
||||
* **Fungsi**: Pembersihan, manipulasi, dan transformasi dataframe Python secara lokal.
|
||||
* **Parameter Form**:
|
||||
- `null_strategy`: Penanganan nilai kosong (`drop_row`, `fill_zero`, `fill_mean`, `keep`).
|
||||
- `drop_duplicates`: Boolean (`true`/`false`) untuk menghapus baris duplikat.
|
||||
- `column_renames`: Pemetaan nama kolom JSON (e.g. `{"tgl": "date_created"}`).
|
||||
- `type_casting`: Penyesuaian tipe data kolom (e.g. `{"ispu_val": "float32"}`).
|
||||
- `custom_script`: Kode Python custom transform snippet (`df['ispu_clean'] = df['ispu_val'].clip(0, 500)`).
|
||||
|
||||
---
|
||||
|
||||
### 2.6. MinIO S3 Storage Target (`minio_storage`) 💽
|
||||
* **Fungsi**: Target penyimpanan file mentah / staging pada Object Storage S3 kompatibel.
|
||||
* **Parameter Form**:
|
||||
- `bucket_name`: Nama bucket S3 (`dlh-landing`, `dlh-bronze`, `dlh-silver`).
|
||||
- `file_format`: Format file penyimpanan (`parquet`, `orc`, `json`, `csv`).
|
||||
- `compression`: Kompresi (`snappy`, `gzip`, `none`).
|
||||
|
||||
---
|
||||
|
||||
### 2.7. Trino / Iceberg Storage Target (`trino_storage`) ⚡
|
||||
* **Fungsi**: Target penyimpanan tabel Lakehouse berperforma tinggi berbasis Apache Iceberg via Trino query engine.
|
||||
* **Parameter Form**:
|
||||
- `trino_catalog`: Catalog engine (default: `iceberg`).
|
||||
- `trino_schema`: Target Schema (`bronze`, `silver`, `gold`).
|
||||
- `iceberg_table`: Nama tabel Iceberg target.
|
||||
- `partition_spec`: Spesifikasi partisi Iceberg (e.g. `day(created_at)` atau `bucket(stasiun_id, 10)`).
|
||||
|
||||
---
|
||||
|
||||
### 2.8. dbt Model Transformation (`dbt_transform`) 🧱
|
||||
* **Fungsi**: Pengelolaan transformasi data berlapis (Medallion Layer) menggunakan dbt (data build tool).
|
||||
* **Parameter Form**:
|
||||
- `dbt_layer`: Pemilihan layer model:
|
||||
- **Silver Staging**: Cleaning & schema normalization (`stg_...`).
|
||||
- **Gold Data Mart**: Aggregated analytics & business metrics (`fct_...`, `dim_...`).
|
||||
- `dbt_model`: Nama tag model dbt.
|
||||
- `sql_query_preview`: Code Mirror SQL Editor untuk preview & edit sintaks dbt:
|
||||
```sql
|
||||
-- dbt Model Transformation (Layer: Silver)
|
||||
SELECT
|
||||
stasiun_id,
|
||||
LOWER(TRIM(nama_stasiun)) AS nama_stasiun_clean,
|
||||
CAST(ispu_val AS FLOAT) AS ispu_value,
|
||||
created_at
|
||||
FROM {{ ref('bronze_spku_raw') }}
|
||||
WHERE ispu_val IS NOT NULL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.9. Bash Operator (`bash_operator`) 💻
|
||||
* **Fungsi**: Eksekusi perintah shell / CLI scripts di dalam kontainer Airflow worker.
|
||||
* **Template Popular**:
|
||||
- Sling CDC execution: `sling run --config /opt/airflow/configs/sling_cdc.yaml`
|
||||
- dbt build execution: `dbt run --select tag:silver`
|
||||
|
||||
---
|
||||
|
||||
### 2.10. Python Operator (`python_operator`) 🐍
|
||||
* **Fungsi**: Eksekusi fungsi kustom Python 3 dengan dukungan penukaran variabel XCom.
|
||||
* **Template Popular**:
|
||||
- S3 Data Pushing script.
|
||||
- Custom REST API Pagination Scraper.
|
||||
|
||||
---
|
||||
|
||||
### 2.11. Great Expectations Quality Suite (`ge_quality`) 🛡️
|
||||
* **Fungsi**: Pengujian dan garansi kualitas data (*Data Quality Assertion*) otomatis.
|
||||
* **Tingkat Pengujian**:
|
||||
1. **Table-Level Tests**:
|
||||
- `expect_table_row_count_to_be_between`: Minimum & Maximum threshold jumlah baris.
|
||||
- `expect_table_columns_to_match_set`: Verifikasi kelengkapan struktur kolom tabel.
|
||||
- `expect_table_row_count_to_not_be_zero`: Memastikan tabel tidak kosong.
|
||||
2. **Column-Level Tests**:
|
||||
- `expect_column_values_to_not_be_null`: Cek nilai NULL pada kolom target.
|
||||
- `expect_column_values_to_be_unique`: Cek keunikan nilai (Primary Key Check).
|
||||
- `expect_column_values_to_be_between`: Range nilai numerik (Min & Max).
|
||||
- `expect_column_values_to_match_regex`: Validasi format string/email/regex.
|
||||
|
||||
---
|
||||
|
||||
## 3. Workflow Langkah demi Langkah (Step-by-Step Tutorial)
|
||||
|
||||
1. **Sign In**: Buka Studio, pilih role (`👑 Admin` untuk full edit / `👁️ Viewer` untuk read-only).
|
||||
2. **Inisialisasi Canvas**: Klik `Create New Blank Pipeline` atau `Load Existing Pipeline`.
|
||||
3. **Tarik Komponen**: Drag simpul dari **Palette kiri** ke canvas.
|
||||
4. **Hubungkan Topology**: Hubungkan output handle simpul A ke input handle simpul B.
|
||||
5. **Inspeksi & Config**: Klik simpul di canvas untuk membuka **Inspector Panel kanan** dan sesuaikan konfigurasi.
|
||||
6. **Simpan Draft**: Klik `💾 Save Draft` untuk menyimpan progres perancangan tanpa men-deploy ke Airflow.
|
||||
7. **Deploy Airflow DAG**: Klik `🚀 Deploy DAG` untuk memvalidasi YAML dan mengaktifkan eksekusi otomatis di **MERICA Airflow Engine**.
|
||||
|
||||
---
|
||||
*Dokumen Spesifikasi User Guide Resmi MERICA Data Lakehouse Studio (DLH-DOC-016)*
|
||||
@@ -0,0 +1,242 @@
|
||||
# PANDUAN TEKNIS MEKANISME INTEGRASI UI STUDIO, APACHE AIRFLOW, DAN OPENMETADATA
|
||||
|
||||
> **Status Dokumen**: Arsitektur Teknis & Panduan Integrasi End-to-End
|
||||
> **Target Audiens**: Data Engineer, Solution Architect, Platform Engineer, & Pengembang Frontend/Backend
|
||||
> **Komponen Utama**: MERICA Studio Canvas UI, FastAPI Backend, Apache Airflow 3.x, OpenMetadata 2.0.0, OpenSearch
|
||||
|
||||
---
|
||||
|
||||
## 1. Ringkasan Eksekutif (Executive Summary)
|
||||
|
||||
Platform MERICA Data Lakehouse mengintegrasikan tiga lapisan fungsional utama untuk menyederhanakan siklus hidup rekayasa data:
|
||||
1. **Lapisan Desain (Design Plane)**: Visual Studio berbasis React Flow untuk merancang pipeline secara low-code.
|
||||
2. **Lapisan Orkestrasi & Eksekusi (Execution Plane)**: Apache Airflow 3.x yang mengeksekusi DAG data ingestion dan transformasi secara terjadwal atau *on-demand*.
|
||||
3. **Lapisan Tata Kelola & Observabilitas (Governance Plane)**: OpenMetadata 2.0 yang mencatat katalog data, memetakan silsilah kolom (*column-level lineage*), dan merekam riwayat eksekusi runtime.
|
||||
|
||||
Dokumen ini menguraikan arsitektur lengkap, komponen dan plugin yang digunakan, alur data end-to-end, payload kontrak API, serta analisis kasus kegagalan sinkronisasi.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pemetaan Komponen & Plugin Terlibat
|
||||
|
||||
| Lapisan | Komponen / Plugin | Peran & Rationale Teknis |
|
||||
| :--- | :--- | :--- |
|
||||
| **Frontend UI** | **React Flow Canvas** (`@xyflow/react`) | Merender antarmuka drag-and-drop node dan edge, memvalidasi form konfigurasi, dan memicu deployment. |
|
||||
| **Backend API** | **FastAPI + PyYAML** | Menerjemahkan visual canvas graph menjadi spesifikasi deklaratif YAML Airflow dan mendaftarkan entitas pipeline ke katalog OpenMetadata. |
|
||||
| **Orchestrator** | **Apache Airflow 3.x** | Mengorkestrasi pipeline penyerapan dan transformasi data (Bronze $\rightarrow$ Silver $\rightarrow$ Gold). |
|
||||
| **Dynamic Loader** | **Airflow DAG Factory** (`dag_factory.py`) | Membaca file `.yaml` di `/opt/airflow/configs/` dan menginstansiasi objek `DAG` Airflow secara dinamis di memori runtime. |
|
||||
| **Lineage Provider** | **Airflow OpenLineage Provider** | Plugin resmi Airflow (`apache-airflow-providers-openlineage`) yang memancarkan event dataset real-time ke OpenMetadata OpenLineage endpoint saat task berjalan. |
|
||||
| **Status Hook** | **Airflow DAG Callbacks** | Hook `on_success_callback` dan `on_failure_callback` di Python yang mengirimkan HTTP PUT status run ke OpenMetadata setelah DAG selesai dieksekusi. |
|
||||
| **Governance Hub** | **OpenMetadata 2.0.0 Server** | Menyimpan katalog entitas pipeline, struktur task, visualisasi graph lineage, dan riwayat kesehatan pipeline (*pipeline run status*). |
|
||||
| **Search Engine** | **OpenSearch 2.11** | Mengindeks entitas metadata untuk pencarian cepat sub-detik di UI OpenMetadata. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Diagram Alur Kerja End-to-End (End-to-End Flow)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph S1["Fase 1: Desain & Deploy di MERICA Studio (:5173)"]
|
||||
UI_CANVAS["Pengguna Mendesain Topologi Canvas<br/>(Source DB ──> Pandas Cleansing)"]
|
||||
UI_CLICK["Klik Tombol 'Deploy Pipeline'"]
|
||||
UI_CANVAS --> UI_CLICK
|
||||
end
|
||||
|
||||
subgraph S2["Fase 2: Backend Orchestration & Registrasi Katalog (:8000)"]
|
||||
API_DEPLOY["Endpoint: POST /api/deploy-dag"]
|
||||
WRITE_YAML["Tulis Berkas Konfigurasi Fisik:<br/>/opt/airflow/configs/<pipeline_id>.yaml"]
|
||||
REG_OM["Registrasi Struktur ke OpenMetadata:<br/>PUT /api/v1/pipelines"]
|
||||
UI_CLICK --> API_DEPLOY
|
||||
API_DEPLOY --> WRITE_YAML
|
||||
API_DEPLOY --> REG_OM
|
||||
end
|
||||
|
||||
subgraph S3["Fase 3: Katalogisasi di OpenMetadata (:8585)"]
|
||||
OM_ENTITY["Entitas Pipeline Terdaftar:<br/>dlh_airflow_service.<pipeline_id><br/>(Menampilkan Daftar Tasks & Metadata)"]
|
||||
REG_OM --> OM_ENTITY
|
||||
end
|
||||
|
||||
subgraph S4["Fase 4: Airflow Dynamic DAG Generation (:8080)"]
|
||||
DAG_PROC["Airflow DAG Processor / Scheduler<br/>Scan otomatis /opt/airflow/configs/*.yaml"]
|
||||
DAG_FAC["dag_factory.py<br/>Membaca YAML & Menginstansiasi Objek DAG"]
|
||||
DAG_EXEC["Eksekusi DAG (Trigger Run)"]
|
||||
DAG_CB["Callback Eksekusi: on_dag_success / on_dag_failure"]
|
||||
|
||||
WRITE_YAML -.-> DAG_PROC
|
||||
DAG_PROC --> DAG_FAC
|
||||
DAG_FAC --> DAG_EXEC
|
||||
DAG_EXEC --> DAG_CB
|
||||
end
|
||||
|
||||
subgraph S5["Fase 5: Sinkronisasi Status Runtime ke OM"]
|
||||
HTTP_PUT["Update Riwayat Run:<br/>PUT /api/v1/pipelines/{fqn}/status"]
|
||||
DAG_CB --> HTTP_PUT
|
||||
HTTP_PUT --> OM_ENTITY
|
||||
end
|
||||
```
|
||||
|
||||
### Diagram Topologi Sistem (ASCII Box Architecture)
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. MERICA STUDIO CANVAS UI │
|
||||
│ [Node: Source DB] ──────────► [Node: Pandas Cleansing] │
|
||||
│ │ Klik "Deploy Pipeline" │
|
||||
└─────────────────────┼──────────────────────────────────────────────────┘
|
||||
│ POST /api/deploy-dag
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. FASTAPI BACKEND (:8000) │
|
||||
│ ├─ Tulis File: /opt/airflow/configs/<pipeline_id>.yaml │
|
||||
│ └─ REST Call: PUT http://openmetadata:8585/api/v1/pipelines │
|
||||
└──────────────┬──────────────────────────────────────────┬──────────────┘
|
||||
│ │
|
||||
File Sync │ REST Sync │
|
||||
▼ ▼
|
||||
┌──────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ 3. APACHE AIRFLOW (:8080) │ │ 4. OPENMETADATA HUB (:8585) │
|
||||
│ ├─ dag-processor scan YAML │ │ ├─ Entitas Pipeline Terbuat │
|
||||
│ ├─ dag_factory.py load DAG │ │ ├─ Daftar Task Ditampilkan │
|
||||
│ ├─ Eksekusi Task Run │ │ └─ OpenSearch Mengindeks │
|
||||
│ └─ on_success_callback │ │ │
|
||||
└──────────────┬───────────────┘ │ │
|
||||
│ │ │
|
||||
│ PUT /api/v1/pipelines/ │ │
|
||||
│ {fqn}/status │ │
|
||||
└────────────────────────┴────────────────────────────────┘
|
||||
Status Berubah Menjadi Hijau (Successful)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Penjelasan Langkah Terinci (Step-by-Step Execution)
|
||||
|
||||
### Langkah 1: Desain & Validasi di Frontend Studio
|
||||
1. Pengguna membuka canvas di antarmuka web MERICA Studio (`http://localhost:5173`).
|
||||
2. Pengguna menghubungkan node-node pemrosesan (misal `node_src` ke `node_cleanse`).
|
||||
3. Saat tombol **Deploy Pipeline** ditekan, browser mengirim request HTTP `POST /api/deploy-dag` dengan struktur JSON:
|
||||
```json
|
||||
{
|
||||
"pipeline_id": "pipeline_2507",
|
||||
"description": "Dynamic Pipeline via MERICA Studio",
|
||||
"schedule": "0 */1 * * *",
|
||||
"nodes": [ ... ],
|
||||
"edges": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Langkah 2: Backend Menulis File YAML & Mendaftarkan ke OpenMetadata
|
||||
Backend FastAPI menjalankan dua tugas utama:
|
||||
1. **Generasi Berkas YAML**: Menulis berkas konfigurasi ke path bersama `/opt/airflow/configs/pipeline_2507.yaml`.
|
||||
2. **Registrasi Entitas Katalog ke OpenMetadata**: Backend melakukan autentikasi menggunakan Service Account / Admin JWT Token, lalu memanggil REST API OpenMetadata:
|
||||
```http
|
||||
PUT /api/v1/pipelines
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
|
||||
{
|
||||
"name": "pipeline_2507",
|
||||
"displayName": "Pipeline 2507",
|
||||
"service": "dlh_airflow_service",
|
||||
"description": "Dynamic Pipeline via MERICA Studio",
|
||||
"pipelineLocation": "/opt/airflow/configs/pipeline_2507.yaml",
|
||||
"tasks": [
|
||||
{"name": "node_1790849283916", "displayName": "Source Database", "taskType": "source_db"},
|
||||
{"name": "node_1790849290442", "displayName": "Pandas Cleansing", "taskType": "pandas_cleansing"}
|
||||
]
|
||||
}
|
||||
```
|
||||
**Hasil**: Begitu request ini sukses, pipeline langsung terdaftar dan muncul di antarmuka OpenMetadata (`http://localhost:8585/pipelines`).
|
||||
|
||||
### Langkah 3: Airflow Menginstansiasi DAG secara Dinamis
|
||||
1. Daemon `dag-processor` di Airflow secara kontinu memindai direktori `/opt/airflow/configs/`.
|
||||
2. Modul `dag_factory.py` membaca file `pipeline_2507.yaml`.
|
||||
3. Setiap entri di dalam daftar `tasks:` diubah menjadi operator Airflow konkret (`SlingDBIngestionOperator`, `PythonOperator`, `BashOperator`), lalu relasi dependensi `>>` dirangkai sesuai array `dependencies:`.
|
||||
4. Airflow Scheduler mendaftarkan DAG tersebut ke database metadata Airflow (`dlh_lakehouse`), dan DAG langsung muncul di dashboard Airflow Webserver (`http://localhost:8080`).
|
||||
|
||||
### Langkah 4: Eksekusi DAG & Pengiriman Sinyal Status Runtime
|
||||
1. Saat jadwal tiba atau pengguna menekan tombol **Trigger DAG** di Airflow:
|
||||
- Worker Airflow mengeksekusi masing-masing task.
|
||||
- Plugin OpenLineage (`apache-airflow-providers-openlineage`) menangkap event runtime dan mengirimkan metadata dataset I/O ke OpenMetadata.
|
||||
2. Ketika seluruh rangkaian task selesai dieksekusi, callback Airflow dipanggil:
|
||||
```python
|
||||
def on_dag_success_callback(context):
|
||||
dag = context.get('dag')
|
||||
dag_id = dag.dag_id
|
||||
task_names = [t.task_id for t in dag.tasks]
|
||||
notify_openmetadata_status(dag_id, "Successful", task_names)
|
||||
```
|
||||
3. Fungsi `notify_openmetadata_status` mengirimkan pembaruan status runtime via HTTP:
|
||||
```http
|
||||
PUT /api/v1/pipelines/dlh_airflow_service.pipeline_2507/status
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
|
||||
{
|
||||
"executionStatus": "Successful",
|
||||
"timestamp": 1790848410000,
|
||||
"taskStatus": [
|
||||
{"name": "node_1790849283916", "executionStatus": "Successful"},
|
||||
{"name": "node_1790849290442", "executionStatus": "Successful"}
|
||||
]
|
||||
}
|
||||
```
|
||||
4. Di UI OpenMetadata, halaman task pipeline tersebut langsung terisi histori run lengkap dengan durasi dan centang hijau status keberhasilan.
|
||||
|
||||
---
|
||||
|
||||
## 5. Analisis Masalah & Troubleshooting (Case Study: `pipeline_2507`)
|
||||
|
||||
### Gejala Masalah
|
||||
Pipeline `pipeline_2507` telah dibuat dari UI dan dijalankan di Airflow, namun tidak terdeteksi atau tidak muncul sama sekali di OpenMetadata (`http://localhost:8585`).
|
||||
|
||||
### Akar Masalah (Root Causes)
|
||||
1. **Ketiadaan Auto-Register di Endpoint Backend**:
|
||||
Endpoint `POST /api/deploy-dag` pada backend awalnya hanya menulis berkas fisik `.json` dan `.yaml` ke folder config Airflow, namun belum memanggil API `PUT /api/v1/pipelines` ke OpenMetadata. Akibatnya, OpenMetadata tidak pernah menerima pencatatan entitas baru tersebut.
|
||||
2. **Flag Sinkronisasi Dinonaktifkan**:
|
||||
Di file lingkungan `.env`, variabel `ENABLE_OPENMETADATA_SYNC` di-set ke `false`. Akibatnya, callback Airflow `notify_openmetadata_status` langsung berhenti (*early exit*) tanpa melakukan panggilan status.
|
||||
|
||||
### Solusi & Langkah Perbaikan
|
||||
1. **Mengaktifkan Environment Variable**:
|
||||
Pastikan di `.env` dan `docker-compose.yml`:
|
||||
```env
|
||||
ENABLE_OPENMETADATA_SYNC=true
|
||||
OPENMETADATA_HOST=http://openmetadata:8585
|
||||
```
|
||||
2. **Memasang Hook Auto-Registration di Backend**:
|
||||
Menyematkan pemanggilan fungsi `sync_pipeline_to_openmetadata(pipeline_data)` di dalam fungsi `deploy_dag` pada backend FastAPI sehingga pendaftaran katalog terjadi secara instan saat tombol deploy ditekan.
|
||||
3. **Mendaftarkan Entitas secara Manual (Fallback via API)**:
|
||||
Jika berkas YAML sudah terlanjur dibuat di Airflow namun belum terdaftar di OpenMetadata, dapat disinkronkan langsung menggunakan perintah cURL:
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8585/api/v1/users/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@open-metadata.org","password":"YWRtaW4="}' | jq -r '.accessToken')
|
||||
|
||||
curl -X PUT http://localhost:8585/api/v1/pipelines \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "pipeline_2507",
|
||||
"displayName": "Pipeline 2507",
|
||||
"service": "dlh_airflow_service",
|
||||
"pipelineLocation": "/opt/airflow/configs/pipeline_2507.yaml",
|
||||
"tasks": [
|
||||
{"name": "node_1790849283916", "displayName": "Source Database", "taskType": "source_db"},
|
||||
{"name": "node_1790849290442", "displayName": "Pandas Cleansing", "taskType": "pandas_cleansing"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Ringkasan Siklus Hidup Metadata
|
||||
|
||||
```text
|
||||
+-------------------+----------------------------+-------------------------------------------+
|
||||
| Tipe Informasi | Waktu Masuk ke OM | Mekanisme Pengiriman |
|
||||
+-------------------+----------------------------+-------------------------------------------+
|
||||
| Entitas & Struktur| Saat Deploy di UI Studio | REST API: PUT /api/v1/pipelines |
|
||||
| Riwayat Run/Status| Saat DAG Selesai Berjalan | Callback Airflow: PUT /api/v1/.../status |
|
||||
| Silsilah Dataset | Saat Task Berjalan | OpenLineage Event Streaming (HTTP :8585) |
|
||||
+-------------------+----------------------------+-------------------------------------------+
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# 📖 MERICA Studio — Panduan & Katalog Komponen
|
||||
|
||||
Selamat datang di Pusat Dokumentasi Interaktif **MERICA Data Lakehouse Studio**.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Daftar Panduan Pengguna
|
||||
|
||||
1. **[01. Pengenalan MERICA Studio](01_pengenalan_merica_studio.md)**: Gambaran umum platform, arsitektur medallion, dan kapabilitas orkestrasi Airflow 3.
|
||||
2. **[02. Panduan Penggunaan UI Canvas](02_panduan_penggunaan_ui_canvas.md)**: Navigasi antarmuka, penambahan node, penyambungan edge, dan validasi topologi.
|
||||
3. **[03. Katalog Komponen Ingestion](03_katalog_komponen_ingestion.md)**: Operator Ingestion CDC Database (Sling), Google Workspace Sheets, dan REST API Sensor IoT.
|
||||
4. **[04. Katalog Komponen Transformation](04_katalog_komponen_transformation.md)**: Modul transformasi Silver dan Gold Data Mart berbasis dbt Trino.
|
||||
5. **[05. Katalog Komponen Storage](05_katalog_komponen_storage.md)**: Konfigurasi MinIO S3 Bronze, Silver Iceberg, dan Gold Mart.
|
||||
6. **[06. Katalog Komponen Quality & Governance](06_katalog_komponen_operators_dan_quality.md)**: Validasi integritas data via Great Expectations dan pelacakan OpenMetadata Lineage.
|
||||
7. **[07. FAQ & Pemecahan Masalah](07_faq_penggunaan_canvas.md)**: Pertanyaan umum seputar eksekusi pipeline dan sinkronisasi.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", "SF Pro Display", "SF Pro Text", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--color-app-background: #08090D;
|
||||
--color-app-background-secondary: #0B0D12;
|
||||
--color-sidebar-background: #0A0B10;
|
||||
--color-surface: #0E1016;
|
||||
--color-surface-raised: #12141B;
|
||||
--color-surface-hover: #171A22;
|
||||
--color-surface-active: #1B1E28;
|
||||
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--color-border-default: rgba(255, 255, 255, 0.09);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--color-text-primary: #F5F7FA;
|
||||
--color-text-secondary: #A5AAB8;
|
||||
--color-text-muted: #707686;
|
||||
--color-text-disabled: #4B5060;
|
||||
|
||||
--color-violet: #8B7CFF;
|
||||
--color-violet-soft: #7467E8;
|
||||
--color-violet-muted: #514A8A;
|
||||
--color-violet-glow: rgba(139, 124, 255, 0.18);
|
||||
|
||||
--color-indigo: #6377FF;
|
||||
--color-blue: #5B7CFF;
|
||||
|
||||
--color-success: #4ADE80;
|
||||
--color-success-soft: rgba(74, 222, 128, 0.12);
|
||||
--color-success-border: rgba(74, 222, 128, 0.22);
|
||||
|
||||
--color-danger: #F87171;
|
||||
--color-danger-soft: rgba(248, 113, 113, 0.12);
|
||||
--color-danger-border: rgba(248, 113, 113, 0.22);
|
||||
|
||||
--color-warning: #FBBF24;
|
||||
--color-warning-soft: rgba(251, 191, 36, 0.12);
|
||||
|
||||
--color-info: #60A5FA;
|
||||
--color-info-soft: rgba(96, 165, 250, 0.12);
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 24px;
|
||||
|
||||
--shadow-card: 0 10px 40px rgba(0, 0, 0, 0.25);
|
||||
--shadow-elevated: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
/* Light Theme Variant Tokens Override */
|
||||
.light-mode {
|
||||
--color-app-background: #F4F6FA;
|
||||
--color-app-background-secondary: #EBEFF5;
|
||||
--color-sidebar-background: #FFFFFF;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-raised: #F8FAFC;
|
||||
--color-surface-hover: #F1F5F9;
|
||||
--color-surface-active: #E2E8F0;
|
||||
|
||||
--color-border-subtle: rgba(0, 0, 0, 0.08);
|
||||
--color-border-default: rgba(0, 0, 0, 0.12);
|
||||
--color-border-strong: rgba(0, 0, 0, 0.20);
|
||||
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #64748B;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
--color-violet: #6D28D9;
|
||||
--color-violet-soft: #7C3AED;
|
||||
--color-violet-glow: rgba(109, 40, 217, 0.12);
|
||||
}
|
||||
|
||||
/* Core resets & tabular numbers */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-app-background);
|
||||
color: var(--color-text-primary);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.numeric-tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Ambient Purple Accent Lighting */
|
||||
.bg-ambient-glow {
|
||||
background-image: radial-gradient(circle at top left, rgba(139, 124, 255, 0.08), transparent 45%);
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-strong);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-violet-muted);
|
||||
}
|
||||
|
||||
/* ReactFlow Controls & MiniMap Styling Override for Dark & Light Themes */
|
||||
.react-flow__controls {
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3) !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid var(--color-border-default, rgba(255,255,255,0.12)) !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background: var(--color-surface, #0E1016) !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08)) !important;
|
||||
color: var(--color-text-primary, #F5F7FA) !important;
|
||||
fill: var(--color-text-primary, #F5F7FA) !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background: var(--color-surface-hover, #171A22) !important;
|
||||
fill: var(--color-violet, #8B7CFF) !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button svg {
|
||||
fill: currentColor !important;
|
||||
stroke: currentColor !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.light-mode .react-flow__controls-button {
|
||||
background: #FFFFFF !important;
|
||||
color: #0F172A !important;
|
||||
fill: #0F172A !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.light-mode .react-flow__controls-button:hover {
|
||||
background: #F1F5F9 !important;
|
||||
color: #6D28D9 !important;
|
||||
fill: #6D28D9 !important;
|
||||
}
|
||||
|
||||
.light-mode .react-flow__controls-button svg {
|
||||
fill: currentColor !important;
|
||||
stroke: currentColor !important;
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DocsViewerModal from '../components/DocsViewerModal';
|
||||
|
||||
export default function ComponentDocsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw', background: '#08090D' }}>
|
||||
<DocsViewerModal isOpen={true} onClose={() => navigate('/studio')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { KEYCLOAK_REALM } from '../utils/env';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { loginLocal, initiateKeycloakSSO, handleSSOCallback, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// SSO (Keycloak) is the default tab according to design-spec.md
|
||||
const [activeTab, setActiveTab] = useState('sso'); // 'sso' | 'pw'
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
// Check if returning from Keycloak SSO callback (?code=...)
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
let code = searchParams.get('code');
|
||||
|
||||
// Also check hash params in case of HashRouter redirect
|
||||
if (!code && window.location.hash.includes('code=')) {
|
||||
const hashQuery = window.location.hash.split('?')[1];
|
||||
if (hashQuery) {
|
||||
const hashParams = new URLSearchParams(hashQuery);
|
||||
code = hashParams.get('code');
|
||||
}
|
||||
}
|
||||
|
||||
if (code) {
|
||||
setIsSubmitting(true);
|
||||
const cleanRedirectUri = window.location.origin + window.location.pathname;
|
||||
handleSSOCallback(code, cleanRedirectUri).then((res) => {
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
navigate('/studio');
|
||||
} else {
|
||||
setErrorMessage(res.error || 'Gagal autentikasi Keycloak SSO');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [handleSSOCallback, navigate]);
|
||||
|
||||
const handleSSOClick = async () => {
|
||||
setErrorMessage('');
|
||||
setIsRedirecting(true);
|
||||
try {
|
||||
await initiateKeycloakSSO();
|
||||
} catch (err) {
|
||||
setIsRedirecting(false);
|
||||
setErrorMessage('Gagal menginisiasi koneksi Keycloak SSO: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
setErrorMessage('Silakan masukkan username dan password');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
const res = await loginLocal(username, password);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (res.success) {
|
||||
navigate('/studio');
|
||||
} else {
|
||||
setErrorMessage(res.error || 'Username atau password tidak valid');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="login"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
background: 'var(--bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '16px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="lcard">
|
||||
{/* BRAND HEADER */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
paddingBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--acc)',
|
||||
color: '#ffffff',
|
||||
fontWeight: 800,
|
||||
fontSize: '15px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
boxShadow: '0 2px 8px rgba(79, 91, 213, 0.35)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: '17px',
|
||||
color: 'var(--text)',
|
||||
letterSpacing: '-0.3px',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
MERICA
|
||||
</div>
|
||||
<small
|
||||
style={{
|
||||
display: 'block',
|
||||
color: 'var(--muted)',
|
||||
fontSize: '11px',
|
||||
lineHeight: 1.3,
|
||||
marginTop: '1px',
|
||||
}}
|
||||
>
|
||||
Managed Engine for Robust Ingestion & Cloud Airflow
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SIGN IN TITLE & SUBTITLE */}
|
||||
<h1
|
||||
style={{
|
||||
margin: '0 0 4px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 16px',
|
||||
color: 'var(--muted)',
|
||||
fontSize: '12.5px',
|
||||
}}
|
||||
>
|
||||
Use your organization account to manage Airflow pipelines.
|
||||
</p>
|
||||
|
||||
{/* TAB SEGMENTED CONTROL */}
|
||||
<div className="seg" role="tablist" style={{ marginBottom: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'sso'}
|
||||
className={activeTab === 'sso' ? 'on' : ''}
|
||||
onClick={() => {
|
||||
setActiveTab('sso');
|
||||
setErrorMessage('');
|
||||
}}
|
||||
>
|
||||
SSO (Keycloak)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'pw'}
|
||||
className={activeTab === 'pw' ? 'on' : ''}
|
||||
onClick={() => {
|
||||
setActiveTab('pw');
|
||||
setErrorMessage('');
|
||||
}}
|
||||
>
|
||||
Username and password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ERROR MESSAGE (INLINE ROLE="ALERT") */}
|
||||
{errorMessage && (
|
||||
<div
|
||||
id="lerr"
|
||||
role="alert"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(210, 63, 63, 0.1)',
|
||||
border: '1px solid var(--err)',
|
||||
borderRadius: '7px',
|
||||
color: 'var(--err)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '16px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 1: SSO (KEYCLOAK) */}
|
||||
{activeTab === 'sso' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<p style={{ margin: 0, fontSize: '12.5px', color: 'var(--muted)', lineHeight: 1.4 }}>
|
||||
Sign in securely via Enterprise Keycloak Single Sign-On using your BPKP credentials.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="lsso"
|
||||
className="btn pri"
|
||||
style={{ height: '38px', width: '100%', fontSize: '13.5px' }}
|
||||
onClick={handleSSOClick}
|
||||
disabled={isRedirecting || isSubmitting}
|
||||
>
|
||||
{isRedirecting ? 'Redirecting to Keycloak…' : (isSubmitting ? 'Completing sign-in…' : 'Continue with Keycloak')}
|
||||
</button>
|
||||
|
||||
<small style={{ color: 'var(--muted)', fontSize: '11px', textAlign: 'center' }}>
|
||||
Realm: <strong>{KEYCLOAK_REALM}</strong> • Secured with PKCE OIDC Flow
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: USERNAME AND PASSWORD (LOCAL BREAK-GLASS) */}
|
||||
{activeTab === 'pw' && (
|
||||
<form onSubmit={handleLocalSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<label style={{ fontSize: '12px', fontWeight: 600, color: 'var(--muted)' }}>
|
||||
Username
|
||||
<input
|
||||
className="f"
|
||||
id="lu"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter administrator username"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ fontSize: '12px', fontWeight: 600, color: 'var(--muted)' }}>
|
||||
Password
|
||||
<input
|
||||
className="f"
|
||||
id="lp"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="lpw"
|
||||
className="btn pri"
|
||||
style={{ height: '38px', width: '100%', fontSize: '13.5px', marginTop: '4px' }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
<small style={{ color: 'var(--muted)', fontSize: '11px', textAlign: 'center' }}>
|
||||
Emergency break-glass access when Keycloak SSO is unavailable.
|
||||
</small>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import LoginPage from './LoginPage';
|
||||
import { AuthProvider } from '../context/AuthContext';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '../utils/pkce';
|
||||
|
||||
describe('LoginPage - Keycloak SSO & Local Auth (Langkah 2)', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('1. Generates RFC 7636 compliant PKCE code verifier and challenge', async () => {
|
||||
const verifier = generateCodeVerifier(64);
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.length).toBe(64);
|
||||
expect(/^[A-Za-z0-9\-._~]+$/.test(verifier)).toBe(true);
|
||||
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
expect(challenge).toBeDefined();
|
||||
expect(challenge.length).toBeGreaterThan(20);
|
||||
// Base64URL string without '+', '/', '='
|
||||
expect(/^[A-Za-z0-9\-_]+$/.test(challenge)).toBe(true);
|
||||
});
|
||||
|
||||
it('2. Renders 400px card with MERICA brand and full subtitle', () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<LoginPage />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('MERICA')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Managed Engine for Robust Ingestion & Cloud Airflow/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /Sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('3. Defaults to SSO (Keycloak) tab with "Continue with Keycloak"', () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<LoginPage />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
const ssoTab = screen.getByRole('tab', { name: /SSO \(Keycloak\)/i });
|
||||
expect(ssoTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('button', { name: /Continue with Keycloak/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('4. Switches to "Username and password" tab and shows credentials fields', () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<LoginPage />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
const pwTab = screen.getByRole('tab', { name: /Username and password/i });
|
||||
fireEvent.click(pwTab);
|
||||
|
||||
expect(pwTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Sign in$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. Displays inline error message with role="alert" on invalid login', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ detail: 'Username atau password tidak valid' }),
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<LoginPage />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
// Switch to password tab
|
||||
const pwTab = screen.getByRole('tab', { name: /Username and password/i });
|
||||
fireEvent.click(pwTab);
|
||||
|
||||
const usernameInput = screen.getByLabelText(/Username/i);
|
||||
const passwordInput = screen.getByLabelText(/Password/i);
|
||||
const submitBtn = screen.getByRole('button', { name: /^Sign in$/i });
|
||||
|
||||
fireEvent.change(usernameInput, { target: { value: 'wrong_user' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrong_pass' } });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(alert).toHaveTextContent(/Username atau password tidak valid/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Layers, Search, Database, Plus, ArrowLeft, Trash2, CheckCircle, RefreshCw, Eye, UserCheck } from 'lucide-react';
|
||||
|
||||
const FALLBACK_TEMPLATES = [
|
||||
{
|
||||
id: "spku_monitoring_pipeline",
|
||||
description: "Ingest SPKU Stasiun Postgres DB -> Pandas Cleansing -> MinIO S3 Bronze -> dbt Silver -> GE Quality Suite",
|
||||
schedule: "0 */1 * * *",
|
||||
status: "deployed",
|
||||
nodes_count: 5
|
||||
},
|
||||
{
|
||||
id: "gws_waste_ingestion",
|
||||
description: "Source GWS Spreadsheet -> Pandas Cleansing -> Trino Iceberg Storage Target -> dbt Gold Mart",
|
||||
schedule: "0 0 * * *",
|
||||
status: "deployed",
|
||||
nodes_count: 4
|
||||
}
|
||||
];
|
||||
|
||||
export default function PipelineListPage() {
|
||||
const { currentUser, userRole } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [pipelines, setPipelines] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetchPipelines();
|
||||
}, []);
|
||||
|
||||
const fetchPipelines = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/pipelines');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPipelines(data.pipelines && data.pipelines.length > 0 ? data.pipelines : FALLBACK_TEMPLATES);
|
||||
} else {
|
||||
setPipelines(FALLBACK_TEMPLATES);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pipelines:', err);
|
||||
setPipelines(FALLBACK_TEMPLATES);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePipeline = async (id, e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(`Are you sure you want to delete pipeline ${id}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/pipelines/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
setPipelines((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPipelines = pipelines.filter((p) => {
|
||||
const matchesSearch =
|
||||
p.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(p.description && p.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const pStatus = p.status || (p.is_draft ? 'draft' : 'deployed');
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'deployed' && pStatus === 'deployed') ||
|
||||
(activeTab === 'draft' && pStatus === 'draft');
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', width: '100vw', background: 'var(--color-app-background, #08090D)', color: '#F5F7FA', padding: '32px' }}>
|
||||
<div style={{ maxWidth: '1100px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{/* HEADER */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||
<button
|
||||
onClick={() => navigate('/studio')}
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
color: '#A5AAB8',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Studio
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '22px', fontWeight: '700', margin: 0 }}>Pipeline Catalog & Repository</h1>
|
||||
<p style={{ fontSize: '13px', color: '#707686', margin: 0 }}>Manage Airflow Deployed DAGs and Saved Drafts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/studio')}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 14px rgba(139,124,255,0.3)',
|
||||
}}
|
||||
>
|
||||
<Plus size={16} /> Create New Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SEARCH & TABS */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px' }}>
|
||||
<div style={{ position: 'relative', width: '320px' }}>
|
||||
<Search size={16} color="#707686" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pipelines..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px 10px 36px',
|
||||
borderRadius: '10px',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
color: '#F5F7FA',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', background: 'var(--color-surface, #0E1016)', padding: '4px', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: activeTab === 'all' ? '#8B7CFF' : 'transparent',
|
||||
color: activeTab === 'all' ? '#ffffff' : '#707686',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
All ({pipelines.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('deployed')}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: activeTab === 'deployed' ? '#10B981' : 'transparent',
|
||||
color: activeTab === 'deployed' ? '#ffffff' : '#707686',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
🚀 Airflow Deployed
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('draft')}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: activeTab === 'draft' ? '#F59E0B' : 'transparent',
|
||||
color: activeTab === 'draft' ? '#ffffff' : '#707686',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
📝 Saved Drafts
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PIPELINE GRID */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#707686' }}>Loading pipeline catalogue...</div>
|
||||
) : filteredPipelines.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#707686' }}>No matching pipelines found.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
||||
{filteredPipelines.map((p) => {
|
||||
const isDraft = p.status === 'draft' || p.is_draft;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => navigate('/studio')}
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
gap: '14px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#8B7CFF';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.09)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '700', color: '#F5F7FA' }}>{p.id}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: '700',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
background: isDraft ? 'rgba(245,158,11,0.15)' : 'rgba(16,185,129,0.15)',
|
||||
color: isDraft ? '#fbbf24' : '#34d399',
|
||||
border: `1px solid ${isDraft ? 'rgba(245,158,11,0.3)' : 'rgba(16,185,129,0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{isDraft ? '📝 DRAFT' : '🚀 DEPLOYED'}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '12px', color: '#A5AAB8', margin: 0, lineHeight: 1.5 }}>
|
||||
{p.description || 'No description provided'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '12px' }}>
|
||||
<span style={{ fontSize: '11px', color: '#707686' }}>Cron: {p.schedule || '@daily'}</span>
|
||||
|
||||
{userRole !== 'viewer' && (
|
||||
<button
|
||||
onClick={(e) => handleDeletePipeline(p.id, e)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
Settings,
|
||||
Shield,
|
||||
UserCheck,
|
||||
Eye,
|
||||
KeyRound,
|
||||
Plus,
|
||||
X,
|
||||
Save,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
Server,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '../utils/env';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { currentUser, isAdmin, token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// Dynamic RBAC State
|
||||
const [editorRoles, setEditorRoles] = useState([]);
|
||||
const [editorGroups, setEditorGroups] = useState([]);
|
||||
const [viewerRoles, setViewerRoles] = useState([]);
|
||||
const [viewerGroups, setViewerGroups] = useState([]);
|
||||
const [adminRoles, setAdminRoles] = useState([]);
|
||||
const [adminGroups, setAdminGroups] = useState([]);
|
||||
const [metadata, setMetadata] = useState({ updated_at: '', updated_by: '' });
|
||||
|
||||
// Inputs for adding new tags
|
||||
const [newEditorRole, setNewEditorRole] = useState('');
|
||||
const [newEditorGroup, setNewEditorGroup] = useState('');
|
||||
const [newViewerRole, setNewViewerRole] = useState('');
|
||||
const [newViewerGroup, setNewViewerGroup] = useState('');
|
||||
const [newAdminRole, setNewAdminRole] = useState('');
|
||||
const [newAdminGroup, setNewAdminGroup] = useState('');
|
||||
|
||||
// Fetch current RBAC configuration
|
||||
const fetchSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/settings/rbac`, { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEditorRoles(data.editor_roles || []);
|
||||
setEditorGroups(data.editor_groups || []);
|
||||
setViewerRoles(data.viewer_roles || []);
|
||||
setViewerGroups(data.viewer_groups || []);
|
||||
setAdminRoles(data.admin_roles || []);
|
||||
setAdminGroups(data.admin_groups || []);
|
||||
setMetadata({
|
||||
updated_at: data.updated_at || '',
|
||||
updated_by: data.updated_by || 'system_default',
|
||||
});
|
||||
} else {
|
||||
setStatusMessage({ type: 'error', text: 'Gagal mengambil konfigurasi pengaturan RBAC.' });
|
||||
}
|
||||
} catch (err) {
|
||||
setStatusMessage({ type: 'error', text: `Network Error: ${err.message}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setStatusMessage({ type: '', text: '' });
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
editor_roles: editorRoles,
|
||||
editor_groups: editorGroups,
|
||||
viewer_roles: viewerRoles,
|
||||
viewer_groups: viewerGroups,
|
||||
admin_roles: adminRoles,
|
||||
admin_groups: adminGroups,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/settings/rbac`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMetadata({
|
||||
updated_at: data.updated_at,
|
||||
updated_by: data.updated_by,
|
||||
});
|
||||
setStatusMessage({
|
||||
type: 'success',
|
||||
text: 'Konfigurasi hak akses berhasil diperbarui! Perubahan langsung aktif (Hot-Reload).',
|
||||
});
|
||||
} else {
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: data.detail || 'Gagal menyimpan konfigurasi hak akses.',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setStatusMessage({ type: 'error', text: `Network Error: ${err.message}` });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (list, setList, value, setValue) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && !list.includes(trimmed)) {
|
||||
setList([...list, trimmed]);
|
||||
setValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (list, setList, indexToRemove) => {
|
||||
setList(list.filter((_, idx) => idx !== indexToRemove));
|
||||
};
|
||||
|
||||
// Guard: If not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--color-app-background, #08090D)',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '460px',
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '16px',
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
>
|
||||
<Lock size={28} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '700', margin: 0 }}>Akses Menu Dibatasi</h2>
|
||||
<p style={{ fontSize: '13px', color: 'var(--color-text-secondary, #A5AAB8)', margin: 0, lineHeight: '1.5' }}>
|
||||
Halaman Pengaturan Hak Akses (RBAC) hanya dapat diakses oleh Administrator atau Superadmin. Akun Anda saat ini memiliki peran{' '}
|
||||
<strong style={{ color: '#38bdf8' }}>{currentUser?.capability || 'Viewer'}</strong>.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/studio')}
|
||||
style={{
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
background: 'var(--color-surface-raised, #171A22)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.1))',
|
||||
color: '#ffffff',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} /> Kembali ke Studio Canvas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--color-app-background, #08090D)',
|
||||
color: 'var(--color-text-primary, #F5F7FA)',
|
||||
padding: '32px 40px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* TOP BAR / NAVIGATION */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '28px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||
<button
|
||||
onClick={() => navigate('/studio')}
|
||||
title="Kembali ke Canvas Studio"
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-secondary, #A5AAB8)',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} /> Kembali ke Studio
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '22px', fontWeight: '800', margin: 0, letterSpacing: '-0.3px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Settings size={22} color="#8B7CFF" />
|
||||
Pengaturan Hak Akses & Dynamic RBAC
|
||||
</h1>
|
||||
<p style={{ fontSize: '13px', color: 'var(--color-text-muted, #707686)', margin: '4px 0 0 0' }}>
|
||||
Petakan Role dan Group Active Directory / LDAP / Keycloak secara dinamis ke hak akses sistem MERICA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<button
|
||||
onClick={fetchSettings}
|
||||
disabled={loading || saving}
|
||||
title="Muat Ulang dari Database"
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: (loading || saving) ? 'wait' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} /> Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading || saving}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
cursor: (loading || saving) ? 'wait' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 14px rgba(139, 124, 255, 0.35)',
|
||||
}}
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>{saving ? 'Menyimpan...' : 'Simpan Konfigurasi (Hot-Reload)'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STATUS NOTIFICATION BANNER */}
|
||||
{statusMessage.text && (
|
||||
<div
|
||||
style={{
|
||||
background: statusMessage.type === 'success' ? 'rgba(74, 222, 128, 0.12)' : 'rgba(239, 68, 68, 0.12)',
|
||||
border: `1px solid ${statusMessage.type === 'success' ? 'rgba(74, 222, 128, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '14px 18px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
color: statusMessage.type === 'success' ? '#4ade80' : '#f87171',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{statusMessage.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
||||
<span>{statusMessage.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* METADATA INFO BANNER */}
|
||||
{metadata.updated_at && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 18px',
|
||||
marginBottom: '24px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-muted, #707686)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Terakhir diperbarui: <strong style={{ color: 'var(--color-text-secondary, #A5AAB8)' }}>{new Date(metadata.updated_at).toLocaleString()}</strong> oleh{' '}
|
||||
<strong style={{ color: '#8B7CFF' }}>{metadata.updated_by}</strong>
|
||||
</span>
|
||||
<span style={{ color: '#4ade80', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#4ade80', display: 'inline-block' }} />
|
||||
Sinkronisasi Realtime Aktif
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3 TIERS GRID */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))', gap: '24px' }}>
|
||||
{/* TIER 1: EDITOR (DATA ENGINEER - FULL ACCESS) */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.25)',
|
||||
borderRadius: '18px',
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(74, 222, 128, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#4ade80',
|
||||
}}
|
||||
>
|
||||
<UserCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '700', margin: 0 }}>Editor (Data Engineer)</h3>
|
||||
<span style={{ fontSize: '11px', color: '#4ade80', fontWeight: '600' }}>Full Access Canvas & Deploy</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '6px',
|
||||
background: 'rgba(74, 222, 128, 0.1)',
|
||||
color: '#4ade80',
|
||||
border: '1px solid rgba(74, 222, 128, 0.2)',
|
||||
}}
|
||||
>
|
||||
{editorRoles.length + editorGroups.length} Mapping
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)', lineHeight: '1.5' }}>
|
||||
Dapat membuat task DAG baru, menghubungkan node, mengonfigurasi koneksi database/API/Iceberg, menyimpan draft, dan men-deploy pipeline ke Airflow 3.
|
||||
</p>
|
||||
|
||||
{/* EDITOR ROLES */}
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '8px' }}>
|
||||
Nama Role Keycloak / LDAP yang Diizinkan:
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||
{editorRoles.map((role, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(74, 222, 128, 0.12)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.25)',
|
||||
color: '#4ade80',
|
||||
fontSize: '12px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
<button
|
||||
onClick={() => removeTag(editorRoles, setEditorRoles, idx)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newEditorRole}
|
||||
onChange={(e) => setNewEditorRole(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag(editorRoles, setEditorRoles, newEditorRole, setNewEditorRole)}
|
||||
placeholder="Tambah role (misal: data_engineer)"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addTag(editorRoles, setEditorRoles, newEditorRole, setNewEditorRole)}
|
||||
style={{
|
||||
background: 'rgba(74, 222, 128, 0.15)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.3)',
|
||||
color: '#4ade80',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EDITOR GROUPS */}
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '8px' }}>
|
||||
Nama Group Keycloak / OU Path yang Diizinkan:
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||
{editorGroups.map((group, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(74, 222, 128, 0.08)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.2)',
|
||||
color: '#86efac',
|
||||
fontSize: '12px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
<button
|
||||
onClick={() => removeTag(editorGroups, setEditorGroups, idx)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newEditorGroup}
|
||||
onChange={(e) => setNewEditorGroup(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag(editorGroups, setEditorGroups, newEditorGroup, setNewEditorGroup)}
|
||||
placeholder="Tambah group (misal: /Data Engineers)"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addTag(editorGroups, setEditorGroups, newEditorGroup, setNewEditorGroup)}
|
||||
style={{
|
||||
background: 'rgba(74, 222, 128, 0.15)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.3)',
|
||||
color: '#4ade80',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TIER 2: VIEWER (PIMPINAN / AUDITOR - READ ONLY) */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.25)',
|
||||
borderRadius: '18px',
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(56, 189, 248, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#38bdf8',
|
||||
}}
|
||||
>
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '700', margin: 0 }}>Viewer (Pimpinan & Auditor)</h3>
|
||||
<span style={{ fontSize: '11px', color: '#38bdf8', fontWeight: '600' }}>Strict Read-Only Mode</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '6px',
|
||||
background: 'rgba(56, 189, 248, 0.1)',
|
||||
color: '#38bdf8',
|
||||
border: '1px solid rgba(56, 189, 248, 0.2)',
|
||||
}}
|
||||
>
|
||||
{viewerRoles.length + viewerGroups.length} Mapping
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)', lineHeight: '1.5' }}>
|
||||
Akses pemantauan visual terproteksi. Dapat melihat topologi graf, parameter inspeksi, mengunduh YAML, dan menelusuri audit trail tanpa hak edit atau deploy.
|
||||
</p>
|
||||
|
||||
{/* VIEWER ROLES */}
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '8px' }}>
|
||||
Nama Role Keycloak / LDAP yang Diizinkan:
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||
{viewerRoles.map((role, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(56, 189, 248, 0.12)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.25)',
|
||||
color: '#38bdf8',
|
||||
fontSize: '12px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
<button
|
||||
onClick={() => removeTag(viewerRoles, setViewerRoles, idx)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newViewerRole}
|
||||
onChange={(e) => setNewViewerRole(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag(viewerRoles, setViewerRoles, newViewerRole, setNewViewerRole)}
|
||||
placeholder="Tambah role (misal: merica_viewer)"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addTag(viewerRoles, setViewerRoles, newViewerRole, setNewViewerRole)}
|
||||
style={{
|
||||
background: 'rgba(56, 189, 248, 0.15)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||
color: '#38bdf8',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEWER GROUPS */}
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '8px' }}>
|
||||
Nama Group Keycloak / OU Path yang Diizinkan:
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||
{viewerGroups.map((group, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(56, 189, 248, 0.08)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.2)',
|
||||
color: '#7dd3fc',
|
||||
fontSize: '12px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
<button
|
||||
onClick={() => removeTag(viewerGroups, setViewerGroups, idx)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newViewerGroup}
|
||||
onChange={(e) => setNewViewerGroup(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag(viewerGroups, setViewerGroups, newViewerGroup, setNewViewerGroup)}
|
||||
placeholder="Tambah group (misal: /Pimpinan Unit Kerja)"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addTag(viewerGroups, setViewerGroups, newViewerGroup, setNewViewerGroup)}
|
||||
style={{
|
||||
background: 'rgba(56, 189, 248, 0.15)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||
color: '#38bdf8',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TIER 3: ADMIN (SUPERADMIN & ACCESS CONTROLLER) */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface, #0E1016)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.25)',
|
||||
borderRadius: '18px',
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(245, 158, 11, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#F59E0B',
|
||||
}}
|
||||
>
|
||||
<KeyRound size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '700', margin: 0 }}>Administrator (Superadmin)</h3>
|
||||
<span style={{ fontSize: '11px', color: '#F59E0B', fontWeight: '600' }}>Full Access + IAM Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '6px',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#F59E0B',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
{adminRoles.length + adminGroups.length} Mapping
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)', lineHeight: '1.5' }}>
|
||||
Hak akses tertinggi. Selain dapat mengelola seluruh DAG Airflow, akun ini dapat memodifikasi pengaturan RBAC ini secara dinamis dan mengelola integrasi eksternal.
|
||||
</p>
|
||||
|
||||
{/* ADMIN ROLES */}
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '8px' }}>
|
||||
Nama Role Keycloak / LDAP yang Diizinkan:
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||
{adminRoles.map((role, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(245, 158, 11, 0.12)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.25)',
|
||||
color: '#F59E0B',
|
||||
fontSize: '12px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
<button
|
||||
onClick={() => removeTag(adminRoles, setAdminRoles, idx)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newAdminRole}
|
||||
onChange={(e) => setNewAdminRole(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag(adminRoles, setAdminRoles, newAdminRole, setNewAdminRole)}
|
||||
placeholder="Tambah role (misal: merica_admin)"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addTag(adminRoles, setAdminRoles, newAdminRole, setNewAdminRole)}
|
||||
style={{
|
||||
background: 'rgba(245, 158, 11, 0.15)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
color: '#F59E0B',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADMIN GROUPS */}
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '8px' }}>
|
||||
Nama Group Keycloak / OU Path yang Diizinkan:
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||
{adminGroups.map((group, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
background: 'rgba(245, 158, 11, 0.08)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
color: '#fcd34d',
|
||||
fontSize: '12px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
<button
|
||||
onClick={() => removeTag(adminGroups, setAdminGroups, idx)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newAdminGroup}
|
||||
onChange={(e) => setNewAdminGroup(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag(adminGroups, setAdminGroups, newAdminGroup, setNewAdminGroup)}
|
||||
placeholder="Tambah group (misal: /Platform Administrators)"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-raised, #12141B)',
|
||||
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addTag(adminGroups, setAdminGroups, newAdminGroup, setNewAdminGroup)}
|
||||
style={{
|
||||
background: 'rgba(245, 158, 11, 0.15)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
color: '#F59E0B',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import WorkspacePage from './WorkspacePage';
|
||||
import { AuthProvider } from '../context/AuthContext';
|
||||
|
||||
const multiUserPipelines = [
|
||||
{
|
||||
id: 'pipeline_user_a',
|
||||
description: 'Pipeline belonging to User A',
|
||||
schedule: '0 0 * * *',
|
||||
status: 'deployed',
|
||||
owner_id: 'user-a-sub',
|
||||
owner_name: 'Data Engineer A',
|
||||
updated_at: '2026-10-15T12:00:00Z',
|
||||
nodes: [{ id: 'n1' }],
|
||||
},
|
||||
{
|
||||
id: 'pipeline_user_b',
|
||||
description: 'Pipeline belonging to User B',
|
||||
schedule: '0 */4 * * *',
|
||||
status: 'draft',
|
||||
owner_id: 'user-b-sub',
|
||||
owner_name: 'Data Engineer B',
|
||||
updated_at: '2026-10-16T14:00:00Z',
|
||||
nodes: [{ id: 'n1' }, { id: 'n2' }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('WorkspaceOwnerFilter.test.jsx - Owner Scoping & Admin Views', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('1. User A only views their own pipeline in Overview', async () => {
|
||||
localStorage.setItem(
|
||||
'merica_user',
|
||||
JSON.stringify({
|
||||
id: 'user-a-sub',
|
||||
username: 'engineer_a',
|
||||
name: 'Data Engineer A',
|
||||
capability: 'editor',
|
||||
is_admin: false,
|
||||
})
|
||||
);
|
||||
|
||||
global.fetch = vi.fn().mockImplementation((url) => {
|
||||
// Backend filters out other users when non-admin
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
pipelines: multiUserPipelines.filter((p) => p.owner_id === 'user-a-sub'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<WorkspacePage isAllPipelines={false} />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline_user_a')).toBeInTheDocument();
|
||||
expect(screen.queryByText('pipeline_user_b')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('2. Admin views all pipelines across users in All Pipelines mode', async () => {
|
||||
localStorage.setItem(
|
||||
'merica_user',
|
||||
JSON.stringify({
|
||||
id: 'admin-sub',
|
||||
username: 'admin',
|
||||
name: 'Platform Admin',
|
||||
capability: 'admin',
|
||||
is_admin: true,
|
||||
})
|
||||
);
|
||||
|
||||
global.fetch = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ pipelines: multiUserPipelines }),
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<WorkspacePage isAllPipelines={true} />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline_user_a')).toBeInTheDocument();
|
||||
expect(screen.getByText('pipeline_user_b')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data Engineer A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data Engineer B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import {
|
||||
MoreHorizontal,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Search,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '../utils/env';
|
||||
|
||||
export default function WorkspacePage({ isAllPipelines = false }) {
|
||||
const { currentUser, userRole, isAdmin, token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [pipelines, setPipelines] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('upd'); // 'upd' | 'name'
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [undoItem, setUndoItem] = useState(null); // { pipeline, timeoutId }
|
||||
|
||||
// Popover menu state for rows
|
||||
const [activeMenuId, setActiveMenuId] = useState(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
// Close popup menu on outside click
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
setActiveMenuId(null);
|
||||
}
|
||||
};
|
||||
if (activeMenuId) {
|
||||
document.addEventListener('pointerdown', handleOutsideClick);
|
||||
}
|
||||
return () => document.removeEventListener('pointerdown', handleOutsideClick);
|
||||
}, [activeMenuId]);
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => {
|
||||
setToastMessage((cur) => (cur === msg ? '' : cur));
|
||||
}, 2800);
|
||||
};
|
||||
|
||||
const fetchPipelines = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const scopeParam = isAllPipelines ? '?scope=all' : '?scope=my';
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/pipelines${scopeParam}`, { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPipelines(data.pipelines || []);
|
||||
} else if (res.status === 403) {
|
||||
showToast('Access denied: Admin role required for All pipelines');
|
||||
navigate('/overview');
|
||||
} else {
|
||||
setPipelines([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load workspace pipelines:', err);
|
||||
setPipelines([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPipelines();
|
||||
}, [isAllPipelines, token]);
|
||||
|
||||
const handleOpenPipeline = (id) => {
|
||||
navigate(`/studio/${id}`);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/studio');
|
||||
};
|
||||
|
||||
const handleDuplicate = async (pipelineId, e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenuId(null);
|
||||
try {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/pipelines/${pipelineId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(`Pipeline "${pipelineId}" duplicated as draft.`);
|
||||
fetchPipelines();
|
||||
} else {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
showToast(errData.detail || 'Failed to duplicate pipeline');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error while duplicating: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWithUndo = (pipeline, e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenuId(null);
|
||||
|
||||
// Optimistically remove from display list
|
||||
setPipelines((prev) => prev.filter((p) => p.id !== pipeline.id));
|
||||
|
||||
// Schedule actual delete after 5 seconds
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
await fetch(`${API_BASE}/pipelines/${pipeline.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to delete on server:', err);
|
||||
}
|
||||
setUndoItem(null);
|
||||
}, 5000);
|
||||
|
||||
setUndoItem({ pipeline, timeoutId });
|
||||
showToast(`Pipeline "${pipeline.id}" deleted.`);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (undoItem) {
|
||||
clearTimeout(undoItem.timeoutId);
|
||||
setPipelines((prev) => [undoItem.pipeline, ...prev]);
|
||||
setUndoItem(null);
|
||||
showToast(`Restored "${undoItem.pipeline.id}".`);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter & Sort
|
||||
const filteredPipelines = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return pipelines
|
||||
.filter((p) => {
|
||||
if (!q) return true;
|
||||
const idMatch = (p.id || '').toLowerCase().includes(q);
|
||||
const descMatch = (p.description || '').toLowerCase().includes(q);
|
||||
return idMatch || descMatch;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
return (a.id || '').localeCompare(b.id || '');
|
||||
}
|
||||
const timeA = new Date(a.updated_at || 0).getTime();
|
||||
const timeB = new Date(b.updated_at || 0).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
}, [pipelines, searchQuery, sortBy]);
|
||||
|
||||
// Statistics
|
||||
const totalCount = pipelines.length;
|
||||
const deployedCount = pipelines.filter((p) => p.status === 'deployed').length;
|
||||
const draftCount = pipelines.filter((p) => p.status === 'draft' || p.is_draft).length;
|
||||
const totalNodesCount = pipelines.reduce(
|
||||
(sum, p) => sum + (Array.isArray(p.nodes) ? p.nodes.length : 0),
|
||||
0
|
||||
);
|
||||
|
||||
const formatRelativeTime = (isoString) => {
|
||||
if (!isoString) return 'recently';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
const diffSec = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||
} catch {
|
||||
return 'recently';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="shell"
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{/* SIDEBAR NAVIGATION */}
|
||||
<Sidebar onToast={showToast} />
|
||||
|
||||
{/* MAIN WORKSPACE BODY */}
|
||||
<div
|
||||
id="app"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
overflow: 'auto',
|
||||
background: 'var(--bg)',
|
||||
}}
|
||||
>
|
||||
<div id="ws" style={{ flex: 1, overflow: 'auto', padding: '28px 32px' }}>
|
||||
<div className="wsi">
|
||||
{/* WORKSPACE HEADER */}
|
||||
<div className="wst" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '22px', fontWeight: 700, color: 'var(--text)' }}>
|
||||
{isAllPipelines ? 'All pipelines' : 'Overview'}
|
||||
</h1>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--muted)', fontSize: '13px' }}>
|
||||
{isAllPipelines
|
||||
? 'Every pipeline in the workspace (Admin only)'
|
||||
: 'Pipelines you created. Other users cannot see or edit them.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn pri"
|
||||
id="pnew"
|
||||
onClick={handleCreateNew}
|
||||
style={{ height: '36px', padding: '0 16px', gap: '8px' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Create pipeline</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 4 STATS CARDS */}
|
||||
<div className="stats">
|
||||
<div>
|
||||
<small>Pipelines</small>
|
||||
<b>{totalCount}</b>
|
||||
</div>
|
||||
<div>
|
||||
<small>Deployed</small>
|
||||
<b>{deployedCount}</b>
|
||||
</div>
|
||||
<div>
|
||||
<small>Drafts</small>
|
||||
<b>{draftCount}</b>
|
||||
</div>
|
||||
<div>
|
||||
<small>Total nodes</small>
|
||||
<b>{totalNodesCount}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOOLS: SEARCH & SORT */}
|
||||
<div className="tools">
|
||||
<input
|
||||
className="f"
|
||||
id="wq"
|
||||
placeholder="Search pipelines"
|
||||
aria-label="Search pipelines"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ flex: 1, margin: 0 }}
|
||||
/>
|
||||
<select
|
||||
className="f"
|
||||
id="wsort"
|
||||
aria-label="Sort"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
style={{ width: 'auto', minWidth: '180px', margin: 0, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="upd">Sort by last updated</option>
|
||||
<option value="name">Sort by name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* PIPELINES LIST ROWS */}
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--muted)' }}>
|
||||
Loading pipelines…
|
||||
</div>
|
||||
) : filteredPipelines.length > 0 ? (
|
||||
<div>
|
||||
{filteredPipelines.map((pipe) => {
|
||||
const isDeployed = pipe.status === 'deployed';
|
||||
const nodeCount = Array.isArray(pipe.nodes) ? pipe.nodes.length : 0;
|
||||
const ownerLabel =
|
||||
pipe.owner_name ||
|
||||
(pipe.owner_id === currentUser?.id ? 'You' : pipe.owner_id || 'System');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pipe.id}
|
||||
className="prow"
|
||||
tabIndex={0}
|
||||
onClick={() => handleOpenPipeline(pipe.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleOpenPipeline(pipe.id);
|
||||
}}
|
||||
>
|
||||
{/* PIPELINE INFO */}
|
||||
<div className="pn">
|
||||
<b>{pipe.id}</b>
|
||||
<small>
|
||||
Last updated {formatRelativeTime(pipe.updated_at)} • {nodeCount} nodes
|
||||
• {pipe.schedule || '0 */1 * * *'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* STATUS CHIP */}
|
||||
<span className={`chip ${isDeployed ? 'okc' : ''}`}>
|
||||
{pipe.status || 'draft'}
|
||||
</span>
|
||||
|
||||
{/* OWNER CHIP */}
|
||||
<span className="chip" title={`Owner ID: ${pipe.owner_id || 'unassigned'}`}>
|
||||
{ownerLabel}
|
||||
</span>
|
||||
|
||||
{/* MORE ACTIONS (⋯) */}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
aria-label={`More actions for ${pipe.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenuId(activeMenuId === pipe.id ? null : pipe.id);
|
||||
}}
|
||||
style={{ width: '30px', height: '30px', padding: 0 }}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
|
||||
{/* ROW POPOVER MENU */}
|
||||
{activeMenuId === pipe.id && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="pop"
|
||||
style={{
|
||||
top: '36px',
|
||||
right: 0,
|
||||
width: '160px',
|
||||
display: 'block',
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={() => handleOpenPipeline(pipe.id)}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>Open</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it"
|
||||
onClick={(e) => handleDuplicate(pipe.id, e)}
|
||||
>
|
||||
<Copy size={13} />
|
||||
<span>Duplicate</span>
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="it danger"
|
||||
onClick={(e) => handleDeleteWithUndo(pipe, e)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 24px',
|
||||
textAlign: 'center',
|
||||
background: 'var(--surface)',
|
||||
border: '1px dashed var(--line)',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--muted)',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: '0 0 12px', fontSize: '14px' }}>
|
||||
{searchQuery
|
||||
? 'No pipeline matches your search.'
|
||||
: 'No pipelines yet. Create your first pipeline to get started.'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn pri"
|
||||
onClick={handleCreateNew}
|
||||
style={{ height: '32px' }}
|
||||
>
|
||||
Create pipeline
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOAST / UNDO BANNER NOTIFICATION */}
|
||||
{toastMessage && (
|
||||
<div className="toast-banner">
|
||||
<span>{toastMessage}</span>
|
||||
{undoItem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: 0,
|
||||
color: 'inherit',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 8px',
|
||||
marginLeft: '8px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<Undo2 size={13} />
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import WorkspacePage from './WorkspacePage';
|
||||
import { AuthProvider } from '../context/AuthContext';
|
||||
|
||||
const mockPipelines = [
|
||||
{
|
||||
id: 'pipeline_user_spku',
|
||||
description: 'SPKU Lakehouse Sensor Pipeline',
|
||||
schedule: '0 */1 * * *',
|
||||
status: 'deployed',
|
||||
owner_id: 'user-a-sub',
|
||||
owner_name: 'Data Engineer A',
|
||||
updated_at: '2026-10-15T12:00:00Z',
|
||||
nodes: [{ id: 'n1' }, { id: 'n2' }, { id: 'n3' }],
|
||||
},
|
||||
{
|
||||
id: 'pipeline_gws_draft',
|
||||
description: 'Google Workspace Ingestion Draft',
|
||||
schedule: '0 0 * * *',
|
||||
status: 'draft',
|
||||
is_draft: true,
|
||||
owner_id: 'user-a-sub',
|
||||
owner_name: 'Data Engineer A',
|
||||
updated_at: '2026-10-14T10:00:00Z',
|
||||
nodes: [{ id: 'n1' }, { id: 'n2' }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('WorkspacePage - Per-User Workspace & All Pipelines (Langkah 3)', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
'merica_user',
|
||||
JSON.stringify({
|
||||
id: 'user-a-sub',
|
||||
username: 'engineer_a',
|
||||
name: 'Data Engineer A',
|
||||
capability: 'editor',
|
||||
can_edit: true,
|
||||
can_deploy: true,
|
||||
is_admin: false,
|
||||
})
|
||||
);
|
||||
|
||||
global.fetch = vi.fn().mockImplementation((url) => {
|
||||
if (url.includes('/pipelines')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ pipelines: mockPipelines }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
});
|
||||
});
|
||||
|
||||
it('1. Renders Overview with title, subtitle, and 4 statistics cards', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<WorkspacePage isAllPipelines={false} />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /^Overview$/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Pipelines you created\. Other users cannot see or edit them\./i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify stats cards
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pipelines')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deployed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drafts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total nodes')).toBeInTheDocument();
|
||||
|
||||
// Values: total 2, deployed 1, drafts 1, nodes 5
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1')).toHaveLength(2); // deployed: 1, drafts: 1
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('2. Filters pipelines via search query input', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<WorkspacePage isAllPipelines={false} />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline_user_spku')).toBeInTheDocument();
|
||||
expect(screen.getByText('pipeline_gws_draft')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByLabelText(/Search pipelines/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'spku' } });
|
||||
|
||||
expect(screen.getByText('pipeline_user_spku')).toBeInTheDocument();
|
||||
expect(screen.queryByText('pipeline_gws_draft')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('3. Deletes a pipeline with Undo capability', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<WorkspacePage isAllPipelines={false} />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline_user_spku')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open more actions menu for pipeline_user_spku
|
||||
const moreBtn = screen.getByLabelText(/More actions for pipeline_user_spku/i);
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /Delete/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
// Optimistically removed
|
||||
expect(screen.queryByText('pipeline_user_spku')).not.toBeInTheDocument();
|
||||
|
||||
// Undo banner is displayed
|
||||
const undoBtn = screen.getByRole('button', { name: /Undo/i });
|
||||
expect(undoBtn).toBeInTheDocument();
|
||||
|
||||
// Click Undo to restore
|
||||
fireEvent.click(undoBtn);
|
||||
expect(screen.getByText('pipeline_user_spku')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('4. Renders All pipelines view for Admin with correct title and subtitle', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<HashRouter>
|
||||
<WorkspacePage isAllPipelines={true} />
|
||||
</HashRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /^All pipelines$/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Every pipeline in the workspace \(Admin only\)/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
import StudioPage from '../pages/StudioPage';
|
||||
import WorkspacePage from '../pages/WorkspacePage';
|
||||
import PipelineListPage from '../pages/PipelineListPage';
|
||||
import ComponentDocsPage from '../pages/ComponentDocsPage';
|
||||
import SettingsPage from '../pages/SettingsPage';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function AppRouter() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={isAuthenticated ? '/studio' : '/login'} replace />} />
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/studio" replace /> : <LoginPage />} />
|
||||
<Route path="/auth/callback" element={<LoginPage />} />
|
||||
|
||||
<Route
|
||||
path="/overview"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<WorkspacePage isAllPipelines={false} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/studio"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<StudioPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/studio/:pipelineId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<StudioPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/pipelines"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<WorkspacePage isAllPipelines={true} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/docs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ComponentDocsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to={isAuthenticated ? '/studio' : '/login'} replace />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// ResizeObserver mock for React Flow in jsdom
|
||||
global.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// Canvas & DOMMatrix Mocks for React Flow in jsdom
|
||||
HTMLCanvasElement.prototype.getContext = () => ({
|
||||
fillRect: () => {},
|
||||
clearRect: () => {},
|
||||
getImageData: (x, y, w, h) => ({ data: new Array(w * h * 4) }),
|
||||
putImageData: () => {},
|
||||
createImageData: () => [],
|
||||
setTransform: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
fillText: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
closePath: () => {},
|
||||
stroke: () => {},
|
||||
translate: () => {},
|
||||
scale: () => {},
|
||||
rotate: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
measureText: () => ({ width: 0 }),
|
||||
transform: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined' && !window.DOMMatrixReadOnly) {
|
||||
window.DOMMatrixReadOnly = class {
|
||||
constructor() {
|
||||
this.m11 = 1;
|
||||
this.m22 = 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* DLH Data Lakehouse - Pipeline Ingestion TypeScript Contract
|
||||
* Synchronized with FastAPI Backend Pydantic Schemas
|
||||
*/
|
||||
|
||||
export type SourceType = 'database' | 'gws' | 'api';
|
||||
export type SyncMode = 'cdc' | 'incremental' | 'full_refresh';
|
||||
export type StorageFormat = 'parquet' | 'json';
|
||||
export type ActionOnFailure = 'fail_pipeline' | 'warn_and_continue';
|
||||
|
||||
export interface TargetConfig {
|
||||
storage_type: 's3_minio';
|
||||
connection_id: string;
|
||||
bucket: string;
|
||||
path_pattern: string;
|
||||
format: StorageFormat;
|
||||
}
|
||||
|
||||
export interface BronzeConfig {
|
||||
source_type: SourceType;
|
||||
connection_id: string;
|
||||
source_config: Record<string, any>;
|
||||
target: TargetConfig;
|
||||
}
|
||||
|
||||
export interface QualityCheck {
|
||||
engine: 'great_expectations';
|
||||
expectation_suite: string;
|
||||
action_on_failure: ActionOnFailure;
|
||||
}
|
||||
|
||||
export interface Transformation {
|
||||
engine: 'dbt';
|
||||
select_models: string;
|
||||
target_schema: string;
|
||||
}
|
||||
|
||||
export interface LayerConfig {
|
||||
quality_check?: QualityCheck;
|
||||
transformation?: Transformation;
|
||||
}
|
||||
|
||||
export interface PipelineSpec {
|
||||
pipeline_id: string;
|
||||
description: string;
|
||||
schedule: string;
|
||||
owner: string;
|
||||
tags: string[];
|
||||
bronze: BronzeConfig;
|
||||
silver: LayerConfig;
|
||||
gold: LayerConfig;
|
||||
}
|
||||
|
||||
export interface MasterSourceType {
|
||||
id: SourceType;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface MasterConnection {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MasterExpectationSuite {
|
||||
id: string;
|
||||
layer: 'bronze' | 'silver' | 'gold';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MasterDbtModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MasterDbtModelsResponse {
|
||||
silver: MasterDbtModel[];
|
||||
gold: MasterDbtModel[];
|
||||
}
|
||||
|
||||
export interface MasterSchedule {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Default component configurations for MERICA Studio canvas nodes.
|
||||
* Ensures all components have complete, production-ready defaults with
|
||||
* query templates, connection parameters, and validation contracts.
|
||||
*/
|
||||
export const getDefaultConfigForType = (nodeType, itemData = {}) => {
|
||||
const baseDefaults = {
|
||||
source_db: {
|
||||
conn: 'conn_postgres_source_db',
|
||||
conn_id: 'conn_postgres_source_db',
|
||||
table: 'public.stasiun_spku',
|
||||
table_name: 'public.stasiun_spku',
|
||||
target_type: 'minio_s3',
|
||||
},
|
||||
source_gws: {
|
||||
url: 'https://docs.google.com/spreadsheets/d/1OSRugXZvfjoY9M0yySKT-F3L4LTZXa40J0knU3_4Hik',
|
||||
spreadsheet_id: '1OSRugXZvfjoY9M0yySKT-F3L4LTZXa40J0knU3_4Hik',
|
||||
sheet_range: 'Sheet1',
|
||||
sheet_name: 'Sheet1',
|
||||
},
|
||||
source_api: {
|
||||
url: 'https://api.spku.dlh.id/v1/stasiun/spku/data-realtime',
|
||||
endpoint_url: 'https://api.spku.dlh.id/v1/stasiun/spku/data-realtime',
|
||||
http_method: 'GET',
|
||||
auth_token: '',
|
||||
json_root_path: 'data',
|
||||
},
|
||||
source_api_dlt: {
|
||||
endpoint_url: 'https://api.spku.dlh.id/v1/telemetry/hourly',
|
||||
http_method: 'GET',
|
||||
auth_type: 'none',
|
||||
auth_token: '',
|
||||
pagination_strategy: 'page_number',
|
||||
page_param: 'page',
|
||||
limit_param: 'limit',
|
||||
page_size: 100,
|
||||
cursor_column: 'measured_at',
|
||||
lookback_hours: 3,
|
||||
unnest_mode: 'auto_flatten',
|
||||
landing_bucket: 'dlh-landing',
|
||||
landing_prefix: 'api/spku_hourly/',
|
||||
bronze_table: 'bronze.api_spku_telemetry',
|
||||
},
|
||||
source_file: {
|
||||
ingestion_mode: 'upload',
|
||||
file_format: 'csv',
|
||||
path: 'uploads/',
|
||||
file_path: 'uploads/',
|
||||
},
|
||||
pandas_cleansing: {
|
||||
null_strategy: 'drop_row',
|
||||
drop_duplicates: true,
|
||||
ops: 'dedupe, null fill',
|
||||
rename_map: '{"stasiun_id": "id_stasiun", "ispu": "nilai_ispu"}',
|
||||
custom_code: 'df["pm10"] = df["pm10"].clip(lower=0, upper=500)',
|
||||
},
|
||||
dbt_transform: {
|
||||
dbt_layer: 'silver',
|
||||
dbt_model: 'stg_spku_realtime',
|
||||
sel: 'silver',
|
||||
tgt: 'postgres',
|
||||
trino_catalog: 'iceberg',
|
||||
sql_query_preview: `SELECT stasiun_id, LOWER(nama_stasiun) AS nama_stasiun, ispu_val FROM {{ ref('bronze_spku_raw') }}`,
|
||||
query: `SELECT stasiun_id, LOWER(nama_stasiun) AS nama_stasiun, ispu_val FROM {{ ref('bronze_spku_raw') }}`,
|
||||
test_configs: {
|
||||
unique_columns: ['stasiun_id'],
|
||||
not_null_columns: ['stasiun_id', 'ispu_val'],
|
||||
accepted_values: {},
|
||||
enforce_contract: false,
|
||||
},
|
||||
},
|
||||
duckdb_transform: {
|
||||
layer: 'silver',
|
||||
source_parquet_path: 's3://dlh-bronze/db_cdc/environmental_permits/',
|
||||
source_path: 's3://dlh-bronze/db_cdc/environmental_permits/',
|
||||
target_table: 'dlh_lakehouse.silver.environmental_permits',
|
||||
table_name: 'dlh_lakehouse.silver.environmental_permits',
|
||||
primary_keys: 'id, updated_at',
|
||||
query: 'SELECT * FROM source_parquet',
|
||||
sql_query: 'SELECT * FROM source_parquet',
|
||||
},
|
||||
pyiceberg_transform: {
|
||||
layer: 'silver',
|
||||
cat: 'iceberg',
|
||||
catalog: 'iceberg',
|
||||
source_parquet_path: 's3://dlh-bronze/gws_reports/waste_reports/',
|
||||
tbl: 'silver.waste_reports',
|
||||
target_table: 'silver.waste_reports',
|
||||
primary_keys: 'id, updated_at',
|
||||
},
|
||||
pyspark_transform: {
|
||||
layer: 'silver',
|
||||
app_name: 'merica_spark_job',
|
||||
source_parquet_path: 's3://dlh-bronze/spku_air_quality/',
|
||||
target_table: 'dlh_lakehouse.silver.spku_air_quality',
|
||||
driver_memory: '1g',
|
||||
sql_query: 'SELECT * FROM source_view',
|
||||
transformation_sql: 'SELECT * FROM source_view',
|
||||
},
|
||||
bash_operator: {
|
||||
command: 'curl -s -f http://api.spku.dlh.id/v1/stasiun/spku/data-realtime -o /tmp/spku.json',
|
||||
bash_command: 'curl -s -f http://api.spku.dlh.id/v1/stasiun/spku/data-realtime -o /tmp/spku.json',
|
||||
},
|
||||
python_operator: {
|
||||
callable: 'execute_task',
|
||||
python_code: 'import requests\ndef execute_task():\n r = requests.get("https://api.spku.dlh.id/v1/data")\n print(r.status_code)',
|
||||
},
|
||||
ge_quality: {
|
||||
ge_scope_level: 'table',
|
||||
ge_table_name: 'public.stasiun_spku',
|
||||
test_table_row_count: true,
|
||||
test_table_schema_match: true,
|
||||
test_table_table_not_empty: true,
|
||||
table_min_rows: 1,
|
||||
table_max_rows: 1000000,
|
||||
ge_col_table: 'stasiun_spku',
|
||||
ge_target_column: 'stasiun_id',
|
||||
ge_column_test_type: 'not_null',
|
||||
sql_assertion: "",
|
||||
sql: "",
|
||||
expected: 0,
|
||||
error_message: 'Unexpected rows detected in table',
|
||||
},
|
||||
minio_storage: {
|
||||
bucket_name: 'dlh-bronze',
|
||||
bucket: 'dlh-bronze',
|
||||
prefix: 'ingestion/',
|
||||
file_format: 'parquet',
|
||||
},
|
||||
trino_storage: {
|
||||
trino_catalog: 'iceberg',
|
||||
trino_schema: 'silver',
|
||||
iceberg_table: 'stasiun_spku_iceberg',
|
||||
table_name: 'stasiun_spku_iceberg',
|
||||
write_mode: 'append',
|
||||
},
|
||||
sitomat_callback: {
|
||||
layer: 'bronze',
|
||||
artifact_ref: 'lakehouse.bronze.dataset_cdc',
|
||||
checksum: 'auto-sha256',
|
||||
sitomat_base_url: 'http://localhost:3000',
|
||||
openmetadata_fqn: 'dlh_airflow_service.pipeline.task',
|
||||
ge_status: 'PASSED',
|
||||
},
|
||||
};
|
||||
|
||||
const fromItem = (itemData.fields || []).reduce((acc, f) => {
|
||||
acc[f.key] = f.default || '';
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { ...(baseDefaults[nodeType] || {}), ...fromItem, ...(itemData.config || {}) };
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* MERICA Runtime Environment Helper
|
||||
* Reads variables dynamically injected into window.__MERICA_ENV__ at container startup.
|
||||
*/
|
||||
|
||||
export const getEnv = (key, fallback = '') => {
|
||||
if (typeof window !== 'undefined' && window.__MERICA_ENV__) {
|
||||
const val = window.__MERICA_ENV__[key];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const API_BASE = getEnv('API_BASE', '/api/v1');
|
||||
export const OPENMETADATA_HOST = getEnv('OPENMETADATA_HOST', 'http://localhost:8585');
|
||||
export const AIRFLOW_WEB_URL = getEnv('AIRFLOW_WEB_URL', 'http://localhost:8080');
|
||||
export const MINIO_CONSOLE_URL = getEnv('MINIO_CONSOLE_URL', 'http://localhost:9001');
|
||||
export const SITOMAT_URL = getEnv('SITOMAT_URL', '');
|
||||
export const APP_VERSION = getEnv('APP_VERSION', '1.0.0');
|
||||
export const KEYCLOAK_URL = getEnv('KEYCLOAK_URL', 'http://localhost:14180');
|
||||
export const KEYCLOAK_REALM = getEnv('KEYCLOAK_REALM', 'merica');
|
||||
export const KEYCLOAK_CLIENT_ID = getEnv('KEYCLOAK_CLIENT_ID', 'merica-app');
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Auto-layout (Tidy) and Graph Topology Helpers
|
||||
* Implements longest-path DAG ranking with 190px horizontal and 150px vertical spacing.
|
||||
*/
|
||||
|
||||
export const SOURCE_NODE_TYPES = [
|
||||
'source_db',
|
||||
'source_gws',
|
||||
'source_api',
|
||||
'source_rest',
|
||||
'source_file',
|
||||
'db',
|
||||
'gws',
|
||||
'rest',
|
||||
'file',
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks whether a node or node type represents a source/ingestion node.
|
||||
* Source nodes do not have input handles (target ports) and have rounded left ends.
|
||||
*/
|
||||
export function isSourceNode(typeOrNode) {
|
||||
if (!typeOrNode) return false;
|
||||
if (typeof typeOrNode === 'object') {
|
||||
if (typeOrNode.data?.isSource) return true;
|
||||
const t = typeOrNode.data?.type || typeOrNode.type;
|
||||
return isSourceNode(t);
|
||||
}
|
||||
const typeStr = String(typeOrNode).toLowerCase();
|
||||
return SOURCE_NODE_TYPES.includes(typeStr) || typeStr.startsWith('source_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies longest-path topological ranking layout to nodes.
|
||||
* Spacing: 190px horizontal, 150px vertical.
|
||||
*
|
||||
* @param {Array} nodes - React Flow node array
|
||||
* @param {Array} edges - React Flow edge array or [src, tgt] tuples
|
||||
* @param {Object} options - Optional { originX, originY, spacingX, spacingY }
|
||||
* @returns {Array} Updated nodes with calculated { position: { x, y } }
|
||||
*/
|
||||
export function applyAutoLayout(nodes, edges = [], options = {}) {
|
||||
if (!nodes || nodes.length === 0) return [];
|
||||
|
||||
const originX = options.originX ?? 60;
|
||||
const originY = options.originY ?? 60;
|
||||
const spacingX = options.spacingX ?? 190;
|
||||
const spacingY = options.spacingY ?? 150;
|
||||
|
||||
// Initialize longest-path distance for each node
|
||||
const d = {};
|
||||
nodes.forEach((n) => {
|
||||
d[n.id] = 0;
|
||||
});
|
||||
|
||||
// Iteratively compute longest path in DAG
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
edges.forEach((e) => {
|
||||
const src = e.source || (Array.isArray(e) ? e[0] : null);
|
||||
const tgt = e.target || (Array.isArray(e) ? e[1] : null);
|
||||
if (src && tgt && d[tgt] !== undefined && d[src] !== undefined) {
|
||||
if (d[tgt] < d[src] + 1) {
|
||||
d[tgt] = d[src] + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Count items per level column
|
||||
const col = {};
|
||||
return nodes.map((n) => {
|
||||
const depth = d[n.id] || 0;
|
||||
col[depth] = col[depth] || 0;
|
||||
const x = originX + depth * spacingX;
|
||||
const y = originY + col[depth] * spacingY;
|
||||
col[depth]++;
|
||||
|
||||
return {
|
||||
...n,
|
||||
position: { x, y },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyAutoLayout, isSourceNode } from './layout';
|
||||
|
||||
describe('Layout and Topology Utils (Langkah 4)', () => {
|
||||
describe('isSourceNode', () => {
|
||||
it('correctly identifies source/ingestion node types', () => {
|
||||
expect(isSourceNode('source_db')).toBe(true);
|
||||
expect(isSourceNode('source_gws')).toBe(true);
|
||||
expect(isSourceNode('source_api')).toBe(true);
|
||||
expect(isSourceNode('source_file')).toBe(true);
|
||||
expect(isSourceNode('db')).toBe(true);
|
||||
expect(isSourceNode({ type: 'source_db' })).toBe(true);
|
||||
expect(isSourceNode({ data: { type: 'source_db' } })).toBe(true);
|
||||
expect(isSourceNode({ data: { isSource: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for downstream transformation, storage, and quality nodes', () => {
|
||||
expect(isSourceNode('dbt_transform')).toBe(false);
|
||||
expect(isSourceNode('dbt')).toBe(false);
|
||||
expect(isSourceNode('pandas_cleansing')).toBe(false);
|
||||
expect(isSourceNode('minio_storage')).toBe(false);
|
||||
expect(isSourceNode('trino_storage')).toBe(false);
|
||||
expect(isSourceNode('ge_quality')).toBe(false);
|
||||
expect(isSourceNode('sitomat_callback')).toBe(false);
|
||||
expect(isSourceNode(null)).toBe(false);
|
||||
expect(isSourceNode(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAutoLayout (Tidy)', () => {
|
||||
it('handles empty nodes array gracefully', () => {
|
||||
expect(applyAutoLayout([])).toEqual([]);
|
||||
expect(applyAutoLayout(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('positions a linear DAG with 190px horizontal spacing', () => {
|
||||
const nodes = [
|
||||
{ id: 'node_a', data: { label: 'Source' } },
|
||||
{ id: 'node_b', data: { label: 'Transform' } },
|
||||
{ id: 'node_c', data: { label: 'Sink' } },
|
||||
];
|
||||
const edges = [
|
||||
{ source: 'node_a', target: 'node_b' },
|
||||
{ source: 'node_b', target: 'node_c' },
|
||||
];
|
||||
|
||||
const tidied = applyAutoLayout(nodes, edges, { originX: 40, originY: 60 });
|
||||
|
||||
expect(tidied).toHaveLength(3);
|
||||
// node_a depth 0
|
||||
expect(tidied[0].position).toEqual({ x: 40, y: 60 });
|
||||
// node_b depth 1 (40 + 190 = 230)
|
||||
expect(tidied[1].position).toEqual({ x: 230, y: 60 });
|
||||
// node_c depth 2 (40 + 2 * 190 = 420)
|
||||
expect(tidied[2].position).toEqual({ x: 420, y: 60 });
|
||||
});
|
||||
|
||||
it('stacks branching nodes vertically with 150px vertical spacing', () => {
|
||||
const nodes = [
|
||||
{ id: 'src', data: { label: 'Source DB' } },
|
||||
{ id: 'branch_1', data: { label: 'Branch 1' } },
|
||||
{ id: 'branch_2', data: { label: 'Branch 2' } },
|
||||
];
|
||||
const edges = [
|
||||
{ source: 'src', target: 'branch_1' },
|
||||
{ source: 'src', target: 'branch_2' },
|
||||
];
|
||||
|
||||
const tidied = applyAutoLayout(nodes, edges, { originX: 40, originY: 60 });
|
||||
|
||||
expect(tidied[0].position).toEqual({ x: 40, y: 60 });
|
||||
// branch_1 at depth 1, row 0
|
||||
expect(tidied[1].position).toEqual({ x: 230, y: 60 });
|
||||
// branch_2 at depth 1, row 1 (60 + 150 = 210)
|
||||
expect(tidied[2].position).toEqual({ x: 230, y: 210 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) Helpers for OIDC Authorization Code Flow
|
||||
* RFC 7636 compliant implementation using standard Web Crypto API.
|
||||
*/
|
||||
|
||||
export function generateCodeVerifier(length = 64) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const array = new Uint8Array(length);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
window.crypto.getRandomValues(array);
|
||||
} else {
|
||||
// Fallback for non-browser/test environments
|
||||
for (let i = 0; i < length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
return Array.from(array, (byte) => charset[byte % charset.length]).join('');
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier) {
|
||||
if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await window.crypto.subtle.digest('SHA-256', data);
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
// Fallback for testing environments where crypto.subtle may be mocked
|
||||
return btoa(verifier).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Graph & Pipeline Validation Engine (Langkah 8 / backlog A1 & A2)
|
||||
*
|
||||
* Validates:
|
||||
* 1. Cycle detection via Kahn's algorithm (topological sort)
|
||||
* 2. Disconnected / isolated nodes in multi-node workflows
|
||||
* 3. Unique task IDs
|
||||
* 4. Source node placement (entry points & no incoming edges to source nodes)
|
||||
* 5. Mandatory configuration fields per component type
|
||||
* 6. Pipeline slug / metadata validity
|
||||
*
|
||||
* Outputs:
|
||||
* - isValid: boolean
|
||||
* - errors: list of critical issues that prevent deployment
|
||||
* - warnings: list of non-critical advisories
|
||||
* - problems: combined list sorted by severity
|
||||
* - nodeStatusMap: per-node { status: 'ok' | 'warn' | 'err', message, missingFields }
|
||||
*/
|
||||
|
||||
import { isSourceNode } from './layout';
|
||||
|
||||
export const MANDATORY_FIELDS = {
|
||||
// Ingestion
|
||||
source_db: ['conn', 'table'],
|
||||
db: ['conn', 'table'],
|
||||
source_gws: ['url'],
|
||||
gws: ['url'],
|
||||
source_api: ['url'],
|
||||
source_api_dlt: ['url'],
|
||||
source_rest: ['url'],
|
||||
rest: ['url'],
|
||||
source_file: ['path'],
|
||||
file: ['path'],
|
||||
|
||||
// Transformation
|
||||
pandas_cleansing: ['ops'],
|
||||
pandas_clean: ['ops'],
|
||||
pandas: ['ops'],
|
||||
dbt_transform: ['sel', 'tgt'],
|
||||
dbt: ['sel', 'tgt'],
|
||||
duckdb_transform: ['query'],
|
||||
duckdb: ['query'],
|
||||
pyiceberg_transform: ['tbl', 'cat'],
|
||||
pyiceberg: ['tbl', 'cat'],
|
||||
pyspark_transform: ['app_name'],
|
||||
pyspark: ['app_name'],
|
||||
bash_operator: ['command'],
|
||||
bash: ['command'],
|
||||
python_operator: ['callable'],
|
||||
python: ['callable'],
|
||||
|
||||
// Quality
|
||||
ge_quality: ['suite'],
|
||||
ge: ['suite'],
|
||||
|
||||
// Storage
|
||||
minio_storage: ['bucket'],
|
||||
minio_target: ['bucket'],
|
||||
minio: ['bucket'],
|
||||
trino_storage: ['tbl'],
|
||||
trino_target: ['tbl'],
|
||||
trino: ['tbl'],
|
||||
|
||||
// Notify
|
||||
sitomat_callback: ['url'],
|
||||
sitomat: ['url'],
|
||||
cb: ['url'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a specific parameter field value is considered empty.
|
||||
*/
|
||||
export function isFieldEmpty(type, key, value) {
|
||||
if (value === undefined || value === null) return true;
|
||||
const str = String(value).trim();
|
||||
if (str === '') return true;
|
||||
|
||||
// For REST API, a default bare 'https://' is considered not yet configured
|
||||
const t = String(type).toLowerCase();
|
||||
if ((key === 'url' || key === 'endpoint') && (t.includes('api') || t.includes('rest')) && (str === 'https://' || str === 'http://')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a pipeline graph specification.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Array} params.nodes - Canvas task nodes
|
||||
* @param {Array} params.edges - React Flow edges or [src, tgt] tuples
|
||||
* @param {string} params.pipelineId - Pipeline slug / identifier
|
||||
* @param {string} params.schedule - Airflow cron expression or preset
|
||||
* @returns {Object} Validation result { isValid, hasWarnings, errors, warnings, problems, nodeStatusMap }
|
||||
*/
|
||||
export function validatePipeline({ nodes = [], edges = [], pipelineId = '', schedule = '' }) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const nodeStatusMap = {};
|
||||
|
||||
// Filter out note nodes if any are passed in
|
||||
const taskNodes = nodes.filter((n) => n.type !== 'noteNode');
|
||||
|
||||
// Initialize nodeStatusMap for all task nodes
|
||||
taskNodes.forEach((n) => {
|
||||
nodeStatusMap[n.id] = {
|
||||
status: 'ok',
|
||||
message: 'Configured & Valid',
|
||||
missingFields: [],
|
||||
};
|
||||
});
|
||||
|
||||
// 1. Check Empty Canvas
|
||||
if (taskNodes.length === 0) {
|
||||
errors.push({
|
||||
id: 'err-empty-canvas',
|
||||
nodeId: null,
|
||||
nodeTitle: 'Canvas Graph',
|
||||
category: 'Topology',
|
||||
severity: 'error',
|
||||
message: 'Canvas is empty. Add components to build a pipeline.',
|
||||
});
|
||||
return {
|
||||
isValid: false,
|
||||
hasWarnings: false,
|
||||
errors,
|
||||
warnings,
|
||||
problems: errors,
|
||||
nodeStatusMap,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check Pipeline Metadata
|
||||
if (!pipelineId || !pipelineId.trim()) {
|
||||
errors.push({
|
||||
id: 'err-pipeline-id-empty',
|
||||
nodeId: null,
|
||||
nodeTitle: 'Pipeline Settings',
|
||||
category: 'Metadata',
|
||||
severity: 'error',
|
||||
message: 'Pipeline name / slug cannot be empty.',
|
||||
});
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(pipelineId.trim())) {
|
||||
errors.push({
|
||||
id: 'err-pipeline-id-format',
|
||||
nodeId: null,
|
||||
nodeTitle: 'Pipeline Settings',
|
||||
category: 'Metadata',
|
||||
severity: 'error',
|
||||
message: 'Pipeline slug must contain only alphanumeric characters, dashes, or underscores.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!schedule || !schedule.trim()) {
|
||||
warnings.push({
|
||||
id: 'warn-schedule-empty',
|
||||
nodeId: null,
|
||||
nodeTitle: 'Pipeline Settings',
|
||||
category: 'Metadata',
|
||||
severity: 'warning',
|
||||
message: 'Schedule is empty. Defaulting to manual trigger (@once).',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check Unique Task IDs
|
||||
const idCounts = {};
|
||||
taskNodes.forEach((n) => {
|
||||
idCounts[n.id] = (idCounts[n.id] || 0) + 1;
|
||||
});
|
||||
Object.entries(idCounts).forEach(([id, count]) => {
|
||||
if (count > 1) {
|
||||
errors.push({
|
||||
id: `err-duplicate-id-${id}`,
|
||||
nodeId: id,
|
||||
nodeTitle: id,
|
||||
category: 'Configuration',
|
||||
severity: 'error',
|
||||
message: `Duplicate task ID "${id}" detected ${count} times. Task IDs must be unique.`,
|
||||
});
|
||||
if (nodeStatusMap[id]) {
|
||||
nodeStatusMap[id].status = 'err';
|
||||
nodeStatusMap[id].message = `Duplicate task ID "${id}"`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build Adjacency List and In-Degree counts
|
||||
const inDegree = {};
|
||||
const outDegree = {};
|
||||
const adjList = {};
|
||||
taskNodes.forEach((n) => {
|
||||
inDegree[n.id] = 0;
|
||||
outDegree[n.id] = 0;
|
||||
adjList[n.id] = [];
|
||||
});
|
||||
|
||||
edges.forEach((e) => {
|
||||
const src = e.source || (Array.isArray(e) ? e[0] : null);
|
||||
const tgt = e.target || (Array.isArray(e) ? e[1] : null);
|
||||
if (src && tgt && inDegree[tgt] !== undefined && inDegree[src] !== undefined) {
|
||||
inDegree[tgt]++;
|
||||
outDegree[src]++;
|
||||
adjList[src].push(tgt);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Cycle Detection using Kahn's Algorithm
|
||||
const queue = taskNodes.filter((n) => inDegree[n.id] === 0).map((n) => n.id);
|
||||
const inDegreeCopy = { ...inDegree };
|
||||
let processedCount = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift();
|
||||
processedCount++;
|
||||
(adjList[curr] || []).forEach((neighbor) => {
|
||||
inDegreeCopy[neighbor]--;
|
||||
if (inDegreeCopy[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (processedCount < taskNodes.length) {
|
||||
// There is a cycle!
|
||||
const cyclicNodeIds = Object.keys(inDegreeCopy).filter((id) => inDegreeCopy[id] > 0);
|
||||
errors.push({
|
||||
id: 'err-cycle-detected',
|
||||
nodeId: cyclicNodeIds[0] || null,
|
||||
nodeTitle: 'Workflow Topology',
|
||||
category: 'Topology',
|
||||
severity: 'error',
|
||||
message: `Circular dependency detected involving task(s): ${cyclicNodeIds.join(', ')}. Airflow DAGs must be strictly acyclic.`,
|
||||
});
|
||||
|
||||
cyclicNodeIds.forEach((id) => {
|
||||
if (nodeStatusMap[id]) {
|
||||
nodeStatusMap[id].status = 'err';
|
||||
nodeStatusMap[id].message = 'Circular dependency (cycle) detected';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Disconnected / Isolated Nodes Check
|
||||
if (taskNodes.length > 1) {
|
||||
taskNodes.forEach((n) => {
|
||||
const isConnected = inDegree[n.id] > 0 || outDegree[n.id] > 0;
|
||||
if (!isConnected) {
|
||||
const title = n.data?.label || n.data?.title || n.id;
|
||||
warnings.push({
|
||||
id: `warn-isolated-${n.id}`,
|
||||
nodeId: n.id,
|
||||
nodeTitle: title,
|
||||
category: 'Topology',
|
||||
severity: 'warning',
|
||||
message: `Node "${title}" is not connected to any other task in the workflow.`,
|
||||
});
|
||||
if (nodeStatusMap[n.id] && nodeStatusMap[n.id].status === 'ok') {
|
||||
nodeStatusMap[n.id].status = 'warn';
|
||||
nodeStatusMap[n.id].message = 'Disconnected from workflow';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Source Node Rules
|
||||
taskNodes.forEach((n) => {
|
||||
const nodeType = n.data?.type || n.type || '';
|
||||
const title = n.data?.label || n.data?.title || n.id;
|
||||
const isSrc = isSourceNode(n);
|
||||
|
||||
// Source node cannot have incoming edges
|
||||
if (isSrc && inDegree[n.id] > 0) {
|
||||
errors.push({
|
||||
id: `err-source-incoming-${n.id}`,
|
||||
nodeId: n.id,
|
||||
nodeTitle: title,
|
||||
category: 'Topology',
|
||||
severity: 'error',
|
||||
message: `Source node "${title}" (${nodeType}) cannot have incoming dependencies.`,
|
||||
});
|
||||
if (nodeStatusMap[n.id]) {
|
||||
nodeStatusMap[n.id].status = 'err';
|
||||
nodeStatusMap[n.id].message = 'Source nodes cannot have incoming dependencies';
|
||||
}
|
||||
}
|
||||
|
||||
// Graph entry points (in-degree 0) in multi-node workflows should ideally be source nodes
|
||||
if (taskNodes.length > 1 && inDegree[n.id] === 0 && !isSrc) {
|
||||
warnings.push({
|
||||
id: `warn-entry-not-source-${n.id}`,
|
||||
nodeId: n.id,
|
||||
nodeTitle: title,
|
||||
category: 'Best Practice',
|
||||
severity: 'warning',
|
||||
message: `Entry task "${title}" is of type "${nodeType}". Workflows typically start with an Ingestion source node.`,
|
||||
});
|
||||
if (nodeStatusMap[n.id] && nodeStatusMap[n.id].status === 'ok') {
|
||||
nodeStatusMap[n.id].status = 'warn';
|
||||
nodeStatusMap[n.id].message = 'Entry task is not an Ingestion source';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Mandatory Fields per Component Type
|
||||
taskNodes.forEach((n) => {
|
||||
const rawType = n.data?.type || n.type || '';
|
||||
const nodeType = String(rawType).toLowerCase();
|
||||
const title = n.data?.label || n.data?.title || n.id;
|
||||
const required = MANDATORY_FIELDS[nodeType] || [];
|
||||
const params = n.data?.params || n.data?.config || {};
|
||||
const missing = [];
|
||||
|
||||
required.forEach((fieldKey) => {
|
||||
// Check in params or directly in node.data
|
||||
let val = params[fieldKey] !== undefined ? params[fieldKey] : n.data?.[fieldKey];
|
||||
|
||||
// Alias resolution for GWS (url, spreadsheet_id, folder_id, drive_folder_id)
|
||||
if ((nodeType === 'source_gws' || nodeType === 'gws') && fieldKey === 'url') {
|
||||
val = val || params.spreadsheet_id || params.folder_id || params.drive_folder_id || n.data?.spreadsheet_id || n.data?.folder_id || n.data?.drive_folder_id;
|
||||
}
|
||||
|
||||
// Alias resolution for REST API (url, endpoint_url, endpoint)
|
||||
if ((nodeType.includes('api') || nodeType.includes('rest')) && fieldKey === 'url') {
|
||||
val = val || params.endpoint_url || params.endpoint || n.data?.endpoint_url || n.data?.endpoint;
|
||||
}
|
||||
|
||||
// Alias resolution for DB (table, table_name, source_table)
|
||||
if ((nodeType === 'source_db' || nodeType === 'db') && fieldKey === 'table') {
|
||||
val = val || params.table_name || params.source_table || n.data?.table_name || n.data?.source_table;
|
||||
}
|
||||
|
||||
// Alias resolution for DB (conn, conn_id, connection_id)
|
||||
if ((nodeType === 'source_db' || nodeType === 'db') && fieldKey === 'conn') {
|
||||
val = val || params.conn_id || params.connection_id || n.data?.conn_id || n.data?.connection_id;
|
||||
}
|
||||
|
||||
// Alias resolution for dbt (sel, dbt_model)
|
||||
if ((nodeType === 'dbt_transform' || nodeType === 'dbt') && fieldKey === 'sel') {
|
||||
val = val || params.dbt_model || n.data?.dbt_model;
|
||||
}
|
||||
|
||||
// Alias resolution for DuckDB (query, sql_query, transformation_sql, target_table)
|
||||
if ((nodeType === 'duckdb_transform' || nodeType === 'duckdb') && fieldKey === 'query') {
|
||||
val = val || params.sql_query || params.transformation_sql || params.target_table || n.data?.sql_query || n.data?.target_table;
|
||||
}
|
||||
|
||||
// Alias resolution for SITOMAT (url, callback_url, artifact_ref)
|
||||
if ((nodeType === 'sitomat_callback' || nodeType === 'sitomat') && fieldKey === 'url') {
|
||||
val = val || params.callback_url || params.artifact_ref || n.data?.callback_url || n.data?.artifact_ref;
|
||||
}
|
||||
|
||||
if (isFieldEmpty(nodeType, fieldKey, val)) {
|
||||
missing.push(fieldKey);
|
||||
}
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
errors.push({
|
||||
id: `err-missing-fields-${n.id}`,
|
||||
nodeId: n.id,
|
||||
nodeTitle: title,
|
||||
category: 'Configuration',
|
||||
severity: 'error',
|
||||
message: `[${title}] Missing mandatory configuration field(s): ${missing.map((f) => `"${f}"`).join(', ')}.`,
|
||||
});
|
||||
if (nodeStatusMap[n.id]) {
|
||||
nodeStatusMap[n.id].status = 'err';
|
||||
nodeStatusMap[n.id].message = `Missing required field(s): ${missing.join(', ')}`;
|
||||
nodeStatusMap[n.id].missingFields = missing;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Combine and sort problems (errors first, then warnings)
|
||||
const problems = [
|
||||
...errors,
|
||||
...warnings,
|
||||
];
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
hasWarnings: warnings.length > 0,
|
||||
errors,
|
||||
warnings,
|
||||
problems,
|
||||
nodeStatusMap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validatePipeline, isFieldEmpty, MANDATORY_FIELDS } from './validation';
|
||||
|
||||
describe('validation.js - Pipeline Validation Engine', () => {
|
||||
it('detects empty canvas', () => {
|
||||
const res = validatePipeline({ nodes: [], edges: [], pipelineId: 'my_pipeline', schedule: '@daily' });
|
||||
expect(res.isValid).toBe(false);
|
||||
expect(res.errors.length).toBeGreaterThan(0);
|
||||
expect(res.errors[0].message).toContain('Canvas is empty');
|
||||
});
|
||||
|
||||
it('validates a correct linear DAG with complete mandatory fields', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-db',
|
||||
type: 'source_db',
|
||||
data: {
|
||||
type: 'source_db',
|
||||
label: 'Source DB',
|
||||
isSource: true,
|
||||
params: { conn: 'Postgres', table: 'permits' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'node-dbt',
|
||||
type: 'dbt_transform',
|
||||
data: {
|
||||
type: 'dbt_transform',
|
||||
label: 'dbt Transform',
|
||||
params: { sel: 'silver', tgt: 'postgres' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'node-minio',
|
||||
type: 'minio_storage',
|
||||
data: {
|
||||
type: 'minio_storage',
|
||||
label: 'MinIO Target',
|
||||
params: { bucket: 'lakehouse' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [
|
||||
{ source: 'node-db', target: 'node-dbt' },
|
||||
{ source: 'node-dbt', target: 'node-minio' },
|
||||
];
|
||||
|
||||
const res = validatePipeline({
|
||||
nodes,
|
||||
edges,
|
||||
pipelineId: 'valid_pipeline_test',
|
||||
schedule: '0 0 * * *',
|
||||
});
|
||||
|
||||
expect(res.isValid).toBe(true);
|
||||
expect(res.errors.length).toBe(0);
|
||||
expect(res.nodeStatusMap['node-db'].status).toBe('ok');
|
||||
expect(res.nodeStatusMap['node-dbt'].status).toBe('ok');
|
||||
expect(res.nodeStatusMap['node-minio'].status).toBe('ok');
|
||||
});
|
||||
|
||||
it('detects cycles in DAG using Kahn algorithm', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', params: { conn: 'Postgres', table: 'tbl1' } },
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
type: 'pandas_cleansing',
|
||||
data: { type: 'pandas_cleansing', params: { ops: 'clean' } },
|
||||
},
|
||||
{
|
||||
id: 'node-3',
|
||||
type: 'dbt_transform',
|
||||
data: { type: 'dbt_transform', params: { sel: 'm', tgt: 't' } },
|
||||
},
|
||||
];
|
||||
|
||||
// Cycle: 1 -> 2 -> 3 -> 1
|
||||
const edges = [
|
||||
{ source: 'node-1', target: 'node-2' },
|
||||
{ source: 'node-2', target: 'node-3' },
|
||||
{ source: 'node-3', target: 'node-1' },
|
||||
];
|
||||
|
||||
const res = validatePipeline({ nodes, edges, pipelineId: 'cyclic_pipeline' });
|
||||
expect(res.isValid).toBe(false);
|
||||
const cycleErr = res.errors.find((e) => e.message.toLowerCase().includes('circular'));
|
||||
expect(cycleErr).toBeDefined();
|
||||
expect(res.nodeStatusMap['node-1'].status).toBe('err');
|
||||
});
|
||||
|
||||
it('warns on disconnected / isolated nodes in multi-node workflows', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', params: { conn: 'Postgres', table: 'tbl' } },
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
type: 'pandas_cleansing',
|
||||
data: { type: 'pandas_cleansing', params: { ops: 'clean' } },
|
||||
},
|
||||
{
|
||||
id: 'node-isolated',
|
||||
type: 'ge_quality',
|
||||
data: { type: 'ge_quality', label: 'Isolated GE', params: { suite: 'suite_1' } },
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [{ source: 'node-1', target: 'node-2' }];
|
||||
|
||||
const res = validatePipeline({ nodes, edges, pipelineId: 'isolated_test' });
|
||||
expect(res.warnings.length).toBeGreaterThan(0);
|
||||
const isoWarn = res.warnings.find((w) => w.message.includes('not connected'));
|
||||
expect(isoWarn).toBeDefined();
|
||||
expect(res.nodeStatusMap['node-isolated'].status).toBe('warn');
|
||||
});
|
||||
|
||||
it('detects duplicate task IDs', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'duplicate-id',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', params: { conn: 'Postgres', table: 'tbl1' } },
|
||||
},
|
||||
{
|
||||
id: 'duplicate-id',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', params: { conn: 'Postgres', table: 'tbl2' } },
|
||||
},
|
||||
];
|
||||
|
||||
const res = validatePipeline({ nodes, edges: [], pipelineId: 'dup_id_test' });
|
||||
expect(res.isValid).toBe(false);
|
||||
const dupErr = res.errors.find((e) => e.message.includes('Duplicate task ID'));
|
||||
expect(dupErr).toBeDefined();
|
||||
expect(res.nodeStatusMap['duplicate-id'].status).toBe('err');
|
||||
});
|
||||
|
||||
it('flags errors when source nodes receive incoming dependencies', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-dbt',
|
||||
type: 'dbt_transform',
|
||||
data: { type: 'dbt_transform', params: { sel: 's', tgt: 'p' } },
|
||||
},
|
||||
{
|
||||
id: 'node-db',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', isSource: true, params: { conn: 'Postgres', table: 'tbl' } },
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [{ source: 'node-dbt', target: 'node-db' }];
|
||||
|
||||
const res = validatePipeline({ nodes, edges, pipelineId: 'source_incoming_test' });
|
||||
expect(res.isValid).toBe(false);
|
||||
const srcErr = res.errors.find((e) => e.message.includes('cannot have incoming dependencies'));
|
||||
expect(srcErr).toBeDefined();
|
||||
expect(res.nodeStatusMap['node-db'].status).toBe('err');
|
||||
});
|
||||
|
||||
it('validates mandatory fields for all 15 component types', () => {
|
||||
// Test database missing table
|
||||
const nodeDb = {
|
||||
id: 'db-1',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', params: { conn: 'Postgres', table: '' } },
|
||||
};
|
||||
|
||||
// Test REST API with default unconfigured url
|
||||
const nodeApi = {
|
||||
id: 'api-1',
|
||||
type: 'source_api',
|
||||
data: { type: 'source_api', params: { url: 'https://' } },
|
||||
};
|
||||
|
||||
// Test dbt missing tgt
|
||||
const nodeDbt = {
|
||||
id: 'dbt-1',
|
||||
type: 'dbt_transform',
|
||||
data: { type: 'dbt_transform', params: { sel: 'gold', tgt: '' } },
|
||||
};
|
||||
|
||||
const res = validatePipeline({
|
||||
nodes: [nodeDb, nodeApi, nodeDbt],
|
||||
edges: [],
|
||||
pipelineId: 'mandatory_fields_test',
|
||||
});
|
||||
|
||||
expect(res.isValid).toBe(false);
|
||||
expect(res.nodeStatusMap['db-1'].status).toBe('err');
|
||||
expect(res.nodeStatusMap['db-1'].missingFields).toContain('table');
|
||||
|
||||
expect(res.nodeStatusMap['api-1'].status).toBe('err');
|
||||
expect(res.nodeStatusMap['api-1'].missingFields).toContain('url');
|
||||
|
||||
expect(res.nodeStatusMap['dbt-1'].status).toBe('err');
|
||||
expect(res.nodeStatusMap['dbt-1'].missingFields).toContain('tgt');
|
||||
});
|
||||
|
||||
it('accepts valid aliases for modern GWS (folder_id, spreadsheet_id) and REST API (endpoint_url)', () => {
|
||||
const nodeGwsFolder = {
|
||||
id: 'gws-1',
|
||||
type: 'source_gws',
|
||||
data: { type: 'source_gws', params: { folder_id: '1OSRugXZvfjoY9M0yySKT' } },
|
||||
};
|
||||
const nodeGwsSheetId = {
|
||||
id: 'gws-2',
|
||||
type: 'source_gws',
|
||||
data: { type: 'source_gws', params: { spreadsheet_id: 'sheet_12345' } },
|
||||
};
|
||||
const nodeApiDlt = {
|
||||
id: 'dlt-1',
|
||||
type: 'source_api_dlt',
|
||||
data: { type: 'source_api_dlt', params: { endpoint_url: 'https://api.spku.dlh.id/v1/telemetry' } },
|
||||
};
|
||||
|
||||
const res = validatePipeline({
|
||||
nodes: [nodeGwsFolder, nodeGwsSheetId, nodeApiDlt],
|
||||
edges: [],
|
||||
pipelineId: 'modern_alias_validation_test',
|
||||
});
|
||||
|
||||
const gwsErrors = res.errors.filter((e) => e.nodeId === 'gws-1' || e.nodeId === 'gws-2' || e.nodeId === 'dlt-1');
|
||||
expect(gwsErrors.length).toBe(0);
|
||||
expect(res.nodeStatusMap['gws-1']?.status).not.toBe('err');
|
||||
expect(res.nodeStatusMap['gws-2']?.status).not.toBe('err');
|
||||
expect(res.nodeStatusMap['dlt-1']?.status).not.toBe('err');
|
||||
});
|
||||
|
||||
it('validates pipeline slug format', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'n1',
|
||||
type: 'source_db',
|
||||
data: { type: 'source_db', params: { conn: 'Postgres', table: 't' } },
|
||||
},
|
||||
];
|
||||
|
||||
const res = validatePipeline({
|
||||
nodes,
|
||||
edges: [],
|
||||
pipelineId: 'invalid slug with spaces!',
|
||||
});
|
||||
|
||||
expect(res.isValid).toBe(false);
|
||||
const slugErr = res.errors.find((e) => e.message.includes('Pipeline slug must contain only'));
|
||||
expect(slugErr).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
/**
|
||||
* Helper to serialize canvas topology to Airflow YAML specification.
|
||||
* Directly mirrors StudioPage.jsx generateCanvasYaml implementation.
|
||||
*/
|
||||
function generateCanvasYaml({ nodes, edges, pipelineId, schedule, bundleName, description }) {
|
||||
const taskNodes = nodes.filter((n) => n.type !== 'noteNode');
|
||||
if (taskNodes.length === 0) {
|
||||
return 'dag_id: ' + (pipelineId || 'example') + '\ntasks: []';
|
||||
}
|
||||
|
||||
const tasks = taskNodes.map((n) => {
|
||||
const nodeType = n.data?.type || n.type || 'source_db';
|
||||
const config = n.data?.params || n.data?.config || {};
|
||||
const targetEdges = edges.filter((e) => (e.target || e[1]) === n.id);
|
||||
const dependencies = targetEdges.map((e) => e.source || e[0]);
|
||||
const isDisabled = n.data?.disabled === true || n.data?.enabled === false;
|
||||
|
||||
return {
|
||||
task_id: n.id,
|
||||
type: isDisabled ? 'empty' : (n.data?.yamlType || nodeType),
|
||||
label: n.data?.label || n.data?.title || n.id,
|
||||
enabled: !isDisabled,
|
||||
...(dependencies.length > 0 ? { dependencies } : {}),
|
||||
config: isDisabled ? {} : config,
|
||||
};
|
||||
});
|
||||
|
||||
const pipelineSpec = {
|
||||
dag_id: pipelineId || 'example_pipeline',
|
||||
schedule: schedule || '@hourly',
|
||||
bundle: bundleName || 'dlh-core-ingestion',
|
||||
description: description || '',
|
||||
tasks,
|
||||
};
|
||||
|
||||
return yaml.dump(pipelineSpec);
|
||||
}
|
||||
|
||||
describe('yamlCodec.test.js - Airflow YAML Generator and Codec', () => {
|
||||
it('1. Generates clean YAML with dag_id, schedule, and tasks from canvas graph', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-db',
|
||||
type: 'customNode',
|
||||
data: {
|
||||
label: 'Source Database',
|
||||
type: 'source_db',
|
||||
yamlType: 'source_db',
|
||||
params: { conn: 'Postgres', table: 'permits' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'node-dbt',
|
||||
type: 'customNode',
|
||||
data: {
|
||||
label: 'dbt Transform',
|
||||
type: 'dbt_transform',
|
||||
yamlType: 'dbt_transform',
|
||||
params: { sel: 'gold', tgt: 'postgres' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [{ source: 'node-db', target: 'node-dbt' }];
|
||||
|
||||
const yamlOutput = generateCanvasYaml({
|
||||
nodes,
|
||||
edges,
|
||||
pipelineId: 'permits_pipeline',
|
||||
schedule: '0 */2 * * *',
|
||||
});
|
||||
|
||||
expect(yamlOutput).toContain('dag_id: permits_pipeline');
|
||||
expect(yamlOutput).toMatch(/schedule:\s*"?0 \*\/2 \* \* \*"?/);
|
||||
expect(yamlOutput).toContain('task_id: node-db');
|
||||
expect(yamlOutput).toContain('type: source_db');
|
||||
expect(yamlOutput).toContain('task_id: node-dbt');
|
||||
expect(yamlOutput).toContain('type: dbt_transform');
|
||||
expect(yamlOutput).toContain('dependencies:');
|
||||
expect(yamlOutput).toContain('- node-db');
|
||||
|
||||
// Parse back to confirm valid YAML syntax
|
||||
const parsed = yaml.load(yamlOutput);
|
||||
expect(parsed.dag_id).toBe('permits_pipeline');
|
||||
expect(parsed.tasks).toHaveLength(2);
|
||||
expect(parsed.tasks[1].dependencies).toContain('node-db');
|
||||
expect(parsed.tasks[0].config.table).toBe('permits');
|
||||
});
|
||||
|
||||
it('2. Maps disabled task to type: empty and enabled: false per Decision #1', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-disabled',
|
||||
type: 'customNode',
|
||||
data: {
|
||||
label: 'Disabled Task',
|
||||
type: 'ge_quality',
|
||||
yamlType: 'ge_quality',
|
||||
disabled: true,
|
||||
enabled: false,
|
||||
params: { suite: 'bronze_suite' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const yamlOutput = generateCanvasYaml({ nodes, edges: [], pipelineId: 'disabled_test' });
|
||||
const parsed = yaml.load(yamlOutput);
|
||||
|
||||
expect(parsed.tasks[0].type).toBe('empty');
|
||||
expect(parsed.tasks[0].enabled).toBe(false);
|
||||
expect(parsed.tasks[0].config).toEqual({});
|
||||
});
|
||||
|
||||
it('3. Strictly omits note nodes from YAML output per data-model.md', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'note-1',
|
||||
type: 'noteNode',
|
||||
data: { text: 'Architecture notes\nInternal details' },
|
||||
},
|
||||
{
|
||||
id: 'node-task',
|
||||
type: 'customNode',
|
||||
data: { label: 'Real Task', type: 'source_file', params: { path: 'data/' } },
|
||||
},
|
||||
];
|
||||
|
||||
const yamlOutput = generateCanvasYaml({ nodes, edges: [], pipelineId: 'notes_test' });
|
||||
const parsed = yaml.load(yamlOutput);
|
||||
|
||||
expect(parsed.tasks).toHaveLength(1);
|
||||
expect(parsed.tasks[0].task_id).toBe('node-task');
|
||||
expect(yamlOutput).not.toContain('note-1');
|
||||
expect(yamlOutput).not.toContain('Architecture notes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||
const backendPort = env.FASTAPI_PORT || 8000;
|
||||
const backendHost = env.FASTAPI_HOST === '0.0.0.0' ? 'localhost' : (env.FASTAPI_HOST || 'localhost');
|
||||
const frontendPort = parseInt(env.VITE_PORT || '5173', 10);
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
server: {
|
||||
port: frontendPort,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://${backendHost}:${backendPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/setupTests.js'
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user