diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx
index 0d17c66..c8876af 100644
--- a/src/layout/AppShell.tsx
+++ b/src/layout/AppShell.tsx
@@ -1,5 +1,6 @@
import { Outlet, NavLink } from "react-router-dom";
import {
+ Badge,
Button,
makeStyles,
shorthands,
@@ -16,6 +17,8 @@ import {
TagMultiple24Regular,
Desktop24Regular,
} from "@fluentui/react-icons";
+import { useQuery } from "@tanstack/react-query";
+import { portalApi } from "../api/portalApi";
const useStyles = makeStyles({
root: {
@@ -63,6 +66,11 @@ const useStyles = makeStyles({
...shorthands.borderBottom("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.padding("0", "28px"),
},
+ apiStatus: {
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ },
main: {
...shorthands.padding("28px"),
},
@@ -84,6 +92,19 @@ const links = [
export function AppShell() {
const styles = useStyles();
+ const apiStatus = useQuery({
+ queryKey: ["api-status"],
+ queryFn: ({ signal }) => portalApi.getServices(signal),
+ retry: 1,
+ staleTime: 60_000,
+ });
+ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
+ const apiBadge =
+ apiStatus.isLoading
+ ? { appearance: "tint" as const, color: "subtle" as const, label: "API pruefen" }
+ : apiStatus.error
+ ? { appearance: "filled" as const, color: "danger" as const, label: "API Fehler" }
+ : { appearance: "filled" as const, color: "success" as const, label: "API verbunden" };
return (
@@ -111,7 +132,12 @@ export function AppShell() {
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
index ddcb6e7..fea4c6a 100644
--- a/src/pages/DashboardPage.tsx
+++ b/src/pages/DashboardPage.tsx
@@ -4,9 +4,11 @@ import {
CardHeader,
Divider,
makeStyles,
+ shorthands,
Text,
tokens,
} from "@fluentui/react-components";
+import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
@@ -28,6 +30,28 @@ const useStyles = makeStyles({
fontWeight: tokens.fontWeightSemibold,
lineHeight: "40px",
},
+ metricLink: {
+ color: "inherit",
+ display: "block",
+ textDecorationLine: "none",
+ ...shorthands.borderRadius(tokens.borderRadiusMedium),
+ ":focus-visible": {
+ outlineStyle: "solid",
+ outlineWidth: "2px",
+ outlineColor: tokens.colorStrokeFocus2,
+ outlineOffset: "2px",
+ },
+ },
+ metricCard: {
+ transitionProperty: "box-shadow, transform, border-color",
+ transitionDuration: tokens.durationFaster,
+ transitionTimingFunction: tokens.curveEasyEase,
+ ":hover": {
+ boxShadow: tokens.shadow8,
+ transform: "translateY(-1px)",
+ ...shorthands.borderColor(tokens.colorNeutralStroke1Hover),
+ },
+ },
donutWrap: {
display: "flex",
alignItems: "center",
@@ -54,6 +78,12 @@ const useStyles = makeStyles({
borderRadius: "50%",
display: "inline-block",
},
+ emptyState: {
+ minHeight: "120px",
+ display: "flex",
+ alignItems: "center",
+ color: tokens.colorNeutralForeground3,
+ },
});
export function DashboardPage() {
@@ -121,16 +151,17 @@ export function DashboardPage() {
{!isLoading && !error && (
-
-
-
-
+
+
+
+
)}
{!isLoading && !error && (
({
label,
value,
@@ -139,6 +170,7 @@ export function DashboardPage() {
/>
- {label}} />
- {value}
-
+
+
+ {label}} />
+ {value}
+
+
);
}
const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"];
function DonutCard({
+ emptyText,
title,
items,
}: {
+ emptyText: string;
title: string;
items: Array<{ label: string; value: number; color: string }>;
}) {
@@ -183,20 +219,26 @@ function DonutCard({
{title}} />
-
-
-
- {items.map((item) => (
-
-
-
- {item.label}: {item.value}
-
-
- ))}
-
Total: {total}
+ {total === 0 ? (
+
+ {emptyText}
-
+ ) : (
+
+
+
+ {items.map((item) => (
+
+
+
+ {item.label}: {item.value}
+
+
+ ))}
+
Total: {total}
+
+
+ )}
);
}
diff --git a/src/pages/DeploymentBatchDetailsPage.tsx b/src/pages/DeploymentBatchDetailsPage.tsx
index 57dfa6b..7a6ecb9 100644
--- a/src/pages/DeploymentBatchDetailsPage.tsx
+++ b/src/pages/DeploymentBatchDetailsPage.tsx
@@ -17,7 +17,7 @@ import {
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, ArrowLeftRegular, DeleteRegular } from "@fluentui/react-icons";
-import { useMemo, useState } from "react";
+import { useMemo, useState, type SetStateAction } from "react";
import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
@@ -42,6 +42,12 @@ const useStyles = makeStyles({
fontSize: "18px",
fontWeight: 600,
},
+ headerActions: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: "12px",
+ },
formGrid: {
display: "grid",
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
@@ -79,6 +85,9 @@ export function DeploymentBatchDetailsPage() {
const [targetTargetId, setTargetTargetId] = useState("");
const [targetRoleKey, setTargetRoleKey] = useState("Node");
const [targetNodeDataJson, setTargetNodeDataJson] = useState("{}");
+ const [selectedTemplateSelectionIds, setSelectedTemplateSelectionIds] = useState
([]);
+ const [selectedParameterValueIds, setSelectedParameterValueIds] = useState([]);
+ const [selectedTargetAssignmentIds, setSelectedTargetAssignmentIds] = useState([]);
const { data: deploymentBatch, error, isLoading } = useQuery({
queryKey: ["deployment-batches", id],
@@ -127,6 +136,15 @@ export function DeploymentBatchDetailsPage() {
mutationFn: (selectionId: string) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId),
onSuccess: invalidateBatch,
});
+ const deleteSelectedTemplateSelections = useMutation({
+ mutationFn: async (selectionIds: string[]) => {
+ await Promise.all(selectionIds.map((selectionId) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId)));
+ },
+ onSuccess: async () => {
+ setSelectedTemplateSelectionIds([]);
+ await invalidateBatch();
+ },
+ });
const addParameterValue = useMutation({
mutationFn: () =>
@@ -149,6 +167,15 @@ export function DeploymentBatchDetailsPage() {
mutationFn: (parameterValueId: string) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId),
onSuccess: invalidateBatch,
});
+ const deleteSelectedParameterValues = useMutation({
+ mutationFn: async (parameterValueIds: string[]) => {
+ await Promise.all(parameterValueIds.map((parameterValueId) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId)));
+ },
+ onSuccess: async () => {
+ setSelectedParameterValueIds([]);
+ await invalidateBatch();
+ },
+ });
const addTargetAssignment = useMutation({
mutationFn: () =>
@@ -168,6 +195,29 @@ export function DeploymentBatchDetailsPage() {
mutationFn: (targetAssignmentId: string) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId),
onSuccess: invalidateBatch,
});
+ const deleteSelectedTargetAssignments = useMutation({
+ mutationFn: async (targetAssignmentIds: string[]) => {
+ await Promise.all(targetAssignmentIds.map((targetAssignmentId) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId)));
+ },
+ onSuccess: async () => {
+ setSelectedTargetAssignmentIds([]);
+ await invalidateBatch();
+ },
+ });
+ const queueDeployment = useMutation({
+ mutationFn: () =>
+ portalApi.addDeploymentRequest({
+ deploymentGroupId: id!,
+ targetIds: [],
+ jsonData: "{}",
+ }),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
+ await queryClient.invalidateQueries({ queryKey: ["queue-jobs"] });
+ await queryClient.invalidateQueries({ queryKey: ["deployments"] });
+ await invalidateBatch();
+ },
+ });
const filteredExecutions = useMemo(() => {
if (deploymentBatch?.deployments && deploymentBatch.deployments.length > 0) {
@@ -182,18 +232,53 @@ export function DeploymentBatchDetailsPage() {
const templateSelectionError = addTemplateSelection.error;
const parameterError = addParameterValue.error;
const targetError = addTargetAssignment.error;
+ const queueError = queueDeployment.error;
+ const templateSelections = deploymentBatch?.templateSelections ?? [];
+ const parameterValues = deploymentBatch?.parameterValues ?? [];
+ const targetAssignments = deploymentBatch?.targetAssignments ?? [];
+ const allTemplateSelectionsSelected =
+ templateSelections.length > 0 && templateSelections.every((selection) => selectedTemplateSelectionIds.includes(selection.id));
+ const allParameterValuesSelected =
+ parameterValues.length > 0 && parameterValues.every((parameterValue) => selectedParameterValueIds.includes(parameterValue.id));
+ const allTargetAssignmentsSelected =
+ targetAssignments.length > 0 && targetAssignments.every((targetAssignment) => selectedTargetAssignmentIds.includes(targetAssignment.id));
+ const toggleSelected = (ids: string[], setIds: (value: SetStateAction) => void, id: string, checked: boolean) => {
+ setIds(checked ? [...new Set([...ids, id])] : ids.filter((entry) => entry !== id));
+ };
+ const canQueueDeployment =
+ Boolean(id) &&
+ ((deploymentBatch?.targetAssignments?.length ?? 0) > 0 || filteredExecutions.some((execution) => Boolean(execution.targetId)));
return (
-
-
}>
- Deployments
+
+
+ }>
+ Deployments
+
+
+ }
+ onClick={() => queueDeployment.mutate()}
+ >
+ Queue Deployment
-
+
@@ -207,6 +292,18 @@ export function DeploymentBatchDetailsPage() {
>
Add
+ }
+ disabled={selectedTemplateSelectionIds.length === 0 || deleteSelectedTemplateSelections.isPending}
+ onClick={() => {
+ if (window.confirm(`${selectedTemplateSelectionIds.length} Template Selections wirklich loeschen?`)) {
+ deleteSelectedTemplateSelections.mutate(selectedTemplateSelectionIds);
+ }
+ }}
+ >
+ Auswahl loeschen
+
@@ -249,6 +346,14 @@ export function DeploymentBatchDetailsPage() {
+
+
+ setSelectedTemplateSelectionIds(checkboxData.checked ? templateSelections.map((selection) => selection.id) : [])
+ }
+ />
+
Order
Role
Alias
@@ -259,8 +364,16 @@ export function DeploymentBatchDetailsPage() {
- {(deploymentBatch?.templateSelections ?? []).map((selection) => (
+ {templateSelections.map((selection) => (
+
+
+ toggleSelected(selectedTemplateSelectionIds, setSelectedTemplateSelectionIds, selection.id, Boolean(checkboxData.checked))
+ }
+ />
+
{selection.sortOrder}
{selection.templateRole}
{selection.alias ?? "-"}
@@ -295,6 +408,18 @@ export function DeploymentBatchDetailsPage() {
>
Add
+ }
+ disabled={selectedParameterValueIds.length === 0 || deleteSelectedParameterValues.isPending}
+ onClick={() => {
+ if (window.confirm(`${selectedParameterValueIds.length} Parameter Values wirklich loeschen?`)) {
+ deleteSelectedParameterValues.mutate(selectedParameterValueIds);
+ }
+ }}
+ >
+ Auswahl loeschen
+
@@ -329,6 +454,14 @@ export function DeploymentBatchDetailsPage() {
+
+
+ setSelectedParameterValueIds(checkboxData.checked ? parameterValues.map((parameterValue) => parameterValue.id) : [])
+ }
+ />
+
Scope
Name
Secret
@@ -337,13 +470,21 @@ export function DeploymentBatchDetailsPage() {
- {(deploymentBatch?.parameterValues ?? []).map((parameterValue) => {
- const selection = (deploymentBatch?.templateSelections ?? []).find(
+ {parameterValues.map((parameterValue) => {
+ const selection = templateSelections.find(
(entry) => entry.id === parameterValue.deploymentTemplateSelectionId,
);
return (
+
+
+ toggleSelected(selectedParameterValueIds, setSelectedParameterValueIds, parameterValue.id, Boolean(checkboxData.checked))
+ }
+ />
+
{selection?.alias ?? selection?.templateRole ?? "Global"}
{parameterValue.name}
{parameterValue.isSecretReference ? "Yes" : "No"}
@@ -377,6 +518,18 @@ export function DeploymentBatchDetailsPage() {
>
Add
+ }
+ disabled={selectedTargetAssignmentIds.length === 0 || deleteSelectedTargetAssignments.isPending}
+ onClick={() => {
+ if (window.confirm(`${selectedTargetAssignmentIds.length} Target Assignments wirklich loeschen?`)) {
+ deleteSelectedTargetAssignments.mutate(selectedTargetAssignmentIds);
+ }
+ }}
+ >
+ Auswahl loeschen
+
@@ -402,6 +555,14 @@ export function DeploymentBatchDetailsPage() {
+
+
+ setSelectedTargetAssignmentIds(checkboxData.checked ? targetAssignments.map((targetAssignment) => targetAssignment.id) : [])
+ }
+ />
+
Order
Target
Role
@@ -410,8 +571,16 @@ export function DeploymentBatchDetailsPage() {
- {(deploymentBatch?.targetAssignments ?? []).map((targetAssignment) => (
+ {targetAssignments.map((targetAssignment) => (
+
+
+ toggleSelected(selectedTargetAssignmentIds, setSelectedTargetAssignmentIds, targetAssignment.id, Boolean(checkboxData.checked))
+ }
+ />
+
{targetAssignment.sortOrder}
{targetAssignment.target?.name ?? targetAssignment.targetId}
{targetAssignment.roleKey}
diff --git a/src/pages/DeploymentGroupsPage.tsx b/src/pages/DeploymentGroupsPage.tsx
index d2a0a39..484f763 100644
--- a/src/pages/DeploymentGroupsPage.tsx
+++ b/src/pages/DeploymentGroupsPage.tsx
@@ -1,4 +1,4 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
@@ -13,25 +13,72 @@ import {
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 { useMemo, useState } from "react";
+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: "6px",
+ 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",
},
});
@@ -40,10 +87,15 @@ export function DeploymentGroupsPage() {
const queryClient = useQueryClient();
const [serviceId, setServiceId] = useState("");
const [templateId, setTemplateId] = useState("");
+ const [templateVersionId, setTemplateVersionId] = useState("");
const [deploymentRuleId, setDeploymentRuleId] = useState("");
const [targetIds, setTargetIds] = useState([]);
const [targetSearch, setTargetSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
+ const [deleteCandidate, setDeleteCandidate] = useState(null);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [expandedStatusGroups, setExpandedStatusGroups] = useState>({});
+
const { data, error, isLoading } = useQuery({
queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
@@ -64,17 +116,25 @@ export function DeploymentGroupsPage() {
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"] });
@@ -83,6 +143,17 @@ export function DeploymentGroupsPage() {
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"] });
},
@@ -106,73 +177,184 @@ export function DeploymentGroupsPage() {
filteredTargets.length > 0 &&
filteredTargets.every((target) => targetIds.includes(target.id));
+ const groupedBatches = useMemo(() => {
+ const groups = (data ?? []).reduce>((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 (
<>
-
- } onClick={() => setDialogOpen(true)}>
- Neues Deployment
-
-
- {data && (
+
+
+ } onClick={() => setDialogOpen(true)}>
+ Neuer Deployment Batch
+
+ }
+ onClick={() => {
+ if (window.confirm(`${selectedIds.length} Deployment Batches wirklich loeschen?`)) {
+ deleteSelectedDeploymentBatches.mutate(selectedIds);
+ }
+ }}
+ >
+ Auswahl loeschen
+
+
+
+ {!isLoading && !error && groupedBatches.length === 0 && (
+ Keine Deployment Batches vorhanden.
+ )}
+ {!isLoading && !error && groupedBatches.length > 0 && (
- Service
- Template
- Rule
- Status
- Created
- Modified
- Id
- Actions
+
+
+ setSelectedIds(checkboxData.checked ? (data ?? []).map((deploymentBatch) => deploymentBatch.id) : [])
+ }
+ />
+
+
+ Service
+
+
+ Primary Template
+
+
+ Templates
+
+
+ Targets
+
+
+ Rule
+
+
+ Created
+
+
+ Batch
+
+
+ Actions
+
- {data.map((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 ?? "-";
- })()}
-
- {(templates ?? []).find((entry) => entry.id === deploymentBatch.templateId)?.name ?? "-"}
-
- {(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
-
- {deploymentBatch.status ?? "Unknown"}
- {deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}
- {deploymentBatch.modified ? new Date(deploymentBatch.modified).toLocaleString() : "-"}
- {deploymentBatch.id}
-
-
-
-
- } />
-
-
-
- }
- onClick={() => deleteDeploymentBatch.mutate(deploymentBatch.id)}
- />
-
-
-
-
+ {groupedBatches.map(([status, deploymentBatches]) => (
+
+ toggleStatusGroup(status)}>
+
+
+ {expandedStatusGroups[status] !== false ? : }
+ {status}
+
+
+
+ {expandedStatusGroups[status] !== false &&
+ deploymentBatches.map((deploymentBatch) => (
+
+
+ toggleSelected(deploymentBatch.id, Boolean(checkboxData.checked))}
+ />
+
+ {getServiceName(deploymentBatch)}
+ {getPrimaryTemplateLabel(deploymentBatch)}
+ {deploymentBatch.templateSelectionCount ?? 0}
+ {deploymentBatch.targetAssignmentCount ?? 0}
+
+ {(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
+
+ {deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}
+
+
+ {deploymentBatch.id.slice(0, 8)}
+
+
+
+
+
+
+ } />
+
+
+
+ }
+ onClick={() => setDeleteCandidate(deploymentBatch)}
+ />
+
+
+
+
+ ))}
+
))}
)}
+