feat: environment model + deployment tracking (phase 3a)

- Environment/Deployment XORM models + migration 010
- Full CRUD API: GET/POST/PATCH/DELETE /environments + /deployments
- Deployment status update endpoint, publishes deployment.* NATS events
- EnvironmentsPage with deploy cards, history accordion, deploy modal
- Sidebar Environments nav item between Pipelines and Settings
- Repo page deployment status badges (env name + SHA pill per
  environment)
- Environment/Deployment types in types/api.ts + environments.ts query
  hooks
This commit is contained in:
2026-05-11 21:20:12 +02:00
parent 4f2fb846dd
commit 24bf4706e1
14 changed files with 1168 additions and 31 deletions
+2
View File
@@ -37,6 +37,7 @@ const PRsPage = lazy(() => import('./pages/PRsPage'))
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage'))
const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
@@ -85,6 +86,7 @@ export default function App() {
<Route path="repos/:owner/:repo/pulls/new" element={<S><CreatePRPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="repos/:owner/:repo/pipelines" element={<S><RepoPipelinesPage /></S>} />
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
<Route path="starred" element={<S><StarredPage /></S>} />
+157
View File
@@ -0,0 +1,157 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { Environment, EnvironmentWithLatest, Deployment, DeployStatus } from '../../types/api'
// ── Schemas ───────────────────────────────────────────────────────────────────
const deployStatusSchema = z.enum(['pending', 'in_progress', 'success', 'failure', 'cancelled'])
const deploymentSchema = z.object({
id: z.number(),
envId: z.number(),
repoId: z.number(),
sha: z.string(),
ref: z.string(),
status: deployStatusSchema,
triggeredBy: z.string(),
description: z.string(),
runId: z.number().nullable(),
startedAt: z.string().nullable(),
finishedAt: z.string().nullable(),
createdAt: z.string(),
})
const environmentSchema = z.object({
id: z.number(),
repoId: z.number(),
name: z.string(),
url: z.string(),
protectionRules: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
const environmentWithLatestSchema = environmentSchema.extend({
latestDeployment: deploymentSchema.nullable(),
})
// ── Queries ───────────────────────────────────────────────────────────────────
export function useEnvironments(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'environments'],
queryFn: () =>
api.get<EnvironmentWithLatest[]>(
`/api/v1/repos/${owner}/${repo}/environments`,
z.array(environmentWithLatestSchema),
),
enabled: Boolean(owner && repo),
refetchInterval: 15_000,
})
}
export function useEnvironment(owner: string, repo: string, envName: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'environments', envName],
queryFn: () =>
api.get<EnvironmentWithLatest>(
`/api/v1/repos/${owner}/${repo}/environments/${envName}`,
environmentWithLatestSchema,
),
enabled: Boolean(owner && repo && envName),
refetchInterval: 10_000,
})
}
export function useDeployments(owner: string, repo: string, envName: string, limit = 30) {
return useQuery({
queryKey: ['repos', owner, repo, 'environments', envName, 'deployments', limit],
queryFn: () =>
api.get<Deployment[]>(
`/api/v1/repos/${owner}/${repo}/environments/${envName}/deployments?limit=${limit}`,
z.array(deploymentSchema),
),
enabled: Boolean(owner && repo && envName),
refetchInterval: 10_000,
})
}
// ── Mutations ─────────────────────────────────────────────────────────────────
export function useCreateEnvironment(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { name: string; url?: string; protectionRules?: string }) =>
api.post<Environment>(
`/api/v1/repos/${owner}/${repo}/environments`,
environmentSchema,
body,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
},
})
}
export function useUpdateEnvironment(owner: string, repo: string, envName: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { url?: string; protectionRules?: string }) =>
api.patch<Environment>(
`/api/v1/repos/${owner}/${repo}/environments/${envName}`,
environmentSchema,
body,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName] })
},
})
}
export function useDeleteEnvironment(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (envName: string) =>
api.delete<void>(
`/api/v1/repos/${owner}/${repo}/environments/${envName}`,
z.unknown() as z.ZodType<void>,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
},
})
}
export function useCreateDeployment(owner: string, repo: string, envName: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { sha: string; ref?: string; description?: string; runId?: number }) =>
api.post<Deployment>(
`/api/v1/repos/${owner}/${repo}/environments/${envName}/deployments`,
deploymentSchema,
body,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName] })
},
})
}
export function useUpdateDeploymentStatus(owner: string, repo: string, envName: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ deployId, status, description }: { deployId: number; status: DeployStatus; description?: string }) =>
api.patch<Deployment>(
`/api/v1/repos/${owner}/${repo}/environments/${envName}/deployments/${deployId}/status`,
deploymentSchema,
{ status, description },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName] })
},
})
}
+4 -2
View File
@@ -161,8 +161,9 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) {
{ label: 'Branches', to: `${base}/branches`, icon: <BranchIcon /> },
{ label: 'Pull requests', to: `${base}/pulls`, icon: <PRIcon /> },
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
{ label: 'Environments', to: `${base}/environments`, icon: <EnvIcon /> },
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
]
return (
<div>
@@ -203,4 +204,5 @@ const CommitsIcon = () => <I d={['M12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5
const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
const EnvIcon = () => <I d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
+433
View File
@@ -0,0 +1,433 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import {
useEnvironments,
useDeployments,
useCreateEnvironment,
useDeleteEnvironment,
useCreateDeployment,
} from '../api/queries/environments'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
import type { DeployStatus, EnvironmentWithLatest } from '../types/api'
// ── Utilities ─────────────────────────────────────────────────────────────────
function timeAgo(iso: string | null): string {
if (!iso) return 'never'
const diff = Date.now() - new Date(iso).getTime()
const min = Math.floor(diff / 60_000)
if (min < 1) return 'just now'
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
return `${Math.floor(hr / 24)}d ago`
}
function shortSHA(sha: string): string {
return sha.slice(0, 7)
}
function shortRef(ref: string): string {
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
}
// ── Deploy status helpers ─────────────────────────────────────────────────────
const DEPLOY_DOT: Record<DeployStatus, string> = {
pending: 'bg-[var(--c-subtle)]',
in_progress: 'bg-[var(--c-brand)] animate-pulse',
success: 'bg-[var(--c-success)]',
failure: 'bg-[var(--c-danger)]',
cancelled: 'bg-[var(--c-subtle)]',
}
const DEPLOY_BADGE: Record<DeployStatus, string> = {
pending: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
in_progress: 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)] text-[var(--c-brand)]',
success: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]',
failure: 'bg-[var(--c-danger-tint)] border-[#FF8F73] text-[var(--c-danger-dark)]',
cancelled: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
}
const DEPLOY_LABEL: Record<DeployStatus, string> = {
pending: 'Pending',
in_progress: 'In progress',
success: 'Active',
failure: 'Failed',
cancelled: 'Cancelled',
}
function DeployBadge({ status }: { status: DeployStatus }) {
return (
<span className={cn(
'inline-flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full border',
DEPLOY_BADGE[status],
)}>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DEPLOY_DOT[status])} />
{DEPLOY_LABEL[status]}
</span>
)
}
// ── Deployment history panel ──────────────────────────────────────────────────
function DeploymentHistory({ owner, repo, envName }: { owner: string; repo: string; envName: string }) {
const { data: deploys, isLoading } = useDeployments(owner, repo, envName, 10)
if (isLoading) {
return (
<div className="space-y-2 p-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-8 rounded" />)}
</div>
)
}
if (!deploys?.length) {
return <p className="text-xs text-[var(--c-muted)] px-4 py-3">No deployments yet.</p>
}
return (
<div className="divide-y divide-[var(--c-border)]">
{deploys.map(d => (
<div key={d.id} className="flex items-center gap-3 px-4 py-2.5">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DEPLOY_DOT[d.status])} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-[var(--c-text)]">{shortSHA(d.sha)}</span>
{d.ref && (
<span className="text-[10px] font-mono text-[var(--c-muted)]">{shortRef(d.ref)}</span>
)}
<DeployBadge status={d.status} />
</div>
<p className="text-[10px] text-[var(--c-muted)] mt-0.5">
by {d.triggeredBy}
{d.description && <span> · {d.description}</span>}
</p>
</div>
<span className="text-[10px] text-[var(--c-subtle)] shrink-0">{timeAgo(d.createdAt)}</span>
</div>
))}
</div>
)
}
// ── New deploy modal ──────────────────────────────────────────────────────────
function DeployModal({
owner, repo, envName,
onClose,
}: { owner: string; repo: string; envName: string; onClose: () => void }) {
const [sha, setSHA] = useState('')
const [ref, setRef] = useState('')
const [desc, setDesc] = useState('')
const createDeployment = useCreateDeployment(owner, repo, envName)
function submit(e: React.FormEvent) {
e.preventDefault()
if (!sha.trim()) return
createDeployment.mutate(
{ sha: sha.trim(), ref: ref.trim(), description: desc.trim() },
{ onSuccess: onClose },
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--c-surface)] border border-[var(--c-border)] rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--c-border)]">
<h3 className="text-sm font-semibold text-[var(--c-text)]">Deploy to {envName}</h3>
<button onClick={onClose} className="text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={submit} className="p-5 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">
Commit SHA <span className="text-[var(--c-danger)]">*</span>
</label>
<input
value={sha} onChange={e => setSHA(e.target.value)}
placeholder="abc1234 or full 40-char SHA"
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)] font-mono"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">Branch / tag (optional)</label>
<input
value={ref} onChange={e => setRef(e.target.value)}
placeholder="main"
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">Description (optional)</label>
<input
value={desc} onChange={e => setDesc(e.target.value)}
placeholder="Release 1.2.3"
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
type="submit"
disabled={!sha.trim() || createDeployment.isPending}
className="flex-1 px-4 py-2 bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{createDeployment.isPending ? 'Deploying…' : 'Deploy'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 border border-[var(--c-border)] text-sm text-[var(--c-text)] rounded-lg hover:bg-[var(--c-surface-muted)] transition-colors">
Cancel
</button>
</div>
{createDeployment.isError && (
<p className="text-xs text-[var(--c-danger)]">
{(createDeployment.error as Error)?.message ?? 'Deployment failed'}
</p>
)}
</form>
</div>
</div>
)
}
// ── New environment modal ─────────────────────────────────────────────────────
function NewEnvModal({ owner, repo, onClose }: { owner: string; repo: string; onClose: () => void }) {
const [name, setName] = useState('')
const [url, setURL] = useState('')
const createEnv = useCreateEnvironment(owner, repo)
function submit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return
createEnv.mutate({ name: name.trim(), url: url.trim() }, { onSuccess: onClose })
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--c-surface)] border border-[var(--c-border)] rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--c-border)]">
<h3 className="text-sm font-semibold text-[var(--c-text)]">New environment</h3>
<button onClick={onClose} className="text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={submit} className="p-5 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">
Name <span className="text-[var(--c-danger)]">*</span>
</label>
<input
value={name} onChange={e => setName(e.target.value)}
placeholder="production"
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">URL (optional)</label>
<input
value={url} onChange={e => setURL(e.target.value)}
placeholder="https://api.example.com"
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
type="submit"
disabled={!name.trim() || createEnv.isPending}
className="flex-1 px-4 py-2 bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{createEnv.isPending ? 'Creating…' : 'Create environment'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 border border-[var(--c-border)] text-sm text-[var(--c-text)] rounded-lg hover:bg-[var(--c-surface-muted)] transition-colors">
Cancel
</button>
</div>
{createEnv.isError && (
<p className="text-xs text-[var(--c-danger)]">
{(createEnv.error as Error)?.message ?? 'Failed to create environment'}
</p>
)}
</form>
</div>
</div>
)
}
// ── Environment card ──────────────────────────────────────────────────────────
function EnvironmentCard({
env, owner, repo,
}: { env: EnvironmentWithLatest; owner: string; repo: string }) {
const [expanded, setExpanded] = useState(false)
const [deploying, setDeploying] = useState(false)
const deleteEnv = useDeleteEnvironment(owner, repo)
const latest = env.latestDeployment
return (
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
{/* Card header */}
<div className="flex items-start justify-between gap-4 px-5 py-4">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h3 className="text-sm font-semibold text-[var(--c-text)]">{env.name}</h3>
{latest && <DeployBadge status={latest.status} />}
</div>
{env.url && (
<a
href={env.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--c-brand)] hover:underline font-mono truncate block max-w-xs"
>
{env.url}
</a>
)}
{latest ? (
<div className="flex items-center gap-2 text-[11px] text-[var(--c-muted)] mt-1 flex-wrap">
<span className="font-mono">{shortSHA(latest.sha)}</span>
{latest.ref && <span className="font-mono">{shortRef(latest.ref)}</span>}
<span>by {latest.triggeredBy}</span>
<span>{timeAgo(latest.createdAt)}</span>
</div>
) : (
<p className="text-[11px] text-[var(--c-muted)] mt-1">No deployments yet</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setDeploying(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
Deploy
</button>
<button
onClick={() => setExpanded(e => !e)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-muted)] hover:text-[var(--c-text)] hover:border-[var(--c-brand-focus)] transition-colors"
>
History
<svg
width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
className={cn('transition-transform', expanded ? 'rotate-180' : '')}
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<button
onClick={() => {
if (confirm(`Delete environment "${env.name}"? This will also delete all deployment records.`)) {
deleteEnv.mutate(env.name)
}
}}
disabled={deleteEnv.isPending}
className="p-1.5 text-[var(--c-muted)] hover:text-[var(--c-danger)] transition-colors disabled:opacity-50"
title="Delete environment"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
</div>
{/* History panel */}
{expanded && (
<div className="border-t border-[var(--c-border)] bg-[var(--c-surface-muted)]">
<p className="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--c-muted)]">
Deployment history
</p>
<DeploymentHistory owner={owner} repo={repo} envName={env.name} />
</div>
)}
{/* Deploy modal */}
{deploying && (
<DeployModal owner={owner} repo={repo} envName={env.name} onClose={() => setDeploying(false)} />
)}
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function EnvironmentsPage() {
const { owner = '', repo = '' } = useParams()
const { data: envs, isLoading } = useEnvironments(owner, repo)
const [showNewEnv, setShowNewEnv] = useState(false)
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-[var(--c-text)]">Environments</h1>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Deployment targets for <span className="font-mono">{owner}/{repo}</span>
</p>
</div>
<button
onClick={() => setShowNewEnv(true)}
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New environment
</button>
</div>
{/* Environment cards */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="border border-[var(--c-border)] rounded-lg p-5 space-y-3">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-48 rounded" />
</div>
))}
</div>
) : !envs?.length ? (
<div className="flex flex-col items-center justify-center py-20 border border-dashed border-[var(--c-border)] rounded-lg gap-3 text-center">
<div className="w-10 h-10 rounded-full bg-[var(--c-surface-muted)] flex items-center justify-center">
<svg width="20" height="20" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">No environments yet</p>
<p className="text-xs text-[var(--c-muted)] mt-1 max-w-xs">
Create environments like production, staging, or dev to track where your code is deployed.
</p>
</div>
<button
onClick={() => setShowNewEnv(true)}
className="px-4 py-2 text-sm font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors"
>
Create first environment
</button>
</div>
) : (
<div className="space-y-3">
{envs.map(env => (
<EnvironmentCard key={env.id} env={env} owner={owner} repo={repo} />
))}
</div>
)}
{showNewEnv && <NewEnvModal owner={owner} repo={repo} onClose={() => setShowNewEnv(false)} />}
</div>
)
}
+33
View File
@@ -3,6 +3,7 @@ import { useParams, useSearchParams, Link } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { useEnvironments } from '../api/queries/environments'
import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
@@ -21,6 +22,7 @@ export default function RepoPage() {
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
const { data: branches } = useRepoBranches(owner, repoName)
const { data: environments } = useEnvironments(owner, repoName)
const { track } = useRecentRepos()
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
@@ -65,6 +67,37 @@ export default function RepoPage() {
{repo.description && (
<p className="text-sm text-[var(--c-muted)]">{repo.description}</p>
)}
{/* Deployment status badges */}
{environments && environments.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap mt-2">
{environments.map(env => {
const status = env.latestDeployment?.status
const dot: Record<string, string> = {
success: 'bg-[var(--c-success)]',
in_progress: 'bg-[var(--c-brand)] animate-pulse',
failure: 'bg-[var(--c-danger)]',
pending: 'bg-[var(--c-subtle)]',
cancelled: 'bg-[var(--c-subtle)]',
}
return (
<Link
key={env.id}
to={`/repos/${owner}/${repoName}/environments`}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-[var(--c-border)] bg-[var(--c-surface-muted)] hover:border-[var(--c-brand-focus)] transition-colors"
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${status ? (dot[status] ?? 'bg-[var(--c-subtle)]') : 'bg-[var(--c-subtle)]'}`} />
<span className="text-[10px] font-medium text-[var(--c-muted)]">{env.name}</span>
{env.latestDeployment?.sha && (
<span className="text-[10px] font-mono text-[var(--c-subtle)]">
{env.latestDeployment.sha.slice(0, 7)}
</span>
)}
</Link>
)
})}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
+39
View File
@@ -221,6 +221,45 @@ export interface Webhook {
createdAt: string
}
// ── Environment + Deployment (Phase 3A) ──────────────────────────────────────
export type DeployStatus =
| 'pending'
| 'in_progress'
| 'success'
| 'failure'
| 'cancelled'
export interface Environment {
id: number
repoId: number
name: string
url: string
protectionRules: string // JSON: {"require_approval":true,"required_reviewers":1}
createdAt: string
updatedAt: string
}
export interface Deployment {
id: number
envId: number
repoId: number
sha: string
ref: string
status: DeployStatus
triggeredBy: string
description: string
runId: number | null
startedAt: string | null
finishedAt: string | null
createdAt: string
}
// EnvironmentWithLatest is what ListEnvironments returns — env + most recent deploy.
export interface EnvironmentWithLatest extends Environment {
latestDeployment: Deployment | null
}
export interface ApiError {
error: string
status: number