variables working

This commit is contained in:
mbecker20
2024-05-19 01:51:09 -07:00
parent f8e371af31
commit 6b25309aed
13 changed files with 535 additions and 130 deletions

View File

@@ -76,6 +76,10 @@ impl Resolve<UpdateVariableValue, User> for State {
let variable = get_variable(&name).await?;
if value == variable.value {
return Err(anyhow!("no change"));
}
db_client()
.await
.variables

View File

@@ -3,6 +3,7 @@ import { useRead } from "@lib/hooks";
import { env_to_text, text_to_env } from "@lib/utils";
import { Types } from "@monitor/client";
import { Button } from "@ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { Textarea } from "@ui/textarea";
import { RefObject, createRef, useEffect, useState } from "react";
@@ -53,29 +54,117 @@ const Secrets = ({
/// eg server id
server: string;
}) => {
const secrets = useRead("GetAvailableSecrets", { server }).data;
const { variables, secrets: core_secrets } = useRead("ListVariables", {})
.data ?? {
variables: [],
secrets: [],
};
const periphery_secrets =
useRead("GetAvailableSecrets", { server }).data || [];
// Get unique list of secrets between core and periphery
const secrets = [...new Set([...core_secrets, ...periphery_secrets])];
const _env = env || "";
return (
secrets &&
secrets.length > 0 && (
<div className="flex gap-4 items-center">
<div className="text-muted-foreground">secrets:</div>
{secrets?.map((secret) => (
<Button
variant="secondary"
key={secret}
onClick={() =>
setEnv(
_env.slice(0, envRef.current?.selectionStart) +
`[[${secret}]]` +
_env.slice(envRef.current?.selectionStart, undefined)
)
}
>
{secret}
</Button>
))}
if (variables.length === 0 && secrets.length === 0) return;
if (variables.length === 0) {
// ONLY SECRETS
return (
<div className="flex flex-col gap-2 w-full">
<h2 className="text-muted-foreground">Secrets</h2>
<div className="flex gap-4 items-center flex-wrap w-full">
{secrets.map((secret) => (
<Button
variant="secondary"
key={secret}
onClick={() =>
setEnv(
_env.slice(0, envRef.current?.selectionStart) +
`[[${secret}]]` +
_env.slice(envRef.current?.selectionStart, undefined)
)
}
>
{secret}
</Button>
))}
</div>
</div>
)
);
}
if (secrets.length === 0) {
// ONLY VARIABLES
return (
<div className="flex flex-col gap-2 w-full">
<h2 className="text-muted-foreground">Variables</h2>
<div className="flex gap-4 items-center flex-wrap w-full">
{variables.map(({ name }) => (
<Button
variant="secondary"
key={name}
onClick={() =>
setEnv(
_env.slice(0, envRef.current?.selectionStart) +
`[[${name}]]` +
_env.slice(envRef.current?.selectionStart, undefined)
)
}
>
{name}
</Button>
))}
</div>
</div>
);
}
return (
<Tabs className="w-full" defaultValue="Variables">
<TabsList>
<TabsTrigger value="Variables">Variables</TabsTrigger>
<TabsTrigger value="Secrets">Secrets</TabsTrigger>
</TabsList>
<TabsContent value="Variables">
<div className="flex gap-4 items-center w-full flex-wrap pt-1">
{variables.map(({ name }) => (
<Button
variant="secondary"
key={name}
onClick={() =>
setEnv(
_env.slice(0, envRef.current?.selectionStart) +
`[[${name}]]` +
_env.slice(envRef.current?.selectionStart, undefined)
)
}
>
{name}
</Button>
))}
</div>
</TabsContent>
<TabsContent value="Secrets">
<div className="flex gap-4 items-center w-full flex-wrap pt-1">
{secrets.map((secret) => (
<Button
variant="secondary"
key={secret}
onClick={() =>
setEnv(
_env.slice(0, envRef.current?.selectionStart) +
`[[${secret}]]` +
_env.slice(envRef.current?.selectionStart, undefined)
)
}
>
{secret}
</Button>
))}
</div>
</TabsContent>
</Tabs>
);
};

View File

@@ -9,6 +9,7 @@ import {
FolderTree,
Tag,
UserCircle2,
Variable,
} from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { ResourceComponents } from "./resources";
@@ -74,6 +75,12 @@ export const Sidebar = () => {
icon={<Bell className="w-4 h-4" />}
/>
<SidebarLink
label="Variables"
to="/variables"
icon={<Variable className="w-4 h-4" />}
/>
<SidebarLink
label="Tags"
to="/tags"

View File

@@ -13,6 +13,7 @@ import {
Tag,
UserCircle2,
Users,
Variable,
} from "lucide-react";
import {
DropdownMenu,
@@ -104,6 +105,8 @@ const PrimaryDropdown = () => {
? [<Key className="w-4 h-4" />, "Api Keys"]
: location.pathname === "/tags"
? [<Tag className="w-4 h-4" />, "Tags"]
: location.pathname === "/variables"
? [<Variable className="w-4 h-4" />, "Variables"]
: location.pathname === "/alerts"
? [<AlertTriangle className="w-4 h-4" />, "Alerts"]
: location.pathname === "/updates"
@@ -164,6 +167,12 @@ const PrimaryDropdown = () => {
to="/updates"
/>
<DropdownLinkItem
label="Variables"
icon={<Variable className="w-4 h-4" />}
to="/variables"
/>
<DropdownLinkItem
label="Tags"
icon={<Tag className="w-4 h-4" />}

View File

@@ -6,7 +6,7 @@ import {
SheetTitle,
SheetTrigger,
} from "@ui/sheet";
import { Calendar, Clock, Milestone, User } from "lucide-react";
import { Calendar, Clock, Loader2, Milestone, User } from "lucide-react";
import {
Card,
CardContent,
@@ -133,16 +133,6 @@ export const UpdateDetailsInner = ({
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
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 open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{children}</SheetTrigger>
@@ -151,17 +141,44 @@ export const UpdateDetailsInner = ({
side="top"
onClick={() => setOpen(false)}
>
<SheetHeader className="mb-4">
<SheetTitle>
{update.operation
.split("_")
.map((s) => s[0].toUpperCase() + s.slice(1))
.join(" ")}{" "}
{!version_is_none(update.version) && fmt_version(update.version)}
</SheetTitle>
<SheetDescription className="flex flex-col gap-2">
<UpdateUser user_id={update.operator} />
<div className="flex gap-4">
{open && <UpdateDetailsContent id={id} setOpen={setOpen} />}
</SheetContent>
</Sheet>
);
};
const UpdateDetailsContent = ({
id,
setOpen,
}: {
id: string;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const update = useRead("GetUpdate", { id }).data;
if (!update)
return (
<div className="w-full flex justify-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
const Components =
update.target.type === "System"
? null
: ResourceComponents[update.target.type];
return (
<>
<SheetHeader className="mb-4">
<SheetTitle>
{update.operation
.split("_")
.map((s) => s[0].toUpperCase() + s.slice(1))
.join(" ")}{" "}
{!version_is_none(update.version) && fmt_version(update.version)}
</SheetTitle>
<SheetDescription className="flex flex-col gap-2">
<UpdateUser user_id={update.operator} />
<div className="flex gap-4">
{Components && (
<Link
to={`/${usableResourcePath(
update.target.type as UsableResource
@@ -175,79 +192,79 @@ export const UpdateDetailsInner = ({
<Components.Name id={update.target.id} />
</div>
</Link>
{update.version && (
<div className="flex items-center gap-2">
<Milestone className="w-4 h-4" />
{fmt_version(update.version)}
)}
{update.version && (
<div className="flex items-center gap-2">
<Milestone className="w-4 h-4" />
{fmt_version(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>
)}
</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
dangerouslySetInnerHTML={{
__html: sanitizeOnlySpan(log.stdout),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
{log.stderr && (
<div>
<CardDescription>stderr</CardDescription>
<pre
dangerouslySetInnerHTML={{
__html: sanitizeOnlySpan(log.stderr),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
</CardContent>
</Card>
))}
</div>
</SheetContent>
</Sheet>
{log.stdout && (
<div>
<CardDescription>stdout</CardDescription>
<pre
dangerouslySetInnerHTML={{
__html: sanitizeOnlySpan(log.stdout),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
{log.stderr && (
<div>
<CardDescription>stderr</CardDescription>
<pre
dangerouslySetInnerHTML={{
__html: sanitizeOnlySpan(log.stderr),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
</CardContent>
</Card>
))}
</div>
</>
);
};

View File

@@ -5,7 +5,7 @@ import {
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { Bell, Check, Circle, Loader2, X } from "lucide-react";
import { Bell, Check, Circle, Loader2, Settings, X } from "lucide-react";
import { Button } from "@ui/button";
import { Calendar } from "lucide-react";
import { UpdateDetails, UpdateUser } from "./details";
@@ -80,6 +80,8 @@ const SingleUpdate = ({ update }: { update: Types.UpdateListItem }) => {
<div className="flex items-center gap-2 text-muted-foreground">
{Components && <Components.Icon />}
{Components && <Components.Name id={update.target.id} />}
{!Components && <Settings className="w-4 h-4" />}
{!Components && "System"}
</div>
</div>
<div className="text-xs text-muted-foreground w-48">

View File

@@ -309,6 +309,7 @@ export const TextUpdateMenu = ({
placeholder,
confirmButton,
disabled,
fullWidth,
}: {
title: string;
value: string | undefined;
@@ -317,6 +318,7 @@ export const TextUpdateMenu = ({
placeholder?: string;
confirmButton?: boolean;
disabled?: boolean;
fullWidth?: boolean;
}) => {
const [open, setOpen] = useState(false);
const [_value, setValue] = useState(value);
@@ -329,7 +331,12 @@ export const TextUpdateMenu = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Card className="px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer w-fit">
<Card
className={cn(
"px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer",
fullWidth ? "w-full" : "w-fit"
)}
>
<div
className={cn(
"text-sm text-nowrap overflow-hidden overflow-ellipsis",

View File

@@ -100,6 +100,13 @@ const on_message = (
["GetServerTemplatesSummary"]
);
}
if (
update.target.type === "System" &&
update.operation.includes("Variable")
) {
invalidate(["ListVariables"], ["GetVariable"]);
}
};
export const WebsocketProvider = ({
@@ -188,5 +195,5 @@ const make_websocket = (
// force close every 30s to trigger reconnect and keep fresh
setTimeout(() => ws.close(), 30_000);
return ws
return ws;
};

View File

@@ -12,6 +12,7 @@ import {
SelectValue,
} from "@ui/select";
import { Switch } from "@ui/switch";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router";
@@ -48,8 +49,10 @@ export const Alerts = () => {
page,
}).data;
return (
<Page title="Alerts">
<div className="flex flex-col gap-4">
<Page
title="Alerts"
icon={<AlertTriangle className="w-8 h-8" />}
actions={
<div className="flex gap-4 items-center justify-end">
<div
className="flex gap-3 items-center cursor-pointer"
@@ -76,9 +79,10 @@ export const Alerts = () => {
</SelectContent>
</Select>
</div>
}
>
<div className="flex flex-col gap-4">
<AlertsTable alerts={alerts?.alerts ?? []} showResolved />
<div className="flex gap-4 justify-center items-center text-muted-foreground">
<Button
variant="outline"

View File

@@ -12,7 +12,7 @@ import {
import { Button } from "@ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/card";
import { useToast } from "@ui/use-toast";
import { Trash, PlusCircle, Loader2, Check } from "lucide-react";
import { Trash, PlusCircle, Loader2, Check, Tag } from "lucide-react";
import { useState } from "react";
import { Input } from "@ui/input";
import { UpdateUser } from "@components/updates/details";
@@ -28,12 +28,14 @@ export const Tags = () => {
return (
<Page
title="Tags"
icon={<Tag className="w-8 h-8" />}
actions={
<div className="flex gap-4">
<Input
placeholder="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
/>
<CreateTag />
</div>

View File

@@ -5,6 +5,7 @@ import { useRead, useResourceParamType, useSetTitle } from "@lib/hooks";
import { Types } from "@monitor/client";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { UsableResource } from "@types";
import { Button } from "@ui/button";
import {
Command,
CommandEmpty,
@@ -14,7 +15,7 @@ import {
CommandList,
} from "@ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import { SearchX } from "lucide-react";
import { Bell, SearchX } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
@@ -31,15 +32,36 @@ export const Updates = () => {
const AllUpdates = () => {
useSetTitle("Updates");
const [operation, setOperation] = useState<Types.Operation | undefined>();
const updates = useRead("ListUpdates", { query: { operation } }).data;
const [page, setPage] = useState(0);
const updates = useRead("ListUpdates", { query: { operation }, page }).data;
return (
<Page
title="Updates"
icon={<Bell className="w-8 h-8" />}
actions={
<OperationSelector selected={operation} onSelect={setOperation} />
}
>
<UpdatesTable updates={updates?.updates ?? []} showTarget />
<div className="flex flex-col gap-4">
<UpdatesTable updates={updates?.updates ?? []} showTarget />
<div className="flex gap-4 justify-center items-center text-muted-foreground">
<Button
variant="outline"
onClick={() => setPage(page - 1)}
disabled={page === 0}
>
Prev Page
</Button>
Page: {page + 1}
<Button
variant="outline"
onClick={() => updates?.next_page && setPage(updates.next_page)}
disabled={!updates?.next_page}
>
Next Page
</Button>
</div>
</div>
</Page>
);
};
@@ -54,12 +76,14 @@ const ResourceUpdates = ({
const name = useRead(`List${type}s`, {}).data?.find((r) => r.id === id)?.name;
useSetTitle(name && `${name} | Updates`);
const [operation, setOperation] = useState<Types.Operation | undefined>();
const [page, setPage] = useState(0);
const updates = useRead("ListUpdates", {
query: {
"target.type": type,
"target.id": id,
operation,
},
page,
}).data;
const Components = ResourceComponents[type];
return (
@@ -75,7 +99,26 @@ const ResourceUpdates = ({
/>
}
>
<UpdatesTable updates={updates?.updates ?? []} />
<div className="flex flex-col gap-4">
<UpdatesTable updates={updates?.updates ?? []} />
<div className="flex gap-4 justify-center items-center text-muted-foreground">
<Button
variant="outline"
onClick={() => setPage(page - 1)}
disabled={page === 0}
>
Prev Page
</Button>
Page: {page + 1}
<Button
variant="outline"
onClick={() => updates?.next_page && setPage(updates.next_page)}
disabled={!updates?.next_page}
>
Next Page
</Button>
</div>
</div>
</Page>
);
};

View File

@@ -0,0 +1,212 @@
import { Page } from "@components/layouts";
import { ConfirmButton, TextUpdateMenu } from "@components/util";
import {
useInvalidate,
useRead,
useSetTitle,
useUser,
useWrite,
} from "@lib/hooks";
import { Button } from "@ui/button";
import { DataTable, SortableHeader } from "@ui/data-table";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Input } from "@ui/input";
import { useToast } from "@ui/use-toast";
import { Check, Loader2, PlusCircle, Trash, Variable } from "lucide-react";
import { useState } from "react";
export const Variables = () => {
const user = useUser().data;
const disabled = !user?.admin;
useSetTitle("Variables");
const [search, setSearch] = useState("");
const { variables } = useRead("ListVariables", {}).data ?? {
variables: [],
secrets: [],
};
const searchSplit = search?.toLowerCase().split(" ") || [];
const filtered =
variables?.filter((variable) => {
if (searchSplit.length > 0) {
const name = variable.name.toLowerCase();
return searchSplit.every((search) => name.includes(search));
} else return true;
}) ?? [];
const { toast } = useToast();
const { mutate: updateValue } = useWrite("UpdateVariableValue");
const inv = useInvalidate();
const { mutate: updateDescription } = useWrite("UpdateVariableDescription", {
onSuccess: () => {
inv(["ListVariables"], ["GetVariable"]);
toast({ title: "Updated variable description" });
},
});
return (
<Page
title="Variables"
icon={<Variable className="w-8 h-8" />}
actions={
<div className="flex gap-4">
<Input
placeholder="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
/>
<CreateVariable />
</div>
}
>
<DataTable
tableKey="variables"
data={filtered}
columns={[
{
accessorKey: "name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
},
{
accessorKey: "value",
header: ({ column }) => (
<SortableHeader column={column} title="Value" />
),
cell: ({ row }) => {
return (
<TextUpdateMenu
title={`${row.original.name} - Value`}
placeholder="Set value"
value={row.original.value}
onUpdate={(value) => {
if (row.original.value === value) {
return;
}
updateValue({ name: row.original.name, value });
}}
triggerClassName="w-full"
disabled={disabled}
fullWidth
/>
);
},
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
return (
<TextUpdateMenu
title={`${row.original.name} - Description`}
placeholder="Set description"
value={row.original.description}
onUpdate={(description) => {
if (row.original.description === description) {
return;
}
updateDescription({
name: row.original.name,
description,
});
}}
triggerClassName="w-full"
disabled={disabled}
fullWidth
/>
);
},
},
{
header: "Delete",
cell: ({ row }) => <DeleteVariable name={row.original.name} />,
},
]}
/>
</Page>
);
};
const CreateVariable = () => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const invalidate = useInvalidate();
const { mutate, isPending } = useWrite("CreateVariable", {
onSuccess: () => {
invalidate(["ListVariables"], ["GetVariable"]);
toast({ title: "Variable Created" });
setOpen(false);
},
onError: (e) => {
console.log("create variable error:" + e);
toast({ title: "Failed to create variable" });
setOpen(false);
},
});
const submit = () => mutate({ name });
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="items-center gap-2">
New Variable <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Variable</DialogTitle>
</DialogHeader>
<div className="py-8 flex flex-col gap-4">
<div className="flex items-center justify-between">
Name
<Input
className="w-72"
value={name}
onChange={(e) =>
setName(e.target.value.toUpperCase().replaceAll(" ", "_"))
}
/>
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={submit} disabled={isPending}>
Submit
{isPending ? (
<Loader2 className="w-4 animate-spin" />
) : (
<Check className="w-4" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const DeleteVariable = ({ name }: { name: string }) => {
const invalidate = useInvalidate();
const { toast } = useToast();
const { mutate, isPending } = useWrite("DeleteVariable", {
onSuccess: () => {
invalidate(["ListVariables"], ["GetVariable"]);
toast({ title: "Variable Deleted" });
},
onError: () => {
toast({ title: "Failed to delete variable" });
},
});
return (
<ConfirmButton
title="Delete"
icon={<Trash className="w-4 h-4" />}
onClick={() => mutate({ name })}
loading={isPending}
/>
);
};

View File

@@ -16,6 +16,7 @@ import { ResourceStats } from "@pages/resource_stats";
import { Alerts } from "@pages/alerts";
import { UserPage } from "@pages/user";
import { UserGroupPage } from "@pages/user-group";
import { Variables } from "@pages/variables";
const ROUTER = createBrowserRouter([
{
@@ -26,9 +27,11 @@ const ROUTER = createBrowserRouter([
{ path: "keys", element: <Keys /> },
{ path: "tags", element: <Tags /> },
{ path: "tree", element: <Tree /> },
{ path: "resources", element: <AllResources /> },
{ path: "alerts", element: <Alerts /> },
{ path: "updates", element: <Updates /> },
{ path: "variables", element: <Variables /> },
{ path: "resources", element: <AllResources /> },
{ path: "user-groups/:id", element: <UserGroupPage /> },
{
path: "users",
children: [
@@ -36,7 +39,6 @@ const ROUTER = createBrowserRouter([
{ path: ":id", element: <UserPage /> },
],
},
{ path: "user-groups/:id", element: <UserGroupPage /> },
{
path: ":type",
children: [