| 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 |
| 20 | release 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 |
| 24 | is this deployment’s own platform. Renaming, moving and deleting it are |
| 25 | refused 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 |
| 35 | record here — after the first ship, the name is wired into addresses and |
| 36 | bucket 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 & 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 |
| 78 | at 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 |
| 90 | attached, the row above says exactly what to create. The platform obtains the |
| 91 | certificate itself and renews it, so nothing has to visit the domain |
| 92 | first.</p> |
| 93 | <p class="small">For an app with instances, attaching <code>*.example.com</code> |
| 94 | makes each instance also answer at <code><label>.example.com</code>, where |
| 95 | the label is the first part of its own hostname. <code>example.com</code> must |
| 96 | already 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 |
| 102 | site or a prototype that should not be world-readable. Visitors see a plain |
| 103 | page 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 |
| 153 | to do{{else if not .Settings.TargetKnown}}The routes name an account this |
| 154 | console does not know{{else}}You are not a member of that account{{end}} — |
| 155 | the 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 |
| 164 | different account moves its bucket objects and re-stamps its routes — |
| 165 | <code>carlos accounts migrate</code>, an operator act; once run, this page |
| 166 | offers 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 |
| 175 | app's default one, per key. An instance bound to it reads the app's config |
| 176 | with these values on top — which is how two instances of one app serve |
| 177 | different 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">—</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">—</span>{{end}}</td> |
| 188 | </tr> |
| 189 | </table> |
| 190 | </div> |
| 191 | {{if and .HasStore (not .Hosts)}}<p class="small">This environment has an object |
| 192 | store but no instance reads it. The bucket still exists and still costs — |
| 193 | destroying a store is always deliberate, so nothing has removed it. Its card is |
| 194 | under <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 <name></code>; secrets list by name only, |
| 198 | wherever you ask.</p> |
| 199 | {{else}}{{if not .Settings.EnvsUnavailable}} |
| 200 | <p class="small">No named environments. Every instance of this app reads its |
| 201 | default config bundle. Add one with <code>carlos env set --app {{.Detail.App}} |
| 202 | --environment <name> KEY=value</code>, then bind an instance to it with |
| 203 | <code>carlos instances set-environment --app {{.Detail.App}} --host <h> |
| 204 | --environment <name></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 |
| 227 | rotation and a repaired grant both move it, so it is not when the store was |
| 228 | created.</p>{{end}} |
| 229 | {{if and (not .Granted) (not .Provisioned)}}<p class="small">Declared and waiting: |
| 230 | an operator of this deployment creates the bucket and delivers the credentials. |
| 231 | Until then the {{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}store{{end}} |
| 232 | variables are not in this app's config.</p>{{end}} |
| 233 | {{if and .Granted (not .Provisioned)}}<p class="small">A grant is half-done: the |
| 234 | bucket was claimed but no credentials are recorded yet. Running the grant again |
| 235 | repairs it — an operator of this deployment does that.</p>{{end}} |
| 236 | {{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys open |
| 237 | this 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 |
| 244 | credentials that opened it, and removes the delivered variables from this app's |
| 245 | config. There is no trash for a store: the objects in it are gone, and nothing |
| 246 | here 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 |
| 258 | credentials — 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 |
| 264 | and delivers its credentials into this app's config.</p> |
| 265 | {{end}} |
| 266 | {{end}}{{end}} |
| 267 | {{with .Settings}}{{range .StoreEnvs}} |
| 268 | <h2>Object storage — {{.Environment}} environment</h2> |
| 269 | <p class="small">Its own bucket and its own credential, delivered into the |
| 270 | <code>{{.Environment}}</code> config bundle — so only the instances bound to |
| 271 | that 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">—</span>{{end}}</td> |
| 277 | <td>{{if .Backend}}{{.Backend}}{{else}}<span title="not granted yet">—</span>{{end}}</td> |
| 278 | <td>{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}<span title="not granted yet">—</span>{{end}}</td> |
| 279 | <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">—</span>{{end}}</td> |
| 280 | <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| 281 | </tr> |
| 282 | </table> |
| 283 | </div> |
| 284 | {{if and (not .Granted) (not .Provisioned)}}<p class="small">Declared and waiting: |
| 285 | an operator of this deployment creates the bucket and delivers the credentials.</p>{{end}} |
| 286 | {{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys open |
| 287 | this 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 |
| 294 | credentials 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 |
| 296 | no 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 |
| 309 | credentials — 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’s Errors log level could not be read just now, so it is not shown |
| 318 | and 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 |
| 333 | errors for this app, from the {{.ErrLevelSetting.LogLevelFrom}}. Only the account owner can |
| 334 | change it.</p> |
| 335 | {{end}} |
| 336 | <p class="small">The <a href="{{.Base}}/apps/{{.Detail.App}}/errors">Errors tab</a> always lets you |
| 337 | widen one look to warn without changing this.</p> |
| 338 | |
| 339 | <h2>Analytics</h2> |
| 340 | {{if .AnalyticsUnread}} |
| 341 | <p class="small">This app’s analytics setting could not be read just now. Reload before |
| 342 | concluding 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 |
| 357 | the {{.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 |
| 360 | JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be |
| 361 | joined across days. Whether that needs consent where your visitors are is your call, which is why it |
| 362 | is 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 |
| 400 | stores and serves these; what each one means is this app's own business. It |
| 401 | reads them off the deployment block its instance token fetches, so a change |
| 402 | lands 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 |
| 431 | rotation moves it. The SMTP username above IS this credential's access key id; |
| 432 | there 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 |
| 435 | out right now. |
| 436 | {{if eq .PauseKind "cap"}}The daily send cap was hit; this clears itself on |
| 437 | the next UTC day roll.{{else if eq .PauseKind "rate"}}SES's bounce/complaint |
| 438 | rate tripped a safety pause; it does not clear itself — an operator has to |
| 439 | resume it.{{else}}An operator paused this deliberately; only an operator can |
| 440 | resume it.{{end}}</p> |
| 441 | {{end}} |
| 442 | {{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys |
| 443 | open 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 |
| 446 | and waiting on a verified sending domain — add one with |
| 447 | <code>carlos email domains add --app {{$.Detail.App}} <domain></code>, |
| 448 | then <code>carlos email enable --app {{$.Detail.App}}</code>.</p>{{end}} |
| 449 | {{if and .Granted (not .Provisioned)}}<p class="small">Authorised to send but |
| 450 | not 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 |
| 467 | none 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 |
| 485 | serves this deployment, so its release history is what the fleet is running. |
| 486 | Clearing the flag removes that protection — trash, permanent deletion, rename |
| 487 | and the account move all become available, and a service credential can promote |
| 488 | it 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 |
| 494 | direction, 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 |
| 497 | plane says it IS this deployment’s platform: trashing, permanent |
| 498 | deletion, renaming and the account move are then refused, the account holding |
| 499 | it cannot be retired, and a service credential can neither promote it nor |
| 500 | shorten its release history. A person with ops membership still deploys it |
| 501 | exactly 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 |
| 507 | this 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> |
| 519 | before the platform destroys its releases for good.{{if not .ExpiresAt.IsZero}} It is a temporary app, so its |
| 520 | window is shorter than usual.{{end}}{{if .Detail.Production.On}} It is marked as production right now, which |
| 521 | blocks 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 | |