diff --git a/frontend-v2/.eslintrc.cjs b/frontend-v2/.eslintrc.cjs new file mode 100644 index 000000000..d6c953795 --- /dev/null +++ b/frontend-v2/.eslintrc.cjs @@ -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 }, + ], + }, +} diff --git a/frontend-v2/.gitignore b/frontend-v2/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/frontend-v2/.gitignore @@ -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? diff --git a/frontend-v2/Dockerfile b/frontend-v2/Dockerfile new file mode 100644 index 000000000..c6545daed --- /dev/null +++ b/frontend-v2/Dockerfile @@ -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 \ No newline at end of file diff --git a/frontend-v2/README.md b/frontend-v2/README.md new file mode 100644 index 000000000..1ebe379f5 --- /dev/null +++ b/frontend-v2/README.md @@ -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 diff --git a/frontend-v2/components.json b/frontend-v2/components.json new file mode 100644 index 000000000..34eae2db6 --- /dev/null +++ b/frontend-v2/components.json @@ -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" + } +} diff --git a/frontend-v2/index.html b/frontend-v2/index.html new file mode 100644 index 000000000..066bac405 --- /dev/null +++ b/frontend-v2/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/frontend-v2/package.json b/frontend-v2/package.json new file mode 100644 index 000000000..0b548ccb1 --- /dev/null +++ b/frontend-v2/package.json @@ -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" + } +} diff --git a/frontend-v2/postcss.config.js b/frontend-v2/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/frontend-v2/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend-v2/src/components/config/index.tsx b/frontend-v2/src/components/config/index.tsx new file mode 100644 index 000000000..e2d4d404e --- /dev/null +++ b/frontend-v2/src/components/config/index.tsx @@ -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 = >(obj: T) => + Object.keys(obj) as Array; + +export const ConfigAgain = ["config"]>({ + config, + update, + components, + set, +}: { + config: T; + update: Partial; + components: Partial<{ + [K in keyof T extends string ? keyof T : never]: + | true + | ((value: T[K], set: (value: Partial) => void) => ReactNode); + }>; + set: (value: Partial) => 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 ( + set({ [key]: value } as Partial)} + /> + ); + case "number": + return ( + + set({ [key]: Number(value) } as Partial) + } + /> + ); + case "boolean": + return ( + set({ [key]: value } as Partial)} + /> + ); + default: + return
{key.toString()}
; + } + } + return ( + {component?.(value, set)} + ); + })} + + ); +}; + +const ConfigLayout = ["config"]>({ + content, + children, + onConfirm, + onReset, +}: { + content: Partial; + children: ReactNode; + onConfirm: () => void; + onReset: () => void; +}) => ( +
} + actions={ +
+ + +
+ } + > + {children} +
+); + +export const ConfigInner = ({ + config, + update, + set, + onSave, + components, +}: { + config: T; + update: Partial; + set: React.Dispatch>>; + onSave: () => void; + components: Record< + string, + Record< + string, + { + [K in keyof Partial]: + | true + | ((value: T[K], set: (value: Partial) => void) => ReactNode); + } + > + >; +}) => { + const [show, setShow] = useState(keys(components)[0]); + + return ( + set({})}> +
+
+ {keys(components).map((tab) => ( + + ))} +
+
+ {Object.entries(components[show]).map(([k, v]) => ( + + + {k} + + + set((p) => ({ ...p, ...u }))} + components={v} + /> + + + ))} +
+
+
+ ); +}; diff --git a/frontend-v2/src/components/config/util.tsx b/frontend-v2/src/components/config/util.tsx new file mode 100644 index 000000000..ab8618914 --- /dev/null +++ b/frontend-v2/src/components/config/util.tsx @@ -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; +}) => ( +
+
{label}
+ {children} +
+); + +export const ConfigInput = ({ + label, + value, + onChange, +}: { + label: string; + value: string | number | undefined; + onChange: (value: string) => void; +}) => ( + + onChange(e.target.value)} + // disabled={loading} + /> + +); + +export const ConfigSwitch = ({ + label, + value, + onChange, +}: { + label: string; + value: boolean | undefined; + onChange: (value: boolean) => void; +}) => ( + + + +); + +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 ( +
+ {values?.map((value, i) => ( +
+ onLeftChange(e.target.value as T[L], i)} + /> + : + onRightChange(e.target.value as T[R], i)} + /> + +
+ ))} + +
+ ); +}; + +type UsableResources = Exclude; + +export const ResourceSelector = ({ + type, + selected, + onSelect, +}: { + type: UsableResources; + selected: string | undefined; + onSelect: (id: string) => void; +}) => { + const resources = useRead(`List${type}s`, {}).data; + return ( + + ); +}; + +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 ( + + + + ); +}; + +export const InputList = ({ + field, + values, + set, +}: { + field: keyof T; + values: string[]; + set: (update: Partial) => void; +}) => ( + +
+ {values.map((arg, i) => ( +
+ { + values[i] = e.target.value; + set({ [field]: [...values] } as Partial); + }} + /> + +
+ ))} + + +
+
+); + +interface ConfirmUpdateProps { + content: string; + onConfirm: () => void; +} + +export const ConfirmUpdate = ({ content, onConfirm }: ConfirmUpdateProps) => { + const [open, set] = useState(false); + return ( + + + + + + + Confirm Update + +
+ New configuration to be applied: +
{content}
+
+ + + +
+
+ ); +}; diff --git a/frontend-v2/src/components/dashboard/builds-chart.tsx b/frontend-v2/src/components/dashboard/builds-chart.tsx new file mode 100644 index 000000000..4ca6bae0c --- /dev/null +++ b/frontend-v2/src/components/dashboard/builds-chart.tsx @@ -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(null); + const line_ref = useRef(); + const series_ref = useRef>(); + 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 ( + + +
+ Builds + {data?.total_time.toFixed(2)} Hours +
+ +
+ +
+ + + ); +}; diff --git a/frontend-v2/src/components/dashboard/deployments-chart.tsx b/frontend-v2/src/components/dashboard/deployments-chart.tsx new file mode 100644 index 000000000..086fcf14e --- /dev/null +++ b/frontend-v2/src/components/dashboard/deployments-chart.tsx @@ -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 ( + + +
+ Deployments + {summary?.total} Total +
+ +
+ +
+ + + {summary?.running}{" "} + + Running + + + {summary?.stopped} + Stopped + + + + {summary?.not_deployed}{" "} + + Not Deployed + + + + {summary?.unknown}{" "} + + Unknown + +
+
+ +
+
+
+ ); +}; diff --git a/frontend-v2/src/components/dashboard/servers-chart.tsx b/frontend-v2/src/components/dashboard/servers-chart.tsx new file mode 100644 index 000000000..064775116 --- /dev/null +++ b/frontend-v2/src/components/dashboard/servers-chart.tsx @@ -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 ( + + +
+ Servers + {data?.total} Total +
+ +
+ +
+ + {data?.healthy} + Healthy + + + {data?.unhealthy} + Unhealthy + + + {data?.disabled} + Disabled + +
+
+ +
+
+
+ ); +}; diff --git a/frontend-v2/src/components/layouts.tsx b/frontend-v2/src/components/layouts.tsx new file mode 100644 index 000000000..169b7d521 --- /dev/null +++ b/frontend-v2/src/components/layouts.tsx @@ -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 ( + <> +
+
+ + Monitor + +
+ + + + + + + + + Dashboard + + {RESOURCE_TARGETS.map((rt) => ( + + {rt}s + + ))} + + + +
+ + + +
+
+
+
+ + + ); +}; + +interface PageProps { + title: ReactNode; + children: ReactNode; + subtitle?: ReactNode; + actions?: ReactNode; +} + +export const Page = ({ title, subtitle, actions, children }: PageProps) => ( +
+
+
+

{title}

+ {subtitle} +
+ {actions} +
+ {children} +
+); + +interface SectionProps { + title: string; + children: ReactNode; + icon?: ReactNode; + actions?: ReactNode; +} + +export const Section = ({ title, icon, actions, children }: SectionProps) => ( +
+
+
+ {icon} +

{title}

+
+ {actions} +
+ {children} +
+); + +export const NewResource = ({ + type, + loading, + children, + onSuccess, +}: { + type: UsableResource; + loading: boolean; + children: ReactNode; + onSuccess: () => Promise; +}) => { + const [open, set] = useState(false); + return ( + + + + + + + New {type} + +
{children}
+ + + +
+
+ ); +}; diff --git a/frontend-v2/src/components/omnibar.tsx b/frontend-v2/src/components/omnibar.tsx new file mode 100644 index 000000000..86fe60a7c --- /dev/null +++ b/frontend-v2/src/components/omnibar.tsx @@ -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 ( + + {data?.map(({ id }) => { + return ( + onSelect(`/${type.toLowerCase()}s/${id}`)} + > + + + + ); + })} + + ); +}; + +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 ( + <> + + + + + No results found. + {RESOURCE_TARGETS.map((rt) => ( + + + + + ))} + + + + ); +}; diff --git a/frontend-v2/src/components/resources/deployment/actions.tsx b/frontend-v2/src/components/resources/deployment/actions.tsx new file mode 100644 index 000000000..e692f3e1b --- /dev/null +++ b/frontend-v2/src/components/resources/deployment/actions.tsx @@ -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 ( + } + 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 ( + } + 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 ( + } + 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 ; + return ; +}; + +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 ( + } + 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 ( + } + 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 ( +
+
Rename Deployment
+
+ set(e.target.value)} + className="w-96" + placeholder="Enter new name" + /> + } + disabled={isLoading} + onClick={() => mutate({ id, name })} + /> +
+
+ ); +}; diff --git a/frontend-v2/src/components/resources/deployment/config/components/environment.tsx b/frontend-v2/src/components/resources/deployment/config/components/environment.tsx new file mode 100644 index 000000000..3dae57e52 --- /dev/null +++ b/frontend-v2/src/components/resources/deployment/config/components/environment.tsx @@ -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) => void; +}) => { + const [env, setEnv] = useState(env_to_text(vars)); + useEffect(() => { + !!env && set({ environment: text_to_env(env) }); + }, [env, set]); + + return ( + +