feat: project init

This commit is contained in:
Khairul Hidayat 2024-08-14 15:36:25 +07:00
commit b0e5d53ee0
40 changed files with 4800 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:3903

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env*
!.env.example

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

26
eslint.config.js Normal file
View File

@ -0,0 +1,26 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config({
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
ignores: ['dist'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
})

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="pastel">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "garage-webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.51.23",
"clsx": "^2.1.1",
"lucide-react": "^0.427.0",
"react": "^18.3.1",
"react-daisyui": "^5.0.3",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
"react-router-dom": "^6.26.0",
"react-select": "^5.8.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@types/node": "^22.2.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.10",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.9",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.0",
"vite": "^5.4.0"
}
}

3093
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

21
src/app/app.tsx Normal file
View File

@ -0,0 +1,21 @@
import { PageContextProvider } from "@/context/page-context";
import Router from "./router";
import "./styles.css";
import { Toaster } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
const App = () => {
const [queryClient] = useState(() => new QueryClient());
return (
<PageContextProvider>
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
<Toaster richColors />
</PageContextProvider>
);
};
export default App;

32
src/app/router.tsx Normal file
View File

@ -0,0 +1,32 @@
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import AuthLayout from "@/components/layouts/auth-layout";
import MainLayout from "@/components/layouts/main-layout";
import ClusterPage from "@/pages/cluster/page";
import HomePage from "@/pages/home/page";
const router = createBrowserRouter([
{
path: "/auth",
Component: AuthLayout,
},
{
path: "/",
Component: MainLayout,
children: [
{
index: true,
Component: HomePage,
},
{
path: "cluster",
Component: ClusterPage,
},
],
},
]);
const Router = () => {
return <RouterProvider router={router} />;
};
export default Router;

29
src/app/styles.css Normal file
View File

@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body {
@apply bg-base-200;
}
}
@layer components {
.card {
@apply bg-base-100;
}
.dropdown-content {
@apply z-10;
}
.table.table-sm th,
.table.table-xs th {
@apply py-4;
}
.modal-backdrop {
@apply border-none outline-none;
}
}

View File

@ -0,0 +1,5 @@
const AuthLayout = () => {
return <div>AuthLayout</div>;
};
export default AuthLayout;

View File

@ -0,0 +1,64 @@
import { PageContext } from "@/context/page-context";
import { useContext } from "react";
import { Link, Outlet } from "react-router-dom";
import { Menu } from "react-daisyui";
import { HardDrive, LayoutDashboard } from "lucide-react";
const MainLayout = () => {
return (
<div className="flex flex-row items-stretch h-screen overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-4 md:p-8">
<div className="container max-w-5xl">
<Outlet />
</div>
</main>
</div>
</div>
);
};
const Sidebar = () => {
return (
<aside className="bg-base-100 border-r border-base-300/30 w-[220px] overflow-y-auto">
<div className="p-4">
<img
src="https://garagehq.deuxfleurs.fr/images/garage-logo.svg"
alt="logo"
className="w-full max-w-[100px] mx-auto"
/>
<p className="text-sm font-medium text-center">WebUI</p>
</div>
<Menu className="gap-y-1">
<Menu.Item>
<Link to="/">
<LayoutDashboard />
<p>Dashboard</p>
</Link>
</Menu.Item>
<Menu.Item>
<Link to="/cluster">
<HardDrive />
<p>Cluster</p>
</Link>
</Menu.Item>
</Menu>
</aside>
);
};
const Header = () => {
const page = useContext(PageContext);
return (
<header className="bg-base-100 p-4 md:p-8 md:py-6 flex flex-row items-center gap-4">
<h1 className="text-2xl font-medium">{page?.title || "Dashboard"}</h1>
</header>
);
};
export default MainLayout;

View File

@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
import React from "react";
type Props = React.ComponentPropsWithoutRef<"code">;
const Code = ({ className, ...props }: Props) => {
return (
<code
className={cn(
"border border-base-content/20 px-4 py-3 rounded-lg font-mono block",
className
)}
{...props}
/>
);
};
export default Code;

View File

