feat: add Deployment Jobs, Template Categories, Virtual Machine details and management pages
- Implemented DeploymentJobsPage for managing deployment jobs with filtering and retry functionality. - Created TemplateCategoriesPage for managing template categories with add, edit, and delete capabilities. - Developed VirtualMachineDetailsPage to display detailed information about a specific virtual machine. - Added VirtualMachinesPage for listing, adding, editing, and deleting virtual machines, including linking and unlinking to domains.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
Option,
|
||||
Tab,
|
||||
TabList,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,18 +25,29 @@ import {
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { Template } from "../types/portal";
|
||||
import type { ServiceItem, Template, TemplateCategory } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
gap: "12px",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
toolbarActions: {
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
},
|
||||
filter: {
|
||||
minWidth: "280px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
@@ -54,6 +68,25 @@ const useStyles = makeStyles({
|
||||
value: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
groupRow: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
groupHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
},
|
||||
nestedLevel1Cell: {
|
||||
paddingLeft: "28px",
|
||||
},
|
||||
nestedLevel2Cell: {
|
||||
paddingLeft: "48px",
|
||||
},
|
||||
categoryHeaderCell: {
|
||||
paddingTop: "10px",
|
||||
paddingBottom: "6px",
|
||||
paddingLeft: "28px",
|
||||
},
|
||||
});
|
||||
|
||||
type DialogMode = "add" | "edit" | "details" | null;
|
||||
@@ -62,6 +95,57 @@ function getTemplateJsonData(template: Template) {
|
||||
return template.jsonData ?? template.jSONData ?? "";
|
||||
}
|
||||
|
||||
type TemplateEditorTab = "parameters" | "variables" | "resources" | "raw";
|
||||
|
||||
function tryFormatJson(value: string) {
|
||||
try {
|
||||
if (!value.trim()) {
|
||||
return "";
|
||||
}
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function getCaseInsensitiveProperty(source: Record<string, unknown>, propertyName: string) {
|
||||
const key = Object.keys(source).find((entry) => entry.toLowerCase() === propertyName.toLowerCase());
|
||||
return key ? source[key] : undefined;
|
||||
}
|
||||
|
||||
function normalizeEditorJsonParts(rawJson: string) {
|
||||
if (!rawJson.trim()) {
|
||||
return {
|
||||
raw: "",
|
||||
parameters: "{}",
|
||||
variables: "{}",
|
||||
resources: "[]",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawJson) as unknown;
|
||||
const root = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||||
|
||||
const parameters = getCaseInsensitiveProperty(root, "parameters");
|
||||
const variables = getCaseInsensitiveProperty(root, "variables");
|
||||
const resources = getCaseInsensitiveProperty(root, "resources");
|
||||
|
||||
return {
|
||||
raw: JSON.stringify(root, null, 2),
|
||||
parameters: JSON.stringify(
|
||||
parameters && typeof parameters === "object" && !Array.isArray(parameters) ? parameters : {},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
variables: JSON.stringify(
|
||||
variables && typeof variables === "object" && !Array.isArray(variables) ? variables : {},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
resources: JSON.stringify(Array.isArray(resources) ? resources : [], null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -69,24 +153,45 @@ export function TemplatesPage() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [templateCategoryId, setTemplateCategoryId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [cloudTemplate, setCloudTemplate] = useState(false);
|
||||
const [version, setVersion] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [jsonData, setJsonData] = useState("");
|
||||
const [jsonParameters, setJsonParameters] = useState("{}");
|
||||
const [jsonVariables, setJsonVariables] = useState("{}");
|
||||
const [jsonResources, setJsonResources] = useState("[]");
|
||||
const [editorTab, setEditorTab] = useState<TemplateEditorTab>("parameters");
|
||||
const [serviceFilterId, setServiceFilterId] = useState("all");
|
||||
const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({});
|
||||
const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({});
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
||||
});
|
||||
const {
|
||||
data: templateCategories,
|
||||
error: templateCategoriesError,
|
||||
isLoading: templateCategoriesLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["template-categories"],
|
||||
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
|
||||
});
|
||||
const { data: services, error: servicesError, isLoading: servicesLoading } = useQuery({
|
||||
queryKey: ["services"],
|
||||
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedTemplate(null);
|
||||
setTemplateCategoryId("");
|
||||
setName("");
|
||||
setCloudTemplate(false);
|
||||
setVersion("");
|
||||
setDescription("");
|
||||
setJsonData("");
|
||||
setJsonParameters("{}");
|
||||
setJsonVariables("{}");
|
||||
setJsonResources("[]");
|
||||
setEditorTab("parameters");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
@@ -98,10 +203,21 @@ export function TemplatesPage() {
|
||||
setSelectedTemplate(template);
|
||||
setTemplateCategoryId(template.templateCategoryId ?? "");
|
||||
setName(template.name);
|
||||
setCloudTemplate(Boolean(template.cloudTemplate));
|
||||
setVersion(template.version ?? "");
|
||||
setDescription(template.description ?? "");
|
||||
setJsonData(getTemplateJsonData(template));
|
||||
const raw = getTemplateJsonData(template);
|
||||
try {
|
||||
const normalized = normalizeEditorJsonParts(raw);
|
||||
setJsonData(normalized.raw);
|
||||
setJsonParameters(normalized.parameters);
|
||||
setJsonVariables(normalized.variables);
|
||||
setJsonResources(normalized.resources);
|
||||
} catch {
|
||||
setJsonParameters("{}");
|
||||
setJsonVariables("{}");
|
||||
setJsonResources("[]");
|
||||
setJsonData(tryFormatJson(raw));
|
||||
}
|
||||
setDialogMode(mode);
|
||||
};
|
||||
|
||||
@@ -122,7 +238,6 @@ export function TemplatesPage() {
|
||||
template: {
|
||||
templateCategoryId: string;
|
||||
name: string;
|
||||
cloudTemplate: boolean;
|
||||
version: string;
|
||||
description: string;
|
||||
jsonData: string;
|
||||
@@ -143,7 +258,29 @@ export function TemplatesPage() {
|
||||
|
||||
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const template = { cloudTemplate, description, jsonData, name, templateCategoryId, version };
|
||||
let composedJson = jsonData;
|
||||
try {
|
||||
const parsedRaw = jsonData.trim() ? JSON.parse(jsonData) : {};
|
||||
const parsedParameters = jsonParameters.trim() ? JSON.parse(jsonParameters) : {};
|
||||
const parsedVariables = jsonVariables.trim() ? JSON.parse(jsonVariables) : {};
|
||||
const parsedResources = jsonResources.trim() ? JSON.parse(jsonResources) : [];
|
||||
|
||||
composedJson = JSON.stringify(
|
||||
{
|
||||
...parsedRaw,
|
||||
parameters: parsedParameters,
|
||||
variables: parsedVariables,
|
||||
resources: parsedResources,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
setJsonData(composedJson);
|
||||
} catch {
|
||||
// keep current raw JSON and let backend validation respond
|
||||
}
|
||||
|
||||
const template = { description, jsonData: composedJson, name, templateCategoryId, version };
|
||||
|
||||
if (dialogMode === "edit" && selectedTemplate) {
|
||||
updateTemplate.mutate({ id: selectedTemplate.id, template });
|
||||
@@ -156,14 +293,80 @@ export function TemplatesPage() {
|
||||
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
|
||||
const isSaving = addTemplate.isPending || updateTemplate.isPending;
|
||||
const isDetails = dialogMode === "details";
|
||||
const categoryNameById = new Map(
|
||||
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.name]),
|
||||
);
|
||||
const categoryServiceIdById = new Map(
|
||||
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.serviceId]),
|
||||
);
|
||||
const serviceNameById = new Map((services ?? []).map((service: ServiceItem) => [service.id, service.name]));
|
||||
const filteredTemplates =
|
||||
serviceFilterId === "all"
|
||||
? (data ?? [])
|
||||
: (data ?? []).filter((template) => {
|
||||
const templateServiceId = template.templateCategoryId
|
||||
? categoryServiceIdById.get(template.templateCategoryId)
|
||||
: undefined;
|
||||
return templateServiceId === serviceFilterId;
|
||||
});
|
||||
const groupedTemplates = (services ?? [])
|
||||
.map((service) => ({
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
categories: (templateCategories ?? [])
|
||||
.filter((category) => category.serviceId === service.id)
|
||||
.map((category) => ({
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
items: filteredTemplates
|
||||
.filter((template) => template.templateCategoryId === category.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}))
|
||||
.filter((categoryGroup) => categoryGroup.items.length > 0)
|
||||
.sort((a, b) => a.categoryName.localeCompare(b.categoryName)),
|
||||
}))
|
||||
.filter((group) => group.categories.length > 0);
|
||||
|
||||
const toggleServiceGroup = (serviceId: string) => {
|
||||
setExpandedServiceGroups((current) => ({
|
||||
...current,
|
||||
[serviceId]: !current[serviceId],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleCategoryGroup = (serviceId: string, categoryId: string) => {
|
||||
const key = `${serviceId}:${categoryId}`;
|
||||
setExpandedCategoryGroups((current) => ({
|
||||
...current,
|
||||
[key]: !current[key],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template hinzufuegen
|
||||
</Button>
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
<Field className={styles.filter} label="Service Filter">
|
||||
<Combobox
|
||||
placeholder="Alle Services"
|
||||
value={serviceFilterId === "all" ? "Alle Services" : serviceNameById.get(serviceFilterId) ?? ""}
|
||||
onOptionSelect={(_, selectData) => setServiceFilterId(selectData.optionValue ?? "all")}
|
||||
>
|
||||
<Option text="Alle Services" value="all">
|
||||
Alle Services
|
||||
</Option>
|
||||
{(services ?? []).map((service) => (
|
||||
<Option key={service.id} text={service.name} value={service.id}>
|
||||
{service.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
@@ -189,20 +392,21 @@ export function TemplatesPage() {
|
||||
onChange={(_, data) => setVersion(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}>
|
||||
<Input
|
||||
<Field className={styles.wide} label="Template Category" required validationMessage={formError}>
|
||||
<Combobox
|
||||
disabled={isDetails}
|
||||
value={templateCategoryId}
|
||||
onChange={(_, data) => setTemplateCategoryId(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide}>
|
||||
<Checkbox
|
||||
checked={cloudTemplate}
|
||||
disabled={isDetails}
|
||||
label="Cloud Template"
|
||||
onChange={(_, data) => setCloudTemplate(Boolean(data.checked))}
|
||||
/>
|
||||
placeholder="Template Category waehlen"
|
||||
value={categoryNameById.get(templateCategoryId) ?? ""}
|
||||
onOptionSelect={(_, optionData) =>
|
||||
setTemplateCategoryId(optionData.optionValue ?? "")
|
||||
}
|
||||
>
|
||||
{(templateCategories ?? []).map((category) => (
|
||||
<Option key={category.id} text={category.name} value={category.id}>
|
||||
{category.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="Description">
|
||||
<Textarea
|
||||
@@ -213,12 +417,47 @@ export function TemplatesPage() {
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="JSONData" required>
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonData}
|
||||
onChange={(_, data) => setJsonData(data.value)}
|
||||
/>
|
||||
<TabList
|
||||
selectedValue={editorTab}
|
||||
onTabSelect={(_, tabData) => setEditorTab(tabData.value as TemplateEditorTab)}
|
||||
>
|
||||
<Tab value="parameters">Parameters</Tab>
|
||||
<Tab value="variables">Variables</Tab>
|
||||
<Tab value="resources">Resources</Tab>
|
||||
<Tab value="raw">Raw JSON</Tab>
|
||||
</TabList>
|
||||
{editorTab === "parameters" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonParameters}
|
||||
onChange={(_, data) => setJsonParameters(data.value)}
|
||||
/>
|
||||
)}
|
||||
{editorTab === "variables" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonVariables}
|
||||
onChange={(_, data) => setJsonVariables(data.value)}
|
||||
/>
|
||||
)}
|
||||
{editorTab === "resources" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonResources}
|
||||
onChange={(_, data) => setJsonResources(data.value)}
|
||||
/>
|
||||
)}
|
||||
{editorTab === "raw" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonData}
|
||||
onChange={(_, data) => setJsonData(data.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{selectedTemplate && (
|
||||
<Field className={styles.wide} label="Id">
|
||||
@@ -246,61 +485,96 @@ export function TemplatesPage() {
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} />
|
||||
<DataState
|
||||
isLoading={isLoading || templateCategoriesLoading || servicesLoading}
|
||||
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error}
|
||||
/>
|
||||
{data && (
|
||||
<Table aria-label="Templates">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Cloud</TableHeaderCell>
|
||||
<TableHeaderCell>TemplateCategoryId</TableHeaderCell>
|
||||
<TableHeaderCell>Template Category</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell>{template.name}</TableCell>
|
||||
<TableCell>{template.version}</TableCell>
|
||||
<TableCell>{template.cloudTemplate ? "Ja" : "Nein"}</TableCell>
|
||||
<TableCell>{template.templateCategoryId}</TableCell>
|
||||
<TableCell>{template.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openTemplateDialog("details", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openTemplateDialog("edit", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteTemplate.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
|
||||
deleteTemplate.mutate(template.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{groupedTemplates.map((group) => (
|
||||
<Fragment key={`group-${group.serviceId}`}>
|
||||
<TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}>
|
||||
<TableCell colSpan={5}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<strong>{group.serviceName}</strong>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedServiceGroups[group.serviceId] !== false && group.categories.map((categoryGroup) => (
|
||||
<Fragment key={`group-${group.serviceId}-${categoryGroup.categoryId}`}>
|
||||
<TableRow
|
||||
className={styles.groupRow}
|
||||
onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)}
|
||||
>
|
||||
<TableCell className={styles.categoryHeaderCell} colSpan={5}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? (
|
||||
<ChevronDownRegular />
|
||||
) : (
|
||||
<ChevronRightRegular />
|
||||
)}
|
||||
<strong>{categoryGroup.categoryName}</strong>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false &&
|
||||
categoryGroup.items.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell>
|
||||
<TableCell>{template.version}</TableCell>
|
||||
<TableCell>
|
||||
{template.templateCategoryId ? categoryNameById.get(template.templateCategoryId) ?? template.templateCategoryId : ""}
|
||||
</TableCell>
|
||||
<TableCell>{template.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openTemplateDialog("details", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openTemplateDialog("edit", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteTemplate.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
|
||||
deleteTemplate.mutate(template.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
Reference in New Issue
Block a user