Skip to content

Commit

Permalink
Add Service settings area to configure ZDD (#935)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdelaossa authored Sep 19, 2024
1 parent 1578e6e commit 848bb44
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 0 deletions.
5 changes: 5 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
AppDetailServiceMetricsPage,
AppDetailServicePage,
AppDetailServiceScalePage,
AppDetailServiceSettingsPage,
AppDetailServicesPage,
AppSettingsPage,
AppSidebarLayout,
Expand Down Expand Up @@ -269,6 +270,10 @@ export const appRoutes: RouteObject[] = [
path: routes.APP_SERVICE_SCALE_PATH,
element: <AppDetailServiceScalePage />,
},
{
path: routes.APP_SERVICE_SETTINGS_PATH,
element: <AppDetailServiceSettingsPage />,
},
],
},

Expand Down
92 changes: 92 additions & 0 deletions src/app/test/app-detail-service-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { hasDeployApp, selectAppById } from "@app/deploy";
import {
server,
stacksWithResources,
testAccount,
testApp,
testEndpoint,
testEnv,
testServiceRails,
verifiedUserHandlers,
} from "@app/mocks";
import { appServiceSettingsPathUrl } from "@app/routes";
import { setupAppIntegrationTest, waitForBootup, waitForData } from "@app/test";
import { render, screen } from "@testing-library/react";
import { rest } from "msw";

describe("AppDetailServiceSettingsPage", () => {
it("should successfully show app service scale page happy path", async () => {
server.use(
...verifiedUserHandlers(),
...stacksWithResources({
accounts: [testAccount],
apps: [testApp],
services: [{ ...testServiceRails, force_zero_downtime: true }],
}),
);
const { App, store } = setupAppIntegrationTest({
initEntries: [
appServiceSettingsPathUrl(`${testApp.id}`, `${testServiceRails.id}`),
],
});

await waitForBootup(store);

render(<App />);
await waitForData(store, (state) => {
return hasDeployApp(selectAppById(state, { id: `${testApp.id}` }));
});

await screen.findByText(/Service Settings/);
const zddCheckbox = await screen.findByRole("checkbox", {
name: "zero-downtime",
});
const simpleHealthcheckCheckbox = await screen.findByRole("checkbox", {
name: "simple-healthcheck",
});

expect(zddCheckbox).toBeInTheDocument();
expect(simpleHealthcheckCheckbox).toBeInTheDocument();
expect(zddCheckbox).toBeChecked();
expect(simpleHealthcheckCheckbox).not.toBeChecked();
});

describe("when endpoints are configured", () => {
it("should not show configuration, instead pointing at endpoints", async () => {
server.use(
rest.get(`${testEnv.apiUrl}/services/:id/vhosts`, (_, res, ctx) => {
return res(ctx.json({ _embedded: { vhosts: [testEndpoint] } }));
}),
...verifiedUserHandlers(),
...stacksWithResources({
accounts: [testAccount],
apps: [testApp],
services: [{ ...testServiceRails, force_zero_downtime: true }],
}),
);
const { App, store } = setupAppIntegrationTest({
initEntries: [
appServiceSettingsPathUrl(`${testApp.id}`, `${testServiceRails.id}`),
],
});

await waitForBootup(store);

render(<App />);
await waitForData(store, (state) => {
return hasDeployApp(selectAppById(state, { id: `${testApp.id}` }));
});

await screen.findByText(/Service Settings/);
await screen.findByText(/managed through the following Endpoints/);
const zddCheckbox = screen.queryByRole("checkbox", {
name: "zero-downtime",
});
const simpleHealthcheckCheckbox = screen.queryByRole("checkbox", {
name: "simple-healthcheck",
});
expect(zddCheckbox).not.toBeInTheDocument();
expect(simpleHealthcheckCheckbox).not.toBeInTheDocument();
});
});
});
28 changes: 28 additions & 0 deletions src/deploy/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const defaultServiceResponse = (
container_count: 0,
container_memory_limit_mb: 0,
instance_class: DEFAULT_INSTANCE_CLASS,
force_zero_downtime: false,
naive_health_check: false,
created_at: now,
updated_at: now,
_type: "service",
Expand Down Expand Up @@ -68,6 +70,8 @@ export const deserializeDeployService = (
containerMemoryLimitMb: payload.container_memory_limit_mb || 512,
currentReleaseId: extractIdFromLink(links.current_release),
instanceClass: payload.instance_class || DEFAULT_INSTANCE_CLASS,
forceZeroDowntime: payload.force_zero_downtime,
naiveHealthCheck: payload.naive_health_check,
createdAt: payload.created_at,
updatedAt: payload.updated_at,
};
Expand Down Expand Up @@ -145,6 +149,30 @@ export const calcServiceMetrics = (
};
};