@ -0,0 +1,58 @@
import { cn } from "@/lib/utils";
import { ComponentPropsWithoutRef } from "react";
import {
FieldValues,
UseFormReturn,
Controller,
ControllerRenderProps,
ControllerFieldState,
FieldPath,
UseFormStateReturn,
} from "react-hook-form";
type FormControlProps<
T extends FieldValues,
U extends FieldPath<T> = FieldPath<T>
> = ComponentPropsWithoutRef<"div"> & {
form: UseFormReturn<T>;
name: FieldPath<T>;
title?: string;
render: (
field: ControllerRenderProps<T, U>,
fieldProps: {
fieldState: ControllerFieldState;
formState: UseFormStateReturn<T>;
}
) => React.ReactElement;
};
const FormControl = <T extends FieldValues>({
form,
name,
title,
className,
render,
}: FormControlProps<T>) => {
return (
<Controller
control={form.control}
name={name}
render={({ field, fieldState, formState }) => (
<div className={cn("form-control", className)}>
{title ? <label className="label label-text">{title}</label> : null}
{render(field, { fieldState, formState })}
{fieldState.error ? (
<label className="label label-text text-error">
{fieldState.error.message}
</label>
) : null}
</div>
)}
/>
);
};
export default FormControl;

View File

@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
import { ComponentPropsWithoutRef } from "react";
import BaseSelect from "react-select";
import Creatable from "react-select/creatable";
type Props = ComponentPropsWithoutRef<typeof BaseSelect> & {
creatable?: boolean;
onCreateOption?: (inputValue: string) => void;
};
const Select = ({ creatable, ...props }: Props) => {
const Comp = creatable ? Creatable : BaseSelect;
return (
<Comp
unstyled
classNames={{
control: (p) =>
cn(
"bg-base-100 px-4 rounded-btn border text-base-content border-base-content/20 h-12",
p.isMulti && "py-2 flex flex-row gap-2 items-center flex-wrap",
p.isMulti && p.hasValue ? "pt-1 px-2 h-auto" : null
),
input: () => "text-base-content",
menuList: () =>
"bg-base-100 rounded-btn border border-base-content/20 p-0",
noOptionsMessage: () => "my-4",
option: (p) =>
cn(
"text-base-content bg-base-100 hover:bg-base-300 px-4 py-3 !cursor-pointer",
p.isSelected || p.isFocused ? "bg-base-200" : null
),
singleValue: () => "text-base-content",
multiValue: () =>
"bg-base-300/80 text-base-content/80 pl-2 mt-1 mr-1 flex flex-row items-center",
multiValueRemove: () =>
"px-2 py-2 hover:bg-primary hover:text-primary-content",
}}
noOptionsMessage={() =>
creatable ? "Type something to add..." : undefined
}
{...props}
/>
);
};
export default Select;

View File

@ -0,0 +1,48 @@
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
} from "react";
type PageContextValues = {
title?: string | null;
setTitle: (title?: string | null) => void;
};
export const PageContext = createContext<PageContextValues | null>(null);
export const PageContextProvider = ({ children }: PropsWithChildren) => {
const [title, setTitle] = useState<PageContextValues["title"]>(null);
const contextValues = {
title,
setTitle,
};
return <PageContext.Provider children={children} value={contextValues} />;
};
type PageProps = {
title?: string;
};
const Page = ({ title }: PageProps) => {
const context = useContext(PageContext);
if (!context) {
throw new Error("Page component must be used within a PageContextProvider");
}
useEffect(() => {
context.setTitle(title);
return () => {
context.setTitle(null);
};
}, [title, context.setTitle]);
return null;
};
export default Page;

69
src/lib/api.ts Normal file
View File

