Integrate Alpine.js for reactive UI
- Add Alpine.js (self-hosted, embedded in static/) - Refactor app.js to use Alpine.js stores and components - Update templates to use x-data, x-bind, x-show, x-text directives - Add reactive deploy button state, live logs, status badges - Add auto-dismiss alerts with close button and transitions - Add copy-to-clipboard component with feedback - Add confirm dialog component for destructive actions - Add relative time component with auto-update - Add prettier to make fmt target for JS formatting
This commit is contained in:
1 parent
ab7e917b03
commit
f1cc7d65a6
7 files changed
+526
-696
No files matched your search
@@ -16,6 +16,7 @@ lint:
|
|||||||
fmt:
|
fmt:
|
||||||
gofmt -s -w .
|
gofmt -s -w .
|
||||||
goimports -w .
|
goimports -w .
|
||||||
|
npx prettier --write --tab-width 4 static/js/*.js
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race -cover ./...
|
go test -v -race -cover ./...
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
(()=>{var nt=!1,it=!1,G=[],ot=-1;function Ut(e){In(e)}function In(e){G.includes(e)||G.push(e),$n()}function Wt(e){let t=G.indexOf(e);t!==-1&&t>ot&&G.splice(t,1)}function $n(){!it&&!nt&&(nt=!0,queueMicrotask(Ln))}function Ln(){nt=!1,it=!0;for(let e=0;e<G.length;e++)G[e](),ot=e;G.length=0,ot=-1,it=!1}var R,N,F,at,st=!0;function Gt(e){st=!1,e(),st=!0}function Jt(e){R=e.reactive,F=e.release,N=t=>e.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),F(i))},i},()=>{t()}]}function Oe(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>F(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function re(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Re(e){Xt.push(e)}function Te(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function pe(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){jn(),ut.disconnect(),ft=!1}var de=[];function jn(){let e=ut.takeRecords();de.push(()=>e.length>0&&mt(e));let t=de.length;queueMicrotask(()=>{if(de.length===t)for(;de.length>0;)de.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return pe(),t}var pt=!1,Ce=[];function rr(){pt=!0}function nr(){pt=!1,mt(Ce),Ce=[]}function mt(e){if(pt){Ce=Ce.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Me(e){return k(B(e))}function D(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function k(e){return new Proxy({objects:e},Fn)}var Fn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Bn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Bn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function ne(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Ne(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>zn(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function zn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function K(e,t){let r=Hn(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Hn(e){let[t,r]=_t(e),n={interceptor:Ne,...t};return re(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){ie(i,e,t)}}function ie(...eLine truncated
|
||||||
|
|
||||||
|
${r?'Expression: "'+r+`"
|
||||||
|
|
||||||
|
`:""}`,t),setTimeout(()=>{throw e},0)}var oe=!0;function De(e){let t=oe;oe=!1;let r=e();return oe=t,r}function T(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return cr(...e)}var cr=xt;function lr(e){cr=e}var ur;function fr(e){ur=e}function xt(e,t){let r={};K(r,e);let n=[r,...B(e)],i=typeof t=="function"?Vn(n,t):Un(n,t,e);return or.bind(null,e,t,i)}function Vn(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!oe){me(r,t,k([n,...e]),i);return}let s=t.apply(k([n,...e]),i);me(r,s)}}var gt={};function qn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return ie(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Un(e,t,r){let n=qn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=k([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>ie(u,r,t));n.finished?(me(i,n.result,c,s,r),n.result=void 0):l.then(u=>{me(i,u,c,s,r)}).catch(u=>ie(u,r,t)).finally(()=>n.result=void 0)}}}function me(e,t,r,n,i){if(oe&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>me(e,s,r,n)).catch(s=>ie(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function dr(...e){return ur(...e)}function pr(e,t,r={}){let n={};K(n,e);let i=[n,...B(e)],o=k([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&oe?l.apply(o,s):l}}var wt="x-";function C(e=""){return wt+e}function mr(e){wt=e}var ke={};function d(e,t){return ke[e]=t,{before(r){if(!ke[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=J.indexOf(r);J.splice(n>=0?n:J.indexOf("DEFAULT"),0,e)}}}function hr(e){return Object.keys(ke).includes(e)}function _e(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(xr((o,s)=>n[o]=s)).filter(br).map(Gn(n,r)).sort(Jn).map(o=>Wn(e,o))}function Et(e){return Array.from(e).map(xr()).filter(t=>!br(t))}var yt=!1,he=new Map,_r=Symbol();function gr(e){yt=!0;let t=Symbol();_r=t,he.set(t,[]);let r=()=>{for(;he.get(t).length;)he.get(t).shift()();he.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:z,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:T.bind(T,e)},()=>t.forEach(a=>a())]}function Wn(e,t){let r=()=>{},n=ke[t.type]||r,[i,o]=_t(e);Te(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?he.get(_r).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function xr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=yr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var yr=[];function se(e){yr.push(e)}function br({name:e}){return wr().test(e)}var wr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function Gn(e,t){return({name:r,value:n})=>{let i=r.match(wr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",J=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Jn(e,t){let r=J.indexOf(e.type)===-1?bt:e.type,n=J.indexOf(t.type)===-1?bt:t.type;return J.indexOf(r)-J.indexOf(n)}function Y(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function P(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>P(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)P(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Er=!1;function vr(){Er&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Er=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` isLine truncated
|
||||||
+443
-626
File diff suppressed because it is too large.
Load diff
+43
-48
@@ -5,7 +5,12 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "nav" .}}
|
{{template "nav" .}}
|
||||||
|
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
<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">
|
<div class="mb-6">
|
||||||
<a href="/" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
|
<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">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -23,15 +28,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
|
||||||
<span id="app-status" class="{{if eq .App.Status "running"}}badge-success{{else if eq .App.Status "building"}}badge-warning{{else if eq .App.Status "error"}}badge-error{{else}}badge-neutral{{end}}">{{.App.Status}}</span>
|
<span x-bind:class="statusBadgeClass" x-text="statusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}@{{.App.Branch}}</p>
|
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}@{{.App.Branch}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form id="deploy-form" method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline" @submit="submitDeploy()">
|
||||||
<button id="deploy-btn" type="submit" class="btn-success" {{if or (eq .App.Status "building") (eq .App.Status "deploying")}}disabled{{end}}>
|
<button type="submit" class="btn-success" x-bind:disabled="deploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': deploying }">
|
||||||
<span id="deploy-btn-text">{{if or (eq .App.Status "building") (eq .App.Status "deploying")}}Deploying...{{else}}Deploy Now{{end}}</span>
|
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,17 +46,20 @@
|
|||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<h2 class="section-title mb-4">Deploy Key</h2>
|
<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>
|
<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">
|
<div class="copy-field" x-data="copyButton('deploy-key')">
|
||||||
<code id="deploy-key" class="copy-field-value text-xs">{{.DeployKey}}</code>
|
<code id="deploy-key" class="copy-field-value text-xs">{{.DeployKey}}</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-copy-target="deploy-key"
|
@click="copy()"
|
||||||
class="copy-btn"
|
class="copy-btn"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
|
<span x-show="!copied">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span x-show="copied" class="text-success-500 text-sm font-medium">Copied!</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,17 +68,20 @@
|
|||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<h2 class="section-title mb-4">Webhook URL</h2>
|
<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>
|
<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">
|
<div class="copy-field" x-data="copyButton('webhook-url')">
|
||||||
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
|
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-copy-target="webhook-url"
|
@click="copy()"
|
||||||
class="copy-btn"
|
class="copy-btn"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
|
<span x-show="!copied">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span x-show="copied" class="text-success-500 text-sm font-medium">Copied!</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +105,7 @@
|
|||||||
<td class="font-mono font-medium">{{.Key}}</td>
|
<td class="font-mono font-medium">{{.Key}}</td>
|
||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" data-confirm="Delete this environment variable?">
|
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -137,7 +148,7 @@
|
|||||||
<td class="font-mono font-medium">{{.Key}}</td>
|
<td class="font-mono font-medium">{{.Key}}</td>
|
||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" data-confirm="Delete this label?">
|
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -180,7 +191,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" data-confirm="Delete this volume mount?">
|
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -232,7 +243,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" data-confirm="Delete this port mapping?">
|
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)">
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -266,10 +277,10 @@
|
|||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="section-title">Container Logs</h2>
|
<h2 class="section-title">Container Logs</h2>
|
||||||
<span id="container-status" class="badge-neutral text-xs">Loading...</span>
|
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="container-logs-wrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||||
<pre id="container-logs" class="text-gray-100 text-xs font-mono whitespace-pre-wrap">Loading container logs...</pre>
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="containerLogs"></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,7 +290,7 @@
|
|||||||
<h2 class="section-title">Recent Deployments</h2>
|
<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>
|
<a href="/apps/{{.App.ID}}/deployments" class="text-primary-600 hover:text-primary-800 text-sm">View All</a>
|
||||||
</div>
|
</div>
|
||||||
{{if .Deployments}}
|
<template x-if="deployments.length > 0">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead class="table-header">
|
<thead class="table-header">
|
||||||
@@ -290,45 +301,36 @@
|
|||||||
<th>Commit</th>
|
<th>Commit</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="deployments-tbody" class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .Deployments}}
|
<template x-for="d in deployments" :key="d.id">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-gray-500">
|
<td class="text-gray-500">
|
||||||
<span class="relative-time" data-time="{{.FinishedAtISO}}" title="{{.FinishedAtFormatted}}">{{.FinishedAtFormatted}}</span>
|
<span x-text="formatTime(d.finishedAtISO)" :title="d.finishedAtLabel"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-gray-500">{{if .Duration}}{{.Duration}}{{else}}-{{end}}</td>
|
<td class="text-gray-500" x-text="d.duration || '-'"></td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Status "success"}}
|
<span x-bind:class="deploymentStatusClass(d.status)" x-text="deploymentStatusLabel(d.status)"></span>
|
||||||
<span class="badge-success">Success</span>
|
|
||||||
{{else if eq .Status "failed"}}
|
|
||||||
<span class="badge-error">Failed</span>
|
|
||||||
{{else if eq .Status "building"}}
|
|
||||||
<span class="badge-warning">Building</span>
|
|
||||||
{{else if eq .Status "deploying"}}
|
|
||||||
<span class="badge-info">Deploying</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="badge-neutral">{{.Status}}</span>
|
|
||||||
{{end}}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-gray-500 text-xs">{{.ShortCommit}}</td>
|
<td class="font-mono text-gray-500 text-xs" x-text="d.shortCommit"></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
</template>
|
||||||
|
<template x-if="deployments.length === 0">
|
||||||
<p class="text-gray-500 text-sm">No deployments yet.</p>
|
<p class="text-gray-500 text-sm">No deployments yet.</p>
|
||||||
{{end}}
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last Deployment Build Logs -->
|
<!-- Last Deployment Build Logs -->
|
||||||
<div id="build-logs-section" class="card p-6 mb-6" {{if not .LatestDeployment}}style="display: none;"{{end}}>
|
<div class="card p-6 mb-6" x-show="showBuildLogs" x-cloak>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="section-title">Last Deployment Build Logs</h2>
|
<h2 class="section-title">Last Deployment Build Logs</h2>
|
||||||
<span id="build-status" class="badge-neutral text-xs">{{if .LatestDeployment}}{{.LatestDeployment.Status}}{{end}}</span>
|
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="build-logs-wrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||||
<pre id="build-logs" class="text-gray-100 text-xs font-mono whitespace-pre-wrap">{{if .LatestDeployment}}Loading build logs...{{else}}No deployments yet{{end}}</pre>
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="buildLogs"></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -336,16 +338,9 @@
|
|||||||
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
|
<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>
|
<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>
|
<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" data-confirm="Are you sure you want to delete this app? This action cannot be undone.">
|
<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)">
|
||||||
<button type="submit" class="btn-danger">Delete App</button>
|
<button type="submit" class="btn-danger">Delete App</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
|
||||||
upaas.initAppDetailPage({
|
|
||||||
appId: "{{.App.ID}}",
|
|
||||||
initialDeploymentId: {{if .LatestDeployment}}{{.LatestDeployment.ID}}{{else}}null{{end}}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
+18
-2
@@ -7,12 +7,14 @@
|
|||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>{{block "title" .}}µPaaS{{end}}</title>
|
<title>{{block "title" .}}µPaaS{{end}}</title>
|
||||||
<link rel="stylesheet" href="/s/css/tailwind.css">
|
<link rel="stylesheet" href="/s/css/tailwind.css">
|
||||||
|
<style>[x-cloak] { display: none !important; }</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
<script defer src="/s/js/alpine.min.js"></script>
|
||||||
<script src="/s/js/app.js"></script>
|
<script src="/s/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -55,26 +57,40 @@
|
|||||||
|
|
||||||
{{define "alert-error"}}
|
{{define "alert-error"}}
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<div class="alert-error" data-auto-dismiss="8000">
|
<div class="alert-error" x-data="autoDismiss(8000)" x-show="show" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{.Error}}</span>
|
<span>{{.Error}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="dismiss()" class="text-error-500 hover:text-error-700 ml-4">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "alert-success"}}
|
{{define "alert-success"}}
|
||||||
{{if .Success}}
|
{{if .Success}}
|
||||||
<div class="alert-success" data-auto-dismiss="5000">
|
<div class="alert-success" x-data="autoDismiss(5000)" x-show="show" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{.Success}}</span>
|
<span>{{.Success}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="dismiss()" class="text-success-500 hover:text-success-700 ml-4">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "nav" .}}
|
{{template "nav" .}}
|
||||||
|
|
||||||
<main class="max-w-6xl mx-auto px-4 py-8">
|
<main class="max-w-6xl mx-auto px-4 py-8" x-data="dashboard()">
|
||||||
{{template "alert-success" .}}
|
{{template "alert-success" .}}
|
||||||
{{template "alert-error" .}}
|
{{template "alert-error" .}}
|
||||||
|
|
||||||
@@ -58,9 +58,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-gray-500 text-sm">
|
<td class="text-gray-500 text-sm">
|
||||||
{{if .LastDeployTime}}
|
{{if .LastDeployTime}}
|
||||||
<span class="relative-time cursor-default" data-time="{{.LastDeployISO}}" title="{{.LastDeployLabel}}">
|
<span x-data="relativeTime('{{.LastDeployISO}}')" x-text="display" class="cursor-default" title="{{.LastDeployLabel}}"></span>
|
||||||
{{.LastDeployLabel}}
|
|
||||||
</span>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+14
-16
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "nav" .}}
|
{{template "nav" .}}
|
||||||
|
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
<main class="max-w-4xl mx-auto px-4 py-8" x-data="deploymentsPage({ appId: '{{.App.ID}}' })">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/apps/{{.App.ID}}" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
|
<a href="/apps/{{.App.ID}}" 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">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -17,19 +17,21 @@
|
|||||||
|
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
|
||||||
<form id="deploy-form" method="POST" action="/apps/{{.App.ID}}/deploy">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
|
||||||
<button id="deploy-btn" type="submit" class="btn-success">Deploy Now</button>
|
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
|
||||||
|
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Build Logs (shown during active deployment) -->
|
<!-- Live Build Logs (shown during active deployment) -->
|
||||||
<div id="live-logs-section" class="card p-6 mb-4" style="display: none;">
|
<div class="card p-6 mb-4" x-show="showLiveLogs" x-cloak>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="section-title">Live Build Logs</h2>
|
<h2 class="section-title">Live Build Logs</h2>
|
||||||
<span id="live-status" class="badge-neutral text-xs">Building</span>
|
<span x-bind:class="liveStatusBadgeClass" x-text="liveStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="live-logs-wrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div x-ref="liveLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||||
<pre id="live-logs" class="text-gray-100 text-xs font-mono whitespace-pre-wrap">Waiting for logs...</pre>
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="liveLogs"></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="relative-time" data-time="{{.StartedAt.Format "2006-01-02T15:04:05Z07:00"}}" title="{{.StartedAt.Format "2006-01-02 15:04:05"}}">{{.StartedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span x-data="relativeTime('{{.StartedAt.Format `2006-01-02T15:04:05Z07:00`}}')" x-text="display" title="{{.StartedAt.Format `2006-01-02 15:04:05`}}"></span>
|
||||||
{{if .FinishedAt.Valid}}
|
{{if .FinishedAt.Valid}}
|
||||||
<span class="text-gray-400">•</span>
|
<span class="text-gray-400">•</span>
|
||||||
<span>{{.Duration}}</span>
|
<span>{{.Duration}}</span>
|
||||||
@@ -100,8 +102,10 @@
|
|||||||
<h3 class="empty-state-title">No deployments yet</h3>
|
<h3 class="empty-state-title">No deployments yet</h3>
|
||||||
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
|
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<form id="deploy-form-empty" method="POST" action="/apps/{{.App.ID}}/deploy">
|
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
|
||||||
<button id="deploy-btn-empty" type="submit" class="btn-success">Deploy Now</button>
|
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
|
||||||
|
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,10 +113,4 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
|
||||||
upaas.initDeploymentsPage({
|
|
||||||
appId: "{{.App.ID}}"
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in new issue
Block a user