All checks were successful
Check / check (pull_request) Successful in 4s
Replace the Save All workflow with the original per-action behavior:
- Edit row: shows Save/Cancel buttons, submits full set immediately
- Delete row: shows confirmation dialog, submits full set immediately
- Add row: submits full set immediately on Add click
Moves Alpine.js logic into a proper envVarEditor component in
app-detail.js. Initializes env var data from hidden span elements
with data attributes for safe HTML escaping.
All actions collect the complete env var set and POST to the single
bulk endpoint POST /apps/{id}/env — no Save All button needed.
449 lines
23 KiB
HTML
449 lines
23 KiB
HTML
{{template "base" .}}
|
|
|
|
{{define "title"}}{{.App.Name}} - µPaaS{{end}}
|
|
|
|
{{define "content"}}
|
|
{{template "nav" .}}
|
|
|
|
<main class="max-w-4xl mx-auto px-4 py-8" x-data="appDetail({
|
|
appId: '{{.App.ID}}',
|
|
initialDeploymentId: {{if .LatestDeployment}}{{.LatestDeployment.ID}}{{else}}null{{end}},
|
|
initialStatus: '{{.App.Status}}',
|
|
initialBuildStatus: '{{if .LatestDeployment}}{{.LatestDeployment.Status}}{{else}}{{end}}'
|
|
})">
|
|
<div class="mb-6">
|
|
<a href="/" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
|
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
</svg>
|
|
Back to Dashboard
|
|
</a>
|
|
</div>
|
|
|
|
{{template "alert-success" .}}
|
|
{{template "alert-error" .}}
|
|
|
|
<!-- Header -->
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
|
<div>
|
|
<div class="flex items-center gap-3">
|
|
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
|
|
<span x-bind:class="statusBadgeClass" x-text="statusLabel"></span>
|
|
</div>
|
|
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}@{{.App.Branch}}</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline" @submit="submitDeploy()">
|
|
{{ .CSRFField }}
|
|
<button type="submit" class="btn-success" x-bind:disabled="deploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': deploying }">
|
|
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="/apps/{{.App.ID}}/deployments/cancel" class="inline" x-show="deploying" x-cloak x-data="confirmAction('Cancel the current deployment?')" @submit="confirm($event)">
|
|
{{ .CSRFField }}
|
|
<button type="submit" class="btn-danger">Cancel Deploy</button>
|
|
</form>
|
|
{{if .App.PreviousImageID.Valid}}
|
|
<form method="POST" action="/apps/{{.App.ID}}/rollback" class="inline" x-data="confirmAction('Roll back to the previous deployment?')" @submit="confirm($event)">
|
|
{{ .CSRFField }}
|
|
<button type="submit" class="btn-warning">Rollback</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Deploy Key -->
|
|
<div class="card p-6 mb-6">
|
|
<h2 class="section-title mb-4">Deploy Key</h2>
|
|
<p class="text-sm text-gray-500 mb-3">Add this SSH public key to your repository as a read-only deploy key:</p>
|
|
<div class="copy-field" x-data="copyButton('deploy-key')">
|
|
<code id="deploy-key" class="copy-field-value text-xs">{{.DeployKey}}</code>
|
|
<button
|
|
type="button"
|
|
@click="copy()"
|
|
class="copy-btn"
|
|
title="Copy to clipboard"
|
|
>
|
|
<span x-show="!copied">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
</span>
|
|
<span x-show="copied" class="text-success-500 text-sm font-medium">Copied!</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Webhook URL -->
|
|
<div class="card p-6 mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="section-title">Webhook URL</h2>
|
|
<a href="/apps/{{.App.ID}}/webhooks" class="text-primary-600 hover:text-primary-800 text-sm">Event History</a>
|
|
</div>
|
|
<p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p>
|
|
<div class="copy-field" x-data="copyButton('webhook-url')">
|
|
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
|
|
<button
|
|
type="button"
|
|
@click="copy()"
|
|
class="copy-btn"
|
|
title="Copy to clipboard"
|
|
>
|
|
<span x-show="!copied">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
</span>
|
|
<span x-show="copied" class="text-success-500 text-sm font-medium">Copied!</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Environment Variables -->
|
|
<div class="card p-6 mb-6" x-data="envVarEditor()">
|
|
<h2 class="section-title mb-4">Environment Variables</h2>
|
|
{{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}}
|
|
<template x-if="vars.length > 0">
|
|
<div class="overflow-x-auto mb-4">
|
|
<table class="table">
|
|
<thead class="table-header">
|
|
<tr>
|
|
<th>Key</th>
|
|
<th>Value</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="table-body">
|
|
<template x-for="(env, idx) in vars" :key="idx">
|
|
<tr>
|
|
<template x-if="editIdx !== idx">
|
|
<td class="font-mono font-medium" x-text="env.key"></td>
|
|
</template>
|
|
<template x-if="editIdx !== idx">
|
|
<td class="font-mono text-gray-500" x-text="env.value"></td>
|
|
</template>
|
|
<template x-if="editIdx !== idx">
|
|
<td class="text-right">
|
|
<button @click="startEdit(idx)" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
|
<button @click="removeVar(idx)" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
|
</td>
|
|
</template>
|
|
<template x-if="editIdx === idx">
|
|
<td colspan="3">
|
|
<form @submit.prevent="saveEdit(idx)" class="flex gap-2 items-center">
|
|
<input type="text" x-model="editKey" required class="input flex-1 font-mono text-sm">
|
|
<input type="text" x-model="editVal" required class="input flex-1 font-mono text-sm">
|
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
|
<button type="button" @click="editIdx = -1" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
|
|
</form>
|
|
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
|
|
</td>
|
|
</template>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
<form @submit.prevent="addVar($refs.newKey, $refs.newVal)" class="flex flex-col sm:flex-row gap-2">
|
|
<input x-ref="newKey" type="text" placeholder="KEY" required class="input flex-1 font-mono text-sm">
|
|
<input x-ref="newVal" type="text" placeholder="value" required class="input flex-1 font-mono text-sm">
|
|
<button type="submit" class="btn-primary">Add</button>
|
|
</form>
|
|
<form x-ref="bulkForm" method="POST" action="/apps/{{.App.ID}}/env" class="hidden">
|
|
{{ .CSRFField }}
|
|
<textarea x-ref="bulkData" name="env_vars"></textarea>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Labels -->
|
|
<div class="card p-6 mb-6">
|
|
<h2 class="section-title mb-4">Docker Labels</h2>
|
|
<div class="overflow-x-auto mb-4">
|
|
<table class="table">
|
|
<thead class="table-header">
|
|
<tr>
|
|
<th>Key</th>
|
|
<th>Value</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="table-body">
|
|
<!-- System-managed upaas.id label -->
|
|
<tr class="bg-gray-50">
|
|
<td class="font-mono font-medium text-gray-600">upaas.id</td>
|
|
<td class="font-mono text-gray-500">{{.App.ID}}</td>
|
|
<td class="text-right">
|
|
<span class="text-xs text-gray-400">System</span>
|
|
</td>
|
|
</tr>
|
|
{{range .Labels}}
|
|
<tr x-data="{ editing: false }">
|
|
<template x-if="!editing">
|
|
<td class="font-mono font-medium">{{.Key}}</td>
|
|
</template>
|
|
<template x-if="!editing">
|
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
|
</template>
|
|
<template x-if="!editing">
|
|
<td class="text-right">
|
|
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
|
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
|
{{ $.CSRFField }}
|
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
|
</form>
|
|
</td>
|
|
</template>
|
|
<template x-if="editing">
|
|
<td colspan="3">
|
|
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center">
|
|
{{ $.CSRFField }}
|
|
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
|
|
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
|
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
|
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
|
|
</form>
|
|
</td>
|
|
</template>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2">
|
|
{{ .CSRFField }}
|
|
<input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm">
|
|
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
|
<button type="submit" class="btn-primary">Add</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Volumes -->
|
|
<div class="card p-6 mb-6">
|
|
<h2 class="section-title mb-4">Volume Mounts</h2>
|
|
{{if .Volumes}}
|
|
<div class="overflow-x-auto mb-4">
|
|
<table class="table">
|
|
<thead class="table-header">
|
|
<tr>
|
|
<th>Host Path</th>
|
|
<th>Container Path</th>
|
|
<th>Mode</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="table-body">
|
|
{{range .Volumes}}
|
|
<tr x-data="{ editing: false }">
|
|
<template x-if="!editing">
|
|
<td class="font-mono">{{.HostPath}}</td>
|
|
</template>
|
|
<template x-if="!editing">
|
|
<td class="font-mono">{{.ContainerPath}}</td>
|
|
</template>
|
|
<template x-if="!editing">
|
|
<td>
|
|
{{if .ReadOnly}}
|
|
<span class="badge-neutral">Read-only</span>
|
|
{{else}}
|
|
<span class="badge-info">Read-write</span>
|
|
{{end}}
|
|
</td>
|
|
</template>
|
|
<template x-if="!editing">
|
|
<td class="text-right">
|
|
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
|
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
|
{{ $.CSRFField }}
|
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
|
</form>
|
|
</td>
|
|
</template>
|
|
<template x-if="editing">
|
|
<td colspan="4">
|
|
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center">
|
|
{{ $.CSRFField }}
|
|
<input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path">
|
|
<input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path">
|
|
<label class="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap">
|
|
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
|
|
RO
|
|
</label>
|
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
|
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
|
|
</form>
|
|
</td>
|
|
</template>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{end}}
|
|
<form method="POST" action="/apps/{{.App.ID}}/volumes" class="flex flex-col sm:flex-row gap-2 items-end">
|
|
{{ .CSRFField }}
|
|
<div class="flex-1 w-full">
|
|
<input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm">
|
|
</div>
|
|
<div class="flex-1 w-full">
|
|
<input type="text" name="container_path" placeholder="/container/path" required class="input font-mono text-sm">
|
|
</div>
|
|
<label class="flex items-center gap-2 text-sm text-gray-600 whitespace-nowrap">
|
|
<input type="checkbox" name="readonly" value="1" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
|
|
Read-only
|
|
</label>
|
|
<button type="submit" class="btn-primary">Add</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Ports -->
|
|
<div class="card p-6 mb-6">
|
|
<h2 class="section-title mb-4">Port Mappings</h2>
|
|
{{if .Ports}}
|
|
<div class="overflow-x-auto mb-4">
|
|
<table class="table">
|
|
<thead class="table-header">
|
|
<tr>
|
|
<th>Host Port</th>
|
|
<th>Container Port</th>
|
|
<th>Protocol</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="table-body">
|
|
{{range .Ports}}
|
|
<tr>
|
|
<td class="font-mono">{{.HostPort}}</td>
|
|
<td class="font-mono">{{.ContainerPort}}</td>
|
|
<td>
|
|
{{if eq .Protocol "udp"}}
|
|
<span class="badge-warning">UDP</span>
|
|
{{else}}
|
|
<span class="badge-info">TCP</span>
|
|
{{end}}
|
|
</td>
|
|
<td class="text-right">
|
|
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)">
|
|
{{ .CSRFField }}
|
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{end}}
|
|
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
|
|
{{ .CSRFField }}
|
|
<div class="flex-1 w-full">
|
|
<label class="block text-xs text-gray-500 mb-1">Host (external)</label>
|
|
<input type="text" name="host_port" placeholder="8080" required pattern="[0-9]+" class="input font-mono text-sm">
|
|
</div>
|
|
<div class="flex-1 w-full">
|
|
<label class="block text-xs text-gray-500 mb-1">Container (internal)</label>
|
|
<input type="text" name="container_port" placeholder="80" required pattern="[0-9]+" class="input font-mono text-sm">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-gray-500 mb-1">Protocol</label>
|
|
<select name="protocol" class="input text-sm">
|
|
<option value="tcp">TCP</option>
|
|
<option value="udp">UDP</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn-primary">Add</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Container Logs -->
|
|
<div class="card p-6 mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="section-title">Container Logs</h2>
|
|
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
|
</div>
|
|
<div class="relative">
|
|
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="containerLogs"></pre>
|
|
</div>
|
|
<button
|
|
x-show="!_containerAutoScroll"
|
|
x-transition
|
|
@click="_containerAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.containerLogsWrapper)"
|
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
|
title="Scroll to bottom"
|
|
>↓ Follow</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Deployments -->
|
|
<div class="card p-6 mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="section-title">Recent Deployments</h2>
|
|
<a href="/apps/{{.App.ID}}/deployments" class="text-primary-600 hover:text-primary-800 text-sm">View All</a>
|
|
</div>
|
|
<template x-if="deployments.length > 0">
|
|
<div class="overflow-x-auto">
|
|
<table class="table">
|
|
<thead class="table-header">
|
|
<tr>
|
|
<th>Finished</th>
|
|
<th>Duration</th>
|
|
<th>Status</th>
|
|
<th>Commit</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="table-body">
|
|
<template x-for="d in deployments" :key="d.id">
|
|
<tr>
|
|
<td class="text-gray-500">
|
|
<span x-text="formatTime(d.finishedAtISO) || '-'" :title="d.finishedAtLabel"></span>
|
|
</td>
|
|
<td class="text-gray-500" x-text="d.duration || '-'"></td>
|
|
<td>
|
|
<span x-bind:class="deploymentStatusClass(d.status)" x-text="deploymentStatusLabel(d.status)"></span>
|
|
</td>
|
|
<td class="font-mono text-gray-500 text-xs" x-text="d.shortCommit"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
<template x-if="deployments.length === 0">
|
|
<p class="text-gray-500 text-sm">No deployments yet.</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Last Deployment Build Logs -->
|
|
<div class="card p-6 mb-6" x-show="showBuildLogs" x-cloak>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="section-title">Last Deployment Build Logs</h2>
|
|
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
|
</div>
|
|
<div class="relative">
|
|
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="buildLogs"></pre>
|
|
</div>
|
|
<button
|
|
x-show="!_buildAutoScroll"
|
|
x-transition
|
|
@click="_buildAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.buildLogsWrapper)"
|
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
|
title="Scroll to bottom"
|
|
>↓ Follow</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Danger Zone -->
|
|
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
|
|
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
|
|
<p class="text-error-600 text-sm mb-4">Deleting this app will remove all configuration and deployment history. This action cannot be undone.</p>
|
|
<form method="POST" action="/apps/{{.App.ID}}/delete" x-data="confirmAction('Are you sure you want to delete this app? This action cannot be undone.')" @submit="confirm($event)">
|
|
{{ .CSRFField }}
|
|
<button type="submit" class="btn-danger">Delete App</button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
{{end}}
|