init frontend v2

This commit is contained in:
karamvir
2023-09-09 00:47:46 -07:00
parent 848fd4d4c8
commit 27f5353ee6
65 changed files with 7780 additions and 0 deletions

18
frontend-v2/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
frontend-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

11
frontend-v2/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20.5-alpine
WORKDIR /app
COPY ./frontend ./frontend
COPY ./client/ts ./client
RUN cd client && yarn && yarn build && yarn link
RUN cd frontend && yarn link @monitor/client && yarn && yarn build
CMD cd frontend && yarn preview --host --port 4174

27
frontend-v2/README.md Normal file
View File

@@ -0,0 +1,27 @@
# 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
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/",
"utils": "@lib/utils"
}
}

13
frontend-v2/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<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 class="min-h-screen">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

54
frontend-v2/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.4",
"@tanstack/react-query": "^4.33.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"jotai": "^2.4.1",
"lightweight-charts": "^4.0.1",
"lucide-react": "^0.274.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-minimal-pie-chart": "^8.4.0",
"react-router-dom": "^6.15.0",
"reconnecting-websocket": "^4.4.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.15",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.29",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-tsconfig-paths": "^4.2.0"
}
}

View File

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

View File

@@ -0,0 +1,173 @@
import {
ConfigInput,
ConfigSwitch,
ConfirmUpdate,
} from "@components/config/util";
import { Section } from "@components/layouts";
import { Resource } from "@monitor/client/dist/types";
import { Button } from "@ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@ui/card";
import { History, Settings } from "lucide-react";
import { Fragment, ReactNode, SetStateAction, useState } from "react";
const keys = <T extends Record<string, unknown>>(obj: T) =>
Object.keys(obj) as Array<keyof T>;
export const ConfigAgain = <T extends Resource<unknown, unknown>["config"]>({
config,
update,
components,
set,
}: {
config: T;
update: Partial<T>;
components: Partial<{
[K in keyof T extends string ? keyof T : never]:
| true
| ((value: T[K], set: (value: Partial<T>) => void) => ReactNode);
}>;
set: (value: Partial<T>) => void;
}) => {
return (
<>
{keys(components).map((key) => {
const component = components[key];
const value = update[key] ?? config[key];
if (component === true) {
switch (typeof value) {
case "string":
return (
<ConfigInput
key={key.toString()}
label={key.toString()}
value={value}
onChange={(value) => set({ [key]: value } as Partial<T>)}
/>
);
case "number":
return (
<ConfigInput
key={key.toString()}
label={key.toString()}
value={Number(value)}
onChange={(value) =>
set({ [key]: Number(value) } as Partial<T>)
}
/>
);
case "boolean":
return (
<ConfigSwitch
key={key.toString()}
label={key.toString()}
value={value}
onChange={(value) => set({ [key]: value } as Partial<T>)}
/>
);
default:
return <div>{key.toString()}</div>;
}
}
return (
<Fragment key={key.toString()}>{component?.(value, set)}</Fragment>
);
})}
</>
);
};
const ConfigLayout = <T extends Resource<unknown, unknown>["config"]>({
content,
children,
onConfirm,
onReset,
}: {
content: Partial<T>;
children: ReactNode;
onConfirm: () => void;
onReset: () => void;
}) => (
<Section
title="Config"
icon={<Settings className="w-4 h-4" />}
actions={
<div className="flex gap-2">
<Button
variant="outline"
onClick={onReset}
disabled={content ? !Object.keys(content).length : true}
>
<History className="w-4 h-4" />
</Button>
<ConfirmUpdate
content={JSON.stringify(content, null, 2)}
onConfirm={onConfirm}
/>
</div>
}
>
{children}
</Section>
);
export const ConfigInner = <T,>({
config,
update,
set,
onSave,
components,
}: {
config: T;
update: Partial<T>;
set: React.Dispatch<SetStateAction<Partial<T>>>;
onSave: () => void;
components: Record<
string,
Record<
string,
{
[K in keyof Partial<T>]:
| true
| ((value: T[K], set: (value: Partial<T>) => void) => ReactNode);
}
>
>;
}) => {
const [show, setShow] = useState(keys(components)[0]);
return (
<ConfigLayout content={update} onConfirm={onSave} onReset={() => set({})}>
<div className="flex gap-4">
<div className="flex flex-col gap-4 w-[300px]">
{keys(components).map((tab) => (
<Button
key={tab}
variant={show === tab ? "secondary" : "outline"}
onClick={() => setShow(tab)}
className="capitalize"
>
{tab}
</Button>
))}
</div>
<div className="flex flex-col gap-6 min-h-[500px] w-full">
{Object.entries(components[show]).map(([k, v]) => (
<Card className="w-full" key={k}>
<CardHeader className="border-b">
<CardTitle className="capitalize">{k}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 mt-4">
<ConfigAgain
config={config}
update={update}
set={(u) => set((p) => ({ ...p, ...u }))}
components={v}
/>
</CardContent>
</Card>
))}
</div>
</div>
</ConfigLayout>
);
};

View File

