add multi-env deploy (prod/staging/dev), branch selection, env badges
This commit is contained in:
@@ -42,11 +42,17 @@ func (d *Deployer) Run(ctx context.Context, deployID, projectID, namespace, env,
|
||||
})
|
||||
|
||||
// Template variable substitution
|
||||
envSuffix := ""
|
||||
if env != "" && env != "prod" {
|
||||
envSuffix = "-" + env
|
||||
}
|
||||
content := string(stackfileContent)
|
||||
replacements := map[string]string{
|
||||
"{{commit}}": gitCommit,
|
||||
"{{branch}}": gitBranch,
|
||||
"{{timestamp}}": fmt.Sprintf("%d", time.Now().Unix()),
|
||||
"{{commit}}": gitCommit,
|
||||
"{{branch}}": gitBranch,
|
||||
"{{env}}": env,
|
||||
"{{env_suffix}}": envSuffix,
|
||||
"{{timestamp}}": fmt.Sprintf("%d", time.Now().Unix()),
|
||||
}
|
||||
for k, v := range replacements {
|
||||
content = strings.ReplaceAll(content, k, v)
|
||||
|
||||
@@ -174,16 +174,39 @@ func (h *StackOpsHandler) DeployProject(
|
||||
stackfile := req.Msg.Stackfile
|
||||
commit := req.Msg.GitCommit
|
||||
|
||||
// Branch: use request override or project default
|
||||
branch := p.GitBranch
|
||||
if req.Msg.GitCommit != "" && len(stackfile) == 0 {
|
||||
// GitCommit field reused as branch override when no stackfile provided
|
||||
}
|
||||
|
||||
env := req.Msg.Env
|
||||
if len(stackfile) == 0 {
|
||||
// Клонируем из git
|
||||
cloneBranch := branch
|
||||
// Check if env has a default branch mapping
|
||||
if env == "dev" && branch == "main" {
|
||||
cloneBranch = "develop"
|
||||
}
|
||||
// Allow explicit branch in git_commit field
|
||||
if req.Msg.GitCommit != "" && !strings.Contains(req.Msg.GitCommit, ":") {
|
||||
cloneBranch = req.Msg.GitCommit
|
||||
commit = ""
|
||||
}
|
||||
var fetchErr error
|
||||
stackfile, commit, fetchErr = gitutil.FetchStackfile(p.GitURL, p.GitBranch)
|
||||
stackfile, commit, fetchErr = gitutil.FetchStackfile(p.GitURL, cloneBranch)
|
||||
if fetchErr != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fetchErr)
|
||||
}
|
||||
branch = cloneBranch
|
||||
}
|
||||
|
||||
id, err := h.createDeploy(p.ID, p.Namespace, req.Msg.Env, commit, p.GitBranch, stackfile)
|
||||
// Namespace: base + env suffix
|
||||
ns := p.Namespace
|
||||
if env != "" && env != "prod" {
|
||||
ns = p.Namespace + "-" + env
|
||||
}
|
||||
|
||||
id, err := h.createDeploy(p.ID, ns, env, commit, branch, stackfile)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, err)
|
||||
}
|
||||
|
||||
@@ -321,10 +321,10 @@ func buildEnvVars(sf *schema.Stackfile, pc *schema.PlatformConfig) []envVar {
|
||||
port := sf.DependsOn.Services[svc]
|
||||
envKey := fmt.Sprintf("%s_URL", strings.ToUpper(strings.ReplaceAll(svc, "-", "_")))
|
||||
// Use <service>.<namespace>.svc for cross-namespace discovery.
|
||||
// Convention: namespace = service name (StackOps creates namespace per project).
|
||||
// {{env_suffix}} is resolved by deployer: "" for prod, "-staging" for staging, etc.
|
||||
envs = append(envs, envVar{
|
||||
Name: envKey,
|
||||
Value: fmt.Sprintf("http://%s.%s.svc:%d", svc, svc, port),
|
||||
Value: fmt.Sprintf("http://%s.%s{{env_suffix}}.svc:%d", svc, svc, port),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -177,10 +177,11 @@ export const api = {
|
||||
getDeploy: (deployId: string) =>
|
||||
rpc<Deploy>('GetDeploy', { deployId }),
|
||||
|
||||
deployProject: (projectId: string, env: string, stackfile?: string) =>
|
||||
deployProject: (projectId: string, env: string, stackfile?: string, branch?: string) =>
|
||||
rpc<{ deployId: string }>('DeployProject', {
|
||||
projectId, env,
|
||||
...(stackfile ? { stackfile: btoa(unescape(encodeURIComponent(stackfile))) } : {}),
|
||||
...(branch ? { gitCommit: branch } : {}),
|
||||
}),
|
||||
|
||||
rollback: (projectId: string, deploymentId: string) =>
|
||||
|
||||
@@ -4,11 +4,14 @@ import { useToast } from './Toast'
|
||||
|
||||
interface Props { onClose: () => void }
|
||||
|
||||
const ENVS = ['prod', 'staging', 'dev'] as const
|
||||
|
||||
export function DeployModal({ onClose }: Props) {
|
||||
const selectedProject = useStore((s) => s.selectedProject)
|
||||
const deployProject = useStore((s) => s.deployProject)
|
||||
const { toast } = useToast()
|
||||
const [env, setEnv] = useState('prod')
|
||||
const [env, setEnv] = useState<string>('prod')
|
||||
const [branch, setBranch] = useState(selectedProject?.gitBranch ?? 'main')
|
||||
const [stackfile, setStackfile] = useState('')
|
||||
const [deploying, setDeploying] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
@@ -24,8 +27,9 @@ export function DeployModal({ onClose }: Props) {
|
||||
if (!selectedProject) return
|
||||
setDeploying(true); setErr(null)
|
||||
try {
|
||||
await deployProject(selectedProject.id, env, stackfile || undefined)
|
||||
toast('Deploy started', 'success')
|
||||
// Pass branch as gitCommit field (handler uses it as branch override)
|
||||
await deployProject(selectedProject.id, env, stackfile || undefined, branch)
|
||||
toast(`Deploy to ${env} started from ${branch}`, 'success')
|
||||
onClose()
|
||||
} catch (ex) {
|
||||
setErr((ex as Error).message)
|
||||
@@ -34,20 +38,49 @@ export function DeployModal({ onClose }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const envColors: Record<string, string> = {
|
||||
prod: 'border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400',
|
||||
staging: 'border-yellow-300 bg-yellow-50 text-yellow-700 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400',
|
||||
dev: 'border-blue-300 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg w-full max-w-lg p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold mb-5 text-gray-900 dark:text-gray-100">Deploy {selectedProject?.name}</h2>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
|
||||
{/* Environment selector */}
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mb-2 block">Environment</span>
|
||||
<div className="flex gap-2">
|
||||
{ENVS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => setEnv(e)}
|
||||
className={`flex-1 py-2 rounded border text-sm font-medium transition-colors ${
|
||||
env === e ? envColors[e] : 'border-gray-200 dark:border-gray-800 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch */}
|
||||
<label className="block">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1 block">Environment</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1 block">Branch</span>
|
||||
<input
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
placeholder="prod"
|
||||
className="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:border-green-500"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-900 dark:text-gray-100 font-mono outline-none focus:border-green-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Stackfile override */}
|
||||
<label className="block">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1 block">
|
||||
Stackfile override <span className="text-gray-400 dark:text-gray-600">(leave empty to use git)</span>
|
||||
@@ -55,19 +88,21 @@ export function DeployModal({ onClose }: Props) {
|
||||
<textarea
|
||||
value={stackfile}
|
||||
onChange={(e) => setStackfile(e.target.value)}
|
||||
placeholder={'[app]\nname = "my-app"\nimage = "registry/my-app:latest"'}
|
||||
rows={6}
|
||||
placeholder={'[app]\nname = "my-app"\nimage = "registry/my-app:{{commit}}"'}
|
||||
rows={4}
|
||||
className="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 text-xs text-gray-700 dark:text-gray-300 font-mono outline-none focus:border-green-500 resize-y"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{err && <p className="text-red-500 dark:text-red-400 text-xs">{err}</p>}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={deploying}
|
||||
className="flex-1 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm py-2 rounded"
|
||||
>
|
||||
{deploying ? 'Deploying...' : 'Deploy'}
|
||||
{deploying ? 'Deploying...' : `Deploy to ${env}`}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-4 text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
Cancel
|
||||
|
||||
@@ -35,7 +35,17 @@ function DeployRow({ deploy, selected, onSelect }: {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={deploy.status} />
|
||||
{active && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />}
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">{deploy.deployId.slice(0, 8)}</span>
|
||||
{deploy.env && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
deploy.env === 'prod' ? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400' :
|
||||
deploy.env === 'staging' ? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40 dark:text-yellow-400' :
|
||||
'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400'
|
||||
}`}>{deploy.env}</span>
|
||||
)}
|
||||
{deploy.gitCommit
|
||||
? <span className="text-xs text-gray-500 dark:text-gray-400 font-mono">{deploy.gitCommit.slice(0, 7)}</span>
|
||||
: <span className="text-xs text-gray-400 dark:text-gray-600">manual</span>
|
||||
}
|
||||
<span className="flex-1" />
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-600" title={fullDate(deploy.createdAt)}>
|
||||
{relativeTime(deploy.createdAt)}
|
||||
@@ -174,7 +184,7 @@ export function ProjectDetail() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={selectedDeploy.status} />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 font-mono">{selectedDeploy.deployId.slice(0, 12)}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">Deploy #{selectedIdx >= 0 ? deploys.length - selectedIdx : '?'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -29,7 +29,7 @@ interface StoreValue {
|
||||
deleteProject: (id: string) => Promise<void>
|
||||
|
||||
loadDeploys: (projectId: string) => Promise<void>
|
||||
deployProject: (projectId: string, env: string, stackfile?: string) => Promise<string>
|
||||
deployProject: (projectId: string, env: string, stackfile?: string, branch?: string) => Promise<string>
|
||||
rollback: (projectId: string, deploymentId: string) => Promise<string>
|
||||
refreshDeploy: (deployId: string) => Promise<void>
|
||||
}
|
||||
@@ -124,8 +124,8 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deployProject = useCallback(async (projectId: string, env: string, stackfile?: string) => {
|
||||
const res = await api.deployProject(projectId, env, stackfile)
|
||||
const deployProject = useCallback(async (projectId: string, env: string, stackfile?: string, branch?: string) => {
|
||||
const res = await api.deployProject(projectId, env, stackfile, branch)
|
||||
const ns = selectedProjectRef.current?.namespace ?? projectId
|
||||
const data = await api.listDeploys(ns)
|
||||
setDeploys(data.deploys ?? [])
|
||||
|
||||
Reference in New Issue
Block a user