Add inline edit functionality for environment variables, labels, and
volume mounts on the app detail page. Each entity row now has an Edit
button that reveals an inline form using Alpine.js.
- POST /apps/{id}/env-vars/{varID}/edit
- POST /apps/{id}/labels/{labelID}/edit
- POST /apps/{id}/volumes/{volumeID}/edit
- Path validation for volume host and container paths
- Warning banner about container restart after env var changes
- Tests for ValidateVolumePath
fixes #67
440 lines
23 KiB
HTML
440 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>
|
|
</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">
|
|
<h2 class="section-title mb-4">Webhook URL</h2>
|
|
<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">
|
|
<h2 class="section-title mb-4">Environment Variables</h2>
|
|
{{if .EnvVars}}
|
|
<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">
|
|
{{range .EnvVars}}
|
|
<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}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @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}}/env-vars/{{.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>
|
|
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
|
|
</td>
|
|
</template>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{end}}
|
|
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
|
|
{{ .CSRFField }}
|
|
<input type="text" name="key" placeholder="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>
|
|
|
|
<!-- 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}}
|