@ -0,0 +1,69 @@
type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
params?: Record<string, any>;
headers?: Record<string, string>;
body?: any;
};
const ADMIN_KEY = "E1tDBf4mhc/XMHq1YJkDE6N1j3AZG9dRWR+vDDTyASk=";
const api = {
async fetch<T = any>(url: string, options?: Partial<FetchOptions>) {
const headers: Record<string, string> = {};
const _url = new URL("/api" + url, window.location.origin);
if (options?.params) {
Object.entries(options.params).forEach(([key, value]) => {
_url.searchParams.set(key, String(value));
});
}
if (
typeof options?.body === "object" &&
!(options.body instanceof FormData)
) {
options.body = JSON.stringify(options.body);
headers["Content-Type"] = "application/json";
}
if (ADMIN_KEY) {
headers["Authorization"] = `Bearer ${ADMIN_KEY}`;
}
const res = await fetch(_url, {
...options,
headers: { ...headers, ...(options?.headers || {}) },
});
if (!res.ok) {
throw new Error(res.statusText);
}
const isJson = res.headers
.get("Content-Type")
?.includes("application/json");
if (isJson) {
const json = (await res.json()) as T;
return json;
}
const text = await res.text();
return text as unknown as T;
},
async get<T = any>(url: string, options?: Partial<FetchOptions>) {
return this.fetch<T>(url, {
...options,
method: "GET",
});
},
async post<T = any>(url: string, options?: Partial<FetchOptions>) {
return this.fetch<T>(url, {
...options,
method: "POST",
});
},
};
export default api;

15
src/lib/disclosure.ts Normal file
View File

@ -0,0 +1,15 @@
import { createStore, useStore } from "zustand";
export const createDisclosure = <T = any>() => {
const store = createStore(() => ({
data: undefined as T | null,
isOpen: false,
}));
return {
store,
use: () => useStore(store),
open: (data?: T | null) => store.setState({ isOpen: true, data }),
close: () => store.setState({ isOpen: false }),
};
};

25
src/lib/utils.ts Normal file
View File

@ -0,0 +1,25 @@
import clsx from "clsx";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
export const cn = (...args: any[]) => {
return twMerge(clsx(...args));
};
export const ucfirst = (text?: string | null) => {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : null;
};
export const readableBytes = (bytes?: number | null, divider = 1024) => {
if (bytes == null || Number.isNaN(bytes)) return "n/a";
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "n/a";
const i = Math.floor(Math.log(bytes) / Math.log(divider));
return `${(bytes / Math.pow(divider, i)).toFixed(1)} ${sizes[i]}`;
};
export const handleError = (err: unknown) => {
toast.error((err as Error)?.message || "Unknown error");
};

