mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(previews): preview environments UI + fix latent insert bugs
- Service detail gains a Previews section: list, create (branch/PR/TTL), promote-to-production, delete — all confirmed actions. Verified e2e create→list→promote→delete. - fix: preview create scanned services.type which real services leave NULL (writes go to service_type); every create 404'd. Now COALESCE(type, service_type, '') matching the rest of the codebase. - fix: deployments.version is NOT NULL but three inserts omitted it — handleCreateDeployment, rollback, and preview promote all failed on any fresh-install schema. All three now write a version. - Roadmap notes preview envs are bookkeeping-only; real branch deploy is tracked separately. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
07ac54aa05
commit
8ecc8dcea5
5 files changed
+226
-15
No files matched your search
+13
-4
@@ -48,7 +48,7 @@ The backend exposes **~95 route registrations**; the frontend consumes
|
|||||||
| GitHub App install flow | done (`install_url` + connect endpoint) | done — Install button + `installation_id` return handling | done (real-provider e2e pending credentials) |
|
| GitHub App install flow | done (`install_url` + connect endpoint) | done — Install button + `installation_id` return handling | done (real-provider e2e pending credentials) |
|
||||||
| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | not documented |
|
| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | not documented |
|
||||||
| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | mentioned in README |
|
| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | mentioned in README |
|
||||||
| Preview environments (CRUD, promote, cleanup) | done | **missing** | not documented |
|
| Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | not documented |
|
||||||
| Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it |
|
| Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it |
|
||||||
| Audit logs | done | done — `/settings/audit-logs` filterable page | done |
|
| Audit logs | done | done — `/settings/audit-logs` filterable page | done |
|
||||||
| Autoscaling policies + manual scale | done | **missing** | `docs/guides/AUTOSCALING.md` describes UI that does not exist |
|
| Autoscaling policies + manual scale | done | **missing** | `docs/guides/AUTOSCALING.md` describes UI that does not exist |
|
||||||
@@ -275,9 +275,18 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
|||||||
edit/enable-toggle, manual Run, execution history with logs. (Not added
|
edit/enable-toggle, manual Run, execution history with logs. (Not added
|
||||||
to Add Service: jobs exec into an existing service's container, so they
|
to Add Service: jobs exec into an existing service's container, so they
|
||||||
live on the service, not as a standalone type.)
|
live on the service, not as a standalone type.)
|
||||||
- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch
|
- [x] **Preview environments UI**: service-detail "Previews" section — list
|
||||||
preview creation, promote-to-production action, expiry + cleanup job.
|
per service, create (branch/PR/TTL), promote-to-production with
|
||||||
Decide first whether this stays Phase 2 or moves with env support (§2.4).
|
confirm, delete with confirm. Verified e2e: create→list→promote→delete.
|
||||||
|
Fixed two latent backend bugs: create scanned NULL `services.type`
|
||||||
|
(every real service failed) and three deployment inserts omitted the
|
||||||
|
NOT NULL `version` column (create + rollback + promote all broke on
|
||||||
|
fresh installs).
|
||||||
|
- [ ] **Preview environments runtime**: preview creation writes bookkeeping
|
||||||
|
rows and a fake `*.preview.containr.local` URL — nothing is actually
|
||||||
|
deployed. Real preview = build the branch + deploy container + route a
|
||||||
|
real subdomain + TTL cleanup that tears down containers. Sizeable;
|
||||||
|
schedule with env support (§2.4).
|
||||||
- [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the
|
- [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the
|
||||||
Settings page) — filterable table by resource, action, actor (email),
|
Settings page) — filterable table by resource, action, actor (email),
|
||||||
and time range, with pagination. Backend widened accordingly: the list
|
and time range, with pagination. Backend widened accordingly: the list
|
||||||
|
|||||||
@@ -200,9 +200,9 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -534,9 +534,9 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
|
rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status,
|
||||||
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
|||||||
// Check if service exists and belongs to the project
|
// Check if service exists and belongs to the project
|
||||||
var service Service
|
var service Service
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
"SELECT id, name, type FROM services WHERE id = $1 AND project_id = $2",
|
"SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2",
|
||||||
req.ServiceID, req.ProjectID,
|
req.ServiceID, req.ProjectID,
|
||||||
).Scan(&service.ID, &service.Name, &service.Type)
|
).Scan(&service.ID, &service.Name, &service.Type)
|
||||||
|
|
||||||
@@ -586,10 +586,11 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deploymentID := uuid.New()
|
deploymentID := uuid.New()
|
||||||
|
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments (id, service_id, status, created_at, updated_at)
|
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, NOW(), NOW())`,
|
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
|
||||||
deploymentID, env.ServiceID, "pending",
|
deploymentID, env.ServiceID, promotionVersion, "pending",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
deleteCronJob,
|
deleteCronJob,
|
||||||
listCronExecutions,
|
listCronExecutions,
|
||||||
triggerCronJob,
|
triggerCronJob,
|
||||||
|
listPreviewEnvironments,
|
||||||
|
createPreviewEnvironment,
|
||||||
|
deletePreviewEnvironment,
|
||||||
|
promotePreviewEnvironment,
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
@@ -49,9 +53,11 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Play,
|
Play,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
GitPullRequest,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'settings';
|
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'settings';
|
||||||
|
|
||||||
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
|
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
|
||||||
{ key: 'metrics', label: 'Metrics', icon: Activity },
|
{ key: 'metrics', label: 'Metrics', icon: Activity },
|
||||||
@@ -59,6 +65,7 @@ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Act
|
|||||||
{ key: 'config', label: 'Config', icon: Sliders },
|
{ key: 'config', label: 'Config', icon: Sliders },
|
||||||
{ key: 'variables', label: 'Variables', icon: KeyRound },
|
{ key: 'variables', label: 'Variables', icon: KeyRound },
|
||||||
{ key: 'cron', label: 'Cron', icon: Clock },
|
{ key: 'cron', label: 'Cron', icon: Clock },
|
||||||
|
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
|
||||||
{ key: 'settings', label: 'Settings', icon: Settings },
|
{ key: 'settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -195,6 +202,38 @@ export function ServiceDetailPage() {
|
|||||||
mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }),
|
mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }),
|
||||||
onSuccess: invalidateCron,
|
onSuccess: invalidateCron,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Preview environments ---
|
||||||
|
const [previewForm, setPreviewForm] = useState<{ branch: string; prNumber: string; ttlHours: string } | null>(null);
|
||||||
|
const previewsQuery = useQuery({
|
||||||
|
queryKey: ['project-preview-envs', projectId],
|
||||||
|
queryFn: () => listPreviewEnvironments(projectId),
|
||||||
|
enabled: Boolean(projectId) && !isDemoMode && activeSection === 'previews',
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
const invalidatePreviews = () => queryClient.invalidateQueries({ queryKey: ['project-preview-envs', projectId] });
|
||||||
|
const createPreviewMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!previewForm) return;
|
||||||
|
await createPreviewEnvironment(projectId, {
|
||||||
|
project_id: projectId,
|
||||||
|
service_id: serviceId,
|
||||||
|
branch_name: previewForm.branch.trim(),
|
||||||
|
pr_number: previewForm.prNumber ? Number(previewForm.prNumber) : undefined,
|
||||||
|
ttl_hours: previewForm.ttlHours ? Number(previewForm.ttlHours) : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => { setPreviewForm(null); invalidatePreviews(); },
|
||||||
|
});
|
||||||
|
const deletePreviewMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deletePreviewEnvironment(id),
|
||||||
|
onSuccess: invalidatePreviews,
|
||||||
|
});
|
||||||
|
const promotePreviewMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => promotePreviewEnvironment(id, { target_environment: 'production' }),
|
||||||
|
onSuccess: invalidatePreviews,
|
||||||
|
});
|
||||||
|
const previews = (previewsQuery.data ?? []).filter((p) => p.service_id === serviceId);
|
||||||
const triggerCronMutation = useMutation({
|
const triggerCronMutation = useMutation({
|
||||||
mutationFn: (id: string) => triggerCronJob(id),
|
mutationFn: (id: string) => triggerCronJob(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -1285,6 +1324,135 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'previews' && (
|
||||||
|
<div className="panel p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<GitPullRequest size={20} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Preview Environments</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> New preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewForm && (
|
||||||
|
<div className="mb-6 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Branch
|
||||||
|
<input
|
||||||
|
value={previewForm.branch}
|
||||||
|
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
|
||||||
|
placeholder="feature/my-branch"
|
||||||
|
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
PR number (optional)
|
||||||
|
<input
|
||||||
|
value={previewForm.prNumber}
|
||||||
|
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
|
||||||
|
placeholder="123"
|
||||||
|
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
TTL hours
|
||||||
|
<input
|
||||||
|
value={previewForm.ttlHours}
|
||||||
|
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
|
||||||
|
placeholder="72"
|
||||||
|
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => createPreviewMutation.mutate()}
|
||||||
|
disabled={!previewForm.branch.trim() || createPreviewMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{createPreviewMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewForm(null)}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{createPreviewMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createPreviewMutation.error instanceof Error ? createPreviewMutation.error.message : 'Create failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewsQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">Loading previews…</p>
|
||||||
|
) : previews.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">No preview environments for this service.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{previews.map((env) => (
|
||||||
|
<div key={env.id} className="flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">{env.environment}</span>
|
||||||
|
<StatusBadge status={env.status ?? 'stopped'} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
|
||||||
|
{env.branch_name}
|
||||||
|
{env.pr_number ? ` · PR #${env.pr_number}` : ''}
|
||||||
|
{env.expires_at ? ` · expires ${formatRelative(env.expires_at)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{env.url && env.status === 'running' && (
|
||||||
|
<a
|
||||||
|
href={env.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} /> Open
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{env.status === 'running' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
|
||||||
|
disabled={promotePreviewMutation.isPending}
|
||||||
|
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Promote
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
|
||||||
|
disabled={deletePreviewMutation.isPending}
|
||||||
|
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--error)] hover:border-[var(--error)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(deletePreviewMutation.isError || promotePreviewMutation.isError) && (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{((deletePreviewMutation.error ?? promotePreviewMutation.error) as Error)?.message ?? 'Operation failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSection === 'settings' && (
|
{activeSection === 'settings' && (
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -1449,3 +1449,36 @@ export async function markNotificationRead(id: string): Promise<void> {
|
|||||||
export async function markAllNotificationsRead(): Promise<void> {
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
await requestJson('/notifications/read-all', { method: 'POST' });
|
await requestJson('/notifications/read-all', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment'];
|
||||||
|
|
||||||
|
export async function listPreviewEnvironments(projectId: string): Promise<PreviewEnvironmentEntity[]> {
|
||||||
|
const payload = await requestJson<{ preview_environments?: PreviewEnvironmentEntity[] }>(
|
||||||
|
`/projects/${encodeURIComponent(projectId)}/preview-environments`,
|
||||||
|
);
|
||||||
|
return payload.preview_environments ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPreviewEnvironment(
|
||||||
|
projectId: string,
|
||||||
|
input: components['schemas']['CreatePreviewEnvironmentRequest'],
|
||||||
|
): Promise<PreviewEnvironmentEntity> {
|
||||||
|
return requestJson<PreviewEnvironmentEntity>(
|
||||||
|
`/projects/${encodeURIComponent(projectId)}/preview-environments`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePreviewEnvironment(id: string): Promise<void> {
|
||||||
|
await requestJson(`/preview-environments/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promotePreviewEnvironment(
|
||||||
|
id: string,
|
||||||
|
input: components['schemas']['PromotePreviewEnvironmentRequest'],
|
||||||
|
): Promise<void> {
|
||||||
|
await requestJson(`/preview-environments/${encodeURIComponent(id)}/promote`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user