diff --git a/apps/server/src/routes/management.ts b/apps/server/src/routes/management.ts index cdcf45b..5d0d1be 100644 --- a/apps/server/src/routes/management.ts +++ b/apps/server/src/routes/management.ts @@ -534,6 +534,58 @@ const routes = { }, }, }), + updateDisabled: createRoute({ + method: "patch", + path: "/apps/:appName/deployments/:deploymentName/release/disabled", + description: "Update release disabled state", + request: { + params: z.object({ + appName: z.string(), + deploymentName: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + label: z.string().optional(), + isDisabled: z.boolean(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Release disabled state updated successfully", + }, + }, + }), + updateMandatory: createRoute({ + method: "patch", + path: "/apps/:appName/deployments/:deploymentName/release/mandatory", + description: "Update release mandatory state", + request: { + params: z.object({ + appName: z.string(), + deploymentName: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + label: z.string().optional(), + isMandatory: z.boolean(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Release mandatory state updated successfully", + }, + }, + }), }, history: createRoute({ method: "get", @@ -1245,116 +1297,278 @@ router.openapi(routes.deployments.release.create, async (c) => { }); } - // Check if there's an unfinished rollout - if ( - deployment.package?.rollout && - deployment.package.rollout < 100 && - !deployment.package.isDisabled - ) { - throw new HTTPException(409, { - message: - "Please update the previous release to 100% rollout before releasing a new package", + const releasePackage = async () => { + // Check if there's an unfinished rollout + if ( + deployment.package?.rollout && + deployment.package.rollout < 100 && + !deployment.package.isDisabled + ) { + throw new HTTPException(409, { + message: + "Please update the previous release to 100% rollout before releasing a new package", + }); + } + + const packageData = await packageFile.arrayBuffer(); + const differ = await createPackageDiffer( + storage, + app.id, + deployment.id, + packageData, + ); + const manifestResult = await differ.generateManifest(); + const packageHash = await manifestResult.computeHash(); + + // Check if this is a duplicate of the current release + const history = await storage.getPackageHistory( + accountId, + app.id, + deployment.id, + ); + const lastPackage = history[history.length - 1]; + if (lastPackage && lastPackage.packageHash === packageHash) { + throw new HTTPException(409, { + message: + "The uploaded package was not released because it is identical to the contents of the specified deployment's current release", + }); + } + + // Store package blob + const blobId = generateKey(); + await storage.addBlob(blobId, packageData, packageData.byteLength); + const blobUrl = await storage.getBlobUrl(blobId); + + // Store manifest if available + let manifestUrl = ""; + if (manifestResult) { + const manifestBlobId = generateKey(); + const manifestJson = manifestResult.serialize(); + const manifestBuffer = new TextEncoder().encode(manifestJson); + await storage.addBlob( + manifestBlobId, + manifestBuffer, + manifestBuffer.length, + ); + manifestUrl = await storage.getBlobUrl(manifestBlobId); + } + + const newPackage: Omit = { + appVersion: packageInfo.appVersion, + description: packageInfo.description, + isDisabled: packageInfo.isDisabled || false, + isMandatory: packageInfo.isMandatory || false, + rollout: packageInfo.rollout, + packageHash, + size: packageData.byteLength, + blobUrl, + manifestBlobUrl: manifestUrl, + uploadTime: Date.now(), + releaseMethod: "Upload", + }; + + const releasedPackage = await storage.commitPackage( + accountId, + app.id, + deployment.id, + newPackage, + ); + + // Generate diffs in background + if (manifestResult) { + await differ.generateDiffs(history); + } + + c.header( + "Location", + urlEncode`/apps/${appName}/deployments/${deploymentName}`, + ); + + const account = await storage.getAccount(accountId); + if (c.executionCtx) { + c.executionCtx.waitUntil( + sendReleaseNotification(c.env, { + appName: app.name, + label: releasedPackage.label, + appVersion: releasedPackage.appVersion, + description: releasedPackage.description, + isMandatory: releasedPackage.isMandatory, + isDisabled: releasedPackage.isDisabled, + action: "Uploaded", + releasedBy: account.name || account.email, + }), + ); + } + + return c.json({ package: releasedPackage }, 201); + }; + + // 인증·권한을 통과한 실제 업로드 시도의 실패(롤아웃 미완/중복 번들/R2·D1 오류)를 Slack에 알린다. + try { + return await releasePackage(); + } catch (error) { + if (c.executionCtx) { + const account = await storage.getAccount(accountId).catch(() => null); + c.executionCtx.waitUntil( + sendReleaseNotification(c.env, { + appName: app.name, + appVersion: packageInfo.appVersion, + description: packageInfo.description, + isMandatory: packageInfo.isMandatory, + isDisabled: packageInfo.isDisabled, + action: "UploadFailed", + releasedBy: account ? account.name || account.email : undefined, + errorMessage: error instanceof Error ? error.message : String(error), + }), + ); + } + throw error; + } +}); + +router.openapi(routes.deployments.release.update, async (c) => { + const storage = getStorageProvider(c); + const accountId = c.var.auth.accountId; + const { appName, deploymentName } = c.req.valid("param"); + const { packageInfo } = c.req.valid("json"); + + const app = await storage.getApp(accountId, { appName }); + if (!app) { + throw new HTTPException(404, { + message: `App "${appName}" not found`, }); } - const packageData = await packageFile.arrayBuffer(); - const differ = await createPackageDiffer( - storage, - app.id, - deployment.id, - packageData, - ); - const manifestResult = await differ.generateManifest(); - const packageHash = await manifestResult.computeHash(); + throwIfInvalidPermissions(app, "Collaborator"); - // Check if this is a duplicate of the current release - const history = await storage.getPackageHistory( + const deployments = await storage.getDeployments(accountId, app.id); + const deployment = deployments.find((d) => d.name === deploymentName); + + if (!deployment) { + throw new HTTPException(404, { + message: `Deployment "${deploymentName}" not found`, + }); + } + + const packageHistory = await storage.getPackageHistory( accountId, app.id, deployment.id, ); - const lastPackage = history[history.length - 1]; - if (lastPackage && lastPackage.packageHash === packageHash) { - throw new HTTPException(409, { - message: - "The uploaded package was not released because it is identical to the contents of the specified deployment's current release", + + const release = packageInfo.label + ? packageHistory.find((p) => p.label === packageInfo.label) + : packageHistory[packageHistory.length - 1]; + + if (!release) { + throw new HTTPException(404, { + message: "Release package not found", }); } + const updatedRelease = { + ...release, + }; + if (packageInfo.appVersion) { + updatedRelease.appVersion = packageInfo.appVersion; + } + if (packageInfo.description) { + updatedRelease.description = packageInfo.description; + } + if (packageInfo.isDisabled != null) { + updatedRelease.isDisabled = packageInfo.isDisabled; + } - // Store package blob - const blobId = generateKey(); - await storage.addBlob(blobId, packageData, packageData.byteLength); - const blobUrl = await storage.getBlobUrl(blobId); - - // Store manifest if available - let manifestUrl = ""; - if (manifestResult) { - const manifestBlobId = generateKey(); - const manifestJson = manifestResult.serialize(); - const manifestBuffer = new TextEncoder().encode(manifestJson); - await storage.addBlob( - manifestBlobId, - manifestBuffer, - manifestBuffer.length, + await storage.updatePackage(updatedRelease, deployment.id); + + // isDisabled 토글이 포함된 요청일 때만 Slack 알림(rollout/version 등 다른 수정은 제외) + if (packageInfo.isDisabled != null && c.executionCtx) { + const account = await storage.getAccount(accountId); + c.executionCtx.waitUntil( + sendReleaseNotification(c.env, { + appName: app.name, + label: updatedRelease.label, + appVersion: updatedRelease.appVersion, + description: updatedRelease.description, + isMandatory: updatedRelease.isMandatory, + isDisabled: updatedRelease.isDisabled, + action: "Disabled", + releasedBy: account.name || account.email, + }), ); - manifestUrl = await storage.getBlobUrl(manifestBlobId); } - const newPackage: Omit = { - appVersion: packageInfo.appVersion, - description: packageInfo.description, - isDisabled: packageInfo.isDisabled || false, - isMandatory: packageInfo.isMandatory || false, - rollout: packageInfo.rollout, - packageHash, - size: packageData.byteLength, - blobUrl, - manifestBlobUrl: manifestUrl, - uploadTime: Date.now(), - releaseMethod: "Upload", - }; + return c.json({ release: updatedRelease }); +}); + +router.openapi(routes.deployments.release.updateDisabled, async (c) => { + const storage = getStorageProvider(c); + const accountId = c.var.auth.accountId; + const { appName, deploymentName } = c.req.valid("param"); + const { label, isDisabled } = c.req.valid("json"); + + const app = await storage.getApp(accountId, { appName }); + if (!app) { + throw new HTTPException(404, { + message: `App "${appName}" not found`, + }); + } + + throwIfInvalidPermissions(app, "Collaborator"); + + const deployments = await storage.getDeployments(accountId, app.id); + const deployment = deployments.find((d) => d.name === deploymentName); - const releasedPackage = await storage.commitPackage( + if (!deployment) { + throw new HTTPException(404, { + message: `Deployment "${deploymentName}" not found`, + }); + } + + const packageHistory = await storage.getPackageHistory( accountId, app.id, deployment.id, - newPackage, ); - // Generate diffs in background - if (manifestResult) { - await differ.generateDiffs(history); + const release = label + ? packageHistory.find((p) => p.label === label) + : packageHistory[packageHistory.length - 1]; + + if (!release?.label) { + throw new HTTPException(404, { + message: "Release package not found", + }); } - c.header( - "Location", - urlEncode`/apps/${appName}/deployments/${deploymentName}`, - ); + const updatedRelease = { ...release, isDisabled }; + await storage.updatePackageMetadata(deployment.id, release.label, { + isDisabled, + }); - const account = await storage.getAccount(accountId); if (c.executionCtx) { + const account = await storage.getAccount(accountId); c.executionCtx.waitUntil( sendReleaseNotification(c.env, { appName: app.name, - label: releasedPackage.label, - appVersion: releasedPackage.appVersion, - description: releasedPackage.description, - isMandatory: releasedPackage.isMandatory, - isDisabled: releasedPackage.isDisabled, - action: "Uploaded", + label: updatedRelease.label, + appVersion: updatedRelease.appVersion, + description: updatedRelease.description, + isMandatory: updatedRelease.isMandatory, + isDisabled: updatedRelease.isDisabled, + action: "Disabled", releasedBy: account.name || account.email, }), ); } - return c.json({ package: releasedPackage }, 201); + return c.json({ release: updatedRelease }); }); -router.openapi(routes.deployments.release.update, async (c) => { +router.openapi(routes.deployments.release.updateMandatory, async (c) => { const storage = getStorageProvider(c); const accountId = c.var.auth.accountId; const { appName, deploymentName } = c.req.valid("param"); - const { packageInfo } = c.req.valid("json"); + const { label, isMandatory } = c.req.valid("json"); const app = await storage.getApp(accountId, { appName }); if (!app) { @@ -1380,32 +1594,22 @@ router.openapi(routes.deployments.release.update, async (c) => { deployment.id, ); - const release = packageInfo.label - ? packageHistory.find((p) => p.label === packageInfo.label) + const release = label + ? packageHistory.find((p) => p.label === label) : packageHistory[packageHistory.length - 1]; - if (!release) { + if (!release?.label) { throw new HTTPException(404, { message: "Release package not found", }); } - const updatedRelease = { - ...release, - }; - if (packageInfo.appVersion) { - updatedRelease.appVersion = packageInfo.appVersion; - } - if (packageInfo.description) { - updatedRelease.description = packageInfo.description; - } - if (packageInfo.isDisabled != null) { - updatedRelease.isDisabled = packageInfo.isDisabled; - } - await storage.updatePackage(updatedRelease, deployment.id); + const updatedRelease = { ...release, isMandatory }; + await storage.updatePackageMetadata(deployment.id, release.label, { + isMandatory, + }); - // isDisabled 토글이 포함된 요청일 때만 Slack 알림(rollout/version 등 다른 수정은 제외) - if (packageInfo.isDisabled != null && c.executionCtx) { + if (c.executionCtx) { const account = await storage.getAccount(accountId); c.executionCtx.waitUntil( sendReleaseNotification(c.env, { @@ -1415,7 +1619,7 @@ router.openapi(routes.deployments.release.update, async (c) => { description: updatedRelease.description, isMandatory: updatedRelease.isMandatory, isDisabled: updatedRelease.isDisabled, - action: updatedRelease.isDisabled ? "Disabled" : "Enabled", + action: "Mandatory", releasedBy: account.name || account.email, }), ); diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index c3bc950..4252c88 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -880,6 +880,27 @@ export class D1StorageProvider implements StorageProvider { return pkg; } + // disabled/mandatory 토글 전용 부분 UPDATE. updatePackage(여러 필드 통째 SET)와 달리 + // 대상 컬럼만 갱신한다 — 히스토리 캐시가 isolate별 메모리라 stale 캐시 기반으로 전체 필드를 + // 쓰면 다른 isolate에서 방금 바꾼 필드를 되돌릴 수 있어(lost update), 토글은 이 경로를 쓴다. + async updatePackageMetadata( + deploymentId: string, + label: string, + updates: { isDisabled?: boolean; isMandatory?: boolean }, + ): Promise { + await this.db + .update(schema.packages) + .set(updates) + .where( + and( + eq(schema.packages.deploymentId, deploymentId), + eq(schema.packages.label, label), + isNull(schema.packages.deletedAt), + ), + ); + await this.cache.del(this.cacheKeys.package(deploymentId)); + } + async getPackageHistory( accountId: string, appId: string, diff --git a/apps/server/src/storage/storage.ts b/apps/server/src/storage/storage.ts index 1f26e63..43181de 100644 --- a/apps/server/src/storage/storage.ts +++ b/apps/server/src/storage/storage.ts @@ -92,6 +92,12 @@ export interface StorageProvider { updatePackage(pkg: Package, deploymentId: string): Promise; + updatePackageMetadata( + deploymentId: string, + label: string, + updates: { isDisabled?: boolean; isMandatory?: boolean }, + ): Promise; + getPackageHistory( accountId: string, appId: string, diff --git a/apps/server/src/utils/slack.ts b/apps/server/src/utils/slack.ts index c122f50..d476f0b 100644 --- a/apps/server/src/utils/slack.ts +++ b/apps/server/src/utils/slack.ts @@ -1,19 +1,15 @@ import type { Env } from "../types/env"; -type ReleaseActionType = "Uploaded" | "Enabled" | "Disabled"; +type ReleaseActionType = "Uploaded" | "UploadFailed" | "Disabled" | "Mandatory"; const SLACK_WEBHOOK_BASE = "https://hooks.slack.com/services"; -const ACTION_HEADER: Record = { - Uploaded: "새 릴리즈 업로드", - Enabled: "릴리즈 활성화", - Disabled: "릴리즈 비활성화", -}; - +// 이모지·헤더는 액션 종류만 구분한다. on/off는 헤더 텍스트가 isDisabled/isMandatory 값으로 표현. const ACTION_EMOJI: Record = { Uploaded: "🚀", - Enabled: "🟢", - Disabled: "🔴", + UploadFailed: "🚨", + Disabled: "🧊", + Mandatory: "🔥", }; type ReleaseNotificationType = { @@ -25,6 +21,7 @@ type ReleaseNotificationType = { isDisabled?: boolean; action: ReleaseActionType; releasedBy?: string; + errorMessage?: string; }; /** @@ -42,11 +39,24 @@ export const sendReleaseNotification = async ( const platform = info.appName.toLowerCase().includes("android") ? "Android" : "iOS"; - const header = `${ACTION_EMOJI[info.action]} ${platform} · ${ACTION_HEADER[info.action]}`; + const headerLabel = + info.action === "Uploaded" + ? "새 릴리즈 업로드" + : info.action === "UploadFailed" + ? "릴리즈 업로드 실패" + : info.action === "Disabled" + ? `Disabled ${info.isDisabled ?? false}` + : `Mandatory ${info.isMandatory ?? false}`; + const header = `${ACTION_EMOJI[info.action]} ${platform} · ${headerLabel}`; - // 라벨 + 앱 버전을 굵게, 설명은 있을 때만 다음 줄에. - const summary = `*${info.label || "-"}* · App ${info.appVersion}`; - const body = info.description ? `${summary}\n${info.description}` : summary; + // 라벨 + 앱 버전을 굵게, 설명·에러는 있을 때만 다음 줄에. + const body = [ + `*${info.label || "-"}* · App ${info.appVersion}`, + info.description, + info.errorMessage && `\`${info.errorMessage}\``, + ] + .filter(Boolean) + .join("\n"); // 부가 정보는 작은 context 줄로 (Mandatory · Disabled · 작성자) const context = [ diff --git a/apps/server/test/routes/management.test.ts b/apps/server/test/routes/management.test.ts index ecc8de9..5ed763a 100644 --- a/apps/server/test/routes/management.test.ts +++ b/apps/server/test/routes/management.test.ts @@ -1797,5 +1797,151 @@ describe("Management Routes", () => { expect(data.release.appVersion).toBe("1.0.0"); }); }); + + describe("PATCH /apps/:appName/deployments/:deploymentName/release/{disabled,mandatory}", () => { + let app: typeof schema.app.$inferInsert; + let deployment: typeof schema.deployment.$inferInsert; + + beforeEach(async () => { + app = createTestApp(); + await db.insert(schema.app).values(app); + await db.insert(schema.collaborator).values({ + appId: app.id, + accountId: auth.getCurrentAccountId(), + permission: "Owner", + }); + deployment = createTestDeployment(app.id); + await db.insert(schema.deployment).values(deployment); + // v1: disabled 상태, v2: mandatory 상태(최신) — 부분 UPDATE가 다른 필드를 건드리지 않는지 검증용 + const package1 = createTestPackage(deployment.id, { + label: "v1", + appVersion: "1.0.0", + isDisabled: true, + uploadTime: Date.now() - 1000, + }); + const package2 = createTestPackage(deployment.id, { + label: "v2", + appVersion: "1.0.1", + isMandatory: true, + uploadTime: Date.now(), + }); + await db.insert(schema.packages).values(package1); + await db.insert(schema.packages).values(package2); + await Promise.all([ + createTestBlob(package1.blobPath, "blob1"), + createTestBlob(package1.manifestBlobPath as string, "blob2"), + createTestBlob(package2.blobPath, "blob3"), + createTestBlob(package2.manifestBlobPath as string, "blob4"), + ]); + }); + + const fetchToggle = ( + kind: "disabled" | "mandatory", + body: object, + headers: HeadersInit = {}, + ) => + SELF.fetch( + `https://example.com/apps/${app.name}/deployments/${deployment.name}/release/${kind}`, + { method: "PATCH", headers, body: JSON.stringify(body) }, + ); + + const getRow = async (label: string) => { + const [row] = await db + .select() + .from(schema.packages) + .where( + and( + eq(schema.packages.deploymentId, deployment.id as string), + eq(schema.packages.label, label), + ), + ); + return row; + }; + + it("disables a release by label without touching isMandatory", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "disabled", + { label: "v2", isDisabled: true }, + headers, + ); + + expect(response.status).toBe(200); + const data: any = await response.json(); + expect(data.release.label).toBe("v2"); + expect(data.release.isDisabled).toBe(true); + + const row = await getRow("v2"); + expect(row.isDisabled).toBe(true); + expect(row.isMandatory).toBe(true); // 부분 UPDATE — mandatory 불변 + }); + + it("enables a disabled release", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "disabled", + { label: "v1", isDisabled: false }, + headers, + ); + + expect(response.status).toBe(200); + const row = await getRow("v1"); + expect(row.isDisabled).toBe(false); + }); + + it("sets mandatory by label without touching isDisabled", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "mandatory", + { label: "v1", isMandatory: true }, + headers, + ); + + expect(response.status).toBe(200); + const data: any = await response.json(); + expect(data.release.label).toBe("v1"); + expect(data.release.isMandatory).toBe(true); + + const row = await getRow("v1"); + expect(row.isMandatory).toBe(true); + expect(row.isDisabled).toBe(true); // 부분 UPDATE — disabled 불변 + }); + + it("targets the latest release when label is omitted", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "mandatory", + { isMandatory: false }, + headers, + ); + + expect(response.status).toBe(200); + const data: any = await response.json(); + expect(data.release.label).toBe("v2"); + + const v2 = await getRow("v2"); + expect(v2.isMandatory).toBe(false); + const v1 = await getRow("v1"); + expect(v1.isMandatory).toBe(false); // 다른 행 불변 + }); + + it("returns 404 for unknown label", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "disabled", + { label: "v99", isDisabled: true }, + headers, + ); + expect(response.status).toBe(404); + }); + + it("returns 401 when not authenticated", async () => { + const response = await fetchToggle("mandatory", { + label: "v1", + isMandatory: true, + }); + expect(response.status).toBe(401); + }); + }); }); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index f9d8924..e096abb 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -24,6 +24,8 @@ export default defineWorkersProject(async () => { d1Databases: ["DB"], bindings: { TEST_MIGRATIONS: migrations, + // .dev.vars의 실제 webhook이 로드되면 테스트가 실채널로 Slack을 쏜다. 반드시 빈 값 고정. + CODE_PUSH_NOTI_SLACK: "", GITHUB_CLIENT_ID: "xxx", GITHUB_CLIENT_SECRET: "xxx", GITHUB_ORG: "test-org", diff --git a/apps/web/src/pages/history/HistoryPage.tsx b/apps/web/src/pages/history/HistoryPage.tsx index 2ad5d43..3bbd261 100644 --- a/apps/web/src/pages/history/HistoryPage.tsx +++ b/apps/web/src/pages/history/HistoryPage.tsx @@ -1,11 +1,4 @@ -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Select, SelectContent, @@ -14,204 +7,42 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - RefreshCw, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; - -const PAGE_SIZE = 20; - -// history 라우트는 서버 OpenAPI 응답 스키마가 비어 있어 api-client가 void로 생성한다. -// 실제 응답은 { history, totalCount, page, pageSize } 이므로 여기서 형태를 명시한다. -interface ReleaseHistoryItem { - label?: string; - appVersion?: string; - isMandatory?: boolean; - isDisabled?: boolean; - description?: string; - size?: number; - rollout?: number | null; - releaseMethod?: string; - uploadTime?: number; -} -interface HistoryResponse { - history: ReleaseHistoryItem[]; - totalCount: number; - page: number; - pageSize: number; -} -interface MetricEntry { - active?: number; - downloads?: number; - installed?: number; - failed?: number; -} -type MergedItem = ReleaseHistoryItem & MetricEntry; - -function formatBytes(bytes?: number): string { - if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-"; - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"] as const; - let value = bytes / 1024; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - return `${value.toFixed(1)} ${units[unitIndex]}`; -} - -function formatTime(ts?: number): string { - if (ts == null || !Number.isFinite(ts) || ts <= 0) return "-"; - return new Date(ts).toLocaleString("ko-KR", { - year: "2-digit", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); -} +import { RefreshCw } from "lucide-react"; +import { HistoryPagination } from "./components/HistoryPagination"; +import { ReleaseCard } from "./components/ReleaseCard"; +import { useHistoryTarget } from "./hooks/useHistoryTarget"; +import { useReleaseHistory } from "./hooks/useReleaseHistory"; +import { useReleaseToggles } from "./hooks/useReleaseToggles"; export const HistoryPage = () => { - const queryClient = useQueryClient(); - const [appName, setAppName] = useState(""); - const [deploymentName, setDeploymentName] = useState(""); - const [page, setPage] = useState(1); - - const { data: appsData } = useQuery({ - queryKey: ["apps"], - queryFn: async () => (await api.appsGet()).data, - }); - const apps = appsData?.apps ?? []; - - // 앱 목록 로드 시 첫 앱 선택 - useEffect(() => { - if (!appName && apps.length > 0 && apps[0].name) { - setAppName(apps[0].name); - } - }, [apps, appName]); - - const { data: deploymentsData } = useQuery({ - queryKey: ["deployments", appName], - queryFn: async () => (await api.appsAppNameDeploymentsGet(appName)).data, - enabled: !!appName, - }); - const deployments = deploymentsData?.deployments ?? []; - - // 배포 목록 로드 시 Production 우선, 없으면 첫 배포 선택 - useEffect(() => { - if (deployments.length === 0) return; - const names = deployments.map((d) => d.name).filter(Boolean) as string[]; - if (deploymentName && names.includes(deploymentName)) return; - setDeploymentName(names.includes("Production") ? "Production" : names[0]); - }, [deployments, deploymentName]); - - // 앱·배포를 바꾸면 첫 페이지로 되돌린다. - useEffect(() => { - setPage(1); - }, [appName, deploymentName]); - - const historyEnabled = !!appName && !!deploymentName; + const { + apps, + deployments, + appName, + setAppName, + deploymentName, + setDeploymentName, + enabled, + } = useHistoryTarget(); const { - data: historyData, - isLoading: isHistoryLoading, - isError: isHistoryError, + rows, + page, + setPage, + totalCount, + totalPages, + isLoading, + isError, isFetching, - } = useQuery({ - queryKey: ["history", appName, deploymentName, page], - queryFn: async () => { - const response = await api.appsAppNameDeploymentsDeploymentNameHistoryGet( - appName, - deploymentName, - page, - PAGE_SIZE, - ); - // 위 주석 참조: 서버 실제 응답 형태로 해석 - return response.data as unknown as HistoryResponse; - }, - enabled: historyEnabled, - }); - - const { data: metricsData } = useQuery({ - queryKey: ["metrics", appName, deploymentName], - queryFn: async () => - ( - await api.appsAppNameDeploymentsDeploymentNameMetricsGet( - appName, - deploymentName, - ) - ).data, - enabled: historyEnabled, - }); - - // 서버가 최신순 페이지를 내려주므로 정렬은 서버에 맡기고 지표만 병합한다. - const rows: MergedItem[] = useMemo(() => { - const history = historyData?.history ?? []; - const metrics = (metricsData?.metrics ?? {}) as Record; - return history.map((item) => ({ - ...item, - ...(item.label ? metrics[item.label] : {}), - })); - }, [historyData, metricsData]); + refetch, + } = useReleaseHistory(appName, deploymentName, enabled); - const totalCount = historyData?.totalCount ?? 0; - const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); - - const disableMutation = useMutation({ - mutationFn: async ({ - label, - isDisabled, - }: { - label: string; - isDisabled: boolean; - }) => - api.appsAppNameDeploymentsDeploymentNameReleasePatch( - appName, - deploymentName, - { packageInfo: { label, isDisabled } }, - ), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["history", appName, deploymentName], - }); - }, - onError: (error) => { - const message = - (error as { response?: { data?: { message?: string } } })?.response - ?.data?.message ?? "릴리즈 업데이트에 실패했습니다."; - window.alert(message); - }, - }); - - const onToggleDisable = (item: MergedItem) => { - if (!item.label) return; - const willDisable = !item.isDisabled; - const confirmed = window.confirm( - `Label: ${item.label}\nDescription: ${item.description ?? "-"}\n\n이 버전을 ${ - willDisable ? "비활성화" : "활성화" - }하시겠습니까?`, - ); - if (!confirmed) return; - disableMutation.mutate({ label: item.label, isDisabled: willDisable }); - }; - - const refetch = () => { - queryClient.invalidateQueries({ - queryKey: ["history", appName, deploymentName], - }); - queryClient.invalidateQueries({ - queryKey: ["metrics", appName, deploymentName], - }); - }; + const { + onToggleDisable, + onToggleMandatory, + isDisablePending, + isMandatoryPending, + } = useReleaseToggles(appName, deploymentName); return (
@@ -257,18 +88,18 @@ export const HistoryPage = () => {
- {isHistoryLoading ? ( + {isLoading ? (
데이터를 불러오는 중입니다...
- ) : isHistoryError ? ( + ) : isError ? (
이력을 불러오지 못했습니다. 새로고침하거나 다시 로그인해 주세요.
@@ -280,135 +111,24 @@ export const HistoryPage = () => { <>
{rows.map((item) => ( - 50 && - "border-l-4 border-l-emerald-500", - )} - > - -
-
- - {item.label} - - - App {item.appVersion ?? "-"} - -
-
- {item.isMandatory && ( - - Mandatory - - )} - {item.isDisabled ? ( - - - Disabled - - ) : ( - - - Active - - )} -
-
-

- {item.description || "-"} -

-
- -
-
-
- Installed -
-
- {(item.installed ?? 0).toLocaleString()} -
-
-
-
- Active -
-
- {(item.active ?? 0).toLocaleString()} -
-
-
-
- Size -
-
- {formatBytes(item.size)} -
-
-
-
- - {formatTime(item.uploadTime)} - - -
-
-
+ item={item} + onToggleDisable={onToggleDisable} + onToggleMandatory={onToggleMandatory} + isDisablePending={isDisablePending} + isMandatoryPending={isMandatoryPending} + /> ))}
-
-

- 전체 {totalCount}개 · {page}/{totalPages} 페이지 -

-
- - - - -
-
+ )} diff --git a/apps/web/src/pages/history/components/HistoryPagination.tsx b/apps/web/src/pages/history/components/HistoryPagination.tsx new file mode 100644 index 0000000..4eb8a2f --- /dev/null +++ b/apps/web/src/pages/history/components/HistoryPagination.tsx @@ -0,0 +1,67 @@ +import { Button } from "@/components/ui/button"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +interface HistoryPaginationProps { + page: number; + totalPages: number; + totalCount: number; + isFetching: boolean; + onPageChange: (page: number) => void; +} + +export const HistoryPagination = ({ + page, + totalPages, + totalCount, + isFetching, + onPageChange, +}: HistoryPaginationProps) => { + return ( +
+

+ 전체 {totalCount}개 · {page}/{totalPages} 페이지 +

+
+ + + + +
+
+ ); +}; diff --git a/apps/web/src/pages/history/components/ReleaseCard.tsx b/apps/web/src/pages/history/components/ReleaseCard.tsx new file mode 100644 index 0000000..8467424 --- /dev/null +++ b/apps/web/src/pages/history/components/ReleaseCard.tsx @@ -0,0 +1,158 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Power, Zap } from "lucide-react"; +import type { MergedItem } from "../types"; + +function formatBytes(bytes?: number): string { + if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-"; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(1)} ${units[unitIndex]}`; +} + +function formatTime(ts?: number): string { + if (ts == null || !Number.isFinite(ts) || ts <= 0) return "-"; + return new Date(ts).toLocaleString("ko-KR", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +interface ReleaseCardProps { + item: MergedItem; + onToggleDisable: (item: MergedItem) => void; + onToggleMandatory: (item: MergedItem) => void; + isDisablePending: boolean; + isMandatoryPending: boolean; +} + +export const ReleaseCard = ({ + item, + onToggleDisable, + onToggleMandatory, + isDisablePending, + isMandatoryPending, +}: ReleaseCardProps) => { + return ( + 50 && "border-l-4 border-l-emerald-500", + )} + > + +
+
+ {item.label} + + App {item.appVersion ?? "-"} + +
+
+ {item.isMandatory && ( + + Mandatory + + )} + {item.isDisabled ? ( + + + Disabled + + ) : ( + + + Active + + )} +
+
+

+ {item.description || "-"} +

+
+ +
+
+
+ Installed +
+
+ {(item.installed ?? 0).toLocaleString()} +
+
+
+
+ Active +
+
+ {(item.active ?? 0).toLocaleString()} +
+
+
+
+ Size +
+
+ {formatBytes(item.size)} +
+
+
+
+ + {formatTime(item.uploadTime)} + +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/history/hooks/useHistoryTarget.ts b/apps/web/src/pages/history/hooks/useHistoryTarget.ts new file mode 100644 index 0000000..0d62ba9 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useHistoryTarget.ts @@ -0,0 +1,52 @@ +import { api } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +// 조회 대상(앱·배포) 선택 상태와 자동 선택 규칙 +export function useHistoryTarget() { + const [appName, setAppName] = useState(""); + const [deploymentName, setDeploymentName] = useState(""); + + const { data: appsData } = useQuery({ + queryKey: ["apps"], + queryFn: async () => (await api.appsGet()).data, + }); + const apps = appsData?.apps ?? []; + + useEffect( + function selectFirstAppOnLoad() { + if (!appName && apps.length > 0 && apps[0].name) { + setAppName(apps[0].name); + } + }, + [apps, appName], + ); + + const { data: deploymentsData } = useQuery({ + queryKey: ["deployments", appName], + queryFn: async () => (await api.appsAppNameDeploymentsGet(appName)).data, + enabled: !!appName, + }); + const deployments = deploymentsData?.deployments ?? []; + + // Production 우선, 없으면 첫 배포 선택 + useEffect( + function selectDefaultDeployment() { + if (deployments.length === 0) return; + const names = deployments.map((d) => d.name).filter(Boolean) as string[]; + if (deploymentName && names.includes(deploymentName)) return; + setDeploymentName(names.includes("Production") ? "Production" : names[0]); + }, + [deployments, deploymentName], + ); + + return { + apps, + deployments, + appName, + setAppName, + deploymentName, + setDeploymentName, + enabled: !!appName && !!deploymentName, + }; +} diff --git a/apps/web/src/pages/history/hooks/useReleaseHistory.ts b/apps/web/src/pages/history/hooks/useReleaseHistory.ts new file mode 100644 index 0000000..7967dd2 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useReleaseHistory.ts @@ -0,0 +1,89 @@ +import { api } from "@/lib/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import type { HistoryResponse, MergedItem, MetricEntry } from "../types"; + +const PAGE_SIZE = 20; + +// 릴리즈 이력·지표 조회와 페이지 상태 +export function useReleaseHistory( + appName: string, + deploymentName: string, + enabled: boolean, +) { + const queryClient = useQueryClient(); + const [page, setPage] = useState(1); + + useEffect( + function resetPageOnTargetChange() { + setPage(1); + }, + [appName, deploymentName], + ); + + const { + data: historyData, + isLoading, + isError, + isFetching, + } = useQuery({ + queryKey: ["history", appName, deploymentName, page], + queryFn: async () => { + const response = await api.appsAppNameDeploymentsDeploymentNameHistoryGet( + appName, + deploymentName, + page, + PAGE_SIZE, + ); + // types.ts 주석 참조: 서버 실제 응답 형태로 해석 + return response.data as unknown as HistoryResponse; + }, + enabled, + }); + + const { data: metricsData } = useQuery({ + queryKey: ["metrics", appName, deploymentName], + queryFn: async () => + ( + await api.appsAppNameDeploymentsDeploymentNameMetricsGet( + appName, + deploymentName, + ) + ).data, + enabled, + }); + + // 서버가 최신순 페이지를 내려주므로 정렬은 서버에 맡기고 지표만 병합한다. + const rows: MergedItem[] = useMemo(() => { + const history = historyData?.history ?? []; + const metrics = (metricsData?.metrics ?? {}) as Record; + return history.map((item) => ({ + ...item, + ...(item.label ? metrics[item.label] : {}), + })); + }, [historyData, metricsData]); + + const totalCount = historyData?.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + const refetch = () => { + queryClient.invalidateQueries({ + queryKey: ["history", appName, deploymentName], + }); + queryClient.invalidateQueries({ + queryKey: ["metrics", appName, deploymentName], + }); + }; + + return { + rows, + page, + setPage, + totalCount, + totalPages, + isLoading, + isError, + isFetching, + refetch, + }; +} diff --git a/apps/web/src/pages/history/hooks/useReleaseToggles.ts b/apps/web/src/pages/history/hooks/useReleaseToggles.ts new file mode 100644 index 0000000..4141bb4 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useReleaseToggles.ts @@ -0,0 +1,90 @@ +import { api } from "@/lib/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { MergedItem } from "../types"; + +// disabled/mandatory 토글 액션 (confirm 포함) +export function useReleaseToggles(appName: string, deploymentName: string) { + const queryClient = useQueryClient(); + + const invalidateHistory = () => { + queryClient.invalidateQueries({ + queryKey: ["history", appName, deploymentName], + }); + }; + + const alertError = (error: unknown) => { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "릴리즈 업데이트에 실패했습니다."; + window.alert(message); + }; + + const disableMutation = useMutation({ + mutationFn: async ({ + label, + isDisabled, + }: { + label: string; + isDisabled: boolean; + }) => + api.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch( + appName, + deploymentName, + { label, isDisabled }, + ), + onSuccess: invalidateHistory, + onError: alertError, + }); + + const mandatoryMutation = useMutation({ + mutationFn: async ({ + label, + isMandatory, + }: { + label: string; + isMandatory: boolean; + }) => + api.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch( + appName, + deploymentName, + { label, isMandatory }, + ), + onSuccess: invalidateHistory, + onError: alertError, + }); + + const onToggleDisable = (item: MergedItem) => { + if (!item.label) return; + const willDisable = !item.isDisabled; + const confirmed = window.confirm( + `Label: ${item.label}\nDescription: ${item.description ?? "-"}\n\n이 버전을 ${ + willDisable ? "비활성화" : "활성화" + }하시겠습니까?`, + ); + if (!confirmed) return; + disableMutation.mutate({ label: item.label, isDisabled: willDisable }); + }; + + const onToggleMandatory = (item: MergedItem) => { + if (!item.label) return; + const willMandatory = !item.isMandatory; + const confirmed = window.confirm( + `Label: ${item.label}\nDescription: ${item.description ?? "-"}\n\n이 버전을 ${ + willMandatory ? "Mandatory로 설정" : "Optional로 해제" + }하시겠습니까?${ + willMandatory + ? "\n\n⚠️ Mandatory 설정 시 이 버전 이전을 실행 중인 기기들의 다음 업데이트가 강제 업데이트로 승격됩니다." + : "" + }`, + ); + if (!confirmed) return; + mandatoryMutation.mutate({ label: item.label, isMandatory: willMandatory }); + }; + + return { + onToggleDisable, + onToggleMandatory, + isDisablePending: disableMutation.isPending, + isMandatoryPending: mandatoryMutation.isPending, + }; +} diff --git a/apps/web/src/pages/history/types.ts b/apps/web/src/pages/history/types.ts new file mode 100644 index 0000000..f1a9600 --- /dev/null +++ b/apps/web/src/pages/history/types.ts @@ -0,0 +1,29 @@ +// history 라우트는 서버 OpenAPI 응답 스키마가 비어 있어 api-client가 void로 생성한다. +// 실제 응답은 { history, totalCount, page, pageSize } 이므로 여기서 형태를 명시한다. +export interface ReleaseHistoryItem { + label?: string; + appVersion?: string; + isMandatory?: boolean; + isDisabled?: boolean; + description?: string; + size?: number; + rollout?: number | null; + releaseMethod?: string; + uploadTime?: number; +} + +export interface HistoryResponse { + history: ReleaseHistoryItem[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface MetricEntry { + active?: number; + downloads?: number; + installed?: number; + failed?: number; +} + +export type MergedItem = ReleaseHistoryItem & MetricEntry; diff --git a/packages/api-client/src/api.ts b/packages/api-client/src/api.ts index bc9135a..2b2b355 100644 --- a/packages/api-client/src/api.ts +++ b/packages/api-client/src/api.ts @@ -295,6 +295,44 @@ export interface AppsAppNameDeploymentsDeploymentNameMetricsGet200ResponseMetric */ 'failed'?: number; } +/** + * + * @export + * @interface AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest + */ +export interface AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest { + /** + * + * @type {string} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest + */ + 'label'?: string; + /** + * + * @type {boolean} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest + */ + 'isDisabled': boolean; +} +/** + * + * @export + * @interface AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest + */ +export interface AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest { + /** + * + * @type {string} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest + */ + 'label'?: string; + /** + * + * @type {boolean} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest + */ + 'isMandatory': boolean; +} /** * * @export @@ -1909,6 +1947,88 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch: async (appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'appName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch', 'appName', appName) + // verify required parameter 'deploymentName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch', 'deploymentName', deploymentName) + const localVarPath = `/apps/{appName}/deployments/{deploymentName}/release/disabled` + .replace(`{${"appName"}}`, encodeURIComponent(String(appName))) + .replace(`{${"deploymentName"}}`, encodeURIComponent(String(deploymentName))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch: async (appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'appName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch', 'appName', appName) + // verify required parameter 'deploymentName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch', 'deploymentName', deploymentName) + const localVarPath = `/apps/{appName}/deployments/{deploymentName}/release/mandatory` + .replace(`{${"appName"}}`, encodeURIComponent(String(appName))) + .replace(`{${"deploymentName"}}`, encodeURIComponent(String(deploymentName))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Update release * @param {string} appName @@ -2909,6 +3029,34 @@ export const DefaultApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['DefaultApi.appsAppNameDeploymentsDeploymentNamePatch']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Update release * @param {string} appName @@ -3343,6 +3491,28 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa appsAppNameDeploymentsDeploymentNamePatch(appName: string, deploymentName: string, appsAppNamePatchRequest?: AppsAppNamePatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.appsAppNameDeploymentsDeploymentNamePatch(appName, deploymentName, appsAppNamePatchRequest, options).then((request) => request(axios, basePath)); }, + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options).then((request) => request(axios, basePath)); + }, + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options).then((request) => request(axios, basePath)); + }, /** * Update release * @param {string} appName @@ -3716,6 +3886,28 @@ export interface DefaultApiInterface { */ appsAppNameDeploymentsDeploymentNamePatch(appName: string, deploymentName: string, appsAppNamePatchRequest?: AppsAppNamePatchRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApiInterface + */ + appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApiInterface + */ + appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** * Update release * @param {string} appName @@ -4123,6 +4315,32 @@ export class DefaultApi extends BaseAPI implements DefaultApiInterface { return DefaultApiFp(this.configuration).appsAppNameDeploymentsDeploymentNamePatch(appName, deploymentName, appsAppNamePatchRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Update release * @param {string} appName