mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-09-15 00:43:21 +07:00
feat: project init
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const AuthLayout = () => {
|
||||
return <div>AuthLayout</div>;
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createDisclosure } from "@/lib/disclosure";
|
||||
import { AssignNodeSchema } from "./schema";
|
||||
|
||||
export const assignNodeDialog = createDisclosure<Partial<AssignNodeSchema>>();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
|
||||
export type GetHealthResult = {
|
||||
status: string;
|
||||
knownNodes: number;
|
||||
connectedNodes: number;
|
||||
storageNodes: number;
|
||||
storageNodesOk: number;
|
||||
partitions: number;
|
||||
partitionsQuorum: number;
|
||||
partitionsAllOk: number;
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user