9
src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './app/app.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,240 @@
import { Button, Checkbox, Input, Modal, Select } from "react-daisyui";
import { useAssignNode, useClusterLayout, useClusterStatus } from "../hooks";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
AssignNodeSchema,
assignNodeSchema,
capacityUnits,
calculateCapacity,
parseCapacity,
} from "../schema";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { assignNodeDialog } from "../stores";
import FormControl from "@/components/ui/form-control";
import Select2 from "@/components/ui/select";
const defaultValues: AssignNodeSchema = {
nodeId: "",
zone: "",
capacity: 1,
capacityUnit: "GB",
isGateway: false,
tags: [],
};
const AssignNodeDialog = () => {
const { isOpen, data } = assignNodeDialog.use();
const { data: cluster } = useClusterStatus();
const { data: layout } = useClusterLayout();
const queryClient = useQueryClient();
const form = useForm<AssignNodeSchema>({
resolver: zodResolver(assignNodeSchema),
defaultValues,
});
const isGateway = useWatch({ control: form.control, name: "isGateway" });
const assignNode = useAssignNode({
onSuccess() {
form.reset();
toast.success("Node staged for assignment!");
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["layout"] });
assignNodeDialog.close();
},
onError(err) {
toast.error(err?.message || "Unknown error");
},
});
useEffect(() => {
if (data) {
const isGateway = data.capacity === null;
const cap = parseCapacity(data.capacity);
form.reset({
...defaultValues,
...data,
capacity: cap.value,
capacityUnit: cap.unit,
isGateway,
});
}
}, [data]);
const zoneList = useMemo(() => {
const list = cluster?.nodes
.flatMap((i) => {
const role = layout?.roles.find((role) => role.id === i.id);
const staged = layout?.stagedRoleChanges.find(
(role) => role.id === i.id
);
return staged?.zone || role?.zone || i.role?.zone;
})
.filter(Boolean);
return [...new Set(list)].map((zone) => ({
label: zone,
value: zone,
}));
}, [cluster, layout]);
const tagsList = useMemo(() => {
const list = cluster?.nodes
.flatMap((i) => {
const role = layout?.roles.find((role) => role.id === i.id);
const staged = layout?.stagedRoleChanges.find(
(role) => role.id === i.id
);
return staged?.tags || role?.tags || i.role?.tags;
})
.filter(Boolean);
return [...new Set(list)].map((tag) => ({
label: tag,
value: tag,
}));
}, [cluster, layout]);
const onSubmit = form.handleSubmit((values) => {
const capacity = !values.isGateway
? calculateCapacity(values.capacity, values.capacityUnit)
: null;
const data = {
id: values.nodeId,
zone: values.zone,
capacity,
tags: values.tags,
};
assignNode.mutate(data);
});
return (
<Modal open={isOpen}>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(e);
}}
>
<Modal.Header>Assign Node</Modal.Header>
<Modal.Body>
<div className="form-control">
<label className="label label-text">Node ID:</label>
<Input
placeholder="..."
className="w-full"
{...form.register("nodeId")}
readOnly
/>
</div>
<FormControl
form={form}
name="zone"
title="Zone"
className="mt-2"
render={(field) => (
<Select2
creatable
{...field}
value={
field.value
? { label: field.value, value: field.value }
: null
}
options={zoneList}
onChange={({ value }: any) => field.onChange(value)}
/>
)}
/>
<div className="flex items-center justify-between mt-2">
<label className="label label-text flex-1 truncate">Capacity</label>
<label className="label label-text cursor-pointer">
<Controller
control={form.control}
name="isGateway"
render={({ field }) => (
<Checkbox
{...(field as any)}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
className="mr-2"
/>
)}
/>
Gateway
</label>
</div>
{!isGateway && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<FormControl
form={form}
name="capacity"
render={(field) => <Input type="number" {...(field as any)} />}
/>
<FormControl
form={form}
name="capacityUnit"
render={(field) => (
<Select {...(field as any)}>
<option value="">Select Unit</option>
{capacityUnits.map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
))}
</Select>
)}
/>
</div>
)}
<FormControl
form={form}
name="tags"
title="Tags"
className="mt-2"
render={(field) => (
<Select2
creatable
isMulti
{...field}
value={
field.value
? (field.value as string[]).map((value) => ({
label: value,
value,
}))
: null
}
options={tagsList}
onChange={(values) => {
if (Array.isArray(values)) {
field.onChange(values.map((value) => value.value));
}
}}
/>
)}
/>
</Modal.Body>
<Modal.Actions>
<Button type="button" onClick={assignNodeDialog.close}>
Cancel
</Button>
<Button type="submit" color="primary" disabled={assignNode.isPending}>
Save
</Button>
</Modal.Actions>
</form>
</Modal>
);
};
export default AssignNodeDialog;

View File

@ -0,0 +1,88 @@
import Code from "@/components/ui/code";
import { Button, Input, Modal } from "react-daisyui";
import { useConnectNode } from "../hooks";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ConnectNodeSchema, connectNodeSchema } from "../schema";
import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
const ConnectNodeDialog = () => {
const dialogRef = useRef<HTMLDialogElement>(null);
const queryClient = useQueryClient();
const form = useForm<ConnectNodeSchema>({
resolver: zodResolver(connectNodeSchema),
defaultValues: { nodeId: "" },
});
const connectNode = useConnectNode({
onSuccess() {
form.reset({ nodeId: "" });
handleHide();
toast.success("Node connected!");
queryClient.invalidateQueries({ queryKey: ["status"] });
},
onError(err) {
handleHide();
toast.error(err?.message || "Unknown error");
},
});
const handleShow = useCallback(() => {
dialogRef.current?.showModal();
}, [dialogRef]);
const handleHide = useCallback(() => {
dialogRef.current?.close();
}, [dialogRef]);
const onSubmit = form.handleSubmit((values) => {
connectNode.mutate(values.nodeId);
});
return (
<>
<Button color="primary" onClick={handleShow}>
Connect
</Button>
<Modal ref={dialogRef} backdrop>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(e);
}}
>
<Modal.Header>Connect Node</Modal.Header>
<Modal.Body>
<p>Run this command to get node id:</p>
<Code className="mt-2">docker exec -it garage /garage node id</Code>
<p className="mt-8">Enter node id:</p>
<Input
placeholder="..."
className="w-full"
{...form.register("nodeId")}
/>
</Modal.Body>
<Modal.Actions>
<Button type="button" onClick={handleHide}>
Cancel
</Button>
<Button
type="submit"
color="primary"
disabled={connectNode.isPending}
>
Save
</Button>
</Modal.Actions>
</form>
</Modal>
</>
);
};
export default ConnectNodeDialog;