@@ -0,0 +1,299 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useRead } from "@lib/hooks";
import { Types } from "@monitor/client";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
} from "@ui/select";
import { Button } from "@ui/button";
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import { MinusCircle, PlusCircle, Save } from "lucide-react";
import { ReactNode, useState } from "react";
import { cn } from "@lib/utils";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
export const ConfigItem = ({
label,
children,
className,
}: {
label: string;
children: ReactNode;
className?: string;
}) => (
<div
className={cn(
"flex justify-between items-center border-b pb-2 min-h-[60px] last:border-none last:pb-0",
className
)}
>
<div className="capitalize"> {label} </div>
{children}
</div>
);
export const ConfigInput = ({
label,
value,
onChange,
}: {
label: string;
value: string | number | undefined;
onChange: (value: string) => void;
}) => (
<ConfigItem label={label}>
<Input
className="max-w-[400px]"
type={typeof value === "number" ? "number" : undefined}
value={value}
onChange={(e) => onChange(e.target.value)}
// disabled={loading}
/>
</ConfigItem>
);
export const ConfigSwitch = ({
label,
value,
onChange,
}: {
label: string;
value: boolean | undefined;
onChange: (value: boolean) => void;
}) => (
<ConfigItem label={label}>
<Switch checked={value} onCheckedChange={onChange} />
</ConfigItem>
);
export const DoubleInput = <
T extends object,
K extends keyof T,
L extends T[K] extends string | number | undefined ? K : never,
R extends T[K] extends string | number | undefined ? K : never
>({
values,
leftval,
leftpl,
rightval,
rightpl,
addName,
onLeftChange,
onRightChange,
onAdd,
onRemove,
}: {
values: T[] | undefined;
leftval: L;
leftpl: string;
rightval: R;
rightpl: string;
addName: string;
onLeftChange: (value: T[L], i: number) => void;
onRightChange: (value: T[R], i: number) => void;
onAdd: () => void;
onRemove: (i: number) => void;
}) => {
return (
<div className="flex flex-col gap-4">
{values?.map((value, i) => (
<div className="flex items-center justify-between gap-4" key={i}>
<Input
value={value[leftval] as any}
placeholder={leftpl}
onChange={(e) => onLeftChange(e.target.value as T[L], i)}
/>
:
<Input
value={value[rightval] as any}
placeholder={rightpl}
onChange={(e) => onRightChange(e.target.value as T[R], i)}
/>
<Button
variant="outline"
// intent="warning"
onClick={() => onRemove(i)}
>
<MinusCircle className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
// intent="success"
className="flex items-center gap-2 w-[200px] place-self-end"
onClick={onAdd}
>
<PlusCircle className="w-4 h-4" />
Add {addName}
</Button>
</div>
);
};
type UsableResources = Exclude<Types.ResourceTarget["type"], "System">;
export const ResourceSelector = ({
type,
selected,
onSelect,
}: {
type: UsableResources;
selected: string | undefined;
onSelect: (id: string) => void;
}) => {
const resources = useRead(`List${type}s`, {}).data;
return (
<Select value={selected || undefined} onValueChange={onSelect}>
<SelectTrigger className="w-full lg:w-[300px]">
<SelectValue placeholder={`Select ${type}`} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{resources?.map((resource) => (
<SelectItem key={resource.id} value={resource.id}>
{resource.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
};
export const AccountSelector = ({
id,
type,
account_type,
selected,
onSelect,
}: {
id: string | undefined;
type: "Server" | "Builder";
account_type: keyof Types.GetBuilderAvailableAccountsResponse;
selected: string | undefined;
onSelect: (id: string) => void;
}) => {
const accounts = useRead(
`Get${type}AvailableAccounts`,
{ id: id! },
{ enabled: !!id }
).data;
return (
<ConfigItem label={`${account_type} Account`}>
<Select
value={type === "Builder" ? selected || undefined : selected}
onValueChange={onSelect}
>
<SelectTrigger className="w-full lg:w-[300px]" disabled={!id}>
<SelectValue placeholder="Select Account" />
</SelectTrigger>
<SelectContent>
{type === "Server" && (
<SelectItem value={""}>Same as build</SelectItem>
)}
{accounts?.[account_type]?.map((account) => (
<SelectItem key={account} value={account}>
{account}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
);
};
export const InputList = <T extends { [key: string]: unknown }>({
field,
values,
set,
}: {
field: keyof T;
values: string[];
set: (update: Partial<T>) => void;
}) => (
<ConfigItem label={field as string} className="items-start">
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{values.map((arg, i) => (
<div className="w-full flex gap-4" key={i}>
<Input
// placeholder="--extra-arg=value"
value={arg}
onChange={(e) => {
values[i] = e.target.value;
set({ [field]: [...values] } as Partial<T>);
}}
/>
<Button
variant="outline"
// intent="warning"
onClick={() =>
set({
[field]: [...values.filter((_, idx) => idx !== i)],
} as Partial<T>)
}
>
<MinusCircle className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
// intent="success"
onClick={() => set({ [field]: [...values, ""] } as Partial<T>)}
>
Add Docker Account
</Button>
</div>
</ConfigItem>
);
interface ConfirmUpdateProps {
content: string;
onConfirm: () => void;
}
export const ConfirmUpdate = ({ content, onConfirm }: ConfirmUpdateProps) => {
const [open, set] = useState(false);
return (
<Dialog open={open} onOpenChange={set}>
<DialogTrigger asChild>
<Button onClick={() => set(true)}>
<Save className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Update</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-4 my-4">
New configuration to be applied:
<pre className="h-[300px] overflow-auto">{content}</pre>
</div>
<DialogFooter>
<Button
onClick={() => {
onConfirm();
set(false);
}}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,89 @@
import {
ColorType,
IChartApi,
ISeriesApi,
Time,
createChart,
} from "lightweight-charts";
import { useEffect, useRef } from "react";
import { useRead } from "@lib/hooks";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { Hammer } from "lucide-react";
export const BuildChart = () => {
const container_ref = useRef<HTMLDivElement>(null);
const line_ref = useRef<IChartApi>();
const series_ref = useRef<ISeriesApi<"Histogram">>();
const { data } = useRead("GetBuildMonthlyStats", {});
const handleResize = () =>
line_ref.current?.applyOptions({
width: container_ref.current?.clientWidth,
});
useEffect(() => {
if (!data) return;
if (line_ref.current) line_ref.current.remove();
const init = () => {
if (!container_ref.current) return;
line_ref.current = createChart(container_ref.current, {
width: container_ref.current.clientWidth,
height: container_ref.current.clientHeight,
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: "grey",
fontSize: 12,
},
grid: {
horzLines: { color: "transparent" },
vertLines: { color: "transparent" },
},
handleScale: false,
handleScroll: false,
});
line_ref.current.timeScale().fitContent();
series_ref.current = line_ref.current.addHistogramSeries({
priceLineVisible: false,
});
const max = data.days.reduce((m, c) => Math.max(m, c.time), 0);
series_ref.current.setData(
data.days.map((d) => ({
time: (d.ts / 1000) as Time,
value: d.count,
color:
d.time > max * 0.7
? "darkred"
: d.time > max * 0.35
? "darkorange"
: "darkgreen",
})) ?? []
);
};
init();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [data]);
return (
<Card className="w-full">
<CardHeader className="justify-between">
<div>
<CardTitle>Builds</CardTitle>
<CardDescription>{data?.total_time.toFixed(2)} Hours</CardDescription>
</div>
<Hammer className="w-4 h-4" />
</CardHeader>
<CardContent className="h-[200px]">
<div className="w-full max-w-full h-full" ref={container_ref} />
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,85 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { PieChart } from "react-minimal-pie-chart";
import { useRead } from "@lib/hooks";
import { Rocket } from "lucide-react";
export const DeploymentsChart = () => {
const summary = useRead("GetDeploymentsSummary", {}).data;
return (
<Card className="w-full">
<CardHeader className="justify-between">
<div>
<CardTitle>Deployments</CardTitle>
<CardDescription>{summary?.total} Total</CardDescription>
</div>
<Rocket className="w-4 h-4" />
</CardHeader>
<CardContent className="flex h-[200px] items-center justify-between">
<div className="flex flex-col gap-2 text-muted-foreground w-full">
<CardDescription>
<span className="text-green-500 font-bold">
{summary?.running}{" "}
</span>
Running
</CardDescription>
<CardDescription>
<span className="text-red-500 font-bold">{summary?.stopped} </span>
Stopped
</CardDescription>
<CardDescription>
<span className="text-blue-500 font-bold">
{summary?.not_deployed}{" "}
</span>
Not Deployed
</CardDescription>
<CardDescription>
<span className="text-purple-500 font-bold">
{summary?.unknown}{" "}
</span>
Unknown
</CardDescription>
</div>
<div className="flex justify-end items-center w-full">
<PieChart
className="w-32 h-32"
radius={42}
lineWidth={30}
data={[
{
color: "#22C55E",
value: summary?.running ?? 0,
title: "running",
key: "running",
},
{
color: "#EF0044",
value: summary?.stopped ?? 0,
title: "stopped",
key: "stopped",
},
{
color: "#3B82F6",
value: summary?.not_deployed ?? 0,
title: "not-deployed",
key: "not-deployed",
},
{
color: "purple",
value: summary?.unknown ?? 0,
title: "unknown",
key: "unknown",
},
]}
/>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,68 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { PieChart } from "react-minimal-pie-chart";
import { useRead } from "@lib/hooks";
import { Server } from "lucide-react";
export const ServersChart = () => {
const { data } = useRead("GetServersSummary", {});
return (
<Card className="w-full">
<CardHeader className="justify-between">
<div>
<CardTitle>Servers</CardTitle>
<CardDescription>{data?.total} Total</CardDescription>
</div>
<Server className="w-4 h-4" />
</CardHeader>
<CardContent className="flex h-[200px] items-center justify-between">
<div className="flex flex-col gap-2 text-muted-foreground w-full">
<CardDescription>
<span className="text-green-500 font-bold">{data?.healthy} </span>
Healthy
</CardDescription>
<CardDescription>
<span className="text-red-500 font-bold">{data?.unhealthy} </span>
Unhealthy
</CardDescription>
<CardDescription>
<span className="text-blue-500 font-bold">{data?.disabled} </span>
Disabled
</CardDescription>
</div>
<div className="flex justify-end items-center w-full">
<PieChart
className="w-32 h-32"
radius={42}
lineWidth={30}
data={[
{
color: "#22C55E",
value: data?.healthy ?? 0,
title: "healthy",
key: "healthy",
},
{
color: "#EF0044",
value: data?.unhealthy ?? 0,
title: "unhealthy",
key: "unhealthy",
},
{
color: "#3B82F6",
value: data?.disabled ?? 0,
title: "disabled",
key: "disabled",
},
]}
/>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,157 @@
import { useResourceParamType } from "@lib/hooks";
import { RESOURCE_TARGETS } from "@lib/utils";
import { Button } from "@ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { ThemeToggle } from "@ui/theme";
import { ChevronDown, LogOut } from "lucide-react";
import { ReactNode, useState } from "react";
import { Link, Outlet } from "react-router-dom";
import { Omnibar } from "./omnibar";
import { WsStatusIndicator } from "@lib/socket";
import { UsableResource } from "@types";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
export const Layout = () => {
const type = useResourceParamType();
return (
<>
<div className="fixed top-0 border-b bg-background z-50 w-full">
<div className="container flex items-center justify-between py-4">
<Link to={"/"} className="text-xl">
Monitor
</Link>
<div className="flex gap-4">
<Omnibar />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-48 justify-between">
{type ? type + "s" : "Dashboard"}
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" side="bottom">
<DropdownMenuGroup>
<Link to="/">
<DropdownMenuItem>Dashboard</DropdownMenuItem>
</Link>
{RESOURCE_TARGETS.map((rt) => (
<Link key={rt} to={`/${rt.toLowerCase()}s`}>
<DropdownMenuItem>{rt}s</DropdownMenuItem>
</Link>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex">
<WsStatusIndicator />
<ThemeToggle />
<Button
variant="ghost"
size="icon"
onClick={() => {
localStorage.removeItem("monitor-auth-token");
window.location.reload();
}}
>
<LogOut className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
<Outlet />
</>
);
};
interface PageProps {
title: ReactNode;
children: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
}
export const Page = ({ title, subtitle, actions, children }: PageProps) => (
<div className="flex flex-col gap-12 container py-32">
<div className="flex flex-col gap-6 lg:flex-row lg:gap-0 lg:items-start justify-between">
<div className="flex flex-col">
<h1 className="text-4xl">{title}</h1>
{subtitle}
</div>
{actions}
</div>
{children}
</div>
);
interface SectionProps {
title: string;
children: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
}
export const Section = ({ title, icon, actions, children }: SectionProps) => (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between min-h-[40px]">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<h2 className="text-xl">{title}</h2>
</div>
{actions}
</div>
{children}
</div>
);
export const NewResource = ({
type,
loading,
children,
onSuccess,
}: {
type: UsableResource;
loading: boolean;
children: ReactNode;
onSuccess: () => Promise<void>;
}) => {
const [open, set] = useState(false);
return (
<Dialog open={open} onOpenChange={set}>
<DialogTrigger asChild>
<Button>New {type}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New {type}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">{children}</div>
<DialogFooter>
<Button
variant="outline"
onClick={async () => {
await onSuccess();
set(false);
}}
disabled={loading}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,92 @@
import { useRead } from "@lib/hooks";
import { Button } from "@ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
CommandItem,
} from "@ui/command";
import { Search } from "lucide-react";
import { useState, useEffect, Fragment } from "react";
import { useNavigate } from "react-router-dom";
import { ResourceComponents } from "./resources";
import { UsableResource } from "@types";
import { RESOURCE_TARGETS } from "@lib/utils";
const ResourceGroup = ({
type,
onSelect,
}: {
type: UsableResource;
onSelect: (value: string) => void;
}) => {
const data = useRead(`List${type}s`, {}).data;
const Components = ResourceComponents[type];
return (
<CommandGroup heading={`${type}s`}>
{data?.map(({ id }) => {
return (
<CommandItem
key={id}
className="flex items-center gap-2"
onSelect={() => onSelect(`/${type.toLowerCase()}s/${id}`)}
>
<Components.Icon id={id} />
<Components.Name id={id} />
</CommandItem>
);
})}
</CommandGroup>
);
};
export const Omnibar = () => {
const [open, set] = useState(false);
const navigate = useNavigate();
const nav = (value: string) => {
set(false);
navigate(value);
};
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.shiftKey && e.key === "S") {
e.preventDefault();
set(true);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
});
return (
<>
<Button
variant="outline"
onClick={() => set(true)}
className="flex items-center gap-4 md:w-72 justify-start"
>
<Search className="w-4 h-4" />{" "}
<span className="text-muted-foreground hidden md:block">
Search {"(shift+s)"}
</span>
</Button>
<CommandDialog open={open} onOpenChange={set}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{RESOURCE_TARGETS.map((rt) => (
<Fragment key={rt}>
<ResourceGroup type={rt} key={rt} onSelect={nav} />
<CommandSeparator />
</Fragment>
))}
</CommandList>
</CommandDialog>
</>
);
};

View File

@@ -0,0 +1,173 @@
import {
ActionButton,
ActionWithDialog,
ConfirmButton,
} from "@components/util";
import { Play, Trash, Pause, Rocket, Pen } from "lucide-react";
import { useExecute, useInvalidate, useRead, useWrite } from "@lib/hooks";
import { DockerContainerState } from "@monitor/client/dist/types";
import { useNavigate } from "react-router-dom";
import { Input } from "@ui/input";
import { useToast } from "@ui/use-toast";
import { useState } from "react";
interface DeploymentId {
id: string;
}
export const RedeployContainer = ({ id }: DeploymentId) => {
const { mutate, isLoading } = useExecute("Deploy");
const deployments = useRead("ListDeployments", {}).data;
const deployment = deployments?.find((d) => d.id === id);
const deploying = useRead("GetDeploymentActionState", { id }).data?.deploying;
return (
<ConfirmButton
title={deployment?.info.status ? "Redeploy" : "Deploy"}
// intent="success"
icon={<Rocket className="h-4 w-4" />}
onClick={() => mutate({ deployment_id: id })}
disabled={isLoading}
loading={deploying}
/>
);
};
const StartContainer = ({ id }: DeploymentId) => {
const { data: d } = useRead("GetDeployment", { id });
const { mutate, isLoading } = useExecute("StartContainer");
const starting = useRead("GetDeploymentActionState", {
id,
}).data?.starting;
if (!d) return null;
return (
<ActionWithDialog
name={d.name}
title="Start"
// intent="success"
icon={<Play className="h-4 w-4" />}
onClick={() => mutate({ deployment_id: id })}
disabled={isLoading}
loading={starting}
/>
);
};
const StopContainer = ({ id }: DeploymentId) => {
const { data: d } = useRead("GetDeployment", { id });
const { mutate, isLoading } = useExecute("StopContainer");
const stopping = useRead("GetDeploymentActionState", {
id,
}).data?.stopping;
if (!d) return null;
return (
<ActionWithDialog
name={d?.name}
title="Stop"
// intent="warning"
icon={<Pause className="h-4 w-4" />}
onClick={() => mutate({ deployment_id: id })}
disabled={isLoading}
loading={stopping}
/>
);
};
export const StartOrStopContainer = ({ id }: DeploymentId) => {
const deployments = useRead("ListDeployments", {}).data;
const deployment = deployments?.find((d) => d.id === id);
if (deployment?.info.state === DockerContainerState.NotDeployed) return null;
if (deployment?.info.state === DockerContainerState.Running)
return <StopContainer id={id} />;
return <StartContainer id={id} />;
};
export const RemoveContainer = ({ id }: DeploymentId) => {
const deployment = useRead("GetDeployment", { id }).data;
const { mutate, isLoading } = useExecute("RemoveContainer");
const deployments = useRead("ListDeployments", {}).data;
const state = deployments?.find((d) => d.id === id)?.info.state;
const removing = useRead("GetDeploymentActionState", {
id,
}).data?.removing;
if (!deployment) return null;
if (state === DockerContainerState.NotDeployed) return null;
return (
<ActionWithDialog
name={deployment.name}
title="Remove"
// intent="warning"
icon={<Trash className="h-4 w-4" />}
onClick={() => mutate({ deployment_id: id })}
disabled={isLoading}
loading={removing}
/>
);
};
export const DeleteDeployment = ({ id }: { id: string }) => {
const nav = useNavigate();
const { data: d } = useRead("GetDeployment", { id });
const { mutateAsync, isLoading } = useWrite("DeleteDeployment");
const deleting = useRead("GetDeploymentActionState", { id }).data?.deleting;
if (!d) return null;
return (
<ActionWithDialog
name={d.name}
title="Delete"
// intent="danger"
icon={<Trash className="h-4 w-4" />}
onClick={async () => {
await mutateAsync({ id });
nav("/");
}}
disabled={isLoading}
loading={deleting}
/>
);
};
export const RenameDeployment = ({ id }: { id: string }) => {
const invalidate = useInvalidate();
const { toast } = useToast();
const { mutate, isLoading } = useWrite("RenameDeployment", {
onSuccess: () => {
invalidate(["ListDeployments"]);
toast({ title: "Deployment Renamed" });
set("");
},
});
const [name, set] = useState("");
return (
<div className="flex items-center justify-between">
<div className="w-full">Rename Deployment</div>
<div className="flex gap-4 w-full justify-end">
<Input
value={name}
onChange={(e) => set(e.target.value)}
className="w-96"
placeholder="Enter new name"
/>
<ActionButton
title="Rename"
icon={<Pen className="w-4 h-4" />}
disabled={isLoading}
onClick={() => mutate({ id, name })}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { ConfigItem } from "@components/config/util";
import { env_to_text, text_to_env } from "@lib/utils";
import { Types } from "@monitor/client";
import { Textarea } from "@ui/textarea";
import { useEffect, useState } from "react";
export const EnvVars = ({
vars,
set,
}: {
vars: Types.EnvironmentVar[];
set: (input: Partial<Types.DeploymentConfig>) => void;
}) => {
const [env, setEnv] = useState(env_to_text(vars));
useEffect(() => {
!!env && set({ environment: text_to_env(env) });
}, [env, set]);
return (
<ConfigItem
label="Environment Variables"
className="flex-col gap-4 items-start"
>
<Textarea
className="min-h-[300px]"
placeholder="VARIABLE=value"
value={env}
onChange={(e) => setEnv(e.target.value)}
/>
</ConfigItem>
);
};

View File

@@ -0,0 +1,48 @@
import { ConfigItem } from "@components/config/util";
import { DeploymentConfig } from "@monitor/client/dist/types";
import { Button } from "@ui/button";
import { Input } from "@ui/input";
import { MinusCircle, PlusCircle } from "lucide-react";
export const ExtraArgs = ({
args,
set,
}: {
args: string[];
set: (update: Partial<DeploymentConfig>) => void;
}) => {
return (
<ConfigItem label="Extra Args" className="items-start">
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{args.map((arg, i) => (
<div className="w-full flex gap-4" key={i}>
<Input
value={arg}
placeholder="--extra-arg=value"
onChange={(e) => {
args[i] = e.target.value;
set({ extra_args: [...args] });
}}
/>
<Button
variant="outline"
onClick={() =>
set({ extra_args: [...args.filter((_, idx) => idx !== i)] })
}
>
<MinusCircle className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
className="flex items-center gap-2 w-[200px] place-self-end"
onClick={() => set({ extra_args: [...args, ""] })}
>
<PlusCircle className="w-4 h-4" /> Add Extra Arg
</Button>
</div>
</ConfigItem>
);
};

View File

@@ -0,0 +1,140 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ConfigItem, ResourceSelector } from "@components/config/util";
import { useRead } from "@lib/hooks";
import { fmt_verison } from "@lib/utils";
import { Types } from "@monitor/client";
import { Input } from "@ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
const BuildVersionSelector = ({
buildId,
selected,
onSelect,
}: {
buildId: string | undefined;
selected: string | undefined;
onSelect: (version: string) => void;
}) => {
const versions = useRead(
"GetBuildVersions",
{ id: buildId! },
{ enabled: !!buildId }
).data;
return (
<Select value={selected || undefined} onValueChange={onSelect}>
<SelectTrigger className="w-full lg:w-[150px]">
<SelectValue placeholder="Select Version" />
</SelectTrigger>
<SelectContent>
<SelectItem value={JSON.stringify({ major: 0, minor: 0, patch: 0 })}>
latest
</SelectItem>
{versions?.map((v) => (
<SelectItem key={JSON.stringify(v)} value={JSON.stringify(v)}>
{fmt_verison(v.version)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
const ImageTypeSelector = ({
selected,
onSelect,
}: {
selected: Types.DeploymentImage["type"] | undefined;
onSelect: (type: Types.DeploymentImage["type"]) => void;
}) => (
<Select value={selected || undefined} onValueChange={onSelect}>
<SelectTrigger className="max-w-[150px]">
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"Image"}>Image</SelectItem>
<SelectItem value={"Build"}>Build</SelectItem>
</SelectContent>
</Select>
);
export const ImageConfig = ({
image,
set,
}: {
image: Types.DeploymentImage | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
}) => (
<ConfigItem label="Image">
<div className="flex gap-4 w-full justify-end">
<ImageTypeSelector
selected={image?.type}
onSelect={(type) =>
set({
image: {
type: type,
params:
type === "Image"
? { image: "" }
: ({
build_id: "",
version: { major: 0, minor: 0, patch: 0 },
} as any),
},
})
}
/>
{image?.type === "Build" && (
<div className="flex gap-4">
<ResourceSelector
type="Build"
selected={image.params.build_id}
onSelect={(id) =>
set({
image: {
...image,
params: { ...image.params, build_id: id },
},
})
}
/>
<BuildVersionSelector
buildId={image.params.build_id}
selected={JSON.stringify(image.params.version)}
onSelect={(version) =>
set({
image: {
...image,
params: {
...image.params,
version: JSON.parse(version),
},
},
})
}
/>
</div>
)}
{image?.type === "Image" && (
<Input
value={image.params.image}
onChange={(e) =>
set({
image: {
...image,
params: { image: e.target.value },
},
})
}
className="w-full lg:w-[300px]"
placeholder="image name"
/>
)}
</div>
</ConfigItem>
);

View File

@@ -0,0 +1,42 @@
import { ConfigItem } from "@components/config/util";
import { useRead } from "@lib/hooks";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
export const NetworkModeSelector = ({
server_id,
selected,
onSelect,
}: {
server_id: string | undefined;
selected: string | undefined;
onSelect: (type: string) => void;
}) => {
const networks = useRead(
"GetAvailableNetworks",
{ server_id: server_id! },
{ enabled: !!server_id }
).data;
return (
<ConfigItem label="Network Mode">
<Select value={selected || undefined} onValueChange={onSelect}>
<SelectTrigger className="max-w-[150px]">
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
{networks?.networks.map((network) => (
<SelectItem key={network.Id} value={network.Name ?? ""}>
{network.Name}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
);
};

View File

@@ -0,0 +1,33 @@
import { ConfigItem, DoubleInput } from "@components/config/util";
import { Types } from "@monitor/client";
export const PortsConfig = ({
ports,
set,
}: {
ports: Types.Conversion[];
set: (input: Partial<Types.DeploymentConfig>) => void;
}) => (
<ConfigItem label="Ports" className="items-start">
<DoubleInput
values={ports}
leftval="local"
leftpl="Local"
rightval="container"
rightpl="Container"
addName="Port"
onLeftChange={(local, i) => {
ports[i].local = local;
set({ ports: [...ports] });
}}
onRightChange={(container, i) => {
ports[i].container = container;
set({ ports: [...ports] });
}}
onAdd={() =>
set({ ports: [...(ports ?? []), { container: "", local: "" }] })
}
onRemove={(idx) => set({ ports: [...ports.filter((_, i) => i !== idx)] })}
/>
</ConfigItem>
);

View File

@@ -0,0 +1,42 @@
import { ConfigItem } from "@components/config/util";
import { Types } from "@monitor/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { keys } from "@lib/utils";
const format_mode = (m: string) => m.split("-").join(" ");
export const RestartModeSelector = ({
selected,
set,
}: {
selected: Types.RestartMode | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
}) => (
<ConfigItem label="Restart Mode">
<Select
value={selected || undefined}
onValueChange={(restart: Types.RestartMode) => set({ restart })}
>
<SelectTrigger className="max-w-[150px] capitalize">
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
{keys(Types.RestartMode).map((mode) => (
<SelectItem
key={mode}
value={Types.RestartMode[mode]}
className="capitalize"
>
{format_mode(Types.RestartMode[mode])}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
);

View File

@@ -0,0 +1,35 @@
import { ConfigItem, DoubleInput } from "@components/config/util";
import { Types } from "@monitor/client";
export const VolumesConfig = ({
volumes,
set,
}: {
volumes: Types.Conversion[];
set: (input: Partial<Types.DeploymentConfig>) => void;
}) => (
<ConfigItem label="Volumes" className="items-start">
<DoubleInput
values={volumes}
leftval="local"
leftpl="Local"
rightval="container"
rightpl="Container"
addName="Volume"
onLeftChange={(local, i) => {
volumes[i].local = local;
set({ volumes: [...volumes] });
}}
onRightChange={(container, i) => {
volumes[i].container = container;
set({ volumes: [...volumes] });
}}
onAdd={() =>
set({ volumes: [...(volumes ?? []), { container: "", local: "" }] })
}
onRemove={(idx) =>
set({ volumes: [...volumes.filter((_, i) => i !== idx)] })
}
/>
</ConfigItem>
);

View File

@@ -0,0 +1,110 @@
import { useRead, useWrite } from "@lib/hooks";
import { Types } from "@monitor/client";
import { useState } from "react";
import {
AccountSelector,
ConfigInput,
ConfigItem,
ResourceSelector,
} from "@components/config/util";
import { ImageConfig } from "./components/image";
import { RestartModeSelector } from "./components/restart";
import { NetworkModeSelector } from "./components/network";
import { PortsConfig } from "./components/ports";
import { EnvVars } from "./components/environment";
import { VolumesConfig } from "./components/volumes";
import { ExtraArgs } from "./components/extra-args";
import { ConfigInner } from "@components/config";
export const ServerSelector = ({
selected,
set,
}: {
selected: string | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
}) => (
<ConfigItem label="Server">
<ResourceSelector
type="Server"
selected={selected}
onSelect={(server_id) => set({ server_id })}
/>
</ConfigItem>
);
export const DeploymentConfig = ({ id }: { id: string }) => {
const config = useRead("GetDeployment", { id }).data?.config;
const [update, set] = useState<Partial<Types.DeploymentConfig>>({});
const { mutate } = useWrite("UpdateDeployment");
if (!config) return null;
const show_ports = update.network
? update.network !== "host"
: config.network
? config.network !== "host"
: false;
return (
<ConfigInner
config={config}
update={update}
set={set}
onSave={() => mutate({ id, config: update })}
components={{
general: {
general: {
server_id: (value, set) => (
<ServerSelector selected={value} set={set} />
),
},
container: {
image: (value, set) => <ImageConfig image={value} set={set} />,
docker_account: (value, set) => (
<AccountSelector
id={update.server_id ?? config.server_id}
account_type="docker"
type="Server"
selected={value}
onSelect={(docker_account) => set({ docker_account })}
/>
),
restart: (value, set) => (
<RestartModeSelector selected={value} set={set} />
),
extra_args: (value, set) => (
<ExtraArgs args={value ?? []} set={set} />
),
process_args: (value, set) => (
<ConfigInput
label="Process Args"
value={value}
onChange={(process_args) => set({ process_args })}
/>
),
},
network: {
network: (value, set) => (
<NetworkModeSelector
server_id={update.server_id ?? config.server_id}
selected={value}
onSelect={(network) => set({ network })}
/>
),
ports: (value, set) =>
show_ports && <PortsConfig ports={value ?? []} set={set} />,
},
volumes: {
volumes: (v, set) => <VolumesConfig volumes={v ?? []} set={set} />,
},
},
environment: {
environment: {
skip_secret_interp: true,
environment: (vars, set) => <EnvVars vars={vars ?? []} set={set} />,
},
},
}}
/>
);
};

View File

@@ -0,0 +1,155 @@
import { useRead } from "@lib/hooks";
import { Types } from "@monitor/client";
import { RequiredResourceComponents } from "@types";
import {
AlertOctagon,
ChevronDown,
RefreshCw,
Rocket,
Server,
TerminalSquare,
} from "lucide-react";
import { cn } from "@lib/utils";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { Section } from "@components/layouts";
import { Button } from "@ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { useServer } from "../server";
import { DeploymentConfig } from "./config";
import {
RedeployContainer,
StartOrStopContainer,
RemoveContainer,
} from "./actions";
export const useDeployment = (id?: string) =>
useRead("ListDeployments", {}).data?.find((d) => d.id === id);
const to_bottom = (id: string) => () =>
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" });
const TailLengthSelector = ({
selected,
onSelect,
}: {
selected: string;
onSelect: (value: string) => void;
}) => (
<Select value={selected} onValueChange={onSelect}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["50", "100", "500", "1000"].map((length) => (
<SelectItem key={length} value={length}>
{length} lines
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
export const Deployment: RequiredResourceComponents = {
Name: ({ id }) => <>{useDeployment(id)?.name}</>,
Description: ({ id }) => (
<>{useDeployment(id)?.info.status ?? "Not Deployed"}</>
),
Info: ({ id }) => (
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
{useServer(useDeployment(id)?.info.server_id)?.name ?? "N/A"}
</div>
),
Icon: ({ id }) => {
const s = useDeployment(id)?.info.state;
const color = () => {
if (s === Types.DockerContainerState.Running) return "fill-green-500";
if (s === Types.DockerContainerState.Paused) return "fill-orange-500";
if (s === Types.DockerContainerState.NotDeployed) return "fill-blue-500";
return "fill-red-500";
};
return <Rocket className={cn("w-4 h-4", color())} />;
},
Actions: ({ id }) => (
<div className="flex gap-4">
<RedeployContainer id={id} />
<StartOrStopContainer id={id} />
<RemoveContainer id={id} />
</div>
),
Page: {
Logs: ({ id }) => {
const [tail, set] = useState("50");
const { data: logs, refetch } = useRead(
"GetLog",
{ deployment_id: id, tail: Number(tail) },
{ refetchInterval: 30000 }
);
const deployment = useDeployment(id);
if (deployment?.info.state === Types.DockerContainerState.NotDeployed)
return null;
return (
<Tabs defaultValue="stdout">
<Section
title="Logs"
icon={<TerminalSquare className="w-4 h-4" />}
actions={
<div className="flex gap-2">
<TabsList className="w-fit place-self-end">
<TabsTrigger value="stdout" onClick={to_bottom("stdout")}>
stdout
</TabsTrigger>
<TabsTrigger value="stderr" onClick={to_bottom("stderr")}>
stderr
{logs?.stderr && (
<AlertOctagon className="w-4 h-4 ml-2 stroke-red-500" />
)}
</TabsTrigger>
</TabsList>
<Button variant="secondary" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
</Button>
<TailLengthSelector selected={tail} onSelect={set} />
</div>
}
>
{["stdout", "stderr"].map((t) => (
<TabsContent key={t} className="h-full relative" value={t}>
<div className="h-[60vh] overflow-y-auto">
<pre id={t} className="-scroll-mt-24">
{logs?.[t as keyof typeof logs] || `no ${t} logs`}
</pre>
</div>
<Button
className="absolute bottom-4 right-4"
onClick={to_bottom(t)}
>
<ChevronDown className="h-4 w-4" />
</Button>
</TabsContent>
))}
</Section>
</Tabs>
);
},
Config: ({ id }) => <DeploymentConfig id={id} />,
},
};

View File

@@ -0,0 +1,100 @@
import { useRead } from "@lib/hooks";
import { Types } from "@monitor/client";
import { RequiredComponents, UsableResource } from "@types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { AlarmClock, Factory, GitBranch, Hammer } from "lucide-react";
import React from "react";
import { Link } from "react-router-dom";
import { Deployment } from "./deployment";
import { Server } from "./server";
const useAlerter = (id?: string) =>
useRead("ListAlerters", {}).data?.find((d) => d.id === id);
const useBuild = (id?: string) =>
useRead("ListBuilds", {}).data?.find((d) => d.id === id);
const useBuilder = (id?: string) =>
useRead("ListBuilders", {}).data?.find((d) => d.id === id);
const useRepo = (id?: string) =>
useRead("ListRepos", {}).data?.find((d) => d.id === id);
export const ResourceComponents: {
[key in UsableResource]: {
[key in RequiredComponents]: React.FC<{ id: string }>;
} & { Page: { [section: string]: React.FC<{ id: string }> } };
} = {
Alerter: {
Name: ({ id }) => <>{useAlerter(id)?.name}</>,
Description: ({ id }) => <>{id}</>,
Info: ({ id }) => <>{id}</>,
Icon: () => <AlarmClock className="w-4 h-4" />,
Page: {},
Actions: () => null,
},
Build: {
Name: ({ id }) => <>{useBuild(id)?.name}</>,
Description: ({ id }) => <>{id}</>,
Info: ({ id }) => <>{id}</>,
Icon: () => <Hammer className="w-4 h-4" />,
Page: {},
Actions: () => null,
},
Builder: {
Name: ({ id }) => <>{useBuilder(id)?.name}</>,
Description: ({ id }) => <>{id}</>,
Info: ({ id }) => <>{id}</>,
Icon: () => <Factory className="w-4 h-4" />,
Page: {},
Actions: () => null,
},
Repo: {
Name: ({ id }) => <>{useRepo(id)?.name}</>,
Description: ({ id }) => <>{id}</>,
Info: ({ id }) => <>{id}</>,
Icon: () => <GitBranch className="w-4 h-4" />,
Page: {},
Actions: () => null,
},
Deployment,
Server,
};
export const ResourceCard = ({
target: { type, id },
}: {
target: Exclude<Types.ResourceTarget, { type: "System" }>;
}) => {
const Components = ResourceComponents[type];
return (
<Link
to={`/${type.toLowerCase()}s/${id}`}
className="group hover:translate-y-[-2.5%] focus:translate-y-[-2.5%] transition-transform"
>
<Card className="h-full hover:bg-accent/50 group-focus:bg-accent/50 transition-colors">
<CardHeader className="justify-between">
<div>
<CardTitle>
<Components.Name id={id} />
</CardTitle>
<CardDescription>
<Components.Description id={id} />
</CardDescription>
</div>
<Components.Icon id={id} />
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Components.Info id={id} />
</CardContent>
</Card>
</Link>
);
};

View File

@@ -0,0 +1,107 @@
import { useRead, useWrite } from "@lib/hooks";
import { cn } from "@lib/utils";
import { Types } from "@monitor/client";
import { RequiredResourceComponents } from "@types";
import { MapPin, Cpu, MemoryStick, Database, ServerIcon } from "lucide-react";
import { ServerStats } from "./stats";
import { ConfigInner } from "@components/config";
import { useState } from "react";
export const useServer = (id?: string) =>
useRead("ListServers", {}).data?.find((d) => d.id === id);
export const Server: RequiredResourceComponents = {
Name: ({ id }) => <>{useServer(id)?.name}</>,
Description: ({ id }) => <>{useServer(id)?.info.status}</>,
Info: ({ id }) => {
const server = useServer(id);
const stats = useRead(
"GetBasicSystemStats",
{ server_id: id },
{ enabled: server ? server.info.status !== "Disabled" : false }
).data;
const info = useRead(
"GetSystemInformation",
{ server_id: id },
{ enabled: server ? server.info.status !== "Disabled" : false }
).data;
return (
<>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
{useServer(id)?.info.region}
</div>
<div className="flex gap-4 text-muted-foreground">
<div className="flex gap-2 items-center">
<Cpu className="w-4 h-4" />
{info?.core_count ?? "N/A"} Core(s)
</div>
<div className="flex gap-2 items-center">
<MemoryStick className="w-4 h-4" />
{stats?.mem_total_gb.toFixed(2) ?? "N/A"} GB
</div>
<div className="flex gap-2 items-center">
<Database className="w-4 h-4" />
{stats?.disk_total_gb.toFixed(2) ?? "N/A"} GB
</div>
</div>
</>
);
},
Actions: () => null,
Icon: ({ id }) => {
const status = useServer(id)?.info.status;
return (
<ServerIcon
className={cn(
"w-4 h-4",
status === Types.ServerStatus.Ok && "fill-green-500",
status === Types.ServerStatus.NotOk && "fill-red-500",
status === Types.ServerStatus.Disabled && "fill-blue-500"
)}
/>
);
},
Page: {
Stats: ({ id }) => <ServerStats id={id} />,
Config: ({ id }: { id: string }) => {
const config = useRead("GetServer", { id }).data?.config;
const [update, set] = useState<Partial<Types.ServerConfig>>({});
const { mutate } = useWrite("UpdateServer");
if (!config) return null;
return (
<ConfigInner
config={config}
update={update}
set={set}
onSave={() => mutate({ id, config: update })}
components={{
general: {
general: {
address: true,
region: true,
enabled: true,
auto_prune: true,
},
},
warnings: {
cpu: {
cpu_warning: true,
cpu_critical: true,
},
memory: {
mem_warning: true,
mem_critical: true,
},
disk: {
disk_warning: true,
disk_critical: true,
},
},
}}
/>
);
},
},
};

View File

@@ -0,0 +1,125 @@
import { Section } from "@components/layouts";
import { client } from "@main";
import { Types } from "@monitor/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { Progress } from "@ui/progress";
import { Cpu, Database, LineChart, MemoryStick } from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import { useServer } from ".";
const useServerStats = (server_id: string) => {
const [stats, set] = useState<Types.AllSystemStats>();
const server = useServer(server_id);
const fetch = useCallback(
() =>
!!server &&
server.info.status !== "Disabled" &&
client
.read({ type: "GetAllSystemStats", params: { server_id } })
.then(set),
[server_id]
);
useEffect(() => {
fetch();
if (!!server && server.info.status !== "Disabled") {
const handle = setInterval(() => {
fetch();
}, 1000);
return () => {
clearInterval(handle);
};
}
}, [server, fetch]);
return stats;
};
export const ServerStats = ({ id }: { id: string }) => {
return (
<Section
title="Server Stats"
icon={<LineChart className="w-4 h-4" />}
actions=""
>
<div className="flex flex-col lg:flex-row gap-4">
<CPU id={id} />
<RAM id={id} />
<DISK id={id} />
</div>
</Section>
);
};
const CPU = ({ id }: { id: string }) => {
const stats = useServerStats(id);
const perc = stats?.cpu.cpu_perc;
return (
<Card className="w-full">
<CardHeader className="flex-row justify-between">
<CardTitle>CPU Usage</CardTitle>
<div className="flex gap-2 items-center">
<CardDescription>{perc?.toFixed(2)}%</CardDescription>
<Cpu className="w-4 h-4" />
</div>
</CardHeader>
<CardContent>
<Progress value={perc} className="h-4" />
</CardContent>
</Card>
);
};
const RAM = ({ id }: { id: string }) => {
const stats = useServerStats(id);
const used = stats?.basic.mem_used_gb;
const total = stats?.basic.mem_total_gb;
const perc = ((used ?? 0) / (total ?? 0)) * 100;
return (
<Card className="w-full">
<CardHeader className="flex-row justify-between">
<CardTitle>RAM Usage</CardTitle>
<div className="flex gap-2 items-center">
<CardDescription>{perc.toFixed(2)}%</CardDescription>
<MemoryStick className="w-4 h-4" />
</div>
</CardHeader>
<CardContent>
<Progress value={perc} className="h-4" />
</CardContent>
</Card>
);
};
const DISK = ({ id }: { id: string }) => {
const stats = useServerStats(id);
const used = stats?.disk.used_gb;
const total = stats?.disk.total_gb;
const perc = ((used ?? 0) / (total ?? 0)) * 100;
return (
<Card className="w-full">
<CardHeader className="flex-row justify-between">
<CardTitle>Disk Usage</CardTitle>
<div className="flex gap-2 items-center">
<CardDescription>{perc?.toFixed(2)}%</CardDescription>
<Database className="w-4 h-4" />
</div>
</CardHeader>
<CardContent>
<Progress value={perc} className="h-4" />
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,139 @@
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@ui/sheet";
import { Calendar, Clock, Milestone, User } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { ReactNode } from "react";
import { useRead } from "@lib/hooks";
import { fmt_duration, fmt_verison } from "@lib/utils";
import { ResourceComponents } from "@components/resources";
export const UpdateUser = ({ user_id }: { user_id: string }) => {
const username = useRead("GetUsername", { user_id }).data;
if (user_id === "github") return <>GitHub</>;
if (user_id === "auto redeploy") return <>Auto Redeploy</>;
return <>{username?.username}</>;
};
export const UpdateDetails = ({
id,
children,
}: {
id: string;
children: ReactNode;
}) => {
const update = useRead("GetUpdate", { id }).data;
if (!update) return null;
const Components =
update.target.type === "System"
? null
: ResourceComponents[update.target.type];
if (!Components) return null;
return (
<Sheet>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent
side="right"
className="overflow-y-auto w-[100vw] md:w-[75vw] lg:w-[50vw]"
>
<SheetHeader className="mb-4">
<SheetTitle>
{update.operation
.split("_")
.map((s) => s[0].toUpperCase() + s.slice(1))
.join(" ")}{" "}
{fmt_verison(update.version)}
</SheetTitle>
<SheetDescription className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<UpdateUser user_id={update.operator} />
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Components.Name id={update.target.id} />
</div>
{update.version && (
<div className="flex items-center gap-2">
<Milestone className="w-4 h-4" />
{fmt_verison(update.version)}
</div>
)}
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{new Date(update.start_ts).toLocaleString()}
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
{update.end_ts
? fmt_duration(update.start_ts, update.end_ts)
: "ongoing"}
</div>
</div>
</SheetDescription>
</SheetHeader>
<div className="grid gap-2">
{update.logs?.map((log, i) => (
<Card key={i}>
<CardHeader className="flex-col">
<CardTitle>{log.stage}</CardTitle>
<CardDescription className="flex gap-2">
<span>
Stage {i + 1} of {update.logs.length}
</span>
<span>|</span>
<span className="flex items-center gap-2">
<Clock className="w-4 h-4" />
{fmt_duration(log.start_ts, log.end_ts)}
</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{log.command && (
<div>
<CardDescription>command</CardDescription>
<pre className="max-h-[500px] overflow-y-auto">
{log.command}
</pre>
</div>
)}
{log.stdout && (
<div>
<CardDescription>stdout</CardDescription>
<pre className="max-h-[500px] overflow-y-auto">
{log.stdout}
</pre>
</div>
)}
{log.stderr && (
<div>
<CardDescription>stdout</CardDescription>
<pre className="max-h-[500px] overflow-y-auto">
{log.stderr}
</pre>
</div>
)}
</CardContent>
</Card>
))}
</div>
</SheetContent>
</Sheet>
);
};

View File

@@ -0,0 +1,86 @@
import { useRead } from "@lib/hooks";
import { Button } from "@ui/button";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription,
} from "@ui/card";
import { Bell, ExternalLink, User, Calendar, Check, X } from "lucide-react";
import { Link } from "react-router-dom";
import { Types } from "@monitor/client";
import { Section } from "@components/layouts";
import { ResourceTarget } from "@monitor/client/dist/types";
import { fmt_update_date } from "@lib/utils";
import { UpdateDetails, UpdateUser } from "./details";
const UpdatePlaceHolder = () => (
<Card>
<CardHeader>
<CardTitle>...</CardTitle>
<CardContent>
<CardDescription className="flex items-center gap-2">
<User className="w-4 h-4" /> ...
</CardDescription>
<CardDescription className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> ...
</CardDescription>
</CardContent>
</CardHeader>
</Card>
);
const UpdateCard = ({ update }: { update: Types.UpdateListItem }) => (
<UpdateDetails id={update.id}>
<Card className="cursor-pointer hover:translate-y-[-2.5%] hover:bg-accent/50 transition-all">
<CardHeader className="flex-row justify-between">
<CardTitle>{update.operation}</CardTitle>
{update.success ? (
<Check className="w-4 h-4 stroke-green-500" />
) : (
<X className="w-4 h-4 stroke-red-500" />
)}
</CardHeader>
<CardContent>
<CardDescription className="flex items-center gap-2">
<User className="w-4 h-4" /> <UpdateUser user_id={update.operator} />
</CardDescription>
<CardDescription className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{fmt_update_date(new Date(update.start_ts))}
</CardDescription>
</CardContent>
</Card>
</UpdateDetails>
);
export const ResourceUpdates = ({ type, id }: ResourceTarget) => {
const { data, isLoading } = useRead("ListUpdates", {
query: {
"target.type": type,
"target.id": id,
},
});
return (
<Section
title="Updates"
icon={<Bell className="w-4 h-4" />}
actions={
<Link to={`/deployments/${id}/updates`}>
<Button variant="secondary">
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
}
>
<div className="grid md:grid-cols-3 gap-4">
{isLoading && <UpdatePlaceHolder />}
{data?.updates.slice(0, 3).map((update) => (
<UpdateCard update={update} key={update.id} />
))}
</div>
</Section>
);
};

View File

@@ -0,0 +1,256 @@
import { ReactNode, forwardRef, useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Check, Copy, Loader2, Moon, SunMedium } from "lucide-react";
import { Input } from "../ui/input";
import {
Dialog,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogContent,
DialogFooter,
} from "@ui/dialog";
// import { useNavigate } from "react-router-dom";
import { toast } from "@ui/use-toast";
import { cn } from "@lib/utils";
import { useInvalidate, useWrite } from "@lib/hooks";
import { useNavigate } from "react-router-dom";
export const WithLoading = ({
children,
isLoading,
loading,
isError,
error,
}: {
children: ReactNode;
isLoading: boolean;
loading?: ReactNode;
isError: boolean;
error?: ReactNode;
}) => {
if (isLoading) return <>{loading ?? "loading"}</>;
if (isError) return <>{error ?? null}</>;
return <>{children}</>;
};
export const ConfigInput = ({
placeholder,
value,
onChange,
}: {
placeholder: string;
value: string | undefined;
onChange: (s: string) => void;
}) => (
<Input
placeholder={placeholder}
className="max-w-[500px]"
value={value}
onChange={({ target }) => onChange(target.value)}
/>
);
export const ThemeToggle = () => {
const [theme, set] = useState(localStorage.getItem("theme"));
useEffect(() => {
localStorage.setItem("theme", theme ?? "dark");
if (theme === "dark") document.body.classList.remove("dark");
else document.body.classList.add("dark");
}, [theme]);
return (
<Button
variant="ghost"
onClick={() => set(theme === "dark" ? "light" : "dark")}
>
<SunMedium className="w-4 h-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="w-4 h-4 absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
);
};
export const ActionButton = forwardRef<
HTMLButtonElement,
{
title: string;
icon: ReactNode;
disabled?: boolean;
className?: string;
onClick?: () => void;
loading?: boolean;
}
>(({ title, icon, disabled, className, loading, onClick }, ref) => (
<Button
variant="outline"
className={cn("flex items-center justify-between w-[150px]", className)}
onClick={onClick}
disabled={disabled}
ref={ref}
>
{title} {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon}
</Button>
));
export const ActionWithDialog = ({
name,
title,
icon,
disabled,
loading,
onClick,
}: {
name: string;
title: string;
icon: ReactNode;
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
}) => {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<ActionButton
title={title}
icon={icon}
disabled={disabled}
onClick={() => setOpen(true)}
loading={loading}
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm {title}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 my-4">
<p
onClick={() => {
navigator.clipboard.writeText(name);
toast({ title: `Copied "${name}" to clipboard!` });
}}
className="cursor-pointer"
>
Please enter <b>{name}</b> below to confirm this action.
<br />
<span className="text-xs text-muted-foreground">
You may click the name in bold to copy it
</span>
</p>
<Input value={input} onChange={(e) => setInput(e.target.value)} />
</div>
<DialogFooter>
<ActionButton
title={title}
icon={icon}
disabled={name !== input}
onClick={() => {
onClick && onClick();
setOpen(false);
}}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const CopyResource = ({
id,
disabled,
type,
}: {
id: string;
disabled?: boolean;
type: "Deployment" | "Build";
}) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const nav = useNavigate();
const inv = useInvalidate();
const { mutate } = useWrite(`Copy${type}`, {
onSuccess: (res) => {
inv([`List${type}s`]);
nav(`/${type.toLowerCase()}s/${res._id?.$oid}`);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<ActionButton
title="Copy"
icon={<Copy className="w-4 h-4" />}
disabled={disabled}
onClick={() => setOpen(true)}
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Copy {type}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 my-4">
<p>Provide a name for the newly created {type.toLowerCase()}.</p>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<DialogFooter>
<ActionButton
title="Confirm"
icon={<Check className="w-4 h-4" />}
disabled={!name}
onClick={() => {
mutate({ id, name });
setOpen(false);
}}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const ConfirmButton = ({
title,
icon,
disabled,
loading,
onClick,
}: {
title: string;
icon: ReactNode;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
}) => {
const [confirmed, set] = useState(false);
return (
<>
<ActionButton
title={confirmed ? "Confirm" : title}
icon={confirmed ? <Check className="w-4 h-4" /> : icon}
disabled={disabled}
onClick={
confirmed
? () => {
onClick();
set(false);
}
: () => set(true)
}
className={confirmed ? "z-50" : ""}
loading={loading}
/>
{confirmed && (
<div
className="fixed z-40 top-0 left-0 w-[100vw] h-[100vh]"
onClick={() => set(false)}
/>
)}
</>
);
};

165
frontend-v2/src/globals.css Normal file
View File

@@ -0,0 +1,165 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: Inter;
}
pre {
@apply bg-accent/50 min-h-full text-xs p-4 whitespace-pre-wrap scroll-m-4 break-all rounded-md;
}
}
/* blue theme */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
/* green theme */
/*
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 76.2% 36.3%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 0 0% 95%;
--card: 24 9.8% 10%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
}
} */
/* neutral theme */
/* @layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
} */

View File

@@ -0,0 +1,111 @@
import { client } from "@main";
import { Types } from "@monitor/client";
import {
ExecuteResponses,
ReadResponses,
WriteResponses,
} from "@monitor/client/dist/responses";
import { ResourceTarget } from "@monitor/client/dist/types";
import {
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { UsableResource } from "@types";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
export const useResourceParamType = () => {
const type = useParams().type;
if (!type) return undefined as unknown as UsableResource;
return (type[0].toUpperCase() + type.slice(1, -1)) as UsableResource;
};
export const useRead = <
T extends Types.ReadRequest["type"],
R extends Extract<Types.ReadRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseQueryOptions<
ReadResponses[R["type"]],
unknown,
ReadResponses[R["type"]],
(T | P)[]
>,
"queryFn" | "queryKey"
>
>(
type: T,
params: P,
config?: C
) => useQuery([type, params], () => client.read({ type, params } as R), config);
export const useWrite = <
T extends Types.WriteRequest["type"],
R extends Extract<Types.WriteRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<WriteResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>
>(
type: T,
config?: C
) =>
useMutation([type], (params: P) => client.write({ type, params } as R), {
...config,
onError: (e, v, c) => {
config?.onError && config.onError(e, v, c);
},
});
export const useExecute = <
T extends Types.ExecuteRequest["type"],
R extends Extract<Types.ExecuteRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<ExecuteResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>
>(
type: T,
config?: C
) =>
useMutation([type], (params: P) => client.execute({ type, params } as R), {
...config,
onError: (e, v, c) => {
// toast({ title: `Error - ${type} Failed`, intent: "danger" });
config?.onError && config.onError(e, v, c);
},
});
export const useInvalidate = () => {
const qc = useQueryClient();
return <
T extends Types.ReadRequest["type"],
P = Extract<Types.ReadRequest, { type: T }>["params"]
>(
...keys: Array<[T] | [T, P]>
) =>
keys.forEach((k) => {
console.log("invalidating", k);
qc.invalidateQueries([...k]);
});
};
export const usePushRecentlyViewed = ({ type, id }: ResourceTarget) => {
const invalidate = useInvalidate();
const push = useWrite("PushRecentlyViewed", {
onSuccess: () => invalidate(["GetUser"]),
}).mutate;
useEffect(() => {
!!type && !!id && push({ resource: { type, id } });
}, [type, id, push]);
return push;
};

View File

@@ -0,0 +1,109 @@
import { useInvalidate } from "@lib/hooks";
import { Types } from "@monitor/client";
import { Button } from "@ui/button";
import { toast } from "@ui/use-toast";
import { atom, useAtom } from "jotai";
import { Circle } from "lucide-react";
import { ReactNode, useCallback, useEffect } from "react";
import rws from "reconnecting-websocket";
import { cn } from "@lib/utils";
// import { UPDATE_WS_URL } from "@main";
const rws_atom = atom<rws | null>(null);
const useWebsocket = () => useAtom(rws_atom);
const on_message = (
{ data }: MessageEvent,
invalidate: ReturnType<typeof useInvalidate>
) => {
if (data == "LOGGED_IN") return console.log("logged in to ws");
const update = JSON.parse(data) as Types.UpdateListItem;
toast({
title: update.operation,
description: update.username,
});
invalidate(["ListUpdates"]);
if (update.target.type === "Deployment") {
invalidate(
["ListDeployments"],
["GetDeployment"],
["GetLog"],
["GetDeploymentActionState"],
["GetDeploymentStatus"]
);
}
if (update.target.type === "Server") {
invalidate(
["ListServers"],
["GetServer"],
["GetServerActionState"],
["GetServerStatus"],
["GetHistoricalServerStats"]
);
}
if (update.target.type === "Build") {
invalidate(["ListBuilds"], ["GetBuild"], ["GetBuildActionState"]);
}
};
const on_open = (ws: rws | null) => {
const token = localStorage.getItem("monitor-auth-token");
if (token && ws) ws.send(token);
};
export const WebsocketProvider = ({
url,
children,
}: {
url: string;
children: ReactNode;
}) => {
const invalidate = useInvalidate();
const [ws, set] = useWebsocket();
const on_open_fn = useCallback(() => on_open(ws), [ws]);
const on_message_fn = useCallback(
(e: MessageEvent) => on_message(e, invalidate),
[invalidate]
);
useEffect(() => {
if (!ws) set(new rws(url));
return () => {
ws?.close();
};
}, [set, url, ws]);
useEffect(() => {
ws?.addEventListener("open", on_open_fn);
ws?.addEventListener("message", on_message_fn);
return () => {
ws?.close();
ws?.removeEventListener("open", on_open_fn);
ws?.removeEventListener("message", on_message_fn);
};
}, [on_message_fn, on_open_fn, ws]);
return <>{children}</>;
};
export const WsStatusIndicator = () => {
const [ws] = useWebsocket();
const onclick = () =>
toast({ title: "surprise", description: "motherfucker" });
return (
<Button variant="ghost" onClick={onclick}>
<Circle
className={cn(
"w-4 h-4 stroke-none",
ws ? "fill-green-500" : "fill-red-500"
)}
/>
</Button>
);
};

View File

@@ -0,0 +1,74 @@
import { Types } from "@monitor/client";
import { UsableResource } from "@types";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const keys = <T extends object>(o: T): (keyof T)[] =>
Object.keys(o) as (keyof T)[];
export const RESOURCE_TARGETS: UsableResource[] = [
"Alerter",
"Build",
"Builder",
"Deployment",
"Repo",
"Server",
];
export const fmt_update_date = (d: Date) =>
`${d.getDate()}/${d.getMonth() + 1} @ ${d.getHours()}:${d.getMinutes()}`;
export const fmt_verison = (version: Types.Version) => {
const { major, minor, patch } = version;
if (major === 0 && minor === 0 && patch === 0) return "latest";
return `v${major}.${minor}.${patch}`;
};
export const fmt_duration = (start_ts: number, end_ts: number) => {
const start = new Date(start_ts);
const end = new Date(end_ts);
const durr = end.getTime() - start.getTime();
const seconds = durr / 1000;
const minutes = Math.floor(seconds / 60);
const remaining_seconds = seconds % 60;
return `${
minutes > 0 ? `${minutes} minute${minutes > 1 ? "s" : ""} ` : ""
}${remaining_seconds.toFixed(minutes > 0 ? 0 : 1)} seconds`;
};
export function env_to_text(envVars: Types.EnvironmentVar[] | undefined) {
return envVars?.reduce(
(prev, { variable, value }) =>
prev + (prev ? "\n" : "") + `${variable}=${value}`,
""
);
}
function keep_line(line: string) {
if (line.length === 0) return false;
let firstIndex = -1;
for (let i = 0; i < line.length; i++) {
if (line[i] !== " ") {
firstIndex = i;
break;
}
}
if (firstIndex === -1) return false;
if (line[firstIndex] === "#") return false;
return true;
}
export function text_to_env(env: string): Types.EnvironmentVar[] {
return env
.split("\n")
.filter((line) => keep_line(line))
.map((entry) => {
const [first, ...rest] = entry.replaceAll('"', "").split("=");
return [first, rest.join("=")];
})
.map(([variable, value]) => ({ variable, value }));
}

34
frontend-v2/src/main.tsx Normal file
View File

@@ -0,0 +1,34 @@
import "globals.css";
import ReactDOM from "react-dom/client";
import { MonitorClient } from "@monitor/client";
import { ThemeProvider } from "@ui/theme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Router } from "@router";
import { WebsocketProvider } from "@lib/socket";
import { Toaster } from "@ui/toaster";
export const MONITOR_BASE_URL =
import.meta.env.VITE_MONITOR_HOST ?? "https://v1.api.monitor.mogh.tech";
export const UPDATE_WS_URL =
MONITOR_BASE_URL.replace("http", "ws") + "/ws/update";
const token = localStorage.getItem("monitor-auth-token");
export const client = MonitorClient(MONITOR_BASE_URL, token ?? undefined);
const query_client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
// <React.StrictMode>
<QueryClientProvider client={query_client}>
<WebsocketProvider url={UPDATE_WS_URL}>
<ThemeProvider>
<Router />
<Toaster />
</ThemeProvider>
</WebsocketProvider>
</QueryClientProvider>
// </React.StrictMode>
);

View File

@@ -0,0 +1,51 @@
import { useRead } from "@lib/hooks";
import { Page, Section } from "@components/layouts";
import { Box, History } from "lucide-react";
import { ResourceCard } from "@components/resources";
import { DeploymentsChart } from "@components/dashboard/deployments-chart";
import { ServersChart } from "@components/dashboard/servers-chart";
import { BuildChart } from "@components/dashboard/builds-chart";
const RecentlyViewed = () => (
<Section
title="Recently Viewed"
icon={<History className="w-4 h-4" />}
actions=""
>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{useRead("GetUser", {})
.data?.recently_viewed?.slice(0, 6)
.map(
(target) =>
target.type !== "System" && (
<ResourceCard target={target} key={target.id} />
)
)}
</div>
</Section>
);
const MyResources = () => (
<Section title="My Resources" icon={<Box className="w-4 h-4" />} actions="">
<div className="flex flex-col lg:flex-row gap-4 w-full">
<div className="flex flex-col md:flex-row gap-4 w-full">
<ServersChart />
<DeploymentsChart />
</div>
<div className="w-full lg:max-w-[50%]">
<BuildChart />
</div>
</div>
</Section>
);
export const Dashboard = () => {
const user = useRead("GetUser", {}).data;
return (
<Page title={`Hello, ${user?.username}.`}>
<RecentlyViewed />
<MyResources />
</Page>
);
};

View File

@@ -0,0 +1,94 @@
import { Button } from "@ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@ui/card";
import { Input } from "@ui/input";
import { Label } from "@ui/label";
import { useInvalidate } from "@lib/hooks";
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { client } from "@main";
import { ThemeToggle } from "@ui/theme";
type LoginCredentials = { username: string; password: string };
const useLogin = (creds: LoginCredentials) => {
const invalidate = useInvalidate();
const mutation = useMutation(
(creds: LoginCredentials) => client.login(creds),
{
onSuccess: (jwt) => {
localStorage.setItem("monitor-auth-token", jwt ?? "");
invalidate(["GetUser"]);
},
}
);
useEffect(() => {
const handler = (e: KeyboardEvent) =>
e.key === "Enter" && !mutation.isLoading && mutation.mutate(creds);
addEventListener("keydown", handler);
return () => {
removeEventListener("keydown", handler);
};
});
return mutation;
};
export const Login = () => {
const [creds, set] = useState({ username: "", password: "" });
const { mutate, isLoading } = useLogin(creds);
return (
<div className="flex flex-col min-h-screen">
<div className="container flex justify-end items-center h-16">
<ThemeToggle />
</div>
<div className="flex justify-center items-center container mt-32">
<Card className="w-full max-w-[500px] place-self-center">
<CardHeader className="flex-col gap-2">
<CardTitle className="text-xl">Monitor</CardTitle>
<CardDescription>Log In</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={creds.username}
onChange={({ target }) =>
set((c) => ({ ...c, username: target.value }))
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={creds.password}
onChange={({ target }) =>
set((c) => ({ ...c, password: target.value }))
}
/>
</div>
</CardContent>
<CardFooter>
<div className="flex w-full justify-end">
<Button onClick={() => mutate(creds)} disabled={isLoading}>
Login
</Button>
</div>
</CardFooter>
</Card>
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { Page } from "@components/layouts";
import { ResourceComponents } from "@components/resources";
import { ResourceUpdates } from "@components/updates/resource";
import { usePushRecentlyViewed, useResourceParamType } from "@lib/hooks";
import { useParams } from "react-router-dom";
export const Resource = () => {
const type = useResourceParamType();
const id = useParams().id as string;
usePushRecentlyViewed({ type, id });
if (!type || !id) return null;
const Components = ResourceComponents[type];
return (
<Page
title={<Components.Name id={id} />}
subtitle={
<div className="text-sm text-muted-foreground">
<div className="flex gap-2">
<Components.Icon id={id} />
<Components.Description id={id} />
</div>
<div className="flex gap-8">
<Components.Info id={id} />
</div>
</div>
}
actions={<Components.Actions id={id} />}
>
<ResourceUpdates type={type} id={id} />
{Object.keys(Components.Page).map((section) => {
const Component = Components.Page[section];
return <Component id={id} key={section} />;
})}
</Page>
);
};

View File

@@ -0,0 +1,41 @@
import { Page, Section } from "@components/layouts";
import { ResourceCard } from "@components/resources";
import { useRead, useResourceParamType } from "@lib/hooks";
import { Button } from "@ui/button";
import { Input } from "@ui/input";
import { PlusCircle } from "lucide-react";
import { useState } from "react";
export const Resources = () => {
const type = useResourceParamType();
const list = useRead(`List${type}s`, {}).data;
const [search, set] = useState("");
return (
<Page
title={`${type}s`}
actions={
<div className="flex gap-4">
<Input
value={search}
onChange={(e) => set(e.target.value)}
placeholder="search..."
className="w-96"
/>
<Button className="gap-2">
New <PlusCircle className="w-4 h-4" />
</Button>
</div>
}
>
<Section title="">
<div className="grid grid-cols-3 gap-4">
{list?.map(({ id }) => (
<ResourceCard key={id} target={{ type, id }} />
))}
</div>
</Section>
</Page>
);
};

View File

@@ -0,0 +1,33 @@
import { Layout } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { Dashboard } from "@pages/dashboard";
import { Login } from "@pages/login";
import { Resource } from "@pages/resource";
import { Resources } from "@pages/resources";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ path: "", element: <Dashboard /> },
{
path: ":type",
children: [
{ path: "", element: <Resources /> },
{ path: ":id", element: <Resource /> },
],
},
],
},
]);
export const Router = () => {
const { data: user, isLoading } = useRead("GetUser", {});
if (isLoading) return null;
if (!user) return <Login />;
return <RouterProvider router={router} />;
};

14
frontend-v2/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import { Types } from "@monitor/client";
export type UsableResource = Exclude<Types.ResourceTarget["type"], "System">;
export type RequiredComponents =
| "Name"
| "Description"
| "Icon"
| "Info"
| "Actions";
export type RequiredResourceComponents = {
[key in RequiredComponents]: React.FC<{ id: string }>;
} & { Page: { [key: string]: React.FC<{ id: string }> } };

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,82 @@
import * as React from "react";
import { cn } from "@lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"font-semibold text-lg leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,153 @@
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@lib/utils"
import { Dialog, DialogContent } from "@//ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,121 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = ({
className,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props} />
)
DialogPortal.displayName = DialogPrimitive.Portal.displayName
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,203 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import { cn } from "@lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,118 @@
import * as React from "react"
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
}

View File

@@ -0,0 +1,142 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = ({
className,
...props
}: SheetPrimitive.DialogPortalProps) => (
<SheetPrimitive.Portal className={cn(className)} {...props} />
);
SheetPortal.displayName = SheetPrimitive.Portal.displayName;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-300",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[20px] w-[36px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,108 @@
import { createContext, useContext, useEffect, useState } from "react";
import { Button } from "@ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { Moon, Sun } from "lucide-react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="w-4 h-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute w-4 h-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import { Cross2Icon } from "@radix-ui/react-icons";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed gap-2 top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@ui/toast";
import { useToast } from "@ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,189 @@
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@ui/toast";
const TOAST_LIMIT = 5;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

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

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

View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

29
frontend-v2/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import tspaths from "vite-tsconfig-paths";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tspaths()],
});

2624
frontend-v2/yarn.lock Normal file

File diff suppressed because it is too large Load Diff