carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1{{define "app_settings"}}{{template "top" .}}
2{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
3{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
4{{template "appnav" .}}
5
6<div class="settings-layout">
7<nav class="settings-nav" aria-label="Settings sections">
8 {{range .SettingsNav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}
9</nav>
10<div class="settings-content">
11{{if eq .SettingsSection "general"}}
12<h2>Identity</h2>
13<div class="tablewrap">
14<table>
15 <tr><th>App</th><th>Account</th></tr>
16 <tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
17</table>
18</div>
19<p class="small"><a href="{{.Nav}}/apps/{{.Detail.App}}/settings/pipeline">Release pipeline</a> — the channels a
20release moves through, and what each one takes to promote onto.</p>
21{{if .IsOwner}}
22{{if .Settings.ControlPlane}}
23<p class="small">This app is marked as the <strong>control plane</strong> — it
24is this deployment&rsquo;s own platform. Renaming, moving and deleting it are
25refused while the flag is set, and a service credential cannot promote it.</p>
26{{else if .Settings.CanRename}}
27<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row">
28 <input type="hidden" name="csrf" value="{{.CSRF}}">
29 <label for="rename-to">New name</label>
30 <input type="text" id="rename-to" name="to" value="{{.SettingsRenameTo}}" placeholder="new-name"
31 required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
32 <button type="submit" class="btn-sm">Rename</button>
33</form>
34<p class="small">This app has never shipped, so its name is still only a
35record here — after the first ship, the name is wired into addresses and
36bucket keys and stays.</p>
37{{else}}
38<p class="small">Renaming: {{.Settings.RenameBlocked}}</p>
39{{end}}
40{{end}}
41
42{{end}}
43{{if eq .SettingsSection "domains"}}
44<h2>Domains &amp; TLS</h2>
45{{if .Settings.Domains}}
46<div class="tablewrap">
47<table>
48 <tr><th>Domain</th><th>Type</th><th>Status</th><th>Certificate</th></tr>
49 {{range $i, $d := .Settings.Domains}}
50 <tr>
51 <td><code>{{$d.Domain}}</code></td>
52 <td>{{$d.Type}}</td>
53 <td>{{with $d.Status}}{{.}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
54 <td>{{if $d.TLS}}{{$d.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
55 </tr>
56 {{with $d.Explain}}
57 <tr><td colspan="4" class="small">{{.}}</td></tr>
58 {{end}}
59 {{if and $.IsOwner $d.Claimed}}
60 <tr><td colspan="4" class="small">
61 <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/domains/detach" class="settings-row">
62 <input type="hidden" name="csrf" value="{{$.CSRF}}">
63 <input type="hidden" name="host" value="{{$d.Domain}}">
64 <label class="visually-hidden" for="detach-confirm-{{$i}}">Type {{$d.Domain}} to confirm detaching it</label>
65 <input type="text" id="detach-confirm-{{$i}}" name="confirm"
66 placeholder="type {{$d.Domain}} to confirm" autocomplete="off" required>
67 <button type="submit" class="btn-sm btn-danger">Detach</button>
68 </form>
69 </td></tr>
70 {{end}}
71 {{end}}
72</table>
73</div>
74{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}}
75{{if .IsOwner}}
76<h3>Attach a custom domain</h3>
77{{if .Settings.OnlyPlatformDomain}}<p class="small">This app already answers
78at its platform domain; attaching adds addresses, it never removes one.</p>{{end}}
79<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/domains/attach" class="settings-row">
80 <input type="hidden" name="csrf" value="{{.CSRF}}">
81 <label for="attach-host">Domain</label>
82 <input type="text" id="attach-host" name="host" value="{{.SettingsDomain}}" placeholder="www.example.com" required>
83 {{if .Settings.NeedsChannel}}
84 <label for="attach-channel">Channel</label>
85 <input type="text" id="attach-channel" name="channel" value="{{.SettingsChannel}}" placeholder="channel (e.g. stable)">
86 {{end}}
87 <button type="submit" class="btn-sm">Attach domain</button>
88</form>
89<p class="small">Point the domain's DNS at this deployment — once it is
90attached, the row above says exactly what to create. The platform obtains the
91certificate itself and renews it, so nothing has to visit the domain
92first.</p>
93<p class="small">For an app with instances, attaching <code>*.example.com</code>
94makes each instance also answer at <code>&lt;label&gt;.example.com</code>, where
95the label is the first part of its own hostname. <code>example.com</code> must
96already be attached to an app in this account, and a wildcard DNS record
97(<code>*</code>) must point here.</p>
98{{end}}
99
100<h2>Password gate</h2>
101<p class="small">One shared password in front of a hostname — for a static
102site or a prototype that should not be world-readable. Visitors see a plain
103page asking for it and stay signed in for a week. It is set per instance.</p>
104{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p>
105{{else if .Settings.Gates}}
106<div class="tablewrap">
107<table>
108 <tr><th>Host</th><th>State</th><th></th></tr>
109 {{range $i, $g := .Settings.Gates}}
110 <tr>
111 <td><code>{{$g.Host}}</code></td>
112 <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td>
113 <td>
114 <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
115 <input type="hidden" name="csrf" value="{{$.CSRF}}">
116 <input type="hidden" name="host" value="{{$g.Host}}">
117 <input type="hidden" name="action" value="set">
118 <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label>
119 <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72"
120 autocomplete="new-password" placeholder="password" required>
121 <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button>
122 </form>
123 {{if $g.Gated}}
124 <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
125 <input type="hidden" name="csrf" value="{{$.CSRF}}">
126 <input type="hidden" name="host" value="{{$g.Host}}">
127 <input type="hidden" name="action" value="clear">
128 <button type="submit" class="btn-sm btn-danger">Turn off</button>
129 </form>
130 {{end}}
131 </td>
132 </tr>
133 {{end}}
134</table>
135</div>
136{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
137
138{{end}}
139{{if eq .SettingsSection "general"}}
140{{if and .Settings .Settings.MismatchTarget}}
141<h2>Account</h2>
142<p>This app's hosts read releases from account
143<code>{{.Settings.MismatchTarget}}</code>, but the console's record says
144{{.Account.Name}} — so promotes from here reach nothing.</p>
145{{if and .IsOwner .Settings.TargetKnown .Settings.ViewerInTarget}}
146<p>Move the record to where the platform already deploys from:</p>
147<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/move">
148 <input type="hidden" name="csrf" value="{{.CSRF}}">
149 <button type="submit" class="btn-sm">Move to {{.Settings.MismatchTarget}}</button>
150</form>
151{{else}}
152<p class="small">{{if not .IsOwner}}Moving the record is the account owner's
153to do{{else if not .Settings.TargetKnown}}The routes name an account this
154console does not know{{else}}You are not a member of that account{{end}} —
155the operator path is <code>console migrate-app --app {{.Detail.App}} --to
156{{.Settings.MismatchTarget}}</code> on the console's box.</p>
157{{end}}
158{{else if .AccountReview.NoInstances}}
159<h2>Account</h2>
160<p>{{.MismatchCopy.NoInstances}} <a href="{{.Nav}}/apps/{{.Detail.App}}/instances">Instances</a></p>
161{{else}}{{if .IsOwner}}
162<h2>Account</h2>
163<p class="small">This app deploys from {{.Account.Name}}. Moving an app to a
164different account moves its bucket objects and re-stamps its routes —
165<code>carlos accounts migrate</code>, an operator act; once run, this page
166offers the final console-record step.</p>
167{{end}}{{end}}
168{{end}}
169{{if eq .SettingsSection "environments"}}
170{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read config</p>{{end}}
171{{if .Settings.EnvsUnavailable}}<p class="banner error">Could not read this app's environments. Please try again.</p>{{end}}
172{{if .Settings}}
173<h2>Environments</h2>
174<p class="small">A named environment is a config bundle that overlays this
175app's default one, per key. An instance bound to it reads the app's config
176with these values on top — which is how two instances of one app serve
177different origins, and how one of them gets a bucket of its own.</p>
178{{if .Settings.Envs}}
179{{range .Settings.Envs}}
180<h3><code>{{.Name}}</code></h3>
181<div class="tablewrap">
182<table>
183 <tr><th>Sets</th><th>Read by</th><th>Object store</th></tr>
184 <tr>
185 <td>{{if .Vars}}{{range $i, $v := .Vars}}{{if $i}}, {{end}}<code>{{$v.Name}}</code>{{if $v.Secret}} <span class="small">sealed</span>{{end}}{{end}}{{else}}<span title="the bundle exists but sets nothing">&mdash;</span>{{end}}</td>
186 <td>{{if .Hosts}}{{range $i, $h := .Hosts}}{{if $i}}, {{end}}<a href="{{$.Nav}}/apps/{{$.Detail.App}}/instances/{{$h}}">{{$h}}</a>{{end}}{{else}}<span title="no instance is bound to this bundle">no instances</span>{{end}}</td>
187 <td>{{if .HasStore}}{{if .StoreBucket}}<code>{{.StoreBucket}}</code>{{else}}declared{{end}}{{else}}<span title="this environment has no store of its own">&mdash;</span>{{end}}</td>
188 </tr>
189</table>
190</div>
191{{if and .HasStore (not .Hosts)}}<p class="small">This environment has an object
192store but no instance reads it. The bucket still exists and still costs —
193destroying a store is always deliberate, so nothing has removed it. Its card is
194under <a href="{{$.Nav}}/apps/{{$.Detail.App}}/settings/storage">Storage</a>.</p>{{end}}
195{{end}}
196<p class="small">Values are not shown here. Read them with <code>carlos env list
197--app {{.Detail.App}} --environment &lt;name&gt;</code>; secrets list by name only,
198wherever you ask.</p>
199{{else}}{{if not .Settings.EnvsUnavailable}}
200<p class="small">No named environments. Every instance of this app reads its
201default config bundle. Add one with <code>carlos env set --app {{.Detail.App}}
202--environment &lt;name&gt; KEY=value</code>, then bind an instance to it with
203<code>carlos instances set-environment --app {{.Detail.App}} --host &lt;h&gt;
204--environment &lt;name&gt;</code>.</p>
205{{end}}{{end}}
206{{end}}
207{{end}}
208{{if eq .SettingsSection "storage"}}
209{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve object storage</p>{{end}}
210{{if .Settings.StoreUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
211{{if .Settings}}{{with .Settings.Store}}
212<h2>Object storage</h2>
213{{if or .Declared .Granted .Provisioned}}
214<div class="tablewrap">
215<table>
216 <tr><th>Env prefix</th><th>Backend</th><th>Bucket</th><th>Access key</th><th>Key age</th></tr>
217 <tr>
218 <td>{{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}<span title="no declaration on the record">—</span>{{end}}</td>
219 <td>{{if .Backend}}{{.Backend}}{{else}}<span title="not granted yet">—</span>{{end}}</td>
220 <td>{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}<span title="not granted yet">—</span>{{end}}</td>
221 <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">—</span>{{end}}</td>
222 <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">—</span>{{end}}</td>
223 </tr>
224</table>
225</div>
226{{if .Provisioned}}<p class="small">Key age is the last credential change — a
227rotation and a repaired grant both move it, so it is not when the store was
228created.</p>{{end}}
229{{if and (not .Granted) (not .Provisioned)}}<p class="small">Declared and waiting:
230an operator of this deployment creates the bucket and delivers the credentials.
231Until then the {{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}store{{end}}
232variables are not in this app's config.</p>{{end}}
233{{if and .Granted (not .Provisioned)}}<p class="small">A grant is half-done: the
234bucket was claimed but no credentials are recorded yet. Running the grant again
235repairs it — an operator of this deployment does that.</p>{{end}}
236{{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys open
237this bucket until you retire the old one:</p>
238<pre><code>carlos store rotate --finish --app {{$.Detail.App}}</code></pre>{{end}}
239{{if $.IsOwner}}
240<h3 class="danger-head">Destroy this store</h3>
241{{if .CanDestroy}}
242<p class="small">Destroying empties and deletes
243{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}the store{{end}}, deletes the
244credentials that opened it, and removes the delivered variables from this app's
245config. There is no trash for a store: the objects in it are gone, and nothing
246here brings them back.</p>
247<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/store/destroy" class="settings-row">
248 <input type="hidden" name="csrf" value="{{$.CSRF}}">
249 <label class="visually-hidden" for="store-destroy-confirm">Type {{if .Bucket}}the bucket name{{else}}the app's name{{end}} to confirm</label>
250 <input type="text" id="store-destroy-confirm" name="confirm"
251 placeholder="type {{if .Bucket}}{{.Bucket}}{{else}}{{$.Detail.App}}{{end}} to confirm" autocomplete="off" required>
252 <button type="submit" class="btn-sm btn-danger">Destroy store</button>
253</form>
254<p class="small">Confirms with your passkey.</p>
255{{else}}
256<p class="small">This console has no storage backend configured
257(<code>CARLOS_STORE_BACKEND</code>), so it cannot delete this bucket or its
258credentials — an operator of this deployment has to tear it down.</p>
259{{end}}
260{{end}}
261{{else}}
262<p class="small">No object store. Ask for one with <code>carlos store create --app
263{{$.Detail.App}}</code>; an operator of this deployment then creates the bucket
264and delivers its credentials into this app's config.</p>
265{{end}}
266{{end}}{{end}}
267{{with .Settings}}{{range .StoreEnvs}}
268<h2>Object storage &mdash; {{.Environment}} environment</h2>
269<p class="small">Its own bucket and its own credential, delivered into the
270<code>{{.Environment}}</code> config bundle &mdash; so only the instances bound to
271that environment can reach it.</p>
272<div class="tablewrap">
273<table>
274 <tr><th>Env prefix</th><th>Backend</th><th>Bucket</th><th>Access key</th><th>Key age</th></tr>
275 <tr>
276 <td>{{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}<span title="no declaration on the record">&mdash;</span>{{end}}</td>
277 <td>{{if .Backend}}{{.Backend}}{{else}}<span title="not granted yet">&mdash;</span>{{end}}</td>
278 <td>{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}<span title="not granted yet">&mdash;</span>{{end}}</td>
279 <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">&mdash;</span>{{end}}</td>
280 <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">&mdash;</span>{{end}}</td>
281 </tr>
282</table>
283</div>
284{{if and (not .Granted) (not .Provisioned)}}<p class="small">Declared and waiting:
285an operator of this deployment creates the bucket and delivers the credentials.</p>{{end}}
286{{if .PendingFinish}}<p class="small">A rotation is half-finished &mdash; two keys open
287this bucket until you retire the old one:</p>
288<pre><code>carlos store rotate --finish --app {{$.Detail.App}} --environment {{.Environment}}</code></pre>{{end}}
289{{if $.IsOwner}}
290<h3 class="danger-head">Destroy this store</h3>
291{{if .CanDestroy}}
292<p class="small">Destroying empties and deletes
293{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}the store{{end}}, deletes the
294credentials that opened it, and removes the delivered variables from the
295<code>{{.Environment}}</code> bundle. The app's own store is not touched. There is
296no trash for a store: the objects in it are gone.</p>
297<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/store/destroy" class="settings-row">
298 <input type="hidden" name="csrf" value="{{$.CSRF}}">
299 <input type="hidden" name="environment" value="{{.Environment}}">
300 <label class="visually-hidden" for="store-destroy-confirm-{{.Environment}}">Type {{if .Bucket}}the bucket name{{else}}the app's name{{end}} to confirm</label>
301 <input type="text" id="store-destroy-confirm-{{.Environment}}" name="confirm"
302 placeholder="type {{if .Bucket}}{{.Bucket}}{{else}}{{$.Detail.App}}{{end}} to confirm" autocomplete="off" required>
303 <button type="submit" class="btn-sm btn-danger">Destroy store</button>
304</form>
305<p class="small">Confirms with your passkey.</p>
306{{else}}
307<p class="small">This console has no storage backend configured
308(<code>CARLOS_STORE_BACKEND</code>), so it cannot delete this bucket or its
309credentials &mdash; an operator of this deployment has to tear it down.</p>
310{{end}}
311{{end}}
312{{end}}{{end}}
313{{end}}
314{{if eq .SettingsSection "general"}}
315<h2>Errors</h2>
316{{if .ErrLevelUnread}}
317<p class="small">This app&rsquo;s Errors log level could not be read just now, so it is not shown
318and cannot be changed from here. Reload before concluding anything.</p>
319{{else if .IsOwner}}
320<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/errors/level" class="settings-row">
321 <input type="hidden" name="csrf" value="{{.CSRF}}">
322 <label>Log lines counted as errors:
323 <select name="level">
324 <option value="error"{{if eq .ErrLevelSetting.LogLevel "error"}} selected{{end}}>error and above</option>
325 <option value="warn"{{if eq .ErrLevelSetting.LogLevel "warn"}} selected{{end}}>warn and above</option>
326 </select>
327 </label>
328 <button type="submit" class="btn-sm">Save</button>
329 <span class="small">currently {{.ErrLevelSetting.LogLevel}}, from the {{.ErrLevelSetting.LogLevelFrom}}</span>
330</form>
331{{else}}
332<p class="small">Log lines at <code>{{.ErrLevelSetting.LogLevel}}</code> and above are counted as
333errors for this app, from the {{.ErrLevelSetting.LogLevelFrom}}. Only the account owner can
334change it.</p>
335{{end}}
336<p class="small">The <a href="{{.Base}}/apps/{{.Detail.App}}/errors">Errors tab</a> always lets you
337widen one look to warn without changing this.</p>
338
339<h2>Analytics</h2>
340{{if .AnalyticsUnread}}
341<p class="small">This app&rsquo;s analytics setting could not be read just now. Reload before
342concluding anything.</p>
343{{else if .IsOwner}}
344<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/analytics/setting" class="settings-row">
345 <input type="hidden" name="csrf" value="{{.CSRF}}">
346 <label>Count page views at the edge:
347 <select name="count">
348 <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option>
349 <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option>
350 </select>
351 </label>
352 <button type="submit" class="btn-sm">Save</button>
353 <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}</span>
354</form>
355{{else}}
356<p class="small">Analytics counting is <code>{{.AnalyticsSetting.Count}}</code> for this app, from
357the {{.AnalyticsSetting.CountFrom}}. Only the account owner can change it.</p>
358{{end}}
359<p class="small">When on, the edge counts HTML page views and daily visitors as it serves them: no
360JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be
361joined across days. Whether that needs consent where your visitors are is your call, which is why it
362is off until you turn it on.</p>
363
364<h2>{{.SwapCopy.Heading}}</h2>
365{{if .SwapUnread}}
366<p class="small">{{.SwapCopy.Unread}}</p>
367{{else}}
368<p class="small">{{.SwapCopy.Intro}}</p>
369<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/swap" class="settings-row">
370 <input type="hidden" name="csrf" value="{{.CSRF}}">
371 <label>{{.SwapCopy.SwapLabel}}
372 <select name="swap">
373 <option value="overlap"{{if eq .SwapPolicy.Swap "overlap"}} selected{{end}}>{{.SwapCopy.OverlapOption}}</option>
374 <option value="hold"{{if eq .SwapPolicy.Swap "hold"}} selected{{end}}>{{.SwapCopy.HoldOption}}</option>
375 </select>
376 </label>
377 <label>{{.SwapCopy.ConcurrencyLabel}}
378 <input type="number" name="swap_concurrency" min="1" step="1" value="{{.SwapPolicy.Concurrency}}" required>
379 </label>
380 <button type="submit" class="btn-sm">Save</button>
381</form>
382<p class="small">{{.SwapCopy.MigrationsNote}}</p>
383{{end}}
384
385{{end}}
386{{if eq .SettingsSection "features"}}
387{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read features</p>{{end}}
388{{if .Settings.FeaturesUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
389{{if .Settings}}{{with .Settings.Features}}
390<h2>Feature flags</h2>
391{{if .Flags}}
392<div class="tablewrap">
393<table>
394 <tr><th>Key</th><th>Value</th></tr>
395 {{range .Flags}}<tr><td><code>{{.Key}}</code></td><td><code>{{.Value}}</code></td></tr>
396 {{end}}
397</table>
398</div>
399<p class="small">Last changed {{.SetAt}}{{if .SetBy}} by {{.SetBy}}{{end}}. The platform
400stores and serves these; what each one means is this app's own business. It
401reads them off the deployment block its instance token fetches, so a change
402lands on its next poll — no restart.</p>
403{{else}}
404<p class="small">No feature flags set.</p>
405{{end}}
406<p class="small">Set or clear one with
407<code>carlos features set --app {{$.Detail.App}} key=value</code>
408(<code>key=</code> clears); every change is audited under Activity.</p>
409{{end}}{{end}}
410{{end}}
411{{if eq .SettingsSection "email"}}
412{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve email</p>{{end}}
413{{if .Settings.MailUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
414{{if .Settings}}{{with .Settings.Mail}}
415<h2>Email</h2>
416{{if or .Declared .Granted .Provisioned .Standalone}}
417<div class="tablewrap">
418<table>
419 <tr><th>Env prefix</th><th>From address</th><th>Sending domains</th><th>Daily cap</th><th>SMTP username</th><th>Key age</th></tr>
420 <tr>
421 <td>{{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}<span title="no declaration on the record">—</span>{{end}}</td>
422 <td>{{if .From}}{{.From}}{{else}}<span title="no declaration on the record">—</span>{{end}}</td>
423 <td>{{if .Domains}}{{range $i, $d := .Domains}}{{if $i}}, {{end}}<code>{{$d}}</code>{{end}}{{else}}<span title="not granted yet">—</span>{{end}}</td>
424 <td>{{if .Granted}}{{.DailyCap}}{{else}}<span title="not granted yet">—</span>{{end}}</td>
425 <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">—</span>{{end}}</td>
426 <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">—</span>{{end}}</td>
427 </tr>
428</table>
429</div>
430{{if .Provisioned}}<p class="small">Key age is the last credential change — a
431rotation moves it. The SMTP username above IS this credential's access key id;
432there is no separate password to show here.</p>{{end}}
433{{if .Paused}}
434<p class="small banner error">Sending is paused for this app — no email goes
435out right now.
436{{if eq .PauseKind "cap"}}The daily send cap was hit; this clears itself on
437the next UTC day roll.{{else if eq .PauseKind "rate"}}SES's bounce/complaint
438rate tripped a safety pause; it does not clear itself — an operator has to
439resume it.{{else}}An operator paused this deliberately; only an operator can
440resume it.{{end}}</p>
441{{end}}
442{{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys
443open this credential until you retire the old one:</p>
444<pre><code>carlos email rotate --finish --app {{$.Detail.App}}</code></pre>{{end}}
445{{if and .Declared (not .Granted) (not .Provisioned)}}<p class="small">Declared
446and waiting on a verified sending domain — add one with
447<code>carlos email domains add --app {{$.Detail.App}} &lt;domain&gt;</code>,
448then <code>carlos email enable --app {{$.Detail.App}}</code>.</p>{{end}}
449{{if and .Granted (not .Provisioned)}}<p class="small">Authorised to send but
450not provisioned yet — run
451<code>carlos email enable --app {{$.Detail.App}}</code> to finish it.</p>{{end}}
452{{if .Standalone}}
453<h3>Standalone credentials</h3>
454<div class="tablewrap">
455<table>
456 <tr><th>Name</th><th>SMTP username</th><th>Key age</th></tr>
457 {{range .Standalone}}
458 <tr>
459 <td>{{.Name}}</td>
460 <td><code>{{.KeyID}}</code></td>
461 <td>{{if .Age}}{{.Age}}{{else}}<span title="unknown">—</span>{{end}}</td>
462 </tr>
463 {{end}}
464</table>
465</div>
466<p class="small">No password is shown here — standalone credentials have
467none on the record; it was handed over once, at creation.</p>
468{{end}}
469{{else}}
470{{if .Available}}
471<p class="small">No email for this app yet — get it with
472<code>carlos email enable --app {{$.Detail.App}}</code>.</p>
473{{else}}
474<p class="small">Email is not available on this deployment.</p>
475{{end}}
476{{end}}
477{{end}}{{end}}
478{{end}}
479{{if eq .SettingsSection "advanced"}}
480{{if and .Settings .IsOwner}}
481
482<h2 class="danger-head">Control plane</h2>
483{{if .Settings.ControlPlane}}
484<p class="small">{{.Detail.App}} is marked as the control plane: it is what
485serves this deployment, so its release history is what the fleet is running.
486Clearing the flag removes that protection — trash, permanent deletion, rename
487and the account move all become available, and a service credential can promote
488it again.</p>
489<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/control-plane/clear" class="settings-row">
490 <input type="hidden" name="csrf" value="{{.CSRF}}">
491 <button type="submit" class="btn-sm btn-danger">Clear the control-plane flag</button>
492</form>
493<p class="small">Confirms with your passkey — clearing is the dangerous
494direction, so it is the one that asks. Marking an app again does not.</p>
495{{else}}
496<p class="small">{{.Detail.App}} is an ordinary app. Marking it as the control
497plane says it IS this deployment&rsquo;s platform: trashing, permanent
498deletion, renaming and the account move are then refused, the account holding
499it cannot be retired, and a service credential can neither promote it nor
500shorten its release history. A person with ops membership still deploys it
501exactly as before.</p>
502<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/control-plane/set" class="settings-row">
503 <input type="hidden" name="csrf" value="{{.CSRF}}">
504 <button type="submit" class="btn-sm">Mark as control plane</button>
505</form>
506<p class="small">No passkey: marking only adds refusals, and it is undone from
507this same card.</p>
508{{end}}
509{{end}}
510{{end}}
511{{if eq .SettingsSection "delete"}}
512{{if .IsOwner}}
513
514<h2 class="danger-head">Delete this app</h2>
515<p class="small">Deleting puts {{.Detail.App}} in the account's
516<a href="{{.Nav}}/trash">trash</a>: hosts stop serving it on their next pass
517(custom domains included), and it is restorable for
518<strong class="restore-days">{{if .ExpiresAt.IsZero}}{{trashCooloffDays}}{{else}}{{temporaryCooloffDays}}{{end}} days</strong>
519before the platform destroys its releases for good.{{if not .ExpiresAt.IsZero}} It is a temporary app, so its
520window is shorter than usual.{{end}}{{if .Detail.Production.On}} It is marked as production right now, which
521blocks deletion — turn the safety delay off on the <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{.DeploymentsCopy.History}}</a> tab first.{{end}}</p>
522<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/delete" class="settings-row">
523 <input type="hidden" name="csrf" value="{{.CSRF}}">
524 <label class="visually-hidden" for="delete-confirm">Type the app's name to confirm</label>
525 <input type="text" id="delete-confirm" name="confirm" placeholder="type {{.Detail.App}} to confirm" autocomplete="off" required>
526 <button type="submit" class="btn-sm btn-danger">Delete {{.Detail.App}}</button>
527</form>
528<p class="small">Confirms with your passkey.</p>
529{{end}}
530{{end}}
531</div>
532</div>
533{{template "bottom" .}}{{end}}
534