display workflow data

This commit is contained in:
yoan
2024-11-14 14:52:02 +08:00
30 changed files with 1740 additions and 105 deletions

View File

@@ -21,6 +21,7 @@ import AccessLocalForm from "./AccessLocalForm";
import AccessSSHForm from "./AccessSSHForm";
import AccessWebhookForm from "./AccessWebhookForm";
import AccessKubernetesForm from "./AccessKubernetesForm";
import AccessVolcengineForm from "./AccessVolcengineForm";
import { Access } from "@/domain/access";
import { AccessTypeSelect } from "./AccessTypeSelect";
@@ -223,6 +224,17 @@ const AccessEditDialog = ({ trigger, op, data, className, outConfigType }: Acces
/>
);
break;
case "volcengine":
childComponent = (
<AccessVolcengineForm
data={data}
op={op}
onAfterReq={() => {
setOpen(false);
}}
/>
);
break;
}
return (

View File

@@ -0,0 +1,194 @@
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ClientResponseError } from "pocketbase";
import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { PbErrorData } from "@/domain/base";
import { accessProvidersMap, accessTypeFormSchema, type Access, type VolcengineConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfigContext } from "@/providers/config";
type AccessVolcengineFormProps = {
op: "add" | "edit" | "copy";
data?: Access;
onAfterReq: () => void;
};
const AccessVolcengineForm = ({ data, op, onAfterReq }: AccessVolcengineFormProps) => {
const { addAccess, updateAccess } = useConfigContext();
const { t } = useTranslation();
const formSchema = z.object({
id: z.string().optional(),
name: z
.string()
.min(1, "access.authorization.form.name.placeholder")
.max(64, t("common.errmsg.string_max", { max: 64 })),
configType: accessTypeFormSchema,
accessKeyId: z
.string()
.min(1, "access.authorization.form.access_key_id.placeholder")
.max(64, t("common.errmsg.string_max", { max: 64 })),
secretAccessKey: z
.string()
.min(1, "access.authorization.form.secret_access_key.placeholder")
.max(64, t("common.errmsg.string_max", { max: 64 })),
});
let config: VolcengineConfig = {
accessKeyId: "",
secretAccessKey: "",
};
if (data) config = data.config as VolcengineConfig;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name || "",
configType: "volcengine",
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
const req: Access = {
id: data.id as string,
name: data.name,
configType: data.configType,
usage: accessProvidersMap.get(data.configType)!.usage,
config: {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
},
};
try {
req.id = op == "copy" ? "" : req.id;
const rs = await save(req);
onAfterReq();
req.id = rs.id;
req.created = rs.created;
req.updated = rs.updated;
if (data.id && op == "edit") {
updateAccess(req);
return;
}
addAccess(req);
} catch (e) {
const err = e as ClientResponseError;
Object.entries(err.response.data as PbErrorData).forEach(([key, value]) => {
form.setError(key as keyof z.infer<typeof formSchema>, {
type: "manual",
message: value.message,
});
});
return;
}
};
return (
<>
<Form {...form}>
<form
onSubmit={(e) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("access.authorization.form.name.label")}</FormLabel>
<FormControl>
<Input placeholder={t("access.authorization.form.name.placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t("access.authorization.form.config.label")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t("access.authorization.form.config.label")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("access.authorization.form.access_key_id.label")}</FormLabel>
<FormControl>
<Input placeholder={t("access.authorization.form.access_key_id.placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t("access.authorization.form.secret_access_key.label")}</FormLabel>
<FormControl>
<Input placeholder={t("access.authorization.form.secret_access_key.placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
<div className="flex justify-end">
<Button type="submit">{t("common.save")}</Button>
</div>
</form>
</Form>
</>
);
};
export default AccessVolcengineForm;

View File

@@ -27,6 +27,7 @@ import DeployToLocal from "./DeployToLocal";
import DeployToSSH from "./DeployToSSH";
import DeployToWebhook from "./DeployToWebhook";
import DeployToKubernetesSecret from "./DeployToKubernetesSecret";
import DeployToVolcengineLive from "./DeployToVolcengineLive"
import { deployTargetsMap, type DeployConfig } from "@/domain/domain";
import { accessProvidersMap } from "@/domain/access";
import { useConfigContext } from "@/providers/config";
@@ -174,6 +175,9 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
case "k8s-secret":
childComponent = <DeployToKubernetesSecret />;
break;
case "volcengine-live":
childComponent = <DeployToVolcengineLive />;
break;
}
return (

View File

@@ -0,0 +1,68 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDeployEditContext } from "./DeployEdit";
type DeployToVolcengineLiveConfigParams = {
domain?: string;
};
const DeployToVolcengineLive = () => {
const { t } = useTranslation();
const { config, setConfig, errors, setErrors } = useDeployEditContext<DeployToVolcengineLiveConfigParams>();
useEffect(() => {
if (!config.id) {
setConfig({
...config,
config: {},
});
}
}, []);
useEffect(() => {
setErrors({});
}, []);
const formSchema = z.object({
domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("common.errmsg.domain_invalid"),
}),
});
useEffect(() => {
const res = formSchema.safeParse(config.config);
setErrors({
...errors,
domain: res.error?.errors?.find((e) => e.path[0] === "domain")?.message,
});
}, [config]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.domain.label.wildsupported")}</Label>
<Input
placeholder={t("domain.deployment.form.domain.placeholder")}
className="w-full mt-1"
value={config?.config?.domain}
onChange={(e) => {
const nv = produce(config, (draft) => {
draft.config ??= {};
draft.config.domain = e.target.value?.trim();
});
setConfig(nv);
}}
/>
<div className="text-red-600 text-sm mt-1">{errors?.domain}</div>
</div>
</div>
);
};
export default DeployToVolcengineLive;

View File

@@ -0,0 +1,46 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTranslation } from "react-i18next";
type CustomAlertDialogProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
title?: string;
description?: string;
confirm?: () => void;
};
const CustomAlertDialog = ({ open, title, description, confirm, onOpenChange }: CustomAlertDialogProps) => {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirm && confirm();
}}
>
{t("common.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default CustomAlertDialog;

View File

@@ -0,0 +1,87 @@
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "../ui/button";
import { useEffect, useState } from "react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
onPageChange?: (pageIndex: number, pageSize?: number) => Promise<void>;
}
export function DataTable<TData, TValue>({ columns, data, onPageChange, pageCount }: DataTableProps<TData, TValue>) {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const pagination = {
pageIndex,
pageSize,
};
const table = useReactTable({
data,
columns,
pageCount: pageCount,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
state: {
pagination,
},
onPaginationChange: setPagination,
manualPagination: true,
});
useEffect(() => {
onPageChange?.(pageIndex, pageSize);
}, [pageIndex]);
return (
<>
<div className="rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-muted-foreground">
{headerGroup.headers.map((header) => {
return <TableHead key={header.id}>{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}</TableHead>;
})}
</TableRow>
))}
</TableHeader>
<TableBody className="dark:text-stone-200">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"} className="border-muted-foreground">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end mt-5">
<div className="flex items-center space-x-2 dark:text-stone-200">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
</Button>
</div>
</div>
</>
);
}

