mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-27 22:39:31 +07:00
feat: project init
This commit is contained in:
commit
b0e5d53ee0
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3903
|
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
50
README.md
Normal 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
26
eslint.config.js
Normal 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
13
index.html
Normal 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
46
package.json
Normal 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
3093
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
21
src/app/app.tsx
Normal 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
32
src/app/router.tsx
Normal 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
29
src/app/styles.css
Normal 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;
|
||||
}
|
||||
}
|
5
src/components/layouts/auth-layout.tsx
Normal file
5
src/components/layouts/auth-layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
const AuthLayout = () => {
|
||||
return <div>AuthLayout</div>;
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
64
src/components/layouts/main-layout.tsx
Normal file
64
src/components/layouts/main-layout.tsx
Normal 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;
|
18
src/components/ui/code.tsx
Normal file
18
src/components/ui/code.tsx
Normal 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;
|
58
src/components/ui/form-control.tsx
Normal file
58
src/components/ui/form-control.tsx
Normal 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;
|
47
src/components/ui/select.tsx
Normal file
47
src/components/ui/select.tsx
Normal 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;
|
48
src/context/page-context.tsx
Normal file
48
src/context/page-context.tsx
Normal 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
69
src/lib/api.ts
Normal 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
15
src/lib/disclosure.ts
Normal 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
25
src/lib/utils.ts
Normal 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
9
src/main.tsx
Normal 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>,
|
||||
)
|
240
src/pages/cluster/components/assign-node-dialog.tsx
Normal file
240
src/pages/cluster/components/assign-node-dialog.tsx
Normal 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;
|
88
src/pages/cluster/components/connect-node-dialog.tsx
Normal file
88
src/pages/cluster/components/connect-node-dialog.tsx
Normal 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;
|
300
src/pages/cluster/components/nodes-list.tsx
Normal file
300
src/pages/cluster/components/nodes-list.tsx
Normal 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;
|
70
src/pages/cluster/hooks.ts
Normal file
70
src/pages/cluster/hooks.ts
Normal 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),
|
||||
});
|
||||
};
|
54
src/pages/cluster/page.tsx
Normal file
54
src/pages/cluster/page.tsx
Normal 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;
|
53
src/pages/cluster/schema.ts
Normal file
53
src/pages/cluster/schema.ts
Normal 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 };
|
||||
};
|
4
src/pages/cluster/stores.ts
Normal file
4
src/pages/cluster/stores.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createDisclosure } from "@/lib/disclosure";
|
||||
import { AssignNodeSchema } from "./schema";
|
||||
|
||||
export const assignNodeDialog = createDisclosure<Partial<AssignNodeSchema>>();
|
57
src/pages/cluster/types.ts
Normal file
57
src/pages/cluster/types.ts
Normal 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;
|
||||
};
|
39
src/pages/home/components/stats-card.tsx
Normal file
39
src/pages/home/components/stats-card.tsx
Normal 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
10
src/pages/home/hooks.ts
Normal 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
69
src/pages/home/page.tsx
Normal 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
12
src/pages/home/types.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
16
tailwind.config.js
Normal file
16
tailwind.config.js
Normal 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
29
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
26
vite.config.ts
Normal 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/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user