Kaynağa Gözat

Certificates react table basis

Jamie Curnow 3 ay önce
ebeveyn
işleme
058f49ceea

+ 1 - 4
backend/internal/certificate.js

@@ -406,10 +406,7 @@ const internalCertificate = {
 			.query()
 			.where("is_deleted", 0)
 			.groupBy("id")
-			.allowGraph("[owner]")
-			.allowGraph("[proxy_hosts]")
-			.allowGraph("[redirection_hosts]")
-			.allowGraph("[dead_hosts]")
+			.allowGraph("[owner,proxy_hosts,redirection_hosts,dead_hosts]")
 			.orderBy("nice_name", "ASC");
 
 		if (accessData.permission_visibility !== "all") {

+ 3 - 1
frontend/src/api/backend/getCertificates.ts

@@ -1,7 +1,9 @@
 import * as api from "./base";
 import type { Certificate } from "./models";
 
-export async function getCertificates(expand?: string[], params = {}): Promise<Certificate[]> {
+export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts";
+
+export async function getCertificates(expand?: CertificateExpansion[], params = {}): Promise<Certificate[]> {
 	return await api.get({
 		url: "/nginx/certificates",
 		params: {

+ 1 - 0
frontend/src/hooks/index.ts

@@ -1,6 +1,7 @@
 export * from "./useAccessLists";
 export * from "./useAuditLog";
 export * from "./useAuditLogs";
+export * from "./useCertificates";
 export * from "./useDeadHosts";
 export * from "./useHealth";
 export * from "./useHostReport";

+ 17 - 0
frontend/src/hooks/useCertificates.ts

@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import { type Certificate, type CertificateExpansion, getCertificates } from "src/api/backend";
+
+const fetchCertificates = (expand?: CertificateExpansion[]) => {
+	return getCertificates(expand);
+};
+
+const useCertificates = (expand?: CertificateExpansion[], options = {}) => {
+	return useQuery<Certificate[], Error>({
+		queryKey: ["certificates", { expand }],
+		queryFn: () => fetchCertificates(expand),
+		staleTime: 60 * 1000,
+		...options,
+	});
+};
+
+export { fetchCertificates, useCertificates };

+ 6 - 0
frontend/src/locale/lang/en.json

@@ -15,6 +15,10 @@
   "action.view-details": "View Details",
   "auditlog.title": "Audit Log",
   "cancel": "Cancel",
+  "certificates.actions-title": "Certificate #{id}",
+  "certificates.add": "Add Certificate",
+  "certificates.custom": "Custom Certificate",
+  "certificates.empty": "There are no Certificates",
   "certificates.title": "SSL Certificates",
   "close": "Close",
   "column.access": "Access",
@@ -22,10 +26,12 @@
   "column.destination": "Destination",
   "column.email": "Email",
   "column.event": "Event",
+  "column.expires": "Expires",
   "column.http-code": "Access",
   "column.incoming-port": "Incoming Port",
   "column.name": "Name",
   "column.protocol": "Protocol",
+  "column.provider": "Provider",
   "column.roles": "Roles",
   "column.satisfy": "Satisfy",
   "column.scheme": "Scheme",

+ 18 - 0
frontend/src/locale/src/en.json

@@ -47,6 +47,18 @@
 	"cancel": {
 		"defaultMessage": "Cancel"
 	},
+	"certificates.actions-title": {
+		"defaultMessage": "Certificate #{id}"
+	},
+	"certificates.add": {
+		"defaultMessage": "Add Certificate"
+	},
+	"certificates.custom": {
+		"defaultMessage": "Custom Certificate"
+	},
+	"certificates.empty": {
+		"defaultMessage": "There are no Certificates"
+	},
 	"certificates.title": {
 		"defaultMessage": "SSL Certificates"
 	},
@@ -71,6 +83,9 @@
 	"column.event": {
 		"defaultMessage": "Event"
 	},
+	"column.expires": {
+		"defaultMessage": "Expires"
+	},
 	"column.http-code": {
 		"defaultMessage": "Access"
 	},
@@ -83,6 +98,9 @@
 	"column.protocol": {
 		"defaultMessage": "Protocol"
 	},
+	"column.provider": {
+		"defaultMessage": "Provider"
+	},
 	"column.roles": {
 		"defaultMessage": "Roles"
 	},

+ 36 - 0
frontend/src/pages/Certificates/Empty.tsx

@@ -0,0 +1,36 @@
+import type { Table as ReactTable } from "@tanstack/react-table";
+import { intl } from "src/locale";
+
+/**
+ * This component should never render as there should always be 1 user minimum,
+ * but I'm keeping it for consistency.
+ */
+
+interface Props {
+	tableInstance: ReactTable<any>;
+}
+export default function Empty({ tableInstance }: Props) {
+	return (
+		<tr>
+			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
+				<div className="text-center my-4">
+					<h2>{intl.formatMessage({ id: "certificates.empty" })}</h2>
+					<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+					<div className="dropdown">
+						<button type="button" className="btn dropdown-toggle btn-pink my-3" data-bs-toggle="dropdown">
+							{intl.formatMessage({ id: "certificates.add" })}
+						</button>
+						<div className="dropdown-menu">
+							<a className="dropdown-item" href="#">
+								{intl.formatMessage({ id: "lets-encrypt" })}
+							</a>
+							<a className="dropdown-item" href="#">
+								{intl.formatMessage({ id: "certificates.custom" })}
+							</a>
+						</div>
+					</div>
+				</div>
+			</td>
+		</tr>
+	);
+}

+ 116 - 0
frontend/src/pages/Certificates/Table.tsx

@@ -0,0 +1,116 @@
+import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
+import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
+import { useMemo } from "react";
+import type { Certificate } from "src/api/backend";
+import { DomainsFormatter, GravatarFormatter } from "src/components";
+import { TableLayout } from "src/components/Table/TableLayout";
+import { intl } from "src/locale";
+import Empty from "./Empty";
+
+interface Props {
+	data: Certificate[];
+	isFetching?: boolean;
+}
+export default function Table({ data, isFetching }: Props) {
+	const columnHelper = createColumnHelper<Certificate>();
+	const columns = useMemo(
+		() => [
+			columnHelper.accessor((row: any) => row.owner, {
+				id: "owner",
+				cell: (info: any) => {
+					const value = info.getValue();
+					return <GravatarFormatter url={value.avatar} name={value.name} />;
+				},
+				meta: {
+					className: "w-1",
+				},
+			}),
+			columnHelper.accessor((row: any) => row, {
+				id: "domainNames",
+				header: intl.formatMessage({ id: "column.name" }),
+				cell: (info: any) => {
+					const value = info.getValue();
+					return <DomainsFormatter domains={value.domainNames} createdOn={value.createdOn} />;
+				},
+			}),
+			columnHelper.accessor((row: any) => row.provider, {
+				id: "provider",
+				header: intl.formatMessage({ id: "column.provider" }),
+				cell: (info: any) => {
+					return info.getValue();
+				},
+			}),
+			columnHelper.accessor((row: any) => row.expires_on, {
+				id: "expires_on",
+				header: intl.formatMessage({ id: "column.expires" }),
+				cell: (info: any) => {
+					return info.getValue();
+				},
+			}),
+			columnHelper.accessor((row: any) => row, {
+				id: "id",
+				header: intl.formatMessage({ id: "column.status" }),
+				cell: (info: any) => {
+					return info.getValue();
+				},
+			}),
+			columnHelper.display({
+				id: "id", // todo: not needed for a display?
+				cell: (info: any) => {
+					return (
+						<span className="dropdown">
+							<button
+								type="button"
+								className="btn dropdown-toggle btn-action btn-sm px-1"
+								data-bs-boundary="viewport"
+								data-bs-toggle="dropdown"
+							>
+								<IconDotsVertical />
+							</button>
+							<div className="dropdown-menu dropdown-menu-end">
+								<span className="dropdown-header">
+									{intl.formatMessage(
+										{
+											id: "certificates.actions-title",
+										},
+										{ id: info.row.original.id },
+									)}
+								</span>
+								<a className="dropdown-item" href="#">
+									<IconEdit size={16} />
+									{intl.formatMessage({ id: "action.edit" })}
+								</a>
+								<a className="dropdown-item" href="#">
+									<IconPower size={16} />
+									{intl.formatMessage({ id: "action.disable" })}
+								</a>
+								<div className="dropdown-divider" />
+								<a className="dropdown-item" href="#">
+									<IconTrash size={16} />
+									{intl.formatMessage({ id: "action.delete" })}
+								</a>
+							</div>
+						</span>
+					);
+				},
+				meta: {
+					className: "text-end w-1",
+				},
+			}),
+		],
+		[columnHelper],
+	);
+
+	const tableInstance = useReactTable<Certificate>({
+		columns,
+		data,
+		getCoreRowModel: getCoreRowModel(),
+		rowCount: data.length,
+		meta: {
+			isFetching,
+		},
+		enableSortingRemoval: false,
+	});
+
+	return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
+}

+ 71 - 0
frontend/src/pages/Certificates/TableWrapper.tsx

@@ -0,0 +1,71 @@
+import { IconSearch } from "@tabler/icons-react";
+import Alert from "react-bootstrap/Alert";
+import { LoadingPage } from "src/components";
+import { useCertificates } from "src/hooks";
+import { intl } from "src/locale";
+import Table from "./Table";
+
+export default function TableWrapper() {
+	const { isFetching, isLoading, isError, error, data } = useCertificates([
+		"owner",
+		"dead_hosts",
+		"proxy_hosts",
+		"redirection_hosts",
+	]);
+
+	if (isLoading) {
+		return <LoadingPage />;
+	}
+
+	if (isError) {
+		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
+	}
+
+	return (
+		<div className="card mt-4">
+			<div className="card-status-top bg-pink" />
+			<div className="card-table">
+				<div className="card-header">
+					<div className="row w-full">
+						<div className="col">
+							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "certificates.title" })}</h2>
+						</div>
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								<div className="input-group input-group-flat w-auto">
+									<span className="input-group-text input-group-text-sm">
+										<IconSearch size={16} />
+									</span>
+									<input
+										id="advanced-table-search"
+										type="text"
+										className="form-control form-control-sm"
+										autoComplete="off"
+									/>
+								</div>
+								<div className="dropdown">
+									<button
+										type="button"
+										className="btn btn-sm dropdown-toggle btn-pink mt-1"
+										data-bs-toggle="dropdown"
+									>
+										{intl.formatMessage({ id: "certificates.add" })}
+									</button>
+									<div className="dropdown-menu">
+										<a className="dropdown-item" href="#">
+											{intl.formatMessage({ id: "lets-encrypt" })}
+										</a>
+										<a className="dropdown-item" href="#">
+											{intl.formatMessage({ id: "certificates.custom" })}
+										</a>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<Table data={data ?? []} isFetching={isFetching} />
+			</div>
+		</div>
+	);
+}

+ 2 - 2
frontend/src/pages/Certificates/index.tsx

@@ -1,10 +1,10 @@
 import { HasPermission } from "src/components";
-import CertificateTable from "./CertificateTable";
+import TableWrapper from "./TableWrapper";
 
 const Certificates = () => {
 	return (
 		<HasPermission permission="certificates" type="view" pageLoading loadingNoLogo>
-			<CertificateTable />
+			<TableWrapper />
 		</HasPermission>
 	);
 };