feat(appliance): initial release of MERICA standalone appliance v1.0.0

This commit is contained in:
2026-10-21 17:16:46 +07:00
commit 851be0cda7
144 changed files with 31135 additions and 0 deletions
+21
View File
@@ -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`
+24
View File
@@ -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>
+4793
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+10
View File
@@ -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'
};
+11
View File
@@ -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>
);
}
+168
View File
@@ -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();
});
});
});
+79
View File
@@ -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();
});
});
+304
View File
@@ -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');
});
});
+92
View File
@@ -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);
});
});
+199
View File
@@ -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);
+176
View File
@@ -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();
});
});
+322
View File
@@ -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>
);
}
+434
View File
@@ -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
+206
View File
@@ -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>
);
}
+317
View File
@@ -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>
);
}
+136
View File
@@ -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);
+168
View File
@@ -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>
);
}
+113
View File
@@ -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();
});
});
});
+433
View File
@@ -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>
);
}
+172
View File
@@ -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>
);
}
+238
View File
@@ -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) |
+-------------------+----------------------------+-------------------------------------------+
```
+15
View File
@@ -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
+173
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+13
View File
@@ -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>
);
}
+290
View File
@@ -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 &amp; 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> &bull; 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>
);
}
+110
View File
@@ -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);
});
});
});
+282
View File
@@ -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>
);
}
+913
View File
@@ -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();
});
});
});
+464
View File
@@ -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)} &bull; {nodeCount} nodes
&bull; {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>
);
}
+156
View File
@@ -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();
});
});
+82
View File
@@ -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>
);
}
+45
View File
@@ -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;
}
};
}
+85
View File
@@ -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;
}
+153
View File
@@ -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 || {}) };
};
+24
View File
@@ -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');
+83
View File
@@ -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 },
};
});
}
+78
View File
@@ -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 });
});
});
});
+30
View File
@@ -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(/=+$/, '');
}
+389
View File
@@ -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,
};
}
+256
View File
@@ -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();
});
});
+139
View File
@@ -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');
});
});
+34
View File
@@ -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'
}
};
});