mirror of
https://github.com/woodchen-ink/certimate.git
synced 2025-07-18 09:21:56 +08:00
Certificate displaying and monitoring
This commit is contained in:
parent
09e4b24445
commit
86761bd3a0
11
internal/domain/statistics.go
Normal file
11
internal/domain/statistics.go
Normal file
@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
type Statistics struct {
|
||||
CertificateTotal int `json:"certificateTotal"`
|
||||
CertificateExpireSoon int `json:"certificateExpireSoon"`
|
||||
CertificateExpired int `json:"certificateExpired"`
|
||||
|
||||
WorkflowTotal int `json:"workflowTotal"`
|
||||
WorkflowEnabled int `json:"workflowEnabled"`
|
||||
WorkflowDisabled int `json:"workflowDisabled"`
|
||||
}
|
@ -19,8 +19,8 @@ const (
|
||||
|
||||
func PushExpireMsg() {
|
||||
// 查询即将过期的证书
|
||||
records, err := app.GetApp().Dao().FindRecordsByFilter("domains", "expiredAt<{:time}&&certUrl!=''", "-created", 500, 0,
|
||||
dbx.Params{"time": xtime.GetTimeAfter(24 * time.Hour * 15)})
|
||||
records, err := app.GetApp().Dao().FindRecordsByFilter("certificate", "expireAt<{:time}&&certUrl!=''", "-created", 500, 0,
|
||||
dbx.Params{"time": xtime.GetTimeAfter(24 * time.Hour * 20)})
|
||||
if err != nil {
|
||||
app.GetApp().Logger().Error("find expired domains by filter", "error", err)
|
||||
return
|
||||
@ -76,7 +76,7 @@ func buildMsg(records []*models.Record) *notifyMessage {
|
||||
domains := make([]string, count)
|
||||
|
||||
for i, record := range records {
|
||||
domains[i] = record.GetString("domain")
|
||||
domains[i] = record.GetString("san")
|
||||
}
|
||||
|
||||
countStr := strconv.Itoa(count)
|
||||
|
63
internal/repository/statistics.go
Normal file
63
internal/repository/statistics.go
Normal file
@ -0,0 +1,63 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/usual2970/certimate/internal/domain"
|
||||
"github.com/usual2970/certimate/internal/utils/app"
|
||||
)
|
||||
|
||||
type StatisticsRepository struct{}
|
||||
|
||||
func NewStatisticsRepository() *StatisticsRepository {
|
||||
return &StatisticsRepository{}
|
||||
}
|
||||
|
||||
type totalResp struct {
|
||||
Total int `json:"total" db:"total"`
|
||||
}
|
||||
|
||||
func (r *StatisticsRepository) Get(ctx context.Context) (*domain.Statistics, error) {
|
||||
rs := &domain.Statistics{}
|
||||
// 所有证书
|
||||
certTotal := totalResp{}
|
||||
if err := app.GetApp().Dao().DB().NewQuery("select count(*) as total from certificate").One(&certTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs.CertificateTotal = certTotal.Total
|
||||
|
||||
// 即将过期证书
|
||||
certExpireSoonTotal := totalResp{}
|
||||
if err := app.GetApp().Dao().DB().
|
||||
NewQuery("select count(*) as total from certificate where expireAt > datetime('now') and expireAt < datetime('now', '+20 days')").
|
||||
One(&certExpireSoonTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs.CertificateExpireSoon = certExpireSoonTotal.Total
|
||||
|
||||
// 已过期证书
|
||||
certExpiredTotal := totalResp{}
|
||||
if err := app.GetApp().Dao().DB().
|
||||
NewQuery("select count(*) as total from certificate where expireAt < datetime('now')").
|
||||
One(&certExpiredTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs.CertificateExpired = certExpiredTotal.Total
|
||||
|
||||
// 所有工作流
|
||||
workflowTotal := totalResp{}
|
||||
if err := app.GetApp().Dao().DB().NewQuery("select count(*) as total from workflow").One(&workflowTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs.WorkflowTotal = workflowTotal.Total
|
||||
|
||||
// 已启用工作流
|
||||
workflowEnabledTotal := totalResp{}
|
||||
if err := app.GetApp().Dao().DB().NewQuery("select count(*) as total from workflow where enabled is TRUE").One(&workflowEnabledTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs.WorkflowEnabled = workflowEnabledTotal.Total
|
||||
rs.WorkflowDisabled = workflowTotal.Total - workflowEnabledTotal.Total
|
||||
|
||||
return rs, nil
|
||||
}
|
35
internal/rest/statistics.go
Normal file
35
internal/rest/statistics.go
Normal file
@ -0,0 +1,35 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/usual2970/certimate/internal/domain"
|
||||
"github.com/usual2970/certimate/internal/utils/resp"
|
||||
)
|
||||
|
||||
type StatisticsService interface {
|
||||
Get(ctx context.Context) (*domain.Statistics, error)
|
||||
}
|
||||
|
||||
type statisticsHandler struct {
|
||||
service StatisticsService
|
||||
}
|
||||
|
||||
func NewStatisticsHandler(route *echo.Group, service StatisticsService) {
|
||||
handler := &statisticsHandler{
|
||||
service: service,
|
||||
}
|
||||
|
||||
group := route.Group("/statistics")
|
||||
|
||||
group.GET("/get", handler.get)
|
||||
}
|
||||
|
||||
func (handler *statisticsHandler) get(c echo.Context) error {
|
||||
if statistics, err := handler.service.Get(c.Request().Context()); err != nil {
|
||||
return resp.Err(c, err)
|
||||
} else {
|
||||
return resp.Succ(c, statistics)
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import (
|
||||
"github.com/usual2970/certimate/internal/notify"
|
||||
"github.com/usual2970/certimate/internal/repository"
|
||||
"github.com/usual2970/certimate/internal/rest"
|
||||
"github.com/usual2970/certimate/internal/statistics"
|
||||
"github.com/usual2970/certimate/internal/workflow"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@ -19,7 +20,12 @@ func Register(e *echo.Echo) {
|
||||
workflowRepo := repository.NewWorkflowRepository()
|
||||
workflowSvc := workflow.NewWorkflowService(workflowRepo)
|
||||
|
||||
statisticsRepo := repository.NewStatisticsRepository()
|
||||
statisticsSvc := statistics.NewStatisticsService(statisticsRepo)
|
||||
|
||||
rest.NewWorkflowHandler(group, workflowSvc)
|
||||
|
||||
rest.NewNotifyHandler(group, notifySvc)
|
||||
|
||||
rest.NewStatisticsHandler(group, statisticsSvc)
|
||||
}
|
||||
|
25
internal/statistics/service.go
Normal file
25
internal/statistics/service.go
Normal file
@ -0,0 +1,25 @@
|
||||
package statistics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/usual2970/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type StatisticsRepository interface {
|
||||
Get(ctx context.Context) (*domain.Statistics, error)
|
||||
}
|
||||
|
||||
type StatisticsService struct {
|
||||
repo StatisticsRepository
|
||||
}
|
||||
|
||||
func NewStatisticsService(repo StatisticsRepository) *StatisticsService {
|
||||
return &StatisticsService{
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StatisticsService) Get(ctx context.Context) (*domain.Statistics, error) {
|
||||
return s.repo.Get(ctx)
|
||||
}
|
15
ui/src/api/statistics.ts
Normal file
15
ui/src/api/statistics.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { getPb } from "@/repository/api";
|
||||
|
||||
export const get = async () => {
|
||||
const pb = getPb();
|
||||
|
||||
const resp = await pb.send("/api/statistics/get", {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (resp.code != 0) {
|
||||
throw new Error(resp.msg);
|
||||
}
|
||||
|
||||
return resp.data;
|
||||
};
|
21
ui/src/api/workflow.ts
Normal file
21
ui/src/api/workflow.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { getPb } from "@/repository/api";
|
||||
|
||||
export const run = async (id: string) => {
|
||||
const pb = getPb();
|
||||
|
||||
const resp = await pb.send("/api/workflow/run", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.code != 0) {
|
||||
throw new Error(resp.msg);
|
||||
}
|
||||
|
||||
return resp;
|
||||
};
|
162
ui/src/components/certificate/CertificateList.tsx
Normal file
162
ui/src/components/certificate/CertificateList.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import CertificateDetail from "@/components/certificate/CertificateDetail";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/workflow/DataTable";
|
||||
import { Certificate as CertificateType } from "@/domain/certificate";
|
||||
import { diffDays, getLeftDays } from "@/lib/time";
|
||||
import { list } from "@/repository/certificate";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type CertificateListProps = {
|
||||
withPagination?: boolean;
|
||||
};
|
||||
|
||||
const CertificateList = ({ withPagination }: CertificateListProps) => {
|
||||
const [data, setData] = useState<CertificateType[]>([]);
|
||||
const [pageCount, setPageCount] = useState<number>(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedCertificate, setSelectedCertificate] = useState<CertificateType>();
|
||||
|
||||
const fetchData = async (page: number, pageSize?: number) => {
|
||||
const resp = await list({ page: page, perPage: pageSize });
|
||||
setData(resp.items);
|
||||
setPageCount(resp.totalPages);
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns: ColumnDef<CertificateType>[] = [
|
||||
{
|
||||
accessorKey: "san",
|
||||
header: "域名",
|
||||
cell: ({ row }) => {
|
||||
let san: string = row.getValue("san");
|
||||
if (!san) {
|
||||
san = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{san.split(";").map((item, i) => {
|
||||
return (
|
||||
<div key={i} className="max-w-[250px] truncate">
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "expireAt",
|
||||
header: "有效期限",
|
||||
cell: ({ row }) => {
|
||||
const expireAt: string = row.getValue("expireAt");
|
||||
const data = row.original;
|
||||
const leftDays = getLeftDays(expireAt);
|
||||
const allDays = diffDays(data.expireAt, data.created);
|
||||
return (
|
||||
<div className="">
|
||||
{leftDays > 0 ? (
|
||||
<div className="text-green-500">
|
||||
{leftDays} / {allDays} 天
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-500">已到期</div>
|
||||
)}
|
||||
|
||||
<div>{new Date(expireAt).toLocaleString().split(" ")[0]} 到期</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "workflow",
|
||||
header: "所属工作流",
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.expand.workflow?.name;
|
||||
const workflowId: string = row.getValue("workflow");
|
||||
return (
|
||||
<div className="max-w-[200px] truncate">
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"link"}
|
||||
onClick={() => {
|
||||
handleWorkflowClick(workflowId);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created",
|
||||
header: "颁发时间",
|
||||
cell: ({ row }) => {
|
||||
const date: string = row.getValue("created");
|
||||
return new Date(date).toLocaleString();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={"link"}
|
||||
onClick={() => {
|
||||
handleView(row.original.id);
|
||||
}}
|
||||
>
|
||||
查看证书
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleWorkflowClick = (id: string) => {
|
||||
navigate(`/workflow/detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleView = (id: string) => {
|
||||
setOpen(true);
|
||||
const certificate = data.find((item) => item.id === id);
|
||||
setSelectedCertificate(certificate);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
onPageChange={fetchData}
|
||||
data={data}
|
||||
pageCount={pageCount}
|
||||
withPagination={withPagination}
|
||||
fallback={
|
||||
<div className="flex flex-col">
|
||||
<div className="text-muted-foreground">暂无证书,添加工作流去生成证书吧😀</div>
|
||||
<Button
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
navigate("/workflow/detail");
|
||||
}}
|
||||
>
|
||||
添加工作流
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<CertificateDetail open={open} onOpenChange={setOpen} certificate={selectedCertificate} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateList;
|
@ -3,6 +3,7 @@ import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, Paginati
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "../ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import Show from "../Show";
|
||||
|
||||
interface DataTableProps<TData extends { id: string }, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@ -10,9 +11,19 @@ interface DataTableProps<TData extends { id: string }, TValue> {
|
||||
pageCount: number;
|
||||
onPageChange?: (pageIndex: number, pageSize?: number) => Promise<void>;
|
||||
onRowClick?: (id: string) => void;
|
||||
withPagination?: boolean;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData extends { id: string }, TValue>({ columns, data, onPageChange, pageCount, onRowClick }: DataTableProps<TData, TValue>) {
|
||||
export function DataTable<TData extends { id: string }, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onPageChange,
|
||||
pageCount,
|
||||
onRowClick,
|
||||
withPagination,
|
||||
fallback,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
@ -77,13 +88,14 @@ export function DataTable<TData extends { id: string }, TValue>({ columns, data,
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
{fallback ? fallback : "暂无数据"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Show when={!!withPagination}>
|
||||
<div className="flex items-center justify-end mt-5">
|
||||
<div className="flex items-center space-x-2 dark:text-stone-200">
|
||||
{table.getCanPreviousPage() && (
|
||||
@ -99,6 +111,7 @@ export function DataTable<TData extends { id: string }, TValue>({ columns, data,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -57,10 +57,13 @@ export type ApplyConfig = {
|
||||
};
|
||||
|
||||
export type Statistic = {
|
||||
total: number;
|
||||
expired: number;
|
||||
enabled: number;
|
||||
disabled: number;
|
||||
certificateTotal: number;
|
||||
certificateExpired: number;
|
||||
certificateExpireSoon: number;
|
||||
|
||||
workflowTotal: number;
|
||||
workflowEnabled: number;
|
||||
workflowDisabled: number;
|
||||
};
|
||||
|
||||
export type DeployTarget = {
|
||||
|
@ -84,4 +84,3 @@ export function getTimeAfter(days: number): string {
|
||||
|
||||
return formattedDate;
|
||||
}
|
||||
|
||||
|
@ -57,19 +57,23 @@ export default function Dashboard() {
|
||||
<Home className="h-4 w-4" />
|
||||
{t("dashboard.page.title")}
|
||||
</Link>
|
||||
<Link to="/domains" className={cn("flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary", getClass("/domains"))}>
|
||||
<Link to="/workflow" className={cn("flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary", getClass("/workflow"))}>
|
||||
<Earth className="h-4 w-4" />
|
||||
{t("domain.page.title")}
|
||||
工作流列表
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/certificate"
|
||||
className={cn("flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary", getClass("/certificate"))}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
证书列表
|
||||
</Link>
|
||||
|
||||
<Link to="/access" className={cn("flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary", getClass("/access"))}>
|
||||
<Server className="h-4 w-4" />
|
||||
{t("access.page.title")}
|
||||
</Link>
|
||||
|
||||
<Link to="/history" className={cn("flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary", getClass("/history"))}>
|
||||
<History className="h-4 w-4" />
|
||||
{t("history.page.title")}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -99,12 +103,21 @@ export default function Dashboard() {
|
||||
{t("dashboard.page.title")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/domains"
|
||||
className={cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground", getClass("/domains"))}
|
||||
to="/workflow"
|
||||
className={cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground", getClass("/workflow"))}
|
||||
>
|
||||
<Earth className="h-5 w-5" />
|
||||
{t("domain.page.title")}
|
||||
工作流
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/certificate"
|
||||
className={cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground", getClass("/certificate"))}
|
||||
>
|
||||
<History className="h-5 w-5" />
|
||||
证书
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/access"
|
||||
className={cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground", getClass("/access"))}
|
||||
@ -112,14 +125,6 @@ export default function Dashboard() {
|
||||
<Server className="h-5 w-5" />
|
||||
{t("access.page.title")}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/history"
|
||||
className={cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground", getClass("/history"))}
|
||||
>
|
||||
<History className="h-5 w-5" />
|
||||
{t("history.page.title")}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="">
|
||||
<Version className="justify-center" />
|
||||
|
@ -1,139 +1,11 @@
|
||||
import CertificateDetail from "@/components/certificate/CertificateDetail";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/workflow/DataTable";
|
||||
import { Certificate as CertificateType } from "@/domain/certificate";
|
||||
import { diffDays, getLeftDays } from "@/lib/time";
|
||||
import { list } from "@/repository/certificate";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import CertificateList from "@/components/certificate/CertificateList";
|
||||
|
||||
const Certificate = () => {
|
||||
const [data, setData] = useState<CertificateType[]>([]);
|
||||
const [pageCount, setPageCount] = useState<number>(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedCertificate, setSelectedCertificate] = useState<CertificateType>();
|
||||
|
||||
const fetchData = async (page: number, pageSize?: number) => {
|
||||
const resp = await list({ page: page, perPage: pageSize });
|
||||
setData(resp.items);
|
||||
setPageCount(resp.totalPages);
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns: ColumnDef<CertificateType>[] = [
|
||||
{
|
||||
accessorKey: "san",
|
||||
header: "域名",
|
||||
cell: ({ row }) => {
|
||||
let san: string = row.getValue("san");
|
||||
if (!san) {
|
||||
san = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{san.split(";").map((item, i) => {
|
||||
return (
|
||||
<div key={i} className="max-w-[250px] truncate">
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "expireAt",
|
||||
header: "有效期限",
|
||||
cell: ({ row }) => {
|
||||
const expireAt: string = row.getValue("expireAt");
|
||||
const data = row.original;
|
||||
const leftDays = getLeftDays(expireAt);
|
||||
const allDays = diffDays(data.expireAt, data.created);
|
||||
return (
|
||||
<div className="">
|
||||
{leftDays > 0 ? (
|
||||
<div className="text-green-500">
|
||||
{leftDays} / {allDays} 天
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-500">已到期</div>
|
||||
)}
|
||||
|
||||
<div>{new Date(expireAt).toLocaleString().split(" ")[0]} 到期</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "workflow",
|
||||
header: "所属工作流",
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.expand.workflow?.name;
|
||||
const workflowId: string = row.getValue("workflow");
|
||||
return (
|
||||
<div className="max-w-[200px] truncate">
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"link"}
|
||||
onClick={() => {
|
||||
handleWorkflowClick(workflowId);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created",
|
||||
header: "颁发时间",
|
||||
cell: ({ row }) => {
|
||||
const date: string = row.getValue("created");
|
||||
return new Date(date).toLocaleString();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={"link"}
|
||||
onClick={() => {
|
||||
handleView(row.original.id);
|
||||
}}
|
||||
>
|
||||
查看证书
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleWorkflowClick = (id: string) => {
|
||||
navigate(`/workflow/detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleView = (id: string) => {
|
||||
setOpen(true);
|
||||
const certificate = data.find((item) => item.id === id);
|
||||
setSelectedCertificate(certificate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-5">
|
||||
<div className="text-muted-foreground">证书</div>
|
||||
|
||||
<DataTable columns={columns} onPageChange={fetchData} data={data} pageCount={pageCount} />
|
||||
|
||||
<CertificateDetail open={open} onOpenChange={setOpen} certificate={selectedCertificate} />
|
||||
<CertificateList withPagination={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,47 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ban, CalendarX2, LoaderPinwheel, Smile, SquareSigma } from "lucide-react";
|
||||
import { CalendarClock, CalendarX2, FolderCheck, SquareSigma, Workflow } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import DeployProgress from "@/components/certimate/DeployProgress";
|
||||
import DeployState from "@/components/certimate/DeployState";
|
||||
import { convertZulu2Beijing } from "@/lib/time";
|
||||
import { Statistic } from "@/domain/domain";
|
||||
import { Deployment, DeploymentListReq, Log } from "@/domain/deployment";
|
||||
import { statistics } from "@/repository/domains";
|
||||
import { list } from "@/repository/deployment";
|
||||
|
||||
import { get } from "@/api/statistics";
|
||||
|
||||
import CertificateList from "@/components/certificate/CertificateList";
|
||||
|
||||
const Dashboard = () => {
|
||||
const [statistic, setStatistic] = useState<Statistic>();
|
||||
const [deployments, setDeployments] = useState<Deployment[]>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStatistic = async () => {
|
||||
const data = await statistics();
|
||||
const data = await get();
|
||||
setStatistic(data);
|
||||
};
|
||||
|
||||
fetchStatistic();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const param: DeploymentListReq = {
|
||||
perPage: 8,
|
||||
};
|
||||
|
||||
const data = await list(param);
|
||||
setDeployments(data.items);
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between items-center">
|
||||
@ -53,12 +34,12 @@ const Dashboard = () => {
|
||||
<SquareSigma size={48} strokeWidth={1} className="text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground font-semibold">{t("dashboard.statistics.all")}</div>
|
||||
<div className="text-muted-foreground font-semibold">全部证书</div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="text-3xl text-stone-700 dark:text-stone-200">
|
||||
{statistic?.total ? (
|
||||
{statistic?.certificateTotal ? (
|
||||
<Link to="/domains" className="hover:underline">
|
||||
{statistic?.total}
|
||||
{statistic?.certificateTotal}
|
||||
</Link>
|
||||
) : (
|
||||
0
|
||||
@ -70,37 +51,37 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
|
||||
<div className="p-3">
|
||||
<CalendarClock size={48} strokeWidth={1} className="text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground font-semibold">即将过期证书</div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="text-3xl text-stone-700 dark:text-stone-200">
|
||||
{statistic?.certificateExpireSoon ? (
|
||||
<Link to="/domains?state=expired" className="hover:underline">
|
||||
{statistic?.certificateExpireSoon}
|
||||
</Link>
|
||||
) : (
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 text-stone-700 dark:text-stone-200">{t("dashboard.statistics.unit")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
|
||||
<div className="p-3">
|
||||
<CalendarX2 size={48} strokeWidth={1} className="text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground font-semibold">{t("dashboard.statistics.near_expired")}</div>
|
||||
<div className="text-muted-foreground font-semibold">已过期证书</div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="text-3xl text-stone-700 dark:text-stone-200">
|
||||
{statistic?.expired ? (
|
||||
<Link to="/domains?state=expired" className="hover:underline">
|
||||
{statistic?.expired}
|
||||
</Link>
|
||||
) : (
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 text-stone-700 dark:text-stone-200">{t("dashboard.statistics.unit")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
|
||||
<div className="p-3">
|
||||
<LoaderPinwheel size={48} strokeWidth={1} className="text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground font-semibold">{t("dashboard.statistics.enabled")}</div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="text-3xl text-stone-700 dark:text-stone-200">
|
||||
{statistic?.enabled ? (
|
||||
{statistic?.certificateExpired ? (
|
||||
<Link to="/domains?state=enabled" className="hover:underline">
|
||||
{statistic?.enabled}
|
||||
{statistic?.certificateExpired}
|
||||
</Link>
|
||||
) : (
|
||||
0
|
||||
@ -113,15 +94,36 @@ const Dashboard = () => {
|
||||
|
||||
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
|
||||
<div className="p-3">
|
||||
<Ban size={48} strokeWidth={1} className="text-gray-400" />
|
||||
<Workflow size={48} strokeWidth={1} className="text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground font-semibold">{t("dashboard.statistics.disabled")}</div>
|
||||
<div className="text-muted-foreground font-semibold">全部工作流</div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="text-3xl text-stone-700 dark:text-stone-200">
|
||||
{statistic?.disabled ? (
|
||||
{statistic?.workflowTotal ? (
|
||||
<Link to="/domains?state=disabled" className="hover:underline">
|
||||
{statistic?.disabled}
|
||||
{statistic?.workflowTotal}
|
||||
</Link>
|
||||
) : (
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 text-stone-700 dark:text-stone-200">{t("dashboard.statistics.unit")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
|
||||
<div className="p-3">
|
||||
<FolderCheck size={48} strokeWidth={1} className="text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground font-semibold">已启用工作流</div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="text-3xl text-stone-700 dark:text-stone-200">
|
||||
{statistic?.workflowEnabled ? (
|
||||
<Link to="/domains?state=disabled" className="hover:underline">
|
||||
{statistic?.workflowEnabled}
|
||||
</Link>
|
||||
) : (
|
||||
0
|
||||
@ -138,132 +140,9 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground mt-5 text-sm">{t("dashboard.history")}</div>
|
||||
<div className="text-muted-foreground mt-5 text-sm">最新证书</div>
|
||||
|
||||
{deployments?.length == 0 ? (
|
||||
<>
|
||||
<Alert className="max-w-[40em] mt-10">
|
||||
<AlertTitle>{t("common.text.nodata")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="flex items-center mt-5">
|
||||
<div>
|
||||
<Smile className="text-yellow-400" size={36} />
|
||||
</div>
|
||||
<div className="ml-2"> {t("history.nodata")}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate("/edit");
|
||||
}}
|
||||
>
|
||||
{t("domain.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
|
||||
<div className="w-48">{t("history.props.domain")}</div>
|
||||
|
||||
<div className="w-24">{t("history.props.status")}</div>
|
||||
<div className="w-56">{t("history.props.stage")}</div>
|
||||
<div className="w-56 sm:ml-2 text-center">{t("history.props.last_execution_time")}</div>
|
||||
|
||||
<div className="grow">{t("common.text.operations")}</div>
|
||||
</div>
|
||||
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.id}
|
||||
className="flex flex-col sm:flex-row text-secondary-foreground border-b dark:border-stone-500 sm:p-2 hover:bg-muted/50 text-sm"
|
||||
>
|
||||
<div className="sm:w-48 w-full pt-1 sm:pt-0 flex flex-col items-start">
|
||||
{deployment.expand.domain?.domain.split(";").map((domain: string) => <div className="pr-3 truncate w-full">{domain}</div>)}
|
||||
</div>
|
||||
<div className="sm:w-24 w-full pt-1 sm:pt-0 flex items-center">
|
||||
<DeployState deployment={deployment} />
|
||||
</div>
|
||||
<div className="sm:w-56 w-full pt-1 sm:pt-0 flex items-center">
|
||||
<DeployProgress phase={deployment.phase} phaseSuccess={deployment.phaseSuccess} />
|
||||
</div>
|
||||
<div className="sm:w-56 w-full pt-1 sm:pt-0 flex items-center sm:justify-center">{convertZulu2Beijing(deployment.deployedAt)}</div>
|
||||
<div className="flex items-center grow justify-start pt-1 sm:pt-0 sm:ml-2">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant={"link"} className="p-0">
|
||||
{t("history.log")}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="sm:max-w-5xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{deployment.expand.domain?.domain}-{deployment.id}
|
||||
{t("history.log")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh] overflow-y-auto">
|
||||
{deployment.log.check && (
|
||||
<>
|
||||
{deployment.log.check.map((item: Log) => {
|
||||
return (
|
||||
<div className="flex flex-col mt-2">
|
||||
<div className="flex">
|
||||
<div>[{item.time}]</div>
|
||||
<div className="ml-2">{item.message}</div>
|
||||
</div>
|
||||
{item.error && <div className="mt-1 text-red-600">{item.error}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{deployment.log.apply && (
|
||||
<>
|
||||
{deployment.log.apply.map((item: Log) => {
|
||||
return (
|
||||
<div className="flex flex-col mt-2">
|
||||
<div className="flex">
|
||||
<div>[{item.time}]</div>
|
||||
<div className="ml-2">{item.message}</div>
|
||||
</div>
|
||||
{item.info &&
|
||||
item.info.map((info: string) => {
|
||||
return <div className="mt-1 text-green-600">{info}</div>;
|
||||
})}
|
||||
{item.error && <div className="mt-1 text-red-600">{item.error}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{deployment.log.deploy && (
|
||||
<>
|
||||
{deployment.log.deploy.map((item: Log) => {
|
||||
return (
|
||||
<div className="flex flex-col mt-2">
|
||||
<div className="flex">
|
||||
<div>[{item.time}]</div>
|
||||
<div className="ml-2">{item.message}</div>
|
||||
</div>
|
||||
{item.error && <div className="mt-1 text-red-600">{item.error}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<CertificateList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { run } from "@/api/workflow";
|
||||
import Show from "@/components/Show";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
@ -11,6 +12,7 @@ import WorkflowLog from "@/components/workflow/WorkflowLog";
|
||||
|
||||
import WorkflowProvider from "@/components/workflow/WorkflowProvider";
|
||||
import { allNodesValidated, WorkflowNode } from "@/domain/workflow";
|
||||
import { getErrMessage } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@ -37,6 +39,8 @@ const WorkflowDetail = () => {
|
||||
|
||||
const [tab, setTab] = useState("workflow");
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(id);
|
||||
init(id ?? "");
|
||||
@ -101,6 +105,28 @@ const WorkflowDetail = () => {
|
||||
return "border-transparent hover:text-primary hover:border-b-primary";
|
||||
};
|
||||
|
||||
const handleRunClick = async () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
try {
|
||||
await run(workflow.id as string);
|
||||
toast({
|
||||
title: "执行成功",
|
||||
description: "工作流已成功执行",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "执行失败",
|
||||
description: getErrMessage(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowProvider>
|
||||
@ -140,7 +166,14 @@ const WorkflowDetail = () => {
|
||||
|
||||
<div className="px-5 flex items-center space-x-3">
|
||||
<Show when={!!workflow.enabled}>
|
||||
<Show when={!!workflow.hasDraft} fallback={<Button variant={"secondary"}>立即执行</Button>}>
|
||||
<Show
|
||||
when={!!workflow.hasDraft}
|
||||
fallback={
|
||||
<Button variant={"secondary"} onClick={handleRunClick}>
|
||||
{running ? "执行中" : "立即执行"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button variant={"secondary"} onClick={handleWorkflowSaveClick}>
|
||||
保存变更
|
||||
</Button>
|
||||
|
@ -200,7 +200,7 @@ const Workflow = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DataTable columns={columns} data={data} onPageChange={fetchData} pageCount={pageCount} onRowClick={handleRowClick} />
|
||||
<DataTable columns={columns} data={data} onPageChange={fetchData} pageCount={pageCount} onRowClick={handleRowClick} withPagination />
|
||||
</div>
|
||||
|
||||
<CustomAlertDialog
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { getTimeAfter } from "@/lib/time";
|
||||
import { Domain, Statistic } from "@/domain/domain";
|
||||
import { Domain } from "@/domain/domain";
|
||||
import { getPb } from "./api";
|
||||
|
||||
type DomainListReq = {
|
||||
@ -42,30 +42,6 @@ export const list = async (req: DomainListReq) => {
|
||||
return response;
|
||||
};
|
||||
|
||||
export const statistics = async (): Promise<Statistic> => {
|
||||
const pb = getPb();
|
||||
const total = await pb.collection("domains").getList(1, 1, {});
|
||||
const expired = await pb.collection("domains").getList(1, 1, {
|
||||
filter: pb.filter("expiredAt<{:expiredAt}", {
|
||||
expiredAt: getTimeAfter(15),
|
||||
}),
|
||||
});
|
||||
|
||||
const enabled = await pb.collection("domains").getList(1, 1, {
|
||||
filter: "enabled=true",
|
||||
});
|
||||
const disabled = await pb.collection("domains").getList(1, 1, {
|
||||
filter: "enabled=false",
|
||||
});
|
||||
|
||||
return {
|
||||
total: total.totalItems,
|
||||
expired: expired.totalItems,
|
||||
enabled: enabled.totalItems,
|
||||
disabled: disabled.totalItems,
|
||||
};
|
||||
};
|
||||
|
||||
export const get = async (id: string) => {
|
||||
const response = await getPb().collection("domains").getOne<Domain>(id);
|
||||
return response;
|
||||
|
@ -89,4 +89,3 @@ export const router = createHashRouter([
|
||||
element: <WorkflowDetail />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user