From bb92716a04ddd5e2f7107b8067cb6d2627f44de8 Mon Sep 17 00:00:00 2001
From: Mark Phelps <209477+markphelps@users.noreply.github.com>
Date: Wed, 20 Nov 2024 15:08:11 -0500
Subject: [PATCH 1/6] feat: redo ui to be per pipeline
Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com>
---
ui/package-lock.json | 128 +++++
ui/package.json | 5 +
ui/src/App.tsx | 38 +-
ui/src/app/layout.tsx | 19 +
ui/src/app/pipeline.tsx | 40 ++
ui/src/app/root.tsx | 12 +
ui/src/app/system.tsx | 67 ---
ui/src/components/header.tsx | 26 +
ui/src/components/pipeline.tsx | 70 ++-
ui/src/components/sidebar.tsx | 128 +++++
ui/src/components/ui/command.tsx | 143 ++++++
ui/src/components/ui/popover.tsx | 31 ++
ui/src/components/ui/separator.tsx | 26 +
ui/src/components/ui/sheet.tsx | 121 +++++
ui/src/components/ui/sidebar.tsx | 736 +++++++++++++++++++++++++++++
ui/src/components/ui/skeleton.tsx | 7 +
ui/src/components/ui/tooltip.tsx | 30 ++
ui/src/hooks/useMobile.ts | 9 +
ui/src/index.css | 18 +
ui/src/index.tsx | 11 +-
ui/src/store/hooks.ts | 5 +
ui/src/store/index.ts | 18 +
ui/src/store/pipelinesSlice.ts | 61 +++
ui/src/store/systemSlice.ts | 49 ++
24 files changed, 1685 insertions(+), 113 deletions(-)
create mode 100644 ui/src/app/layout.tsx
create mode 100644 ui/src/app/pipeline.tsx
create mode 100644 ui/src/app/root.tsx
delete mode 100644 ui/src/app/system.tsx
create mode 100644 ui/src/components/header.tsx
create mode 100644 ui/src/components/sidebar.tsx
create mode 100644 ui/src/components/ui/command.tsx
create mode 100644 ui/src/components/ui/popover.tsx
create mode 100644 ui/src/components/ui/separator.tsx
create mode 100644 ui/src/components/ui/sheet.tsx
create mode 100644 ui/src/components/ui/sidebar.tsx
create mode 100644 ui/src/components/ui/skeleton.tsx
create mode 100644 ui/src/components/ui/tooltip.tsx
create mode 100644 ui/src/hooks/useMobile.ts
create mode 100644 ui/src/store/hooks.ts
create mode 100644 ui/src/store/index.ts
create mode 100644 ui/src/store/pipelinesSlice.ts
create mode 100644 ui/src/store/systemSlice.ts
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 6d65889..5bc0fa4 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -12,17 +12,22 @@
"@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-router-dom": "^6.28.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
@@ -2379,6 +2384,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 +2533,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 +2793,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",
@@ -3242,6 +3313,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 +3902,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",
@@ -7316,6 +7414,36 @@
}
}
},
+ "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-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..472526c 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -34,17 +34,22 @@
"@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-router-dom": "^6.28.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index c9f89b6..fcf66b2 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,10 +1,44 @@
-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';
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ path: '/',
+ element:
+ },
+ {
+ path: 'pipelines/:pipelineId',
+ element:
+ }
+ ]
+ // errorElement: ,
+ }
+]);
export function App() {
+ const dispatch = useAppDispatch();
+ const pipelinesState = useAppSelector((state: RootState) => state.pipelines);
+
+ useEffect(() => {
+ dispatch(fetchSystem());
+ dispatch(fetchPipelines());
+ }, [dispatch]);
+
return (
-
+
);
}
diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx
new file mode 100644
index 0000000..9d0abde
--- /dev/null
+++ b/ui/src/app/layout.tsx
@@ -0,0 +1,19 @@
+import { SidebarProvider } from '@/components/ui/sidebar';
+import { Sidebar } from '@/components/sidebar';
+import { ThemeToggle } from '@/components/theme-toggle';
+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..f47b2ac
--- /dev/null
+++ b/ui/src/app/root.tsx
@@ -0,0 +1,12 @@
+export default function Root() {
+ return (
+
+
+
Welcome to Glu
+
+ Please 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/components/header.tsx b/ui/src/components/header.tsx
new file mode 100644
index 0000000..09642e8
--- /dev/null
+++ b/ui/src/components/header.tsx
@@ -0,0 +1,26 @@
+import { useAppSelector } from '@/store/hooks';
+import { RootState } from '@/store';
+import { ThemeToggle } from './theme-toggle';
+import { ChevronRight } from 'lucide-react';
+
+export function Header({ className }: HeaderProps) {
+ 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/pipeline.tsx b/ui/src/components/pipeline.tsx
index 0f5fd38..31eaf5d 100644
--- a/ui/src/components/pipeline.tsx
+++ b/ui/src/components/pipeline.tsx
@@ -51,49 +51,33 @@ export function Pipeline(props: { pipeline: PipelineType }) {
}, [nodes, edges, fitView]);
return (
-
-
-
- {pipeline.name}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx
new file mode 100644
index 0000000..9da433f
--- /dev/null
+++ b/ui/src/components/sidebar.tsx
@@ -0,0 +1,128 @@
+import { useAppSelector } from '@/store/hooks';
+import { RootState } from '@/store/index';
+import { Button } from '@/components/ui/button';
+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, WorkflowIcon, BookOpen, Github } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+
+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) => (
+ {
+ setValue(currentValue === value ? '' : currentValue);
+ setOpen(false);
+ navigate(`/pipelines/${currentValue}`);
+ }}
+ >
+
+ {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..cded491 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -37,6 +37,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 +81,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/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..a833fe8
--- /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.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 };
From 6f90cdeb4154dfe10cb97387218f7f4fcde7f2de Mon Sep 17 00:00:00 2001
From: Mark Phelps <209477+markphelps@users.noreply.github.com>
Date: Wed, 20 Nov 2024 15:21:05 -0500
Subject: [PATCH 2/6] chore: add link back to root
Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com>
---
ui/src/app/root.tsx | 8 +++-----
ui/src/components/sidebar.tsx | 12 +++++++-----
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/ui/src/app/root.tsx b/ui/src/app/root.tsx
index f47b2ac..8d9befb 100644
--- a/ui/src/app/root.tsx
+++ b/ui/src/app/root.tsx
@@ -1,11 +1,9 @@
export default function Root() {
return (
-
-
+
+
Welcome to Glu
-
- Please select a pipeline from the sidebar to get started
-
+
Select a pipeline from the sidebar to get started.
);
diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx
index 9da433f..bb73548 100644
--- a/ui/src/components/sidebar.tsx
+++ b/ui/src/components/sidebar.tsx
@@ -21,7 +21,7 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar';
import { Check, ChevronsUpDown, WorkflowIcon, BookOpen, Github } from 'lucide-react';
-import { useNavigate } from 'react-router-dom';
+import { Link, useNavigate } from 'react-router-dom';
export function Sidebar() {
const navigate = useNavigate();
@@ -34,10 +34,12 @@ export function Sidebar() {
-
-
-
Glu
-
+
+
+
+
Glu
+
+
From 96c92d4af05ee3c97b649d1ef13c526e4458abf8 Mon Sep 17 00:00:00 2001
From: Mark Phelps <209477+markphelps@users.noreply.github.com>
Date: Wed, 20 Nov 2024 16:29:35 -0500
Subject: [PATCH 3/6] chore: add stu; dynamic titlebar
Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com>
---
ui/.parcelrc | 4 +++
ui/package-lock.json | 55 ++++++++++++++++++++++++++++++++--
ui/package.json | 6 ++++
ui/public/favicon.ico | Bin 0 -> 936732 bytes
ui/src/App.tsx | 21 ++++++++++---
ui/src/assets/stu.png | Bin 0 -> 936732 bytes
ui/src/components/sidebar.tsx | 3 +-
7 files changed, 81 insertions(+), 8 deletions(-)
create mode 100644 ui/.parcelrc
create mode 100644 ui/public/favicon.ico
create mode 100644 ui/src/assets/stu.png
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 5bc0fa4..e2571f3 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -27,6 +27,7 @@
"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"
@@ -35,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",
@@ -42,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",
@@ -3112,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",
@@ -6830,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",
@@ -7261,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",
@@ -7330,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",
@@ -7444,6 +7485,14 @@
"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 472526c..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",
@@ -49,8 +51,12 @@
"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 0000000000000000000000000000000000000000..2a0834188f1e4c0ee1ed11f9d4b1fdb0390ea385
GIT binary patch
literal 936732
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