carlos / platform Public

console: a declined box's hold and retry on the instance page (copy pending review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6e6ad000ed4988fefc0721f58588f0461ea06ee0 parent 18bde56
8 files changed, +270 −52
  • copy-review/strings.json +50 −0
  • internal/console/apiretry.go +5 −3
  • internal/console/browserretry.go +5 −1
  • internal/console/browserretry_test.go +125 −0
  • internal/console/instancepage.go +32 −5
  • internal/console/swapcopy.go +49 −40
  • internal/console/swapcopy_truth_test.go +3 −2
  • internal/console/templates/app_instance.html +1 −1
diff --git a/copy-review/strings.json b/copy-review/strings.json
new file mode 100644
index 000000000..1452cb144
--- /dev/null
+++ b/copy-review/strings.json
@@ -0,0 +1,50 @@
+[
+ {
+ "id": "zdd-readiness2.hold-declined",
+ "section": "Instance page: a box that cannot run two builds side by side",
+ "label": "Failure hold row (declined box)",
+ "text": "Build %s failed on this box. This box cannot run two builds side by side, so it will not start this instance on that build. Retry it, or ship a newer build.",
+ "context": "The \"Failure hold\" row on an instance's page when the instance's box cannot run two builds side by side (it was not set up for zero-downtime deploys) but still remembers that a build failed. %s is the failed build. Today's hold row says the instance \"stays on\" the old build until you retry, which is untrue here: this box refuses to start the instance on the held build at all. A Retry button sits below the row. The reader is a member looking at one of their instances.",
+ "notes": "Draft. Two sentences after the build, then the next step, in the same voice as the existing hold row (\"Retry it, or ship a newer build.\" is its closing line verbatim). No reason is given because a box in this state does not report one. No em dashes."
+ },
+ {
+ "id": "zdd-readiness2.retry-line-declined",
+ "section": "Instance page: a box that cannot run two builds side by side",
+ "label": "Retry confirmation line (declined box)",
+ "text": "This clears the failure hold on {host}. Its box cannot run two builds side by side, so its next start runs {build} the old way, with no canary.",
+ "context": "The line in the confirmation page after a member presses Retry on an instance held on a box that cannot run two builds side by side. {host} is the instance's host name and {build} the build being retried. The usual line says the next attempt \"runs as a canary\" and that the host is held again if it fails; neither is true on this box, so this replaces it. The title above it (\"Retry build {build} on {host}?\") and the button are unchanged.",
+ "notes": "Draft. \"The old way\" means a plain start with no side-by-side check; \"canary\" is the word the usual line already uses. No em dashes."
+ },
+ {
+ "id": "zdd-readiness2.retry-unsupported.api",
+ "section": "Retry when the box is too old: command line",
+ "label": "Box too old to take a retry (command line and API)",
+ "text": "this instance's box is too old to take a retry. update the box, then try again",
+ "context": "Printed by `carlos instances retry` (an HTTP 409 body) when the instance's box was built before retries were signed and read from their own log, so a retry written now would silently clear nothing. The console refuses instead of writing it. The reader is an operator or developer at a terminal.",
+ "notes": "Draft. Lower case, full stop then a new sentence, no em dashes. Sibling of the \"is not held back by a failed build\" line. Pairs with zdd-readiness2.retry-unsupported.browser."
+ },
+ {
+ "id": "zdd-readiness2.retry-unsupported.browser",
+ "section": "Retry when the box is too old: console page",
+ "label": "Box too old to take a retry (console page)",
+ "text": "This instance's box is too old to take a retry. Update the box, then try again.",
+ "context": "The message shown on an instance's page after a member presses Retry (or confirms it) when the instance's box is too old to take a retry. Nothing was requested. In practice the Retry button is hidden for such a box, so a member sees this only if the box changed between loading the page and pressing the button.",
+ "notes": "Draft. Same fact as the command line string, in sentence case for the browser. Pairs with zdd-readiness2.retry-unsupported.api."
+ },
+ {
+ "id": "zdd-readiness2.retry-unsaved.api",
+ "section": "Retry before the box has saved its failure: command line",
+ "label": "Box has not saved its failure yet (command line and API)",
+ "text": "this instance's box has not saved its failure yet. try again in a minute",
+ "context": "Printed by `carlos instances retry` (an HTTP 409 body) when the box reports the instance as held but has not yet written the failure to disk. A retry signed now would name a failure the box cannot match and clear nothing, so the console asks the caller to wait. The box saves within moments. The reader is an operator or developer at a terminal.",
+ "notes": "Draft. Lower case, full stop then a new sentence, no em dashes. Pairs with zdd-readiness2.retry-unsaved.browser."
+ },
+ {
+ "id": "zdd-readiness2.retry-unsaved.browser",
+ "section": "Retry before the box has saved its failure: console page",
+ "label": "Box has not saved its failure yet (console page)",
+ "text": "This instance's box has not saved its failure yet. Try again in a minute.",
+ "context": "The message shown on an instance's page after a member presses Retry in the short window before the box has saved the failure it reports. Nothing was requested. The Retry button is hidden in that window, so this appears only if the box changed between loading the page and pressing the button.",
+ "notes": "Draft. Same fact as the command line string, in sentence case for the browser. Pairs with zdd-readiness2.retry-unsaved.api."
+ }
+]
diff --git a/internal/console/apiretry.go b/internal/console/apiretry.go
index 90ec3347e..7eea13326 100644
--- a/internal/console/apiretry.go
+++ b/internal/console/apiretry.go
@@ -27,7 +27,6 @@ import (
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
- "github.com/carlosframework/platform/internal/rolloutwords"
)
const (
@@ -89,7 +88,7 @@ func (s *Server) retryHostBuild(ctx context.Context, acct *Account, app, host st
}
join, _ := s.loadRolloutJoin(ctx, acct.Sqid, time.Now(), nil)
ro := join.rollout(host, acct.Sqid, app)
- if ro == nil || ro.HeldBy == "" || (ro.Unavailable != "" && !rolloutwords.UnavailableDeclines(ro.Unavailable)) {
+ if !ro.retryable() {
return heldRestriction{}, errRetryNotHeld
}
switch {
@@ -98,14 +97,17 @@ func (s *Server) retryHostBuild(ctx context.Context, acct *Account, app, host st
case ro.heldEpoch == 0:
return heldRestriction{}, errRetryUnsaved
}
- return heldRestriction{build: ro.HeldBy, book: ro.heldBook, epoch: ro.heldEpoch}, nil
+ return heldRestriction{build: ro.HeldBy, book: ro.heldBook, epoch: ro.heldEpoch, declined: ro.Unavailable != ""}, nil
}
// heldRestriction is what a retry grant names: the build holding the host
// back, and that restriction's epoch and book as its box reported them.
+// declined marks a box that declined the flag: it runs no canary, so the
+// browser's confirmation says so.
type heldRestriction struct {
build, book string
epoch uint64
+ declined bool
}
// requestHostRetry signs the retry grant for held (its build, book and
diff --git a/internal/console/browserretry.go b/internal/console/browserretry.go
index 042c50e79..104307af5 100644
--- a/internal/console/browserretry.go
+++ b/internal/console/browserretry.go
@@ -82,10 +82,14 @@ func (s *Server) handleInstanceRetry(w http.ResponseWriter, r *http.Request) {
return
}
fill := strings.NewReplacer("{host}", host, "{build}", held.build)
+ line := swapCopy.RetryLine
+ if held.declined {
+ line = swapCopy.RetryLineDeclined
+ }
if !confirmed(r) {
s.renderConfirm(w, r, addr, raw, acct, role, app, &ConfirmView{
Action: r.URL.Path, Fields: [][2]string{{"csrf", raw}}, CancelURL: back,
- Title: fill.Replace(swapCopy.RetryTitle), Lines: []string{fill.Replace(swapCopy.RetryLine)},
+ Title: fill.Replace(swapCopy.RetryTitle), Lines: []string{fill.Replace(line)},
Button: swapCopy.RetryButton,
})
return
diff --git a/internal/console/browserretry_test.go b/internal/console/browserretry_test.go
index ad0075325..009265091 100644
--- a/internal/console/browserretry_test.go
+++ b/internal/console/browserretry_test.go
@@ -170,3 +170,128 @@ func TestBrowserRetryAsksTheBoxsReportFirst(t *testing.T) {
})
}
}
+
+const downHost = "api.kass.carlosapp.dev"
+
+// writeDeclinedBox publishes a box that declined the flag but honours its
+// failure book: heldHost failed, downHost was left down, both held by v2 at
+// the given epoch. Reason is empty, as on the real box (recon D §1).
+func writeDeclinedBox(t *testing.T, fx *consoleFixture, code string, epoch uint64) {
+ t.Helper()
+ hr := func(attempt string) status.HostRollout {
+ return status.HostRollout{Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", Attempt: attempt, AttemptBuild: "v2",
+ HeldBy: "v2", HeldEpoch: epoch, HeldBook: heldBook, RetryV: status.HostRetryV}
+ }
+ box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
+ box.Metrics = &status.BoxMetrics{
+ RouteVersions: map[string]string{heldHost: "v2", downHost: "v2"},
+ Rollout: &status.RolloutReport{Unavailable: code, FailureBook: "honoured", Hosts: map[string]status.HostRollout{
+ heldHost: hr(status.AttemptFailed), downHost: hr(status.AttemptDown)}},
+ }
+ if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func declinedFixture(t *testing.T, code string, epoch uint64) *consoleFixture {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ for _, h := range []string{heldHost, downHost} {
+ fx.addRoute(t, registry.Route{Host: h, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"})
+ sibling(t, fx, "kass", h, "stable")
+ }
+ writeDeclinedBox(t, fx, code, epoch)
+ return fx
+}
+
+// A box that declined the flag still holds a failed build; its page says so
+// in words that are true for it (it will not start the build, not "stays
+// on"), and offers Retry, whose confirmation says no canary runs there.
+func TestInstancePageOfADeclinedBoxShowsTheHoldAndRetry(t *testing.T) {
+ fx := declinedFixture(t, "host-claims-on", heldEpoch)
+ c := signInPaul(t, fx)
+ retry := func(h string) string { return `action="/a/` + fx.sqid + `/apps/kass/instances/` + h + `/retry"` }
+ for _, tc := range []struct {
+ host, hold string
+ }{
+ {heldHost, fmt.Sprintf(swapCopy.HoldDeclinedText, "v2")},
+ {downHost, fmt.Sprintf(swapCopy.HoldDownText, "v2")},
+ } {
+ code, _, body := get(t, c, fx.u("/apps/kass/instances/"+tc.host))
+ if code != http.StatusOK {
+ t.Fatalf("%s: page = %d", tc.host, code)
+ }
+ for _, want := range []string{swapCopy.Hold, html.EscapeString(tc.hold), fmt.Sprintf(swapCopy.PromoteRetry, "kass", "v2", "stable"), retry(tc.host)} {
+ if !strings.Contains(body, want) {
+ t.Errorf("%s: page lacks %q", tc.host, want)
+ }
+ }
+ for _, bad := range []string{"it failed ()", "stays on", "stays on until"} {
+ if strings.Contains(body, bad) {
+ t.Errorf("%s: a declined box's page must not say %q", tc.host, bad)
+ }
+ }
+ }
+ // The confirmation line is the declined one: no canary runs there.
+ csrf := sessionToken(t, fx, c)
+ code, _, body := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}})
+ line := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryLineDeclined)
+ canary := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryLine)
+ if code != http.StatusOK || !strings.Contains(body, html.EscapeString(line)) || strings.Contains(body, html.EscapeString(canary)) {
+ t.Fatalf("confirm = %d; want the declined line %q and not the canary line", code, line)
+ }
+ code, _, _ = postGated(t, fx, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if code != http.StatusSeeOther {
+ t.Fatalf("retry = %d", code)
+ }
+ if r := lastScoped(t, fx, "kass"); r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != heldHost {
+ t.Fatalf("stored = %+v", r)
+ }
+}
+
+// A declined host whose book is unsaved (epoch 0) shows the hold, which is
+// true, and no Retry form, which would only be refused.
+func TestInstancePageOfADeclinedHostAtEpochZeroShowsTheHoldAndNoForm(t *testing.T) {
+ fx := declinedFixture(t, "host-claims-on", 0)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if !strings.Contains(body, html.EscapeString(fmt.Sprintf(swapCopy.HoldDeclinedText, "v2"))) {
+ t.Error("the hold row must show")
+ }
+ if strings.Contains(body, "/retry\"") || strings.Contains(body, ">"+swapCopy.RetryButton+"<") {
+ t.Error("a host at epoch 0 must not offer Retry")
+ }
+}
+
+// A refusing code reports no per-host state worth acting on, and an unknown
+// code is never retried: with a held host, still no hold row and no button.
+func TestInstancePageOfARefusingBoxWithAHeldHostShowsNoHoldAndNoRetry(t *testing.T) {
+ for _, code := range []string{"no-deploy-store", "a-newer-code"} {
+ t.Run(code, func(t *testing.T) {
+ fx := declinedFixture(t, code, heldEpoch)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ for _, bad := range []string{swapCopy.Hold, "/retry\"", "carlos promote --app", html.EscapeString(fmt.Sprintf(swapCopy.HoldDeclinedText, "v2"))} {
+ if strings.Contains(body, bad) {
+ t.Errorf("a %s box's page must not show %q", code, bad)
+ }
+ }
+ })
+ }
+}
+
+// A retry unsupported by an older box hides the form but keeps the hold.
+func TestInstancePageOfAnOlderBoxKeepsTheHoldAndHidesRetry(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"})
+ sibling(t, fx, "kass", heldHost, "stable")
+ writeRetryReport(t, fx, heldHost, "", 0, heldEpoch, heldBook)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if !strings.Contains(body, swapCopy.Hold) || !strings.Contains(body, "carlos promote --app") {
+ t.Error("the hold row and the promote line must stay")
+ }
+ if strings.Contains(body, "/retry\"") {
+ t.Error("a box that cannot take a retry must not be offered one")
+ }
+}
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go
index 387b6bd9d..35dfb0e59 100644
--- a/internal/console/instancepage.go
+++ b/internal/console/instancepage.go
@@ -31,19 +31,46 @@ type instanceDetail struct {
App, Channel string
}
+// retryable is whether the box's report holds this host back in a way a
+// retry can clear: the host is held, and the box either armed its scheduler
+// or declined the flag while still honouring its failure book
+// (rolloutwords.UnavailableDeclines). A refusing box, and one whose code
+// this console does not know, are never retried. The CLI door
+// (retryHostBuild) and the page both ask this, so they cannot disagree.
+func (r *rolloutView) retryable() bool {
+ return r != nil && r.HeldBy != "" && (r.Unavailable == "" || rolloutwords.UnavailableDeclines(r.Unavailable))
+}
+
+// CanRetry is whether the page offers Retry: the host is retryable, and the
+// report lets the door sign a grant the box accepts (a retry_v, a saved
+// epoch and the book's identity; the same test retryHostBuild refuses on).
+func (r *rolloutView) CanRetry() bool {
+ return r.retryable() && r.retryV >= 1 && r.heldEpoch != 0 && r.heldBook != ""
+}
+
+// CanRetry forwards to the rollout view, since the template's dot is the
+// instanceDetail.
+func (d *instanceDetail) CanRetry() bool { return d.Rollout.CanRetry() }
+
// HoldText is the Failure hold row (spec §9.2), or "" when nothing holds
-// this host back (and always for a box that could not arm its scheduler,
-// whose view carries no hold). A hold-mode failure that left the instance
-// down (attempt down) has nothing serving it, so its row says so rather
-// than naming a build it "stays on" (Astra plan 6 R1 #8).
+// this host back that a retry can clear (and always for a box that could
+// not arm its scheduler and did not decline the flag, whose view carries no
+// hold). A hold-mode failure that left the instance down (attempt down) has
+// nothing serving it, so its row says so rather than naming a build it
+// "stays on" (Astra plan 6 R1 #8). A box that declined the flag holds the
+// build too but will not start the instance on it, so its row says that
+// instead; its Reason is empty and is never formatted.
func (d *instanceDetail) HoldText() string {
r := d.Rollout
- if r == nil || r.Unavailable != "" || r.HeldBy == "" {
+ if !r.retryable() {
return ""
}
if r.Attempt == status.AttemptDown {
return fmt.Sprintf(swapCopy.HoldDownText, r.HeldBy)
}
+ if r.Unavailable != "" {
+ return fmt.Sprintf(swapCopy.HoldDeclinedText, r.HeldBy)
+ }
stays := r.Serving
if stays == "" {
stays = r.Good
diff --git a/internal/console/swapcopy.go b/internal/console/swapcopy.go
index 6d2264b52..6d362b136 100644
--- a/internal/console/swapcopy.go
+++ b/internal/console/swapcopy.go
@@ -5,35 +5,37 @@ package console
// serving facts, failure hold and Retry action on an instance page. Every
// string is approved copy, pinned by the zero-downtime copy record.
type swapCopyStrings struct {
- Heading string
- Intro string
- SwapLabel string
- OverlapOption string
- HoldOption string
- ConcurrencyLabel string
- MigrationsNote string
- Saved string
- Invalid string
- StoreFailed string
- RateLimited string
- Unread string
- NoBucket string
- Installed string
- Serving string
- Hold string
- HoldText string
- HoldDownText string
- PromoteRetry string
- RetryButton string
- Rolling string
- Asleep string
- RetryTitle string
- RetryLine string
- RetryDone string
- RetryFailed string
- RetryLimited string
- NotHeld string
- Audit string
+ Heading string
+ Intro string
+ SwapLabel string
+ OverlapOption string
+ HoldOption string
+ ConcurrencyLabel string
+ MigrationsNote string
+ Saved string
+ Invalid string
+ StoreFailed string
+ RateLimited string
+ Unread string
+ NoBucket string
+ Installed string
+ Serving string
+ Hold string
+ HoldText string
+ HoldDownText string
+ HoldDeclinedText string
+ PromoteRetry string
+ RetryButton string
+ Rolling string
+ Asleep string
+ RetryTitle string
+ RetryLine string
+ RetryLineDeclined string
+ RetryDone string
+ RetryFailed string
+ RetryLimited string
+ NotHeld string
+ Audit string
}
var swapCopy = swapCopyStrings{
@@ -58,17 +60,24 @@ var swapCopy = swapCopyStrings{
// down (hold mode, attempt down): nothing serves it, so it stays on
// nothing (Astra plan 6 R1 #8).
HoldDownText: "Build %s failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it.",
- PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s",
- RetryButton: "Retry",
- Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.",
- Asleep: "Build %s is installed and starts on the next request.",
- RetryTitle: "Retry build {build} on {host}?",
- RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again.",
- RetryDone: "Retry requested for {host}.",
- RetryFailed: "The retry request failed. Try again.",
- RetryLimited: "Too many retry requests. Try again later.",
- NotHeld: "{host} is no longer held back by a failed build, so there is nothing to retry.",
- Audit: "Retry of build {build} requested for {host}.",
+ // HoldDeclinedText is the hold row for a box that declined the flag: it
+ // holds the failed build too, but it will not start the instance on it,
+ // so "stays on" would be untrue. The decline's Reason is empty there.
+ HoldDeclinedText: "Build %s failed on this box. This box cannot run two builds side by side, so it will not start this instance on that build. Retry it, or ship a newer build.", // TODO-COPY(zdd-readiness2.hold-declined)
+ PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s",
+ RetryButton: "Retry",
+ Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.",
+ Asleep: "Build %s is installed and starts on the next request.",
+ RetryTitle: "Retry build {build} on {host}?",
+ RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again.",
+ // RetryLineDeclined is the confirmation for a declined box: no canary
+ // runs there, the next start runs the build as it always has.
+ RetryLineDeclined: "This clears the failure hold on {host}. Its box cannot run two builds side by side, so its next start runs {build} the old way, with no canary.", // TODO-COPY(zdd-readiness2.retry-line-declined)
+ RetryDone: "Retry requested for {host}.",
+ RetryFailed: "The retry request failed. Try again.",
+ RetryLimited: "Too many retry requests. Try again later.",
+ NotHeld: "{host} is no longer held back by a failed build, so there is nothing to retry.",
+ Audit: "Retry of build {build} requested for {host}.",
}
func (p page) SwapCopy() swapCopyStrings { return swapCopy }
diff --git a/internal/console/swapcopy_truth_test.go b/internal/console/swapcopy_truth_test.go
index a93f5b106..6f696bacb 100644
--- a/internal/console/swapcopy_truth_test.go
+++ b/internal/console/swapcopy_truth_test.go
@@ -44,9 +44,10 @@ func TestInstancePageHoldTextTellsDownFromHeld(t *testing.T) {
}
box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
- heldHost: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", Reason: status.ReasonHealth},
+ heldHost: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", Reason: status.ReasonHealth,
+ HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV},
down: {Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", Attempt: status.AttemptDown, AttemptBuild: "v2",
- HeldBy: "v2", Reason: status.ReasonExit},
+ HeldBy: "v2", Reason: status.ReasonExit, HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV},
}}}
if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
t.Fatal(err)
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
index bb68c62bb..7cb017dff 100644
--- a/internal/console/templates/app_instance.html
+++ b/internal/console/templates/app_instance.html
@@ -25,7 +25,7 @@
{{if .Aliases}}<dt>Web addresses</dt><dd>{{range .Aliases}}<div><a href="https://{{.}}">{{.}}</a></div>{{end}}</dd>{{end}}
</dl>
{{with .PromoteRetryText}}<p class="small">{{.}}</p>{{end}}
-{{if .HoldText}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{.Row.Host}}/retry">
+{{if .CanRetry}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{.Row.Host}}/retry">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="btn-sm">{{$.SwapCopy.RetryButton}}</button>
</form>{{end}}