View File

@@ -86,7 +86,7 @@ const StartForm = ({ data }: StartFormProps) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
className="space-y-8 dark:text-stone-200"
>
<FormField
control={form.control}

View File

@@ -17,6 +17,7 @@ export const accessProviders = [
["baiducloud", "common.provider.baiducloud", "/imgs/providers/baiducloud.svg", "all", "百度智能云:百度云:baidu cloud"],
["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛云:qiniu"],
["dogecloud", "common.provider.dogecloud", "/imgs/providers/dogecloud.svg", "deploy", "多吉云:doge cloud"],
["volcengine", "common.provider.volcengine", "/imgs/providers/volcengine.svg", "all", "火山引擎:volcengine"],
["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"],
["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"],
["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"],
@@ -51,6 +52,7 @@ export const accessTypeFormSchema = z.union(
z.literal("ssh"),
z.literal("webhook"),
z.literal("k8s"),
z.literal("volcengine"),
],
{ message: "access.authorization.form.type.placeholder" }
);
@@ -76,7 +78,8 @@ export type Access = {
| LocalConfig
| SSHConfig
| WebhookConfig
| KubernetesConfig;
| KubernetesConfig
| VolcengineConfig;
deleted?: string;
created?: string;
updated?: string;
@@ -164,3 +167,8 @@ export type WebhookConfig = {
export type KubernetesConfig = {
kubeConfig: string;
};
export type VolcengineConfig = {
accessKeyId: string;
secretAccessKey: string;
};

View File

@@ -91,6 +91,7 @@ export const deployTargetList: string[][] = [
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg"],
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg"],
["k8s-secret", "common.provider.kubernetes.secret", "/imgs/providers/k8s.svg"],
["volcengine-live", "common.provider.volcengine.live", "/imgs/providers/volcengine.svg"],
];
export const deployTargetsMap: Map<DeployTarget["type"], DeployTarget> = new Map(

View File

@@ -90,5 +90,7 @@
"common.provider.lark": "Lark",
"common.provider.telegram": "Telegram",
"common.provider.serverchan": "ServerChan",
"common.provider.bark": "Bark"
"common.provider.bark": "Bark",
"common.provider.volcengine": "Volcengine",
"common.provider.volcengine.live": "Volcengine - Live"
}

View File

@@ -90,5 +90,7 @@
"common.provider.lark": "飞书",
"common.provider.telegram": "Telegram",
"common.provider.serverchan": "Server酱",
"common.provider.bark": "Bark"
"common.provider.bark": "Bark",
"common.provider.volcengine": "火山引擎",
"common.provider.volcengine.live": "火山引擎 - 视频直播"
}

View File

@@ -29,7 +29,7 @@ const Notify = () => {
</div>
<div className="border rounded-md p-5 mt-7 shadow-lg">
<Accordion type={"single"} className="dark:text-stone-200">
<Accordion type={"single"} collapsible={true} className="dark:text-stone-200">
<AccordionItem value="item-email" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.email")}</AccordionTrigger>
<AccordionContent>

View File

@@ -96,8 +96,8 @@ const WorkflowDetail = () => {
<WorkflowBaseInfoEditDialog
trigger={
<div className="flex flex-col space-y-1 cursor-pointer items-start">
<div className="">{workflow.name ?? "未命名工作流"}</div>
<div className="text-sm text-muted-foreground">{workflow.description ?? "添加流程说明"}</div>
<div className="">{workflow.name ? workflow.name : "未命名工作流"}</div>
<div className="text-sm text-muted-foreground">{workflow.description ? workflow.description : "添加流程说明"}</div>
</div>
}
/>

View File

@@ -1,9 +1,182 @@
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { MoreHorizontal, Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ColumnDef } from "@tanstack/react-table";
import { Workflow as WorkflowType } from "@/domain/workflow";
import { DataTable } from "@/components/workflow/DataTable";
import { useState } from "react";
import { list, remove, save } from "@/repository/workflow";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import CustomAlertDialog from "@/components/workflow/CustomAlertDialog";
const Workflow = () => {
const navigate = useNavigate();
const [data, setData] = useState<WorkflowType[]>([]);
const [pageCount, setPageCount] = useState<number>(0);
const { t } = useTranslation();
const [alertOpen, setAlertOpen] = useState(false);
const [alertProps, setAlertProps] = useState<{
title: string;
description: string;
onConfirm: () => void;
}>();
const fetchData = async (page: number, pageSize?: number) => {
const resp = await list({ page: page, perPage: pageSize });
setData(resp.items);
setPageCount(resp.totalPages);
};
const columns: ColumnDef<WorkflowType>[] = [
{
accessorKey: "name",
header: "名称",
cell: ({ row }) => {
let name: string = row.getValue("name");
if (!name) {
name = "未命名工作流";
}
return <div className="flex items-center">{name}</div>;
},
},
{
accessorKey: "description",
header: "描述",
cell: ({ row }) => {
let description: string = row.getValue("description");
if (!description) {
description = "-";
}
return description;
},
},
{
accessorKey: "executionMethod",
header: "执行方式",
cell: ({ row }) => {
const method = row.getValue("executionMethod");
if (!method) {
return "-";
} else if (method === "manual") {
return "手动";
} else if (method === "auto") {
const crontab: string = row.getValue("crontab");
return (
<div className="flex flex-col">
<div className="text-muted-foreground text-xs">{crontab}</div>
</div>
);
}
},
},
{
accessorKey: "enabled",
header: "是否启用",
cell: ({ row }) => {
const enabled: boolean = row.getValue("enabled");
return (
<>
<Switch
checked={enabled}
onCheckedChange={() => {
handleCheckedChange(row.original.id ?? "");
}}
/>
</>
);
},
},
{
accessorKey: "created",
header: "创建时间",
cell: ({ row }) => {
const date: string = row.getValue("created");
return new Date(date).toLocaleString();
},
},
{
accessorKey: "updated",
header: "更新时间",
cell: ({ row }) => {
const date: string = row.getValue("updated");
return new Date(date).toLocaleString();
},
},
{
id: "actions",
cell: ({ row }) => {
const workflow = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
navigate(`/workflow/detail?id=${workflow.id}`);
}}
>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-500"
onClick={() => {
handleDeleteClick(workflow.id ?? "");
}}
>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
const handleCheckedChange = async (id: string) => {
const resp = await save({ id, enabled: !data.find((item) => item.id === id)?.enabled });
if (resp) {
setData((prev) => {
return prev.map((item) => {
if (item.id === id) {
return resp;
}
return item;
});
});
}
};
const handleDeleteClick = (id: string) => {
setAlertProps({
title: "删除工作流",
description: "确定删除工作流吗?",
onConfirm: async () => {
const resp = await remove(id);
if (resp) {
setData((prev) => {
return prev.filter((item) => item.id !== id);
});
}
},
});
setAlertOpen(true);
};
const handleCreateClick = () => {
navigate("/workflow/detail");
};
@@ -16,6 +189,18 @@ const Workflow = () => {
</Button>
</div>
<div>
<DataTable columns={columns} data={data} onPageChange={fetchData} pageCount={pageCount} />
</div>
<CustomAlertDialog
open={alertOpen}
onOpenChange={setAlertOpen}
title={alertProps?.title}
description={alertProps?.description}
confirm={alertProps?.onConfirm}
/>
</>
);
};

View File

@@ -44,6 +44,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
if (!id) {
data = initWorkflow();
data = await save(data);
} else {
data = await getWrokflow(id);
}

View File

@@ -14,3 +14,28 @@ export const save = async (data: Record<string, string | boolean | WorkflowNode>
}
return await getPb().collection("workflow").create<Workflow>(data);
};
type WorkflowListReq = {
page: number;
perPage?: number;
};
export const list = async (req: WorkflowListReq) => {
let page = 1;
if (req.page) {
page = req.page;
}
let perPage = 10;
if (req.perPage) {
perPage = req.perPage;
}
const response = await getPb().collection("workflow").getList<Workflow>(page, perPage, {
sort: "-created",
});
return response;
};
export const remove = async (id: string) => {
return await getPb().collection("workflow").delete(id);
};