View File

@ -0,0 +1,300 @@
import { Alert, Badge, Button, Dropdown, Input, Table } from "react-daisyui";
import { Node } from "../types";
import { cn, handleError, readableBytes } from "@/lib/utils";
import {
Check,
CheckCircle,
Cylinder,
EllipsisVertical,
Info,
Network,
RouteIcon,
Share2,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import ConnectNodeDialog from "./connect-node-dialog";
import AssignNodeDialog from "./assign-node-dialog";
import { assignNodeDialog } from "../stores";
import {
useApplyChanges,
useClusterLayout,
useRevertChanges,
useUnassignNode,
} from "../hooks";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
type NodeListProps = {
nodes: Node[];
};
const NodesList = ({ nodes }: NodeListProps) => {
const { data, refetch } = useClusterLayout();
const [filter, setFilter] = useState({
search: "",
});
const queryClient = useQueryClient();
const unassignNode = useUnassignNode({
onSuccess: () => {
toast.success("Node unassigned!");
refetch();
},
onError: handleError,
});
const revertChanges = useRevertChanges({
onSuccess: () => {
toast.success("Layout reverted!");
refetch();
},
onError: handleError,
});
const applyChanges = useApplyChanges({
onSuccess: () => {
toast.success("Layout applied!");
setTimeout(refetch, 100);
queryClient.invalidateQueries({ queryKey: ["status"] });
},
onError: handleError,
});
const items = useMemo(() => {
return nodes
.filter((item) => {
if (filter.search) {
const q = filter.search.toLowerCase();
return (
item.hostname.toLowerCase().includes(q) ||
item.id.includes(q) ||
item.addr.includes(q) ||
item.role?.zone?.includes(q) ||
item.role?.tags?.find((tag) => tag.toLowerCase().includes(q))
);
}
return true;
})
.map((item) => {
const role = data?.roles?.find((r) => r.id === item.role?.id);
const stagedChanges = data?.stagedRoleChanges?.find(
(i) => i.id === item.id
);
return {
...item,
role: stagedChanges || role || item.role,
isStaged: !!stagedChanges,
};
});
}, [nodes, data, filter]);
const onAssign = (node: Node) => {
assignNodeDialog.open({
nodeId: node.id,
zone: node.role?.zone,
capacity: node.role?.capacity,
tags: node.role?.tags,
});
};
const onUnassign = (id: string) => {
if (window.confirm("Are you sure you want to unassign this node?")) {
unassignNode.mutate(id);
}
};
const onRevert = () => {
if (
window.confirm("Are you sure you want to revert layout changes?") &&
data?.version != null
) {
revertChanges.mutate(data?.version + 1);
}
};
const onApply = () => {
if (
window.confirm("Are you sure you want to revert layout changes?") &&
data?.version != null
) {
applyChanges.mutate(data?.version + 1);
}
};
const hasStagedChanges = data && data.stagedRoleChanges?.length > 0;
return (
<>
<div className="flex flex-row items-center my-2 gap-4">
<Input
placeholder="Search..."
value={filter.search}
onChange={(e) => {
setFilter((state) => ({ ...state, search: e.target.value }));
}}
/>
<div className="flex-1" />
{hasStagedChanges ? (
<>
<Button
onClick={onRevert}
disabled={revertChanges.isPending || applyChanges.isPending}
>
Revert
</Button>
<Button
color="primary"
onClick={onApply}
disabled={revertChanges.isPending || applyChanges.isPending}
>
<Check />
Apply
</Button>
</>
) : (
<ConnectNodeDialog />
)}
</div>
{hasStagedChanges && (
<Alert icon={<Info />}>
There are staged layout changes that need to be applied. Press Apply
to apply them, or Revert to discard them.
</Alert>
)}
{applyChanges.data?.message ? (
<Alert
icon={<CheckCircle />}
className="items-start overflow-x-auto relative text-sm"
>
<pre>{applyChanges.data.message.join("\n")}</pre>
<Button
onClick={applyChanges.reset}
className="absolute right-2 top-2"
shape="circle"
size="sm"
>
<X />
</Button>
</Alert>
) : null}
<div className="w-full overflow-x-auto min-h-[400px] pb-16">
<Table size="sm">
<Table.Head>
<span>#</span>
<span>ID</span>
<span>Hostname</span>
<span>Zone</span>
<span>Capacity</span>
<span>Status</span>
<span />
</Table.Head>
<Table.Body>
{items.map((item, idx) => (
<Table.Row
key={item.id}
className={cn(
item.isStaged && "bg-warning/10",
item.role && "remove" in item.role ? "bg-error/10" : null
)}
>
<span>{idx + 1}</span>
<p className="max-w-[80px] truncate" title={item.id}>
{item.id}
</p>
<>
<p className="font-medium">{item.hostname}</p>
<div className="flex flex-row items-center gap-1">
<Share2 size={12} />
<p className="text-base-content/80 text-xs">{item.addr}</p>
</div>
</>
<>
<p>{item.role?.zone || "-"}</p>
<div className="flex flex-row items-center flex-wrap gap-1">
{item.role?.tags?.map((tag: any) => (
<Badge key={tag} color="primary">
{tag}
</Badge>
))}
</div>
</>
<>
<p>
{item.role?.capacity === null ? (
<>
<Network className="inline mr-1" size={18} />
Gateway
</>
) : (
readableBytes(item.role?.capacity, 1000)
)}
</p>
{item.role?.capacity !== null && item.dataPartition ? (
<div className="flex flex-row items-center gap-1">
<Cylinder size={12} />
<p className="text-xs text-base-content/80">
{readableBytes(item.dataPartition?.available) +
` (${Math.round(
(item.dataPartition.available /
item.dataPartition.total) *
100
)}%)`}
</p>
</div>
) : null}
</>
<Badge
color={
item.draining ? "warning" : item.isUp ? "success" : "error"
}
>
{item.draining
? "Draining"
: item.isUp
? "Active"
: "Inactive"}
</Badge>
<Dropdown end>
<Dropdown.Toggle button={false}>
<Button shape="circle" color="ghost">
<EllipsisVertical />
</Button>
</Dropdown.Toggle>
<Dropdown.Menu className="min-w-40 gap-y-1">
<Dropdown.Item onClick={() => onAssign(item)}>
<RouteIcon size={20} /> Assign
</Dropdown.Item>
{item.role != null && (
<Dropdown.Item
className="text-error bg-error/10"
onClick={() => onUnassign(item.id)}
>
<Trash2 size={20} /> Remove
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<AssignNodeDialog />
</>
);
};
export default NodesList;

View File

@ -0,0 +1,70 @@
import api from "@/lib/api";
import {
ApplyLayoutResult,
AssignNodeBody,
GetClusterLayoutResult,
GetStatusResult,
} from "./types";
import {
useMutation,
UseMutationOptions,
useQuery,
} from "@tanstack/react-query";
export const useClusterStatus = () => {
return useQuery({
queryKey: ["status"],
queryFn: () => api.get<GetStatusResult>("/v1/status"),
});
};
export const useClusterLayout = () => {
return useQuery({
queryKey: ["layout"],
queryFn: () => api.get<GetClusterLayoutResult>("/v1/layout"),
});
};
export const useConnectNode = (options?: Partial<UseMutationOptions>) => {
return useMutation<any, Error, string>({
mutationFn: async (nodeId) => {
const [res] = await api.post("/v1/connect", { body: [nodeId] });
if (!res.success) {
throw new Error(res.error || "Unknown error");
}
return res;
},
...(options as any),
});
};
export const useAssignNode = (options?: Partial<UseMutationOptions>) => {
return useMutation<any, Error, AssignNodeBody>({
mutationFn: (data) => api.post("/v1/layout", { body: [data] }),
...(options as any),
});
};
export const useUnassignNode = (options?: Partial<UseMutationOptions>) => {
return useMutation<any, Error, string>({
mutationFn: (nodeId) =>
api.post("/v1/layout", { body: [{ id: nodeId, remove: true }] }),
...(options as any),
});
};
export const useRevertChanges = (options?: Partial<UseMutationOptions>) => {
return useMutation<any, Error, number>({
mutationFn: (version) =>
api.post("/v1/layout/revert", { body: { version } }),
...(options as any),
});
};
export const useApplyChanges = (options?: Partial<UseMutationOptions>) => {
return useMutation<ApplyLayoutResult, Error, number>({
mutationFn: (version) =>
api.post("/v1/layout/apply", { body: { version } }),
...(options as any),
});
};

View File

@ -0,0 +1,54 @@
import Page from "@/context/page-context";
import { useClusterStatus } from "./hooks";
import { Card } from "react-daisyui";
import NodesList from "./components/nodes-list";
const ClusterPage = () => {
const { data } = useClusterStatus();
return (
<div>
<Page title="Cluster" />
<Card>
<Card.Body className="gap-1">
<Card.Title className="mb-2">Details</Card.Title>
<DetailItem title="Node ID" value={data?.node} />
<DetailItem title="Version" value={data?.garageVersion} />
{/* <DetailItem title="Rust version" value={data?.rustVersion} /> */}
<DetailItem title="DB engine" value={data?.dbEngine} />
<DetailItem title="Layout version" value={data?.layoutVersion} />
</Card.Body>
</Card>
<Card className="mt-4 md:mt-8">
<Card.Body>
<Card.Title>Nodes</Card.Title>
<NodesList nodes={data?.nodes || []} />
</Card.Body>
</Card>
</div>
);
};
type DetailItemProps = {
title: string;
value?: string | number | null;
};
const DetailItem = ({ title, value }: DetailItemProps) => {
return (
<div className="flex flex-row items-start max-w-xl gap-3 text-left text-sm">
<div className="shrink-0 w-[200px]">
<p className="text-base-content/80">{title}</p>
</div>
<div className="flex-1 truncate">
<p className="truncate">{value}</p>
</div>
</div>
);
};
export default ClusterPage;

View File

@ -0,0 +1,53 @@
import { z } from "zod";
export const connectNodeSchema = z.object({
nodeId: z.string().min(1, "Node ID is required"),
});
export type ConnectNodeSchema = z.infer<typeof connectNodeSchema>;
export const capacityUnits = ["MB", "GB", "TB"] as const;
export const assignNodeSchema = z
.object({
nodeId: z.string().min(1, "Node ID is required"),
zone: z.string().min(1, 'Zone is required, e.g. "dc1"'),
capacity: z.coerce.number().nullish(),
capacityUnit: z.enum(capacityUnits),
isGateway: z.boolean(),
tags: z.string().min(1).array(),
})
.refine(
(values) => values.isGateway || (values.capacity && values.capacity > 0),
{
message: "Capacity required",
path: ["capacity"],
}
);
export type AssignNodeSchema = z.infer<typeof assignNodeSchema>;
export const calculateCapacity = (
value?: number | null,
unit?: (typeof capacityUnits)[number]
) => {
if (!value || !unit) return 0;
return value * 1000 ** (capacityUnits.indexOf(unit) + 2);
};
export const parseCapacity = (value?: number | null) => {
if (!value) {
return { value: 0, unit: undefined };
}
for (let i = capacityUnits.length - 1; i >= 0; i--) {
if (value >= 1000 ** (i + 2)) {
return {
value: Math.floor(value / 1000 ** (i + 2)),
unit: capacityUnits[i],
};
}
}
return { value, unit: undefined };
};

View File

@ -0,0 +1,4 @@
import { createDisclosure } from "@/lib/disclosure";
import { AssignNodeSchema } from "./schema";
export const assignNodeDialog = createDisclosure<Partial<AssignNodeSchema>>();

View File

@ -0,0 +1,57 @@
//
export type GetStatusResult = {
node: string;
garageVersion: string;
garageFeatures: string[];
rustVersion: string;
dbEngine: string;
layoutVersion: number;
nodes: Node[];
};
export type Node = {
id: string;
role?: Role | StagedRole;
addr: string;
hostname: string;
isUp: boolean;
lastSeenSecsAgo: number | null;
draining: boolean;
dataPartition: DataPartition;
metadataPartition: DataPartition;
};
export type DataPartition = {
available: number;
total: number;
};
export type Role = {
id: string;
zone: string;
capacity: number;
tags: string[];
};
export type StagedRole = { id: string; remove: boolean } & Partial<
Omit<Role, "id">
>;
export type GetClusterLayoutResult = {
version: number;
roles: Role[];
stagedRoleChanges: StagedRole[];
};
export type AssignNodeBody = {
id: string;
zone: string;
capacity: number | null;
tags: string[];
};
export type ApplyLayoutResult = {
message: string[];
layout: GetClusterLayoutResult;
};

View File

@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react";
type Props = {
title: string;
value?: string | number | null;
icon: LucideIcon;
valueClassName?: string;
children?: React.ReactNode;
};
const StatsCard = ({
title,
value,
icon: Icon,
valueClassName,
children,
}: Props) => {
return (
<div className="bg-base-100 rounded-box p-4 md:p-6 flex flex-row items-center">
<div className="shrink-0 w-[60px]">
<Icon size={32} />
</div>
<div className="flex-1 truncate">
{children != null ? (
children
) : (
<p className={cn("flex-1 text-3xl font-bold", valueClassName)}>
{typeof value === "undefined" ? "..." : value}
</p>
)}
<p className="text-sm mt-0.5">{title}</p>
</div>
</div>
);
};
export default StatsCard;

10
src/pages/home/hooks.ts Normal file
View File

@ -0,0 +1,10 @@
import api from "@/lib/api";
import { GetHealthResult } from "./types";
import { useQuery } from "@tanstack/react-query";
export const useNodesHealth = () => {
return useQuery({
queryKey: ["health"],
queryFn: () => api.get<GetHealthResult>("/v1/health"),
});
};

69
src/pages/home/page.tsx Normal file
View File

@ -0,0 +1,69 @@
import Page from "@/context/page-context";
import { useNodesHealth } from "./hooks";
import StatsCard from "./components/stats-card";
import {
Database,
DatabaseZap,
FileBox,
FileCheck,
FileClock,
HardDrive,
HardDriveUpload,
Leaf,
} from "lucide-react";
import { cn, ucfirst } from "@/lib/utils";
const HomePage = () => {
const { data: health } = useNodesHealth();
return (
<div>
<Page title="Dashboard" />
<section className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<StatsCard
title="Status"
icon={Leaf}
value={ucfirst(health?.status)}
valueClassName={cn(
"text-lg",
health?.status === "healthy" ? "text-success" : "text-error"
)}
/>
<StatsCard title="Nodes" icon={HardDrive} value={health?.knownNodes} />
<StatsCard
title="Connected Nodes"
icon={HardDriveUpload}
value={health?.connectedNodes}
/>
<StatsCard
title="Storage Nodes"
icon={Database}
value={health?.storageNodes}
/>
<StatsCard
title="Active Storage Nodes"
icon={DatabaseZap}
value={health?.storageNodesOk}
/>
<StatsCard
title="Partitions"
icon={FileBox}
value={health?.partitions}
/>
<StatsCard
title="Partitions Quorum"
icon={FileClock}
value={health?.partitionsQuorum}
/>
<StatsCard
title="Active Partitions"
icon={FileCheck}
value={health?.partitionsAllOk}
/>
</section>
</div>
);
};
export default HomePage;

12
src/pages/home/types.ts Normal file
View File

@ -0,0 +1,12 @@
//
export type GetHealthResult = {
status: string;
knownNodes: number;
connectedNodes: number;
storageNodes: number;
storageNodesOk: number;
partitions: number;
partitionsQuorum: number;
partitionsAllOk: number;
};

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

16
tailwind.config.js Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"node_modules/daisyui/dist/**/*.js",
"node_modules/react-daisyui/dist/**/*.js",
],
theme: {
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: ["light", "dark", "cupcake", "pastel", "dracula"],
},
};

29
tsconfig.app.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
}
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
proxy: {
"/api": {
target: process.env.VITE_API_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
};
});