diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c09ea67..449d189 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: Go Tests
on:
push:
- branches: [ main ]
+ branches: [main]
pull_request:
- branches: [ main ]
+ branches: [main]
jobs:
test:
@@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.23'
+ go-version: "1.23"
check-latest: true
- name: Install dependencies
diff --git a/ui/.parcelrc b/ui/.parcelrc
new file mode 100644
index 0000000..55a9ca4
--- /dev/null
+++ b/ui/.parcelrc
@@ -0,0 +1,4 @@
+{
+ "extends": ["@parcel/config-default"],
+ "reporters": ["...", "parcel-reporter-static-files-copy"]
+}
\ No newline at end of file
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 6d65889..e2571f3 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -12,17 +12,23 @@
"@dagrejs/dagre": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
+ "@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@reduxjs/toolkit": "^2.3.0",
+ "@uidotdev/usehooks": "^2.4.1",
"@xyflow/react": "^12.3.4",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+ "cmdk": "^1.0.4",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-helmet": "^6.1.0",
+ "react-router-dom": "^6.28.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
@@ -30,6 +36,7 @@
"@eslint/js": "^9.14.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@types/react-helmet": "^6.1.11",
"buffer": "^6.0.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0",
@@ -37,6 +44,7 @@
"eslint-plugin-react": "^7.37.2",
"globals": "^15.11.0",
"parcel": "^2.12.0",
+ "parcel-reporter-static-files-copy": "^1.5.3",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
@@ -2379,6 +2387,42 @@
}
}
},
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
+ "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.0",
+ "@radix-ui/react-compose-refs": "1.1.0",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.1",
+ "@radix-ui/react-focus-guards": "1.1.1",
+ "@radix-ui/react-focus-scope": "1.1.0",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-popper": "1.2.0",
+ "@radix-ui/react-portal": "1.1.2",
+ "@radix-ui/react-presence": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.0",
+ "@radix-ui/react-slot": "1.1.0",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.6.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@@ -2492,6 +2536,28 @@
}
}
},
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz",
+ "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
@@ -2730,6 +2796,14 @@
}
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz",
+ "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@swc/core": {
"version": "1.7.42",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.42.tgz",
@@ -3041,6 +3115,15 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-helmet": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz",
+ "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@@ -3242,6 +3325,18 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@uidotdev/usehooks": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
+ "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
"node_modules/@xyflow/react": {
"version": "12.3.4",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.3.4.tgz",
@@ -3819,6 +3914,21 @@
"node": ">=6"
}
},
+ "node_modules/cmdk": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz",
+ "integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==",
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.2",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.0",
+ "use-sync-external-store": "^1.2.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -6732,6 +6842,18 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/parcel-reporter-static-files-copy": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/parcel-reporter-static-files-copy/-/parcel-reporter-static-files-copy-1.5.3.tgz",
+ "integrity": "sha512-Ukq2SyJYn3GFIPCLamXuQ+2t+0j54llujjOUoRjtmVvfsuGnJDEpMznADeIoKuQDvy0jpxtWzWkQvxqI/j+U4A==",
+ "dev": true,
+ "dependencies": {
+ "@parcel/plugin": "^2.0.0-beta.1"
+ },
+ "engines": {
+ "parcel": "^2.0.0-beta.1"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7163,7 +7285,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -7232,11 +7353,29 @@
"integrity": "sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==",
"dev": true
},
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
+ },
+ "node_modules/react-helmet": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
+ "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.7.2",
+ "react-fast-compare": "^3.1.1",
+ "react-side-effect": "^2.1.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.3.0"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-redux": {
"version": "9.1.2",
@@ -7316,6 +7455,44 @@
}
}
},
+ "node_modules/react-router": {
+ "version": "6.28.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz",
+ "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==",
+ "dependencies": {
+ "@remix-run/router": "1.21.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.28.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz",
+ "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==",
+ "dependencies": {
+ "@remix-run/router": "1.21.0",
+ "react-router": "6.28.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-side-effect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
+ "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
+ "peerDependencies": {
+ "react": "^16.3.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index 1682ca9..367149b 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -16,6 +16,7 @@
"@eslint/js": "^9.14.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@types/react-helmet": "^6.1.11",
"buffer": "^6.0.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0",
@@ -23,6 +24,7 @@
"eslint-plugin-react": "^7.37.2",
"globals": "^15.11.0",
"parcel": "^2.12.0",
+ "parcel-reporter-static-files-copy": "^1.5.3",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
@@ -34,18 +36,27 @@
"@dagrejs/dagre": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
+ "@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@reduxjs/toolkit": "^2.3.0",
+ "@uidotdev/usehooks": "^2.4.1",
"@xyflow/react": "^12.3.4",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+ "cmdk": "^1.0.4",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-helmet": "^6.1.0",
+ "react-router-dom": "^6.28.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
+ },
+ "staticFiles": {
+ "staticPath": "public"
}
}
diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico
new file mode 100644
index 0000000..2a08341
Binary files /dev/null and b/ui/public/favicon.ico differ
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index c9f89b6..b07825e 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,10 +1,57 @@
-import System from './app/system';
+import Layout from './app/layout';
+import Root from './app/root';
import { ThemeProvider } from './components/theme-provider';
+import { useEffect } from 'react';
+import { useAppDispatch, useAppSelector } from './store/hooks';
+import { fetchPipelines } from './store/pipelinesSlice';
+import { fetchSystem } from './store/systemSlice';
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+import Pipeline from './app/pipeline';
+import { RootState } from './store';
+import { Helmet } from 'react-helmet';
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ path: '/',
+ element:
+ },
+ {
+ path: 'pipelines/:pipelineId',
+ element:
+ }
+ ]
+ // errorElement: ,
+ }
+]);
export function App() {
+ const dispatch = useAppDispatch();
+ const system = useAppSelector((state: RootState) => state.system.data?.name);
+
+ useEffect(() => {
+ dispatch(fetchSystem());
+ dispatch(fetchPipelines());
+ }, [dispatch]);
+
+ let title = 'Glu';
+ if (system) {
+ title = `Glu - ${system}`;
+ }
+
return (
-
-
-
+ <>
+
+
+ {title}
+
+
+
+
+
+ >
);
}
diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx
new file mode 100644
index 0000000..1c895c1
--- /dev/null
+++ b/ui/src/app/layout.tsx
@@ -0,0 +1,20 @@
+import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
+import { Sidebar } from '@/components/sidebar';
+import { Outlet } from 'react-router-dom';
+import { Header } from '@/components/header';
+
+export default function Layout() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/app/pipeline.tsx b/ui/src/app/pipeline.tsx
new file mode 100644
index 0000000..5bdb4f2
--- /dev/null
+++ b/ui/src/app/pipeline.tsx
@@ -0,0 +1,40 @@
+import '@xyflow/react/dist/style.css';
+import { Pipeline as PipelineComponent } from '@/components/pipeline';
+import { ReactFlowProvider } from '@xyflow/react';
+import { useParams } from 'react-router-dom';
+import { useAppSelector } from '@/store/hooks';
+import { selectPipelineByName, setSelectedPipeline } from '@/store/pipelinesSlice';
+import { RootState } from '@/store';
+import { useEffect } from 'react';
+import { useAppDispatch } from '@/store/hooks';
+
+export default function Pipeline() {
+ const { pipelineId } = useParams();
+ const dispatch = useAppDispatch();
+
+ const { data: pipelines, loading } = useAppSelector((state: RootState) => state.pipelines);
+ const pipeline = useAppSelector((state: RootState) => selectPipelineByName(state, pipelineId));
+
+ useEffect(() => {
+ if (pipeline) {
+ dispatch(setSelectedPipeline(pipeline));
+ }
+ return () => {
+ dispatch(setSelectedPipeline(null));
+ };
+ }, [pipeline, dispatch]);
+
+ if (loading || !pipelines) {
+ return
Loading pipeline...
;
+ }
+
+ if (!pipeline) {
+ return Pipeline not found: {pipelineId}
;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/app/root.tsx b/ui/src/app/root.tsx
new file mode 100644
index 0000000..8d9befb
--- /dev/null
+++ b/ui/src/app/root.tsx
@@ -0,0 +1,10 @@
+export default function Root() {
+ return (
+
+
+
Welcome to Glu
+
Select a pipeline from the sidebar to get started.
+
+
+ );
+}
diff --git a/ui/src/app/system.tsx b/ui/src/app/system.tsx
deleted file mode 100644
index 57eb597..0000000
--- a/ui/src/app/system.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useEffect, useState } from 'react';
-import { WorkflowIcon } from 'lucide-react';
-import '@xyflow/react/dist/style.css';
-import { ThemeToggle } from '@/components/theme-toggle';
-import { getSystem, listPipelines } from '@/services/api';
-import { Badge } from '@/components/ui/badge';
-import { Pipeline } from '@/components/pipeline';
-import { System as SystemType } from '@/types/system';
-import { Pipeline as PipelineType } from '@/types/pipeline';
-import { ReactFlowProvider } from '@xyflow/react';
-
-export default function System() {
- const [system, setSystem] = useState();
- const [pipelines, setPipelines] = useState();
-
- useEffect(() => {
- const fetchData = async () => {
- setSystem(await getSystem());
- setPipelines(await listPipelines());
- };
- fetchData();
- }, []);
-
- return (
-
-
-
-
-
- {system && (
-
-
{system.name}
- {system.labels && (
- <>
- {Object.keys(system.labels).map((key: string) => {
- return (
-
- {key}: {(system.labels ?? {})[key]}
-
- );
- })}
- >
- )}
-
- )}
-
-
-
-
-
-
-
- {pipelines &&
- pipelines.map((pipeline: PipelineType) => (
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/ui/src/assets/stu.png b/ui/src/assets/stu.png
new file mode 100644
index 0000000..2a08341
Binary files /dev/null and b/ui/src/assets/stu.png differ
diff --git a/ui/src/components/header.tsx b/ui/src/components/header.tsx
new file mode 100644
index 0000000..ea10a7d
--- /dev/null
+++ b/ui/src/components/header.tsx
@@ -0,0 +1,31 @@
+import { useAppSelector } from '@/store/hooks';
+import { RootState } from '@/store';
+import { ThemeToggle } from './theme-toggle';
+import { ChevronRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { SidebarTrigger } from './ui/sidebar';
+
+export function Header({ className }: { className?: string }) {
+ const { data: system, loading } = useAppSelector((state: RootState) => state.system);
+ const selectedPipeline = useAppSelector((state: RootState) => state.pipelines.selectedPipeline);
+
+ return (
+
+
+
+
+
+ {loading ? 'Loading...' : system?.name}
+ {selectedPipeline && (
+ <>
+
+ {selectedPipeline.name}
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/node-panel.tsx b/ui/src/components/node-panel.tsx
new file mode 100644
index 0000000..f824de1
--- /dev/null
+++ b/ui/src/components/node-panel.tsx
@@ -0,0 +1,85 @@
+import { PhaseNode } from '@/types/flow';
+import { Badge } from './ui/badge';
+import { Package, GitBranch, ChevronDown, ChevronUp } from 'lucide-react';
+import { getLabelColor } from '@/lib/utils';
+import { Button } from './ui/button';
+import { cn } from '@/lib/utils';
+
+interface NodePanelProps {
+ node: PhaseNode | null;
+ isExpanded: boolean;
+ onToggle: () => void;
+}
+
+export function NodePanel({ node, isExpanded, onToggle }: NodePanelProps) {
+ if (!node) return null;
+
+ const getIcon = () => {
+ switch (node.data.source.name ?? '') {
+ case 'oci':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+ {getIcon()}
+
{node.data.name}
+
+
+
+
+
+
+
+
Details
+
+
+ Pipeline:
+ {node.data.pipeline}
+
+
+ Depends on:
+ {node.data.depends_on || 'None'}
+
+
+ Digest:
+ {node.data.digest}
+
+
+
+
+
+
+ {node.data.labels && Object.keys(node.data.labels).length > 0 && (
+
+
Labels
+
+ {node.data.labels &&
+ Object.entries(node.data.labels).map(([key, value]) => (
+
+ {key}: {value}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/components/node.tsx b/ui/src/components/node.tsx
index 752f036..f6cf834 100644
--- a/ui/src/components/node.tsx
+++ b/ui/src/components/node.tsx
@@ -15,6 +15,7 @@ import {
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { ANNOTATION_OCI_IMAGE_URL } from '@/types/metadata';
+import { getLabelColor } from '@/lib/utils';
const PhaseNode = ({ data }: NodeProps) => {
const getIcon = () => {
@@ -34,7 +35,7 @@ const PhaseNode = ({ data }: NodeProps) => {
};
return (
-
+
@@ -119,24 +120,4 @@ const PhaseNode = ({ data }: NodeProps
) => {
);
};
-function getLabelColor(key: string, value: string): string {
- const hash = `${key}:${value}`.split('').reduce((acc, char) => {
- return char.charCodeAt(0) + ((acc << 5) - acc);
- }, 0);
-
- const colors = [
- 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900 dark:hover:bg-red-800 dark:text-red-200',
- 'bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 dark:text-blue-200',
- 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 dark:text-green-200',
- 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900 dark:hover:bg-yellow-800 dark:text-yellow-200',
- 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900 dark:hover:bg-purple-800 dark:text-purple-200',
- 'bg-pink-100 text-pink-800 hover:bg-pink-200 dark:bg-pink-900 dark:hover:bg-pink-800 dark:text-pink-200',
- 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 dark:text-indigo-200',
- 'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900 dark:hover:bg-orange-800 dark:text-orange-200',
- 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-900 dark:hover:bg-gray-800 dark:text-gray-200'
- ];
-
- return colors[Math.abs(hash) % colors.length];
-}
-
export { PhaseNode };
diff --git a/ui/src/components/pipeline.tsx b/ui/src/components/pipeline.tsx
index 0f5fd38..f13da0c 100644
--- a/ui/src/components/pipeline.tsx
+++ b/ui/src/components/pipeline.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import {
+ Node,
ReactFlow,
Controls,
Background,
@@ -15,8 +16,7 @@ import { PhaseNode as PhaseNodeComponent } from '@/components/node';
import { Pipeline as PipelineType } from '@/types/pipeline';
import { FlowPipeline, PipelineEdge, PhaseNode, PipelineNode } from '@/types/flow';
import Dagre from '@dagrejs/dagre';
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
-import { ChevronDown } from 'lucide-react';
+import { NodePanel } from '@/components/node-panel';
const nodeTypes = {
phase: PhaseNodeComponent
@@ -25,7 +25,8 @@ const nodeTypes = {
export function Pipeline(props: { pipeline: PipelineType }) {
const { theme } = useTheme();
const { fitView, getNodes, getEdges } = useReactFlow();
- const [isOpen, setIsOpen] = useState(true);
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [isPanelExpanded, setIsPanelExpanded] = useState(true);
const { pipeline } = props;
const { nodes: initNodes, edges: initEdges } = getElements(pipeline);
@@ -50,50 +51,47 @@ export function Pipeline(props: { pipeline: PipelineType }) {
return () => window.removeEventListener('resize', handleResize);
}, [nodes, edges, fitView]);
+ const onNodeClick = (event: React.MouseEvent, node: Node) => {
+ setSelectedNode(node as PhaseNode);
+ };
+
return (
-
-
-
- {pipeline.name}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
setIsPanelExpanded(!isPanelExpanded)}
+ />
+
);
}
diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx
new file mode 100644
index 0000000..3f12387
--- /dev/null
+++ b/ui/src/components/sidebar.tsx
@@ -0,0 +1,133 @@
+import { useAppSelector } from '@/store/hooks';
+import { RootState } from '@/store/index';
+import { Button } from '@/components/ui/button';
+import stu from '@/assets/stu.png';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+import { useState } from 'react';
+import {
+ Sidebar as SidebarComponent,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuItem
+} from '@/components/ui/sidebar';
+import { Check, ChevronsUpDown, BookOpen, Github } from 'lucide-react';
+import { Link, useNavigate } from 'react-router-dom';
+import { Pipeline } from '@/types/pipeline';
+
+export function Sidebar() {
+ const navigate = useNavigate();
+ const [open, setOpen] = useState(false);
+ const [value, setValue] = useState('');
+ const { data: pipelines, loading } = useAppSelector((state: RootState) => state.pipelines);
+
+ return (
+
+
+
+
+
+
+
+
Glu
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No pipeline found.
+
+ {loading ? (
+ Loading pipelines...
+ ) : (
+ pipelines?.map((pipeline: Pipeline) => (
+ {
+ setValue(currentValue === value ? '' : currentValue);
+ setOpen(false);
+ navigate(`/pipelines/${currentValue}`);
+ }}
+ className="truncate"
+ >
+
+ {pipeline.name}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx
new file mode 100644
index 0000000..2ee33a0
--- /dev/null
+++ b/ui/src/components/ui/command.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import * as React from 'react';
+import { type DialogProps } from '@radix-ui/react-dialog';
+import { Command as CommandPrimitive } from 'cmdk';
+import { Search } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+const CommandDialog = ({ children, ...props }: DialogProps) => {
+ return (
+
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+CommandShortcut.displayName = 'CommandShortcut';
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator
+};
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx
new file mode 100644
index 0000000..f224d5e
--- /dev/null
+++ b/ui/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import * as React from 'react';
+import * as PopoverPrimitive from '@radix-ui/react-popover';
+
+import { cn } from '@/lib/utils';
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/ui/src/components/ui/separator.tsx b/ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..89b9167
--- /dev/null
+++ b/ui/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import * as React from 'react';
+import * as SeparatorPrimitive from '@radix-ui/react-separator';
+
+import { cn } from '@/lib/utils';
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
+
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..9b07382
--- /dev/null
+++ b/ui/src/components/ui/sheet.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import * as React from 'react';
+import * as SheetPrimitive from '@radix-ui/react-dialog';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { X } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+ {
+ variants: {
+ side: {
+ top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
+ bottom:
+ 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
+ left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
+ right:
+ 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
+ }
+ },
+ defaultVariants: {
+ side: 'right'
+ }
+ }
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = 'right', className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = 'SheetHeader';
+
+const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = 'SheetFooter';
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription
+};
diff --git a/ui/src/components/ui/sidebar.tsx b/ui/src/components/ui/sidebar.tsx
new file mode 100644
index 0000000..b274bb2
--- /dev/null
+++ b/ui/src/components/ui/sidebar.tsx
@@ -0,0 +1,736 @@
+'use client';
+
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { VariantProps, cva } from 'class-variance-authority';
+import { PanelLeft } from 'lucide-react';
+
+import { useIsMobile } from '@/hooks/useMobile';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Separator } from '@/components/ui/separator';
+import { Sheet, SheetContent } from '@/components/ui/sheet';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+
+const SIDEBAR_COOKIE_NAME = 'sidebar:state';
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = '16rem';
+const SIDEBAR_WIDTH_MOBILE = '18rem';
+const SIDEBAR_WIDTH_ICON = '3rem';
+const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
+
+type SidebarContext = {
+ state: 'expanded' | 'collapsed';
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error('useSidebar must be used within a SidebarProvider.');
+ }
+
+ return context;
+}
+
+const SidebarProvider = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }
+>(
+ (
+ {
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === 'function' ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open]
+ );
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? 'expanded' : 'collapsed';
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+);
+SidebarProvider.displayName = 'SidebarProvider';
+
+const Sidebar = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ side?: 'left' | 'right';
+ variant?: 'sidebar' | 'floating' | 'inset';
+ collapsible?: 'offcanvas' | 'icon' | 'none';
+ }
+>(
+ (
+ {
+ side = 'left',
+ variant = 'sidebar',
+ collapsible = 'offcanvas',
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === 'none') {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+ }
+);
+Sidebar.displayName = 'Sidebar';
+
+const SidebarTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, onClick, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+});
+SidebarTrigger.displayName = 'SidebarTrigger';
+
+const SidebarRail = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+ }
+);
+SidebarRail.displayName = 'SidebarRail';
+
+const SidebarInset = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarInset.displayName = 'SidebarInset';
+
+const SidebarInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarInput.displayName = 'SidebarInput';
+
+const SidebarHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarHeader.displayName = 'SidebarHeader';
+
+const SidebarFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarFooter.displayName = 'SidebarFooter';
+
+const SidebarSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarSeparator.displayName = 'SidebarSeparator';
+
+const SidebarContent = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarContent.displayName = 'SidebarContent';
+
+const SidebarGroup = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarGroup.displayName = 'SidebarGroup';
+
+const SidebarGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'div';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupLabel.displayName = 'SidebarGroupLabel';
+
+const SidebarGroupAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 after:md:hidden',
+ 'group-data-[collapsible=icon]:hidden',
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupAction.displayName = 'SidebarGroupAction';
+
+const SidebarGroupContent = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarGroupContent.displayName = 'SidebarGroupContent';
+
+const SidebarMenu = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenu.displayName = 'SidebarMenu';
+
+const SidebarMenuItem = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenuItem.displayName = 'SidebarMenuItem';
+
+const sidebarMenuButtonVariants = cva(
+ 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
+ {
+ variants: {
+ variant: {
+ default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
+ outline:
+ 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]'
+ },
+ size: {
+ default: 'h-8 text-sm',
+ sm: 'h-7 text-xs',
+ lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0'
+ }
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default'
+ }
+ }
+);
+
+const SidebarMenuButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+ } & VariantProps
+>(
+ (
+ {
+ asChild = false,
+ isActive = false,
+ variant = 'default',
+ size = 'default',
+ tooltip,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const Comp = asChild ? Slot : 'button';
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === 'string') {
+ tooltip = {
+ children: tooltip
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+ }
+);
+SidebarMenuButton.displayName = 'SidebarMenuButton';
+
+const SidebarMenuAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+ }
+>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 after:md:hidden',
+ 'peer-data-[size=sm]/menu-button:top-1',
+ 'peer-data-[size=default]/menu-button:top-1.5',
+ 'peer-data-[size=lg]/menu-button:top-2.5',
+ 'group-data-[collapsible=icon]:hidden',
+ showOnHover &&
+ 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuAction.displayName = 'SidebarMenuAction';
+
+const SidebarMenuBadge = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenuBadge.displayName = 'SidebarMenuBadge';
+
+const SidebarMenuSkeleton = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ showIcon?: boolean;
+ }
+>(({ className, showIcon = false, ...props }, ref) => {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ }, []);
+
+ return (
+
+ {showIcon && }
+
+
+ );
+});
+SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
+
+const SidebarMenuSub = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenuSub.displayName = 'SidebarMenuSub';
+
+const SidebarMenuSubItem = React.forwardRef>(
+ ({ ...props }, ref) =>
+);
+SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
+
+const SidebarMenuSubButton = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentProps<'a'> & {
+ asChild?: boolean;
+ size?: 'sm' | 'md';
+ isActive?: boolean;
+ }
+>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'a';
+
+ return (
+ svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
+ 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
+ size === 'sm' && 'text-xs',
+ size === 'md' && 'text-sm',
+ 'group-data-[collapsible=icon]:hidden',
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar
+};
diff --git a/ui/src/components/ui/skeleton.tsx b/ui/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..f0a2781
--- /dev/null
+++ b/ui/src/components/ui/skeleton.tsx
@@ -0,0 +1,7 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+export { Skeleton };
diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..e015b75
--- /dev/null
+++ b/ui/src/components/ui/tooltip.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import * as React from 'react';
+import * as TooltipPrimitive from '@radix-ui/react-tooltip';
+
+import { cn } from '@/lib/utils';
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/ui/src/hooks/useMobile.ts b/ui/src/hooks/useMobile.ts
new file mode 100644
index 0000000..e956a1b
--- /dev/null
+++ b/ui/src/hooks/useMobile.ts
@@ -0,0 +1,9 @@
+import { useMediaQuery } from '@uidotdev/usehooks';
+
+/**
+ * Hook that returns true if the current viewport is considered mobile-sized (< 768px)
+ * @returns boolean indicating if the device is mobile-sized
+ */
+export const useIsMobile = (): boolean => {
+ return useMediaQuery('only screen and (max-width: 768px)');
+};
diff --git a/ui/src/index.css b/ui/src/index.css
index 9769e7b..ba168fe 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -5,6 +5,7 @@
.react-flow__handle {
opacity: 0;
}
+
@layer base {
:root {
--background: 0 0% 100%;
@@ -37,6 +38,15 @@
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
+
+ --sidebar-background: 0 0% 98%;
+ --sidebar-foreground: 240 5.3% 26.1%;
+ --sidebar-primary: 240 5.9% 10%;
+ --sidebar-primary-foreground: 0 0% 98%;
+ --sidebar-accent: 240 4.8% 95.9%;
+ --sidebar-accent-foreground: 240 5.9% 10%;
+ --sidebar-border: 220 13% 91%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
@@ -72,6 +82,15 @@
--radius: 0.5rem;
color-scheme: dark;
+
+ --sidebar-background: 240 5.9% 10%;
+ --sidebar-foreground: 240 4.8% 95.9%;
+ --sidebar-primary: 224.3 76.3% 48%;
+ --sidebar-primary-foreground: 0 0% 100%;
+ --sidebar-accent: 240 3.7% 15.9%;
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
+ --sidebar-border: 240 3.7% 15.9%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
}
}
diff --git a/ui/src/index.tsx b/ui/src/index.tsx
index 8b14c02..a378e68 100644
--- a/ui/src/index.tsx
+++ b/ui/src/index.tsx
@@ -1,6 +1,15 @@
import { createRoot } from 'react-dom/client';
+import { Provider } from 'react-redux';
import { App } from './App';
+import { store } from './store';
+import React from 'react';
const container = document.getElementById('app') as HTMLElement;
const root = createRoot(container);
-root.render();
+root.render(
+ //
+
+
+
+ //
+);
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index 2819a83..3b53dd4 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -4,3 +4,23 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+export function getLabelColor(key: string, value: string): string {
+ const hash = `${key}:${value}`.split('').reduce((acc, char) => {
+ return char.charCodeAt(0) + ((acc << 5) - acc);
+ }, 0);
+
+ const colors = [
+ 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900 dark:hover:bg-red-800 dark:text-red-200',
+ 'bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 dark:text-blue-200',
+ 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 dark:text-green-200',
+ 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900 dark:hover:bg-yellow-800 dark:text-yellow-200',
+ 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900 dark:hover:bg-purple-800 dark:text-purple-200',
+ 'bg-pink-100 text-pink-800 hover:bg-pink-200 dark:bg-pink-900 dark:hover:bg-pink-800 dark:text-pink-200',
+ 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 dark:text-indigo-200',
+ 'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900 dark:hover:bg-orange-800 dark:text-orange-200',
+ 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-900 dark:hover:bg-gray-800 dark:text-gray-200'
+ ];
+
+ return colors[Math.abs(hash) % colors.length];
+}
diff --git a/ui/src/store/hooks.ts b/ui/src/store/hooks.ts
new file mode 100644
index 0000000..81bfc17
--- /dev/null
+++ b/ui/src/store/hooks.ts
@@ -0,0 +1,5 @@
+import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
+import type { RootState, AppDispatch } from './index';
+
+export const useAppDispatch = () => useDispatch();
+export const useAppSelector: TypedUseSelectorHook = useSelector;
diff --git a/ui/src/store/index.ts b/ui/src/store/index.ts
new file mode 100644
index 0000000..26a26f7
--- /dev/null
+++ b/ui/src/store/index.ts
@@ -0,0 +1,18 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { systemSlice, SystemState } from './systemSlice';
+import { pipelinesSlice, PipelinesState } from './pipelinesSlice';
+
+export interface StoreState {
+ system: SystemState;
+ pipelines: PipelinesState;
+}
+
+export const store = configureStore({
+ reducer: {
+ system: systemSlice.reducer,
+ pipelines: pipelinesSlice.reducer
+ }
+});
+
+export type RootState = StoreState;
+export type AppDispatch = typeof store.dispatch;
diff --git a/ui/src/store/pipelinesSlice.ts b/ui/src/store/pipelinesSlice.ts
new file mode 100644
index 0000000..7e562ba
--- /dev/null
+++ b/ui/src/store/pipelinesSlice.ts
@@ -0,0 +1,61 @@
+import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
+import { listPipelines } from '@/services/api';
+import { RootState } from '.';
+import { Pipeline } from '@/types/pipeline';
+
+export const fetchPipelines = createAsyncThunk('pipelines/fetchPipelines', async () => {
+ return await listPipelines();
+});
+
+interface PipelinesState {
+ data: Pipeline[] | null;
+ loading: boolean;
+ error: string | null;
+ selectedPipeline: Pipeline | null;
+}
+
+const initialState: PipelinesState = {
+ data: null,
+ loading: false,
+ error: null,
+ selectedPipeline: null
+};
+
+export const pipelinesSlice = createSlice({
+ name: 'pipelines',
+ initialState,
+ reducers: {
+ setSelectedPipeline: (state, action: PayloadAction) => {
+ state.selectedPipeline = action.payload;
+ }
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchPipelines.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(fetchPipelines.fulfilled, (state, action) => {
+ state.loading = false;
+ state.data = action.payload;
+ })
+ .addCase(fetchPipelines.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.error.message || 'Failed to fetch pipelines';
+ });
+ }
+});
+
+export const selectPipelineByName = (state: RootState, name?: string): Pipeline | undefined => {
+ if (!name) return undefined;
+ return state.pipelines.data?.find((pipeline: Pipeline) => pipeline.name === name);
+};
+
+export const selectSelectedPipeline = (state: RootState): Pipeline | null =>
+ state.pipelines.selectedPipeline;
+
+export const { setSelectedPipeline } = pipelinesSlice.actions;
+
+export default pipelinesSlice.reducer;
+
+export type { PipelinesState };
diff --git a/ui/src/store/systemSlice.ts b/ui/src/store/systemSlice.ts
new file mode 100644
index 0000000..4ba49e8
--- /dev/null
+++ b/ui/src/store/systemSlice.ts
@@ -0,0 +1,49 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
+import { getSystem } from '@/services/api';
+
+export const fetchSystem = createAsyncThunk('system/fetchSystem', async () => {
+ return await getSystem();
+});
+
+interface SystemType {
+ name: string;
+ labels?: Record;
+ // add other system properties you need
+}
+
+interface SystemState {
+ data: SystemType | null;
+ loading: boolean;
+ error: string | null;
+}
+
+const initialState: SystemState = {
+ data: null,
+ loading: false,
+ error: null
+};
+
+export const systemSlice = createSlice({
+ name: 'system',
+ initialState,
+ reducers: {},
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchSystem.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(fetchSystem.fulfilled, (state, action) => {
+ state.loading = false;
+ state.data = action.payload;
+ })
+ .addCase(fetchSystem.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.error.message || 'Failed to fetch system';
+ });
+ }
+});
+
+export default systemSlice.reducer;
+
+export type { SystemType, SystemState };