feat: Enhance Domains, Environments, Services, and Templates management
- Implemented dialog-based forms for adding and editing Domains and Environments. - Added delete functionality for Domains and Environments with confirmation prompts. - Introduced EnvironmentDetailsPage to display details of selected environments and their linked domains. - Created EnvironmentDomainsPage for linking domains to environments. - Enhanced ServicesPage with dialog support for adding, editing, and viewing service details. - Updated TemplatesPage to manage templates with comprehensive form fields and validation. - Improved type definitions in portal.ts to support new features and ensure type safety.
This commit is contained in:
@@ -56,3 +56,63 @@ export async function postJson<TBody, TResult = unknown>(path: string, body: TBo
|
||||
return text as TResult;
|
||||
}
|
||||
}
|
||||
|
||||
export async function putJson<TBody, TResult = unknown>(path: string, body: TBody): Promise<TResult> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new ApiError(
|
||||
message || `API request failed: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as TResult;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as TResult;
|
||||
} catch {
|
||||
return text as TResult;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteJson<TResult = unknown>(path: string): Promise<TResult> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new ApiError(
|
||||
message || `API request failed: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as TResult;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as TResult;
|
||||
} catch {
|
||||
return text as TResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { getJson, postJson } from "./httpClient";
|
||||
import { deleteJson, getJson, postJson, putJson } from "./httpClient";
|
||||
import type {
|
||||
AddDeployment,
|
||||
AddDeploymentGroup,
|
||||
AddDomain,
|
||||
AddEnvironment,
|
||||
AddRunbook,
|
||||
AddService,
|
||||
AddTemplate,
|
||||
Deployment,
|
||||
DeploymentGroup,
|
||||
Domain,
|
||||
DomainWithEnvironments,
|
||||
EnvironmentItem,
|
||||
EnvironmentWithDomains,
|
||||
Runbook,
|
||||
ServiceItem,
|
||||
Template,
|
||||
@@ -17,17 +21,27 @@ import type {
|
||||
export const portalApi = {
|
||||
getDeployments: (signal?: AbortSignal) => getJson<Deployment[]>("/Deployment", signal),
|
||||
addDeployment: (deployment: AddDeployment) => postJson<AddDeployment, string>("/Deployment", deployment),
|
||||
getDeploymentGroups: (signal?: AbortSignal) =>
|
||||
getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
|
||||
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) =>
|
||||
postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
|
||||
getDeploymentGroups: (signal?: AbortSignal) => getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
|
||||
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) => postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
|
||||
getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal),
|
||||
getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson<DomainWithEnvironments>(`/Domain/${domainId}/Environments`, signal),
|
||||
addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain),
|
||||
updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain),
|
||||
deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`),
|
||||
linkDomainToEnvironment: (domainId: string, environmentId: string) => postJson<undefined, string>(`/Domain/${domainId}/Environment/${environmentId}`, undefined),
|
||||
getEnvironments: (signal?: AbortSignal) => getJson<EnvironmentItem[]>("/Environment", signal),
|
||||
addEnvironment: (environment: AddEnvironment) =>
|
||||
postJson<AddEnvironment, string>("/Environment", environment),
|
||||
getEnvironmentDomains: (environmentId: string, signal?: AbortSignal) => getJson<EnvironmentWithDomains>(`/Environment/${environmentId}/Domains`, signal),
|
||||
addEnvironment: (environment: AddEnvironment) => postJson<AddEnvironment, string>("/Environment", environment),
|
||||
updateEnvironment: (environmentId: string, environment: AddEnvironment) => putJson<AddEnvironment, void>(`/Environment/${environmentId}`, environment),
|
||||
deleteEnvironment: (environmentId: string) => deleteJson<void>(`/Environment/${environmentId}`),
|
||||
getRunbooks: (signal?: AbortSignal) => getJson<Runbook[]>("/Runbook", signal),
|
||||
addRunbook: (runbook: AddRunbook) => postJson<AddRunbook, string>("/Runbook", runbook),
|
||||
getTemplates: (signal?: AbortSignal) => getJson<Template[]>("/Template", signal),
|
||||
addTemplate: (template: AddTemplate) => postJson<AddTemplate, string>("/Template", template),
|
||||
updateTemplate: (templateId: string, template: AddTemplate) => putJson<AddTemplate, void>(`/Template/${templateId}`, template),
|
||||
deleteTemplate: (templateId: string) => deleteJson<void>(`/Template/${templateId}`),
|
||||
getServices: (signal?: AbortSignal) => getJson<ServiceItem[]>("/Service", signal),
|
||||
addService: (service: AddService) => postJson<AddService, string>("/Service", service),
|
||||
updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service),
|
||||
deleteService: (serviceId: string) => deleteJson<void>(`/Service/${serviceId}`),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BoxMultiple24Regular,
|
||||
CloudFlow24Regular,
|
||||
DatabasePlugConnectedRegular,
|
||||
LinkMultiple24Regular,
|
||||
Globe24Regular,
|
||||
Home24Regular,
|
||||
PlayCircle24Regular,
|
||||
@@ -71,11 +72,12 @@ const useStyles = makeStyles({
|
||||
|
||||
const links = [
|
||||
{ to: "/", label: "Dashboard", icon: <Home24Regular /> },
|
||||
{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
|
||||
{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
|
||||
//{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
|
||||
//{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
|
||||
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> },
|
||||
{ to: "/environment-domains", label: "Environment Domains", icon: <LinkMultiple24Regular /> },
|
||||
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
|
||||
{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
|
||||
//{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
|
||||
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
|
||||
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
|
||||
];
|
||||
|
||||
@@ -7,7 +7,10 @@ import { AppShell } from "./layout/AppShell";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { DeploymentsPage } from "./pages/DeploymentsPage";
|
||||
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
|
||||
import { DomainDetailsPage } from "./pages/DomainDetailsPage";
|
||||
import { DomainsPage } from "./pages/DomainsPage";
|
||||
import { EnvironmentDomainsPage } from "./pages/EnvironmentDomainsPage";
|
||||
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
|
||||
import { EnvironmentsPage } from "./pages/EnvironmentsPage";
|
||||
import { RunbooksPage } from "./pages/RunbooksPage";
|
||||
import { TemplatesPage } from "./pages/TemplatesPage";
|
||||
@@ -32,7 +35,10 @@ const router = createBrowserRouter([
|
||||
{ path: "deployments", element: <DeploymentsPage /> },
|
||||
{ path: "deployment-groups", element: <DeploymentGroupsPage /> },
|
||||
{ path: "domains", element: <DomainsPage /> },
|
||||
{ path: "domains/:id", element: <DomainDetailsPage /> },
|
||||
{ path: "environment-domains", element: <EnvironmentDomainsPage /> },
|
||||
{ path: "environments", element: <EnvironmentsPage /> },
|
||||
{ path: "environments/:id", element: <EnvironmentDetailsPage /> },
|
||||
{ path: "runbooks", element: <RunbooksPage /> },
|
||||
{ path: "templates", element: <TemplatesPage /> },
|
||||
{ path: "services", element: <ServicesPage /> },
|
||||
|
||||
@@ -25,9 +25,9 @@ const useStyles = makeStyles({
|
||||
|
||||
export function DashboardPage() {
|
||||
const styles = useStyles();
|
||||
const deployments = useQuery({
|
||||
queryKey: ["deployments"],
|
||||
queryFn: ({ signal }) => portalApi.getDeployments(signal),
|
||||
const domains = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||
});
|
||||
const environments = useQuery({
|
||||
queryKey: ["environments"],
|
||||
@@ -42,9 +42,9 @@ export function DashboardPage() {
|
||||
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||
});
|
||||
|
||||
const error = deployments.error ?? environments.error ?? templates.error ?? services.error;
|
||||
const error = domains.error ?? environments.error ?? templates.error ?? services.error;
|
||||
const isLoading =
|
||||
deployments.isLoading || environments.isLoading || templates.isLoading || services.isLoading;
|
||||
domains.isLoading || environments.isLoading || templates.isLoading || services.isLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,7 +52,7 @@ export function DashboardPage() {
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{!isLoading && !error && (
|
||||
<div className={styles.grid}>
|
||||
<MetricCard label="Deployments" value={deployments.data?.length ?? 0} />
|
||||
<MetricCard label="Domains" value={domains.data?.length ?? 0} />
|
||||
<MetricCard label="Environments" value={environments.data?.length ?? 0} />
|
||||
<MetricCard label="Templates" value={templates.data?.length ?? 0} />
|
||||
<MetricCard label="Services" value={services.data?.length ?? 0} />
|
||||
|
||||
139
src/pages/DomainDetailsPage.tsx
Normal file
139
src/pages/DomainDetailsPage.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
makeStyles,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Title3,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowLeftRegular } from "@fluentui/react-icons";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
backLink: {
|
||||
display: "inline-flex",
|
||||
marginBottom: "18px",
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
details: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
|
||||
marginBottom: "24px",
|
||||
maxWidth: "980px",
|
||||
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
|
||||
...shorthands.borderRadius("8px"),
|
||||
...shorthands.padding("18px"),
|
||||
},
|
||||
field: {
|
||||
display: "grid",
|
||||
gap: "4px",
|
||||
minWidth: 0,
|
||||
},
|
||||
value: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: "12px",
|
||||
},
|
||||
});
|
||||
|
||||
export function DomainDetailsPage() {
|
||||
const styles = useStyles();
|
||||
const { id } = useParams();
|
||||
const { data, error, isLoading } = useQuery({
|
||||
enabled: Boolean(id),
|
||||
queryKey: ["domain", id, "environments"],
|
||||
queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal),
|
||||
});
|
||||
const links = data?.environmentDomains?.filter((link) => link.environment) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className={styles.backLink} to="/domains">
|
||||
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
|
||||
Domains
|
||||
</Button>
|
||||
</Link>
|
||||
<PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." />
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{data && (
|
||||
<>
|
||||
<section className={styles.details} aria-label="Domain details">
|
||||
<div className={styles.field}>
|
||||
<Text size={200}>Name</Text>
|
||||
<Text className={styles.value} weight="semibold">
|
||||
{data.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Text size={200}>FQDN</Text>
|
||||
<Text className={styles.value} weight="semibold">
|
||||
{data.fqdn}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Text size={200}>NetBIOS</Text>
|
||||
<Text className={styles.value} weight="semibold">
|
||||
{data.netBIOS}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Text size={200}>Id</Text>
|
||||
<Text className={styles.value} weight="semibold">
|
||||
{data.id}
|
||||
</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Title3 className={styles.sectionTitle}>Linked Environments</Title3>
|
||||
{links.length === 0 ? (
|
||||
<MessageBar>
|
||||
<MessageBarBody>Diese Domain ist noch mit keinem Environment verknuepft.</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : (
|
||||
<Table aria-label="Linked environments">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Details</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{links.map((link) => (
|
||||
<TableRow key={link.environment!.id}>
|
||||
<TableCell>{link.environment!.name}</TableCell>
|
||||
<TableCell>{link.environment!.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge appearance="filled" color="success">
|
||||
Linked
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/environments/${link.environment!.id}`}>Oeffnen</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,56 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { Domain } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
...shorthands.padding("2px", "0"),
|
||||
},
|
||||
});
|
||||
|
||||
type DialogMode = "add" | "edit" | null;
|
||||
|
||||
export function DomainsPage() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [selectedDomain, setSelectedDomain] = useState<Domain | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [fqdn, setFqdn] = useState("");
|
||||
const [netBIOS, setNetBIOS] = useState("");
|
||||
@@ -25,47 +58,109 @@ export function DomainsPage() {
|
||||
queryKey: ["domains"],
|
||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedDomain(null);
|
||||
setName("");
|
||||
setFqdn("");
|
||||
setNetBIOS("");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setSelectedDomain(null);
|
||||
setName("");
|
||||
setFqdn("");
|
||||
setNetBIOS("");
|
||||
setDialogMode("add");
|
||||
};
|
||||
|
||||
const openEditDialog = (domain: Domain) => {
|
||||
setSelectedDomain(domain);
|
||||
setName(domain.name);
|
||||
setFqdn(domain.fqdn);
|
||||
setNetBIOS(domain.netBIOS);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const addDomain = useMutation({
|
||||
mutationFn: portalApi.addDomain,
|
||||
onSuccess: async () => {
|
||||
setName("");
|
||||
setFqdn("");
|
||||
setNetBIOS("");
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateDomain = useMutation({
|
||||
mutationFn: ({ id, domain }: { id: string; domain: { name: string; fqdn: string; netBIOS: string } }) =>
|
||||
portalApi.updateDomain(id, domain),
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDomain = useMutation({
|
||||
mutationFn: portalApi.deleteDomain,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
||||
},
|
||||
});
|
||||
|
||||
const submitDomain = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const domain = { fqdn, name, netBIOS };
|
||||
|
||||
if (dialogMode === "edit" && selectedDomain) {
|
||||
updateDomain.mutate({ id: selectedDomain.id, domain });
|
||||
return;
|
||||
}
|
||||
|
||||
addDomain.mutate(domain);
|
||||
};
|
||||
|
||||
const formError = addDomain.error?.message ?? updateDomain.error?.message;
|
||||
const isSaving = addDomain.isPending || updateDomain.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Domains" description="Verfuegbare Active Directory Domaenen." />
|
||||
<FormSection
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addDomain.mutate({ fqdn, name, netBIOS });
|
||||
}}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</Field>
|
||||
<Field label="FQDN" required>
|
||||
<Input value={fqdn} onChange={(_, data) => setFqdn(data.value)} />
|
||||
</Field>
|
||||
<Field label="NetBIOS" required validationMessage={addDomain.error?.message}>
|
||||
<Input value={netBIOS} onChange={(_, data) => setNetBIOS(data.value)} />
|
||||
</Field>
|
||||
</FormGrid>
|
||||
<FormActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!name || !fqdn || !netBIOS || addDomain.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Add domain
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormSection>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Domain hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
<DialogSurface>
|
||||
<form onSubmit={submitDomain}>
|
||||
<DialogBody>
|
||||
<DialogTitle>{dialogMode === "edit" ? "Domain aendern" : "Domain hinzufuegen"}</DialogTitle>
|
||||
<DialogContent className={styles.form}>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</Field>
|
||||
<Field label="FQDN" required>
|
||||
<Input value={fqdn} onChange={(_, data) => setFqdn(data.value)} />
|
||||
</Field>
|
||||
<Field label="NetBIOS" required validationMessage={formError}>
|
||||
<Input value={netBIOS} onChange={(_, data) => setNetBIOS(data.value)} />
|
||||
</Field>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={closeDialog}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button appearance="primary" disabled={!name || !fqdn || !netBIOS || isSaving} type="submit">
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</form>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteDomain.error} />
|
||||
{data && (
|
||||
<Table aria-label="Domains">
|
||||
<TableHeader>
|
||||
@@ -74,6 +169,7 @@ export function DomainsPage() {
|
||||
<TableHeaderCell>FQDN</TableHeaderCell>
|
||||
<TableHeaderCell>NetBIOS</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -83,6 +179,36 @@ export function DomainsPage() {
|
||||
<TableCell>{domain.fqdn}</TableCell>
|
||||
<TableCell>{domain.netBIOS}</TableCell>
|
||||
<TableCell>{domain.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Link to={`/domains/${domain.id}`}>
|
||||
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openEditDialog(domain)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteDomain.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Domain "${domain.name}" wirklich loeschen?`)) {
|
||||
deleteDomain.mutate(domain.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
129
src/pages/EnvironmentDetailsPage.tsx
Normal file
129
src/pages/EnvironmentDetailsPage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
makeStyles,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Title3,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowLeftRegular } from "@fluentui/react-icons";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
backLink: {
|
||||
display: "inline-flex",
|
||||
marginBottom: "18px",
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
details: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
gridTemplateColumns: "repeat(2, minmax(180px, 1fr))",
|
||||
marginBottom: "24px",
|
||||
maxWidth: "720px",
|
||||
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
|
||||
...shorthands.borderRadius("8px"),
|
||||
...shorthands.padding("18px"),
|
||||
},
|
||||
field: {
|
||||
display: "grid",
|
||||
gap: "4px",
|
||||
minWidth: 0,
|
||||
},
|
||||
value: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: "12px",
|
||||
},
|
||||
});
|
||||
|
||||
export function EnvironmentDetailsPage() {
|
||||
const styles = useStyles();
|
||||
const { id } = useParams();
|
||||
const { data, error, isLoading } = useQuery({
|
||||
enabled: Boolean(id),
|
||||
queryKey: ["environment", id, "domains"],
|
||||
queryFn: ({ signal }) => portalApi.getEnvironmentDomains(id!, signal),
|
||||
});
|
||||
const links = data?.environmentDomains?.filter((link) => link.domain) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className={styles.backLink} to="/environments">
|
||||
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
|
||||
Environments
|
||||
</Button>
|
||||
</Link>
|
||||
<PageHeader title={data?.name ?? "Environment"} description="Details und verknuepfte Domains." />
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{data && (
|
||||
<>
|
||||
<section className={styles.details} aria-label="Environment details">
|
||||
<div className={styles.field}>
|
||||
<Text size={200}>Name</Text>
|
||||
<Text className={styles.value} weight="semibold">
|
||||
{data.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Text size={200}>Id</Text>
|
||||
<Text className={styles.value} weight="semibold">
|
||||
{data.id}
|
||||
</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Title3 className={styles.sectionTitle}>Linked Domains</Title3>
|
||||
{links.length === 0 ? (
|
||||
<MessageBar>
|
||||
<MessageBarBody>Dieses Environment ist noch mit keiner Domain verknuepft.</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : (
|
||||
<Table aria-label="Linked domains">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>FQDN</TableHeaderCell>
|
||||
<TableHeaderCell>NetBIOS</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Details</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{links.map((link) => (
|
||||
<TableRow key={link.domain!.id}>
|
||||
<TableCell>{link.domain!.name}</TableCell>
|
||||
<TableCell>{link.domain!.fqdn}</TableCell>
|
||||
<TableCell>{link.domain!.netBIOS}</TableCell>
|
||||
<TableCell>
|
||||
<Badge appearance="filled" color="success">
|
||||
Linked
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/domains/${link.domain!.id}`}>Oeffnen</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/pages/EnvironmentDomainsPage.tsx
Normal file
104
src/pages/EnvironmentDomainsPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Field,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Option,
|
||||
} from "@fluentui/react-components";
|
||||
import { useMemo, useState } from "react";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function EnvironmentDomainsPage() {
|
||||
const [domainId, setDomainId] = useState("");
|
||||
const [environmentId, setEnvironmentId] = useState("");
|
||||
|
||||
const domains = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||
});
|
||||
const environments = useQuery({
|
||||
queryKey: ["environments"],
|
||||
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
||||
});
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: () => portalApi.linkDomainToEnvironment(domainId, environmentId),
|
||||
});
|
||||
|
||||
const selectedDomain = useMemo(
|
||||
() => domains.data?.find((domain) => domain.id === domainId),
|
||||
[domainId, domains.data],
|
||||
);
|
||||
const selectedEnvironment = useMemo(
|
||||
() => environments.data?.find((environment) => environment.id === environmentId),
|
||||
[environmentId, environments.data],
|
||||
);
|
||||
|
||||
const isLoading = domains.isLoading || environments.isLoading;
|
||||
const error = domains.error ?? environments.error;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Environment Domains"
|
||||
description="Domains mit Environments verknuepfen."
|
||||
/>
|
||||
<FormSection
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
linkMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label="Environment" required>
|
||||
<Combobox
|
||||
disabled={!environments.data?.length}
|
||||
onOptionSelect={(_, data) => setEnvironmentId(data.optionValue ?? "")}
|
||||
placeholder="Select environment"
|
||||
value={selectedEnvironment?.name ?? ""}
|
||||
>
|
||||
{environments.data?.map((environment) => (
|
||||
<Option key={environment.id} text={environment.name} value={environment.id}>
|
||||
{environment.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field label="Domain" required validationMessage={linkMutation.error?.message}>
|
||||
<Combobox
|
||||
disabled={!domains.data?.length}
|
||||
onOptionSelect={(_, data) => setDomainId(data.optionValue ?? "")}
|
||||
placeholder="Select domain"
|
||||
value={selectedDomain?.name ?? ""}
|
||||
>
|
||||
{domains.data?.map((domain) => (
|
||||
<Option key={domain.id} text={domain.name} value={domain.id}>
|
||||
{domain.name} ({domain.fqdn})
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
<FormActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!domainId || !environmentId || linkMutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Link domain
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormSection>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{linkMutation.isSuccess && (
|
||||
<MessageBar intent="success">
|
||||
<MessageBarBody>Domain wurde mit dem Environment verknuepft.</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,198 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { EnvironmentItem } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
...shorthands.padding("2px", "0"),
|
||||
},
|
||||
});
|
||||
|
||||
type DialogMode = "add" | "edit" | null;
|
||||
|
||||
export function EnvironmentsPage() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["environments"],
|
||||
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedEnvironment(null);
|
||||
setName("");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setSelectedEnvironment(null);
|
||||
setName("");
|
||||
setDialogMode("add");
|
||||
};
|
||||
|
||||
const openEditDialog = (environment: EnvironmentItem) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setName(environment.name);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const addEnvironment = useMutation({
|
||||
mutationFn: portalApi.addEnvironment,
|
||||
onSuccess: async () => {
|
||||
setName("");
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateEnvironment = useMutation({
|
||||
mutationFn: ({ id, environment }: { id: string; environment: { name: string } }) =>
|
||||
portalApi.updateEnvironment(id, environment),
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteEnvironment = useMutation({
|
||||
mutationFn: portalApi.deleteEnvironment,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
||||
},
|
||||
});
|
||||
|
||||
const submitEnvironment = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const environment = { name };
|
||||
|
||||
if (dialogMode === "edit" && selectedEnvironment) {
|
||||
updateEnvironment.mutate({ id: selectedEnvironment.id, environment });
|
||||
return;
|
||||
}
|
||||
|
||||
addEnvironment.mutate(environment);
|
||||
};
|
||||
|
||||
const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message;
|
||||
const isSaving = addEnvironment.isPending || updateEnvironment.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Environments" description="Umgebungen und Cloud-Faehigkeit." />
|
||||
<FormSection
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addEnvironment.mutate({ name });
|
||||
}}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label="Name" required validationMessage={addEnvironment.error?.message}>
|
||||
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</Field>
|
||||
</FormGrid>
|
||||
<FormActions>
|
||||
<Button appearance="primary" disabled={!name || addEnvironment.isPending} type="submit">
|
||||
Add environment
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormSection>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Environment hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
<DialogSurface>
|
||||
<form onSubmit={submitEnvironment}>
|
||||
<DialogBody>
|
||||
<DialogTitle>
|
||||
{dialogMode === "edit" ? "Environment aendern" : "Environment hinzufuegen"}
|
||||
</DialogTitle>
|
||||
<DialogContent className={styles.form}>
|
||||
<Field label="Name" required validationMessage={formError}>
|
||||
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</Field>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={closeDialog}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button appearance="primary" disabled={!name || isSaving} type="submit">
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</form>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteEnvironment.error} />
|
||||
{data && (
|
||||
<Table aria-label="Environments">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>{environment.name}</TableCell>
|
||||
<TableCell>{environment.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Link to={`/environments/${environment.id}`}>
|
||||
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openEditDialog(environment)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteEnvironment.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Environment "${environment.name}" wirklich loeschen?`)) {
|
||||
deleteEnvironment.mutate(environment.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,39 +1,230 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { ServiceItem } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
...shorthands.padding("2px", "0"),
|
||||
},
|
||||
value: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
});
|
||||
|
||||
type DialogMode = "add" | "edit" | "details" | null;
|
||||
|
||||
export function ServicesPage() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [selectedService, setSelectedService] = useState<ServiceItem | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["services"],
|
||||
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedService(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setSelectedService(null);
|
||||
setDialogMode("add");
|
||||
};
|
||||
|
||||
const openServiceDialog = (mode: "edit" | "details", service: ServiceItem) => {
|
||||
setSelectedService(service);
|
||||
setName(service.name);
|
||||
setDescription(service.description ?? "");
|
||||
setDialogMode(mode);
|
||||
};
|
||||
|
||||
const addService = useMutation({
|
||||
mutationFn: portalApi.addService,
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateService = useMutation({
|
||||
mutationFn: ({ id, service }: { id: string; service: { name: string; description: string } }) =>
|
||||
portalApi.updateService(id, service),
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteService = useMutation({
|
||||
mutationFn: portalApi.deleteService,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
},
|
||||
});
|
||||
|
||||
const submitService = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const service = { description, name };
|
||||
|
||||
if (dialogMode === "edit" && selectedService) {
|
||||
updateService.mutate({ id: selectedService.id, service });
|
||||
return;
|
||||
}
|
||||
|
||||
addService.mutate(service);
|
||||
};
|
||||
|
||||
const formError = addService.error?.message ?? updateService.error?.message;
|
||||
const isSaving = addService.isPending || updateService.isPending;
|
||||
const isDetails = dialogMode === "details";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Services" description="Service-Katalog aus der Core API." />
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Service hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
<DialogSurface>
|
||||
<form onSubmit={submitService}>
|
||||
<DialogBody>
|
||||
<DialogTitle>
|
||||
{dialogMode === "edit"
|
||||
? "Service aendern"
|
||||
: dialogMode === "details"
|
||||
? "Service Details"
|
||||
: "Service hinzufuegen"}
|
||||
</DialogTitle>
|
||||
<DialogContent className={styles.form}>
|
||||
<Field label="Name" required>
|
||||
<Input disabled={isDetails} value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</Field>
|
||||
<Field label="Description" validationMessage={formError}>
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={description}
|
||||
onChange={(_, data) => setDescription(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
{selectedService && (
|
||||
<Field label="Id">
|
||||
<div className={styles.value}>{selectedService.id}</div>
|
||||
</Field>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={closeDialog}>
|
||||
{isDetails ? "Schliessen" : "Abbrechen"}
|
||||
</Button>
|
||||
{!isDetails && (
|
||||
<Button appearance="primary" disabled={!name || isSaving} type="submit">
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</form>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteService.error} />
|
||||
{data && (
|
||||
<Table aria-label="Services">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((service) => (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell>{service.name}</TableCell>
|
||||
<TableCell>{service.description}</TableCell>
|
||||
<TableCell>{service.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openServiceDialog("details", service)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openServiceDialog("edit", service)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteService.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Service "${service.name}" wirklich loeschen?`)) {
|
||||
deleteService.mutate(service.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,39 +1,305 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { Template } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
},
|
||||
grid: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
gridTemplateColumns: "repeat(2, minmax(180px, 1fr))",
|
||||
},
|
||||
wide: {
|
||||
gridColumn: "1 / -1",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
...shorthands.padding("2px", "0"),
|
||||
},
|
||||
value: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
});
|
||||
|
||||
type DialogMode = "add" | "edit" | "details" | null;
|
||||
|
||||
function getTemplateJsonData(template: Template) {
|
||||
return template.jsonData ?? template.jSONData ?? "";
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
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 { data, error, isLoading } = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedTemplate(null);
|
||||
setTemplateCategoryId("");
|
||||
setName("");
|
||||
setCloudTemplate(false);
|
||||
setVersion("");
|
||||
setDescription("");
|
||||
setJsonData("");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
closeDialog();
|
||||
setDialogMode("add");
|
||||
};
|
||||
|
||||
const openTemplateDialog = (mode: "edit" | "details", template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setTemplateCategoryId(template.templateCategoryId ?? "");
|
||||
setName(template.name);
|
||||
setCloudTemplate(Boolean(template.cloudTemplate));
|
||||
setVersion(template.version ?? "");
|
||||
setDescription(template.description ?? "");
|
||||
setJsonData(getTemplateJsonData(template));
|
||||
setDialogMode(mode);
|
||||
};
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: portalApi.addTemplate,
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateTemplate = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
template,
|
||||
}: {
|
||||
id: string;
|
||||
template: {
|
||||
templateCategoryId: string;
|
||||
name: string;
|
||||
cloudTemplate: boolean;
|
||||
version: string;
|
||||
description: string;
|
||||
jsonData: string;
|
||||
};
|
||||
}) => portalApi.updateTemplate(id, template),
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTemplate = useMutation({
|
||||
mutationFn: portalApi.deleteTemplate,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
},
|
||||
});
|
||||
|
||||
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const template = { cloudTemplate, description, jsonData, name, templateCategoryId, version };
|
||||
|
||||
if (dialogMode === "edit" && selectedTemplate) {
|
||||
updateTemplate.mutate({ id: selectedTemplate.id, template });
|
||||
return;
|
||||
}
|
||||
|
||||
addTemplate.mutate(template);
|
||||
};
|
||||
|
||||
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
|
||||
const isSaving = addTemplate.isPending || updateTemplate.isPending;
|
||||
const isDetails = dialogMode === "details";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
<DialogSurface>
|
||||
<form onSubmit={submitTemplate}>
|
||||
<DialogBody>
|
||||
<DialogTitle>
|
||||
{dialogMode === "edit"
|
||||
? "Template aendern"
|
||||
: dialogMode === "details"
|
||||
? "Template Details"
|
||||
: "Template hinzufuegen"}
|
||||
</DialogTitle>
|
||||
<DialogContent className={styles.form}>
|
||||
<div className={styles.grid}>
|
||||
<Field label="Name" required>
|
||||
<Input disabled={isDetails} value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</Field>
|
||||
<Field label="Version" required>
|
||||
<Input
|
||||
disabled={isDetails}
|
||||
value={version}
|
||||
onChange={(_, data) => setVersion(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}>
|
||||
<Input
|
||||
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))}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="Description">
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={description}
|
||||
onChange={(_, data) => setDescription(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="JSONData" required>
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonData}
|
||||
onChange={(_, data) => setJsonData(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
{selectedTemplate && (
|
||||
<Field className={styles.wide} label="Id">
|
||||
<div className={styles.value}>{selectedTemplate.id}</div>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={closeDialog}>
|
||||
{isDetails ? "Schliessen" : "Abbrechen"}
|
||||
</Button>
|
||||
{!isDetails && (
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!name || !templateCategoryId || !version || !jsonData || isSaving}
|
||||
type="submit"
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</form>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} />
|
||||
{data && (
|
||||
<Table aria-label="Templates">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Cloud</TableHeaderCell>
|
||||
<TableHeaderCell>TemplateCategoryId</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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -27,6 +27,12 @@ export type Domain = {
|
||||
netBIOS: string;
|
||||
};
|
||||
|
||||
export type DomainWithEnvironments = Domain & {
|
||||
environmentDomains?: Array<{
|
||||
environment?: EnvironmentItem;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AddDomain = {
|
||||
name: string;
|
||||
fqdn: string;
|
||||
@@ -38,6 +44,12 @@ export type EnvironmentItem = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type EnvironmentWithDomains = EnvironmentItem & {
|
||||
environmentDomains?: Array<{
|
||||
domain?: Domain;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AddEnvironment = {
|
||||
name: string;
|
||||
};
|
||||
@@ -56,9 +68,30 @@ export type AddRunbook = {
|
||||
export type Template = {
|
||||
id: string;
|
||||
name: string;
|
||||
templateCategoryId?: string;
|
||||
cloudTemplate?: boolean;
|
||||
version?: string;
|
||||
description?: string;
|
||||
jsonData?: string;
|
||||
jSONData?: string;
|
||||
};
|
||||
|
||||
export type AddTemplate = {
|
||||
templateCategoryId: string;
|
||||
name: string;
|
||||
cloudTemplate: boolean;
|
||||
version: string;
|
||||
description: string;
|
||||
jsonData: string;
|
||||
};
|
||||
|
||||
export type ServiceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type AddService = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user