export interface ServiceEditProps {
forceZeroDowntime: boolean;
naiveHealthCheck: boolean;
}

const serializeServiceEditProps = (payload: ServiceEditProps) => ({
force_zero_downtime: payload.forceZeroDowntime,
naive_health_check: payload.naiveHealthCheck,
});

export interface ModifyServiceProps extends ServiceEditProps {
id: string;
}

export const updateServiceById = api.put<
ModifyServiceProps,
DeployServiceResponse
>("/services/:id", function* (ctx, next) {
ctx.request = ctx.req({
body: JSON.stringify(serializeServiceEditProps(ctx.payload)),
});
yield* next();
});

export const selectServiceById = schema.services.selectById;
export const selectServicesByIds = schema.services.selectByIds;
export const selectServices = schema.services.selectTable;
Expand Down
3 changes: 3 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export const appServicePathMetricsUrl = (appId: string, serviceId: string) =>
export const APP_SERVICE_SCALE_PATH = `${APP_DETAIL_PATH}/services/:serviceId/scale`;
export const appServiceScalePathUrl = (appId: string, serviceId: string) =>
`${appDetailUrl(appId)}/services/${serviceId}/scale`;
export const APP_SERVICE_SETTINGS_PATH = `${APP_DETAIL_PATH}/services/:serviceId/settings`;
export const appServiceSettingsPathUrl = (appId: string, serviceId: string) =>
`${appDetailUrl(appId)}/services/${serviceId}/settings`;
export const APP_ACTIVITY_PATH = `${APP_DETAIL_PATH}/activity`;
export const appActivityUrl = (id: string) => `${appDetailUrl(id)}/activity`;
export const APP_ENDPOINTS_PATH = `${APP_DETAIL_PATH}/endpoints`;
Expand Down
2 changes: 2 additions & 0 deletions src/schema/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ export const defaultDeployService = (
containerMemoryLimitMb: 512,
currentReleaseId: "",
instanceClass: DEFAULT_INSTANCE_CLASS,
forceZeroDowntime: false,
naiveHealthCheck: false,
createdAt: now,
updatedAt: now,
...s,
Expand Down
4 changes: 4 additions & 0 deletions src/types/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface DeployService extends Timestamps {
containerMemoryLimitMb: number;
currentReleaseId: string;
instanceClass: InstanceClass;
forceZeroDowntime: boolean;
naiveHealthCheck: boolean;
}

export interface AcmeChallenge {
Expand Down Expand Up @@ -598,6 +600,8 @@ export interface DeployServiceResponse {
container_count: number | null;
container_memory_limit_mb: number | null;
instance_class: InstanceClass;
force_zero_downtime: boolean;
naive_health_check: boolean;
_links: {
current_release: LinkResponse;
app?: LinkResponse;
Expand Down
35 changes: 35 additions & 0 deletions src/ui/layouts/service-detail-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
appDetailUrl,
appServicePathMetricsUrl,
appServiceScalePathUrl,
appServiceSettingsPathUrl,
appServiceUrl,
appServicesUrl,
environmentAppsUrl,
Expand Down Expand Up @@ -44,6 +45,33 @@ import {
} from "../shared";
import { AppSidebarLayout } from "./app-sidebar-layout";

const getDeploymentStrategy = (
service: DeployService,
endpoints: DeployEndpoint[],
) => {
if (endpoints.length > 0) {
if (
endpoints.find(
(e) => e.type === "http" || e.type === "http_proxy_protocol",
)
) {
return "Zero-Downtime";
}
return "Minimal-Downtime";
}

const strategies = [];
if (service.forceZeroDowntime) {
strategies.push("Zero-Downtime");
} else {
strategies.push("Zero-Overlap");
}
if (service.naiveHealthCheck) {
strategies.push("Simple Healthcheck");
}
return strategies.join(", ");
};

export function ServiceHeader({
app,
service,
Expand All @@ -63,6 +91,7 @@ export function ServiceHeader({
const metrics = calcServiceMetrics(service, endpoints);
const { totalCPU } = calcMetrics([service]);
const [isOpen, setOpen] = useState(true);
const deploymentStrategy = getDeploymentStrategy(service, endpoints);

return (
<DetailHeader>
Expand Down Expand Up @@ -101,6 +130,11 @@ export function ServiceHeader({
<DetailInfoItem title="Container Profile">
{metrics.containerProfile.name}
</DetailInfoItem>
<DetailInfoItem title="Deployment Strategy">
<Link to={appServiceSettingsPathUrl(app.id, service.id)}>
{deploymentStrategy}
</Link>
</DetailInfoItem>
</DetailInfoGrid>
{service.command ? (
<div>
Expand Down Expand Up @@ -162,6 +196,7 @@ function ServicePageHeader() {
const tabs: TabItem[] = [
{ name: "Metrics", href: appServicePathMetricsUrl(id, serviceId) },
{ name: "Scale", href: appServiceScalePathUrl(id, serviceId) },
{ name: "Settings", href: appServiceSettingsPathUrl(id, serviceId) },
];

return (
Expand Down
130 changes: 130 additions & 0 deletions src/ui/pages/app-detail-service-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
fetchApp,
fetchService,
getEndpointUrl,
selectEndpointsByServiceId,
selectServiceById,
updateServiceById,
} from "@app/deploy";
import { useDispatch, useLoader, useQuery, useSelector } from "@app/react";
import { endpointDetailUrl } from "@app/routes";
import { type SyntheticEvent, useEffect, useState } from "react";
import { useParams } from "react-router";
import { Link } from "react-router-dom";
import {
Banner,
BannerMessages,
Box,
Button,
ButtonLinkDocs,
CheckBox,
Tooltip,
} from "../shared";

export const AppDetailServiceSettingsPage = () => {
const dispatch = useDispatch();
const { id = "", serviceId = "" } = useParams();
useQuery(fetchApp({ id }));
useQuery(fetchService({ id: serviceId }));
const service = useSelector((s) => selectServiceById(s, { id: serviceId }));
const endpoints = useSelector((s) =>
selectEndpointsByServiceId(s, { serviceId: service.id }),
);

const [nextService, setNextService] = useState(service);
useEffect(() => {
setNextService(service);
}, [service.id]);

const action = updateServiceById({ ...nextService });
const modifyLoader = useLoader(action);
const cancelChanges = () => setNextService(service);
const changesExist = service !== nextService;

const onSubmitForm = (e: SyntheticEvent) => {
e.preventDefault();
dispatch(action);
};

return (
<div className="flex flex-col gap-4">
<Box>
<form onSubmit={onSubmitForm}>
<BannerMessages {...modifyLoader} />
<div className="flex flex-col gap-2">
<div className="flex justify-between items-start">
<h1 className="text-lg text-gray-500 mb-4">Service Settings</h1>
<ButtonLinkDocs href="https://www.aptible.com/docs/core-concepts/apps/deploying-apps/releases/overview" />
</div>
{endpoints.length > 0 ? (
<Banner>
Service settings are managed through the following Endpoints:
{endpoints.map((endpoint, index) => {
return (
<span key={index}>
{index === 0 && " "}
<Link to={endpointDetailUrl(endpoint.id)}>
{getEndpointUrl(endpoint)}
</Link>
{index < endpoints.length - 1 && ", "}
</span>
);
})}
</Banner>
) : (
<>
<h2 className="text-md font-semibold">Deployment Strategy</h2>
<CheckBox
name="zero-downtime"
label="Enable Zero-Downtime Deployment"
checked={nextService.forceZeroDowntime}
onChange={(e) =>
setNextService({
...nextService,
forceZeroDowntime: e.currentTarget.checked,
})
}
/>
<div className="flex gap-2">
<Tooltip text="When enabled, ignores Docker healthchecks and instead only waits to ensure the container stays up for at least 30 seconds.">
<CheckBox
name="simple-healthcheck"
label="Use simple healthcheck (30s)"
checked={nextService.naiveHealthCheck}
onChange={(e) =>
setNextService({
...nextService,
naiveHealthCheck: e.currentTarget.checked,
})
}
/>
</Tooltip>
</div>
<div className="flex mt-4">
<Button
name="autoscaling"
className="w-40 flex font-semibold"
type="submit"
disabled={!changesExist}
isLoading={modifyLoader.isLoading}
>
Save Changes
</Button>
{changesExist ? (
<Button
className="w-40 ml-2 flex font-semibold"
onClick={cancelChanges}
variant="white"
>
Cancel
</Button>
) : null}
</div>
</>
)}
</div>
</form>
</Box>
</div>
);
};
Loading

0 comments on commit 848bb44

Please sign in to comment.