- Implemented checkbox selection for multiple items in DeploymentRulesPage, DomainsPage, EnvironmentsPage, ServicesPage, TargetsPage, TemplateCategoriesPage, and TemplatesPage. - Added delete functionality for selected items with confirmation prompts. - Updated state management to handle selected IDs for batch operations. - Enhanced UI with a toolbar for actions related to selected items. - Adjusted API calls to accommodate changes in data structure, including deployment requests and job details.
534 lines
22 KiB
TypeScript
534 lines
22 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Button,
|
|
Checkbox,
|
|
Combobox,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogSurface,
|
|
DialogTitle,
|
|
Field,
|
|
Input,
|
|
makeStyles,
|
|
Option,
|
|
shorthands,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
Text,
|
|
tokens,
|
|
Tooltip,
|
|
} from "@fluentui/react-components";
|
|
import { AddRegular, DeleteRegular, OpenRegular } from "@fluentui/react-icons";
|
|
import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { portalApi } from "../api/portalApi";
|
|
import { DataState } from "../components/DataState";
|
|
import { PageHeader } from "../components/PageHeader";
|
|
import type { DeploymentBatch } from "../types/portal";
|
|
|
|
const statusOrder = ["New", "Pending", "Running", "Failed", "Completed", "Unknown"];
|
|
|
|
const useStyles = makeStyles({
|
|
pageActions: {
|
|
display: "flex",
|
|
gap: "10px",
|
|
marginBottom: "16px",
|
|
},
|
|
groupRow: {
|
|
cursor: "pointer",
|
|
},
|
|
groupHeader: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
},
|
|
nestedLevel2Cell: {
|
|
paddingLeft: "48px",
|
|
},
|
|
actions: {
|
|
display: "flex",
|
|
gap: "4px",
|
|
...shorthands.padding("2px", "0"),
|
|
},
|
|
muted: {
|
|
color: tokens.colorNeutralForeground3,
|
|
},
|
|
idText: {
|
|
color: tokens.colorNeutralForeground3,
|
|
fontFamily: "Consolas, monospace",
|
|
fontSize: "12px",
|
|
},
|
|
emptyState: {
|
|
backgroundColor: tokens.colorNeutralBackground1,
|
|
color: tokens.colorNeutralForeground3,
|
|
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
|
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
|
...shorthands.padding("24px"),
|
|
},
|
|
dialogIntro: {
|
|
color: tokens.colorNeutralForeground3,
|
|
marginBottom: "16px",
|
|
},
|
|
targetPicker: {
|
|
display: "grid",
|
|
gap: "10px",
|
|
},
|
|
});
|
|
|
|
export function DeploymentGroupsPage() {
|
|
const styles = useStyles();
|
|
const queryClient = useQueryClient();
|
|
const [serviceId, setServiceId] = useState("");
|
|
const [templateId, setTemplateId] = useState("");
|
|
const [templateVersionId, setTemplateVersionId] = useState("");
|
|
const [deploymentRuleId, setDeploymentRuleId] = useState("");
|
|
const [targetIds, setTargetIds] = useState<string[]>([]);
|
|
const [targetSearch, setTargetSearch] = useState("");
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [deleteCandidate, setDeleteCandidate] = useState<DeploymentBatch | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [expandedStatusGroups, setExpandedStatusGroups] = useState<Record<string, boolean>>({});
|
|
|
|
const { data, error, isLoading } = useQuery({
|
|
queryKey: ["deployment-batches"],
|
|
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
|
|
});
|
|
const { data: services } = useQuery({
|
|
queryKey: ["services"],
|
|
queryFn: ({ signal }) => portalApi.getServices(signal),
|
|
});
|
|
const { data: templates } = useQuery({
|
|
queryKey: ["templates"],
|
|
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
|
});
|
|
const { data: templateCategories } = useQuery({
|
|
queryKey: ["template-categories"],
|
|
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
|
|
});
|
|
const { data: targets } = useQuery({
|
|
queryKey: ["targets"],
|
|
queryFn: ({ signal }) => portalApi.getTargets(signal),
|
|
});
|
|
const { data: templateVersions } = useQuery({
|
|
queryKey: ["template-versions", templateId],
|
|
queryFn: ({ signal }) => portalApi.getTemplateVersions(templateId, signal),
|
|
enabled: Boolean(templateId),
|
|
});
|
|
const { data: deploymentRules } = useQuery({
|
|
queryKey: ["deployment-rules"],
|
|
queryFn: ({ signal }) => portalApi.getDeploymentRules(signal),
|
|
});
|
|
|
|
const addDeploymentBatch = useMutation({
|
|
mutationFn: portalApi.addDeploymentBatch,
|
|
onSuccess: async () => {
|
|
setServiceId("");
|
|
setTemplateId("");
|
|
setTemplateVersionId("");
|
|
setDeploymentRuleId("");
|
|
setTargetIds([]);
|
|
setTargetSearch("");
|
|
setDialogOpen(false);
|
|
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
|
|
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
|
|
},
|
|
});
|
|
const deleteDeploymentBatch = useMutation({
|
|
mutationFn: portalApi.deleteDeploymentBatch,
|
|
onSuccess: async () => {
|
|
setDeleteCandidate(null);
|
|
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
|
|
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
|
|
},
|
|
});
|
|
const deleteSelectedDeploymentBatches = useMutation({
|
|
mutationFn: async (ids: string[]) => {
|
|
await Promise.all(ids.map((id) => portalApi.deleteDeploymentBatch(id)));
|
|
},
|
|
onSuccess: async () => {
|
|
setSelectedIds([]);
|
|
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
|
|
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
|
|
},
|
|
});
|
|
|
|
const selectedService = (services ?? []).find((service) => service.id === serviceId);
|
|
const isOnPremService = selectedService ? !selectedService.isCloudService : false;
|
|
const filteredTargets = useMemo(() => {
|
|
const search = targetSearch.trim().toLowerCase();
|
|
if (!search) {
|
|
return targets ?? [];
|
|
}
|
|
|
|
return (targets ?? []).filter((target) => {
|
|
const name = target.name?.toLowerCase() ?? "";
|
|
const id = target.id?.toLowerCase() ?? "";
|
|
return name.includes(search) || id.includes(search);
|
|
});
|
|
}, [targetSearch, targets]);
|
|
const allVisibleSelected =
|
|
filteredTargets.length > 0 &&
|
|
filteredTargets.every((target) => targetIds.includes(target.id));
|
|
|
|
const groupedBatches = useMemo(() => {
|
|
const groups = (data ?? []).reduce<Record<string, DeploymentBatch[]>>((acc, deploymentBatch) => {
|
|
const status = deploymentBatch.status || "Unknown";
|
|
acc[status] = [...(acc[status] ?? []), deploymentBatch];
|
|
return acc;
|
|
}, {});
|
|
|
|
return Object.entries(groups).sort(([left], [right]) => {
|
|
const leftIndex = statusOrder.indexOf(left);
|
|
const rightIndex = statusOrder.indexOf(right);
|
|
return (leftIndex === -1 ? statusOrder.length : leftIndex) - (rightIndex === -1 ? statusOrder.length : rightIndex);
|
|
});
|
|
}, [data]);
|
|
const allSelected = Boolean(data?.length) && data!.every((deploymentBatch) => selectedIds.includes(deploymentBatch.id));
|
|
const toggleSelected = (id: string, checked: boolean) => {
|
|
setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id)));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!templateVersions || templateVersions.length === 0 || templateVersionId) {
|
|
return;
|
|
}
|
|
|
|
const publishedVersion = templateVersions.find((version) => version.isPublished);
|
|
setTemplateVersionId((publishedVersion ?? templateVersions[0]).id);
|
|
}, [templateVersionId, templateVersions]);
|
|
|
|
const getServiceName = (deploymentBatch: DeploymentBatch) => {
|
|
const template = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId);
|
|
const category = (templateCategories ?? []).find((entry) => entry.id === template?.templateCategoryId);
|
|
const service = (services ?? []).find((entry) => entry.id === category?.serviceId);
|
|
return service?.name ?? "-";
|
|
};
|
|
|
|
const getPrimaryTemplateLabel = (deploymentBatch: DeploymentBatch) => {
|
|
const fallbackTemplate = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId);
|
|
const templateName = deploymentBatch.primaryTemplateName ?? fallbackTemplate?.name ?? "-";
|
|
const templateVersion = deploymentBatch.primaryTemplateVersion ? ` ${deploymentBatch.primaryTemplateVersion}` : "";
|
|
const additionalTemplates =
|
|
deploymentBatch.templateSelectionCount > 1 ? ` +${deploymentBatch.templateSelectionCount - 1}` : "";
|
|
return `${templateName}${templateVersion}${additionalTemplates}`;
|
|
};
|
|
|
|
const toggleStatusGroup = (status: string) => {
|
|
setExpandedStatusGroups((current) => ({
|
|
...current,
|
|
[status]: !current[status],
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Deployments" description="Deployment Batches und Compositionen nach Status." />
|
|
<div className={styles.pageActions}>
|
|
<Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}>
|
|
Neuer Deployment Batch
|
|
</Button>
|
|
<Button
|
|
appearance="secondary"
|
|
disabled={selectedIds.length === 0 || deleteSelectedDeploymentBatches.isPending}
|
|
icon={<DeleteRegular />}
|
|
onClick={() => {
|
|
if (window.confirm(`${selectedIds.length} Deployment Batches wirklich loeschen?`)) {
|
|
deleteSelectedDeploymentBatches.mutate(selectedIds);
|
|
}
|
|
}}
|
|
>
|
|
Auswahl loeschen
|
|
</Button>
|
|
</div>
|
|
<DataState isLoading={isLoading} error={error ?? deleteSelectedDeploymentBatches.error} />
|
|
{!isLoading && !error && groupedBatches.length === 0 && (
|
|
<div className={styles.emptyState}>Keine Deployment Batches vorhanden.</div>
|
|
)}
|
|
{!isLoading && !error && groupedBatches.length > 0 && (
|
|
<Table aria-label="Deployment batches">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>
|
|
<Checkbox
|
|
checked={allSelected}
|
|
onChange={(_, checkboxData) =>
|
|
setSelectedIds(checkboxData.checked ? (data ?? []).map((deploymentBatch) => deploymentBatch.id) : [])
|
|
}
|
|
/>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Service</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Primary Template</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Templates</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Targets</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Rule</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Created</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Batch</Text>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>
|
|
<Text weight="semibold">Actions</Text>
|
|
</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groupedBatches.map(([status, deploymentBatches]) => (
|
|
<Fragment key={`status-${status}`}>
|
|
<TableRow className={styles.groupRow} onClick={() => toggleStatusGroup(status)}>
|
|
<TableCell colSpan={9}>
|
|
<div className={styles.groupHeader}>
|
|
{expandedStatusGroups[status] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
|
<strong>{status}</strong>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{expandedStatusGroups[status] !== false &&
|
|
deploymentBatches.map((deploymentBatch) => (
|
|
<TableRow key={deploymentBatch.id}>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selectedIds.includes(deploymentBatch.id)}
|
|
onChange={(_, checkboxData) => toggleSelected(deploymentBatch.id, Boolean(checkboxData.checked))}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className={styles.nestedLevel2Cell}>{getServiceName(deploymentBatch)}</TableCell>
|
|
<TableCell>{getPrimaryTemplateLabel(deploymentBatch)}</TableCell>
|
|
<TableCell>{deploymentBatch.templateSelectionCount ?? 0}</TableCell>
|
|
<TableCell>{deploymentBatch.targetAssignmentCount ?? 0}</TableCell>
|
|
<TableCell>
|
|
{(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
|
|
</TableCell>
|
|
<TableCell>{deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}</TableCell>
|
|
<TableCell>
|
|
<Tooltip content={deploymentBatch.id} relationship="label">
|
|
<span className={styles.idText}>{deploymentBatch.id.slice(0, 8)}</span>
|
|
</Tooltip>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className={styles.actions}>
|
|
<Tooltip content="Details" relationship="label">
|
|
<Link to={`/deployments/${deploymentBatch.id}`}>
|
|
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
|
|
</Link>
|
|
</Tooltip>
|
|
<Tooltip content="Delete" relationship="label">
|
|
<Button
|
|
appearance="subtle"
|
|
aria-label="Delete"
|
|
icon={<DeleteRegular />}
|
|
onClick={() => setDeleteCandidate(deploymentBatch)}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={(_, data) => setDialogOpen(data.open)}>
|
|
<DialogSurface>
|
|
<DialogBody>
|
|
<DialogTitle>Neuer Deployment Batch</DialogTitle>
|
|
<DialogContent>
|
|
<Text className={styles.dialogIntro}>
|
|
Erstellt eine neue Deployment Composition mit einer initialen Template Selection.
|
|
</Text>
|
|
<Field label="Service" required>
|
|
<Combobox
|
|
placeholder="Service waehlen"
|
|
value={services?.find((service) => service.id === serviceId)?.name ?? ""}
|
|
onOptionSelect={(_, data) => {
|
|
const nextServiceId = data.optionValue ?? "";
|
|
setServiceId(nextServiceId);
|
|
setTemplateId("");
|
|
setTemplateVersionId("");
|
|
setTargetIds([]);
|
|
}}
|
|
>
|
|
{(services ?? []).map((service) => (
|
|
<Option key={service.id} text={service.name} value={service.id}>
|
|
{service.name}
|
|
</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
<Field label="Initial Template" required>
|
|
<Combobox
|
|
placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"}
|
|
disabled={!serviceId}
|
|
value={templates?.find((template) => template.id === templateId)?.name ?? ""}
|
|
onOptionSelect={(_, data) => {
|
|
setTemplateId(data.optionValue ?? "");
|
|
setTemplateVersionId("");
|
|
}}
|
|
>
|
|
{(templates ?? [])
|
|
.filter((template) => {
|
|
const category = (templateCategories ?? []).find((entry) => entry.id === template.templateCategoryId);
|
|
return category?.serviceId === serviceId;
|
|
})
|
|
.map((template) => (
|
|
<Option key={template.id} text={template.name} value={template.id}>
|
|
{template.name}
|
|
</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
<Field label="Version" required>
|
|
<Combobox
|
|
placeholder={templateId ? "Version waehlen" : "Erst Template waehlen"}
|
|
disabled={!templateId}
|
|
value={templateVersions?.find((version) => version.id === templateVersionId)?.version ?? ""}
|
|
onOptionSelect={(_, data) => setTemplateVersionId(data.optionValue ?? "")}
|
|
>
|
|
{(templateVersions ?? []).map((version) => (
|
|
<Option key={version.id} text={version.version} value={version.id}>
|
|
{version.version}
|
|
{version.isPublished ? " published" : ""}
|
|
</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
<Field label="Deployment Rule">
|
|
<Combobox
|
|
placeholder="Rule waehlen (optional)"
|
|
value={deploymentRules?.find((rule) => rule.id === deploymentRuleId)?.name ?? ""}
|
|
onOptionSelect={(_, data) => setDeploymentRuleId(data.optionValue ?? "")}
|
|
>
|
|
{(deploymentRules ?? []).map((rule) => (
|
|
<Option key={rule.id} text={rule.name} value={rule.id}>
|
|
{rule.name}
|
|
</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
{isOnPremService && (
|
|
<Field label="Initial Targets" required>
|
|
<div className={styles.targetPicker}>
|
|
<Input
|
|
placeholder="Target suchen (Name oder Id)"
|
|
value={targetSearch}
|
|
onChange={(_, data) => setTargetSearch(data.value)}
|
|
/>
|
|
<Table aria-label="Targets selection">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>
|
|
<Checkbox
|
|
checked={allVisibleSelected}
|
|
onChange={(_, data) => {
|
|
if (data.checked) {
|
|
setTargetIds((prev) =>
|
|
Array.from(new Set([...prev, ...filteredTargets.map((target) => target.id)])),
|
|
);
|
|
} else {
|
|
const visibleIds = new Set(filteredTargets.map((target) => target.id));
|
|
setTargetIds((prev) => prev.filter((id) => !visibleIds.has(id)));
|
|
}
|
|
}}
|
|
/>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell>Name</TableHeaderCell>
|
|
<TableHeaderCell>Provider</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredTargets.map((target) => (
|
|
<TableRow key={target.id}>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={targetIds.includes(target.id)}
|
|
onChange={(_, data) => {
|
|
if (data.checked) {
|
|
setTargetIds((prev) => [...prev, target.id]);
|
|
} else {
|
|
setTargetIds((prev) => prev.filter((entry) => entry !== target.id));
|
|
}
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{target.name}</TableCell>
|
|
<TableCell>{target.providerType}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</Field>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button appearance="secondary" onClick={() => setDialogOpen(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
appearance="primary"
|
|
disabled={!templateId || !templateVersionId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending}
|
|
onClick={() =>
|
|
addDeploymentBatch.mutate({
|
|
status: "New",
|
|
templateId,
|
|
templateVersionId,
|
|
deploymentRuleId: deploymentRuleId || undefined,
|
|
targetIds: isOnPremService ? targetIds : undefined,
|
|
})
|
|
}
|
|
>
|
|
Batch anlegen
|
|
</Button>
|
|
</DialogActions>
|
|
</DialogBody>
|
|
</DialogSurface>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(deleteCandidate)} onOpenChange={(_, data) => !data.open && setDeleteCandidate(null)}>
|
|
<DialogSurface>
|
|
<DialogBody>
|
|
<DialogTitle>Deployment Batch loeschen?</DialogTitle>
|
|
<DialogContent>
|
|
<Text>
|
|
Der Batch {deleteCandidate?.id.slice(0, 8)} und seine Composition werden geloescht.
|
|
</Text>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button appearance="secondary" onClick={() => setDeleteCandidate(null)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
appearance="primary"
|
|
disabled={!deleteCandidate || deleteDeploymentBatch.isPending}
|
|
icon={<DeleteRegular />}
|
|
onClick={() => deleteCandidate && deleteDeploymentBatch.mutate(deleteCandidate.id)}
|
|
>
|
|
Loeschen
|
|
</Button>
|
|
</DialogActions>
|
|
</DialogBody>
|
|
</DialogSurface>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|