Connect console instance status, activity and live logs
10 files changed,
+79
−16
internal/console/activity_test.go+25 −0internal/console/browserrestart.go+5 −1internal/console/deployments.go+2 −2internal/console/deployments_test.go+4 −1internal/console/instancepage.go+6 −2internal/console/instancepage_test.go+28 −0internal/console/overview.go+1 −2internal/console/server.go+2 −7internal/console/templates/app_deployments.html+1 −0internal/console/templates/app_logs.html+5 −1
CI passed — run details
diff --git a/internal/console/activity_test.go b/internal/console/activity_test.go| index 721f46a5b..181dca877 100644 |
| --- a/internal/console/activity_test.go |
| +++ b/internal/console/activity_test.go |
| @@ -5,6 +5,8 @@ import ( |
| "strings" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| func TestValidActivityMessage(t *testing.T) { |
| @@ -23,6 +25,29 @@ func TestValidActivityMessage(t *testing.T) { |
| } |
| } |
| +func TestActivityDoesNotLinkPlatformAliasAsInstance(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + host := "platform-alias.example.com" |
| + fx.addRoute(t, registry.Route{Host: host, App: "kass", Account: fx.sqid, Kind: "static", Platform: true}) |
| + if err := fx.st.RecordActivity(OpsAccountID, "kass", "paul@keymail.dev", Activity{ |
| + Action: "instance-create", TargetType: "instance", Target: host, Detail: "created instance " + host, |
| + }, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/activity")) |
| + if status != 200 { |
| + t.Fatalf("status = %d", status) |
| + } |
| + if !strings.Contains(body, host) { |
| + t.Fatalf("activity target missing: %s", body) |
| + } |
| + if strings.Contains(body, `/instances/`+host) { |
| + t.Fatalf("platform alias target linked as an instance: %s", body) |
| + } |
| +} |
| + |
| func TestCheckedActivityMessageRejectsTrailingControl(t *testing.T) { |
| for _, message := range []string{"description\n", "\tdescription", "description\r"} { |
| w := httptest.NewRecorder() |
diff --git a/internal/console/browserrestart.go b/internal/console/browserrestart.go| index 1429f13f6..287b89fc6 100644 |
| --- a/internal/console/browserrestart.go |
| +++ b/internal/console/browserrestart.go |
| @@ -136,7 +136,11 @@ func (s *Server) handleBrowserRestart(w http.ResponseWriter, r *http.Request, ho |
| } |
| detail := strings.ReplaceAll(words.Audit, "{target}", target) |
| success := strings.ReplaceAll(words.Success, "{target}", target) |
| - s.audit(acct, app, addr, "restart", detail) |
| + targetType := "app" |
| + if host != "" { |
| + targetType = "instance" |
| + } |
| + s.auditActivity(acct, app, addr, Activity{Action: "restart", TargetType: targetType, Target: target, Detail: detail}) |
| s.bumpSerial(ctx) |
| s.setFlash(w, "ok", success) |
| } |
diff --git a/internal/console/deployments.go b/internal/console/deployments.go| index 2acb62939..973747de8 100644 |
| --- a/internal/console/deployments.go |
| +++ b/internal/console/deployments.go |
| @@ -39,7 +39,7 @@ func assembleDeploymentTargets(view *AppView, account string) []deploymentTarget |
| } |
| versions := map[string]map[string]int{} |
| for _, rt := range view.Routes { |
| - if rt.Account != account { |
| + if rt.Account != account || rt.Platform { |
| continue |
| } |
| ch := rt.Channel |
| @@ -51,7 +51,7 @@ func assembleDeploymentTargets(view *AppView, account string) []deploymentTarget |
| } |
| group := groups[ch] |
| group.Total++ |
| - if rt.Version == "" { |
| + if (rt.Kind != "" && rt.Kind != "instance") || rt.Version == "" { |
| group.Unknown++ |
| continue |
| } |
diff --git a/internal/console/deployments_test.go b/internal/console/deployments_test.go| index 42ac77249..5f816b21f 100644 |
| --- a/internal/console/deployments_test.go |
| +++ b/internal/console/deployments_test.go |
| @@ -12,6 +12,9 @@ func TestDeploymentTargetsKeepObservationSeparate(t *testing.T) { |
| view := &AppView{ |
| Channels: []ChannelView{{Name: "edge", Version: "new"}, {Name: "stable", Err: "unavailable"}, {Name: "empty"}}, |
| Routes: []RouteView{ |
| + {Account: "ours", Channel: "edge", Version: "alias", Platform: true}, |
| + {Account: "ours", Channel: "edge", Version: "new", Kind: "static"}, |
| + {Account: "ours", Channel: "edge", Version: "old", Kind: "service"}, |
| {Account: "ours", Channel: "edge", Version: "old"}, |
| {Account: "ours", Channel: "edge", Version: "new"}, |
| {Account: "ours", Channel: "edge", Version: "old"}, |
| @@ -23,7 +26,7 @@ func TestDeploymentTargetsKeepObservationSeparate(t *testing.T) { |
| } |
| want := []deploymentTarget{ |
| {Channel: "canary/custom", Total: 1, Versions: []deploymentObservedVersion{{Version: "custom", Count: 1}}}, |
| - {Channel: "edge", Target: "new", Total: 4, Unknown: 1, Versions: []deploymentObservedVersion{{Version: "new", Count: 1}, {Version: "old", Count: 2}}}, |
| + {Channel: "edge", Target: "new", Total: 6, Unknown: 3, Versions: []deploymentObservedVersion{{Version: "new", Count: 1}, {Version: "old", Count: 2}}}, |
| {Channel: "empty"}, |
| {Channel: "stable", TargetErr: "unavailable", Total: 1, Versions: []deploymentObservedVersion{{Version: "running", Count: 1}}}, |
| } |
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go| index e454687eb..23400b7f8 100644 |
| --- a/internal/console/instancepage.go |
| +++ b/internal/console/instancepage.go |
| @@ -28,7 +28,7 @@ type instanceGroup struct { |
| func ownedInstanceRoutes(routes []RouteView, account string) []RouteView { |
| var out []RouteView |
| for _, rt := range routes { |
| - if rt.Account == account { |
| + if rt.Account == account && !rt.Platform { |
| out = append(out, rt) |
| } |
| } |
| @@ -122,7 +122,7 @@ func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) { |
| } |
| var selected *RouteView |
| for _, rt := range p.Detail.Routes { |
| - if rt.Host == host && rt.Account == acct.Sqid { |
| + if rt.Host == host && rt.Account == acct.Sqid && !rt.Platform { |
| copy := rt |
| selected = © |
| break |
| @@ -136,6 +136,10 @@ func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) { |
| view.Routes = []RouteView{*selected} |
| metrics := s.appResourceMetrics(r.Context(), &view, acct) |
| rows := assembleInstanceRows(&view, acct, metrics) |
| + if len(rows) == 0 { |
| + http.NotFound(w, r) |
| + return |
| + } |
| detail := &instanceDetail{Row: rows[0]} |
| for _, ch := range p.Detail.Channels { |
| if ch.Name == selected.Channel { |
diff --git a/internal/console/instancepage_test.go b/internal/console/instancepage_test.go| index 62b50e354..a3dee4db4 100644 |
| --- a/internal/console/instancepage_test.go |
| +++ b/internal/console/instancepage_test.go |
| @@ -45,6 +45,23 @@ func TestInstanceGroupingPaginatesWithoutLoss(t *testing.T) { |
| } |
| } |
| +func TestInstanceRowsExcludePlatformAliases(t *testing.T) { |
| + routes := []RouteView{ |
| + {Host: "app.example", Account: "ours", Kind: "instance"}, |
| + {Host: "static.example", Account: "ours", Kind: "static"}, |
| + {Host: "alias.example", Account: "ours", Kind: "static", Platform: true}, |
| + {Host: "foreign.example", Account: "theirs", Kind: "instance"}, |
| + } |
| + owned := ownedInstanceRoutes(routes, "ours") |
| + if len(owned) != 2 || owned[0].Host != "app.example" || owned[1].Host != "static.example" { |
| + t.Fatalf("owned instance routes = %+v", owned) |
| + } |
| + rows := assembleInstanceRows(&AppView{Routes: routes}, &Account{Sqid: "ours"}, nil) |
| + if len(rows) != 2 || rows[0].Host != "app.example" || rows[1].Host != "static.example" { |
| + t.Fatalf("instance rows = %+v", rows) |
| + } |
| +} |
| + |
| func TestInstanceChannelFilterAndLinks(t *testing.T) { |
| rows := []InstanceRow{{Host: "a.example.com", Channel: "canary/a+b", HealthWord: "Running"}, {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"}} |
| q := instanceQuery{Channel: "canary/a+b", Group: true} |
| @@ -137,6 +154,17 @@ func TestInstanceDetailOwnsRouteAndAliases(t *testing.T) { |
| } |
| } |
| +func TestInstanceDetailRejectsPlatformAlias(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + fx.addRoute(t, registry.Route{Host: "platform-alias.example.com", App: "kass", Account: fx.sqid, Kind: "static", Platform: true}) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/instances/platform-alias.example.com")) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("platform alias detail = %d %q", status, body) |
| + } |
| +} |
| + |
| func TestInstanceListLinksToDetailAndKeepsHealthShort(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
diff --git a/internal/console/overview.go b/internal/console/overview.go| index d5ba9e4f3..108537bfa 100644 |
| --- a/internal/console/overview.go |
| +++ b/internal/console/overview.go |
| @@ -201,7 +201,6 @@ type InstanceRow struct { |
| // plain word — the column's whole job. |
| HealthWord string |
| Restartable bool |
| - RestartURL string |
| } |
| // assembleInstanceRows joins the app's routes with the sweep's readings, in |
| @@ -215,7 +214,7 @@ func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetric |
| } |
| rows := make([]InstanceRow, 0, len(view.Routes)) |
| for _, rt := range view.Routes { |
| - if rt.Account != acct.Sqid { |
| + if rt.Account != acct.Sqid || rt.Platform { |
| continue |
| } |
| status := rt.Status |
diff --git a/internal/console/server.go b/internal/console/server.go| index 1b91ff9ed..e7b54de51 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -2883,11 +2883,6 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| } |
| pg := pageInstanceRows(rows, q, instancePageSize) |
| p.InstanceRows = pg.Rows |
| - for i := range p.InstanceRows { |
| - if p.InstanceRows[i].Restartable { |
| - p.InstanceRows[i].RestartURL = p.Base + "/apps/" + url.PathEscape(p.AppName) + "/instances/" + url.PathEscape(p.InstanceRows[i].Host) + "/restart" |
| - } |
| - } |
| p.InstancePage = pg |
| p.InstanceQuery = q |
| p.InstanceChannels = instanceChannels(rows) |
| @@ -3139,7 +3134,7 @@ func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| // plain text; an activity link must never cross the membership boundary. |
| instances := make(map[string]bool) |
| for _, route := range p.Detail.Routes { |
| - if route.Account == acct.Sqid { |
| + if route.Account == acct.Sqid && !route.Platform { |
| instances[route.Host] = true |
| } |
| } |
| @@ -3151,7 +3146,7 @@ func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/deployments" |
| } |
| if events[i].TargetType == "instance" && instances[events[i].Target] { |
| - events[i].TargetURL = p.Base + "/apps/" + url.PathEscape(p.AppName) + |
| + events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + |
| "/instances/" + url.PathEscape(events[i].Target) |
| } |
| } |
diff --git a/internal/console/templates/app_deployments.html b/internal/console/templates/app_deployments.html| index d86613886..61cfebdfd 100644 |
| --- a/internal/console/templates/app_deployments.html |
| +++ b/internal/console/templates/app_deployments.html |
| @@ -2,6 +2,7 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| +{{template "accountreviewbanner" .}} |
| <h2>{{.DeploymentsCopy.Title}}</h2> |
| <nav class="tabs" aria-label="{{.DeploymentsCopy.Title}}"> |
| <a href="{{.Nav}}/apps/{{.AppName}}/deployments" aria-current="page">{{.DeploymentsCopy.Title}}</a> |
diff --git a/internal/console/templates/app_logs.html b/internal/console/templates/app_logs.html| index 903a27789..fdfe6ed66 100644 |
| --- a/internal/console/templates/app_logs.html |
| +++ b/internal/console/templates/app_logs.html |
| @@ -135,7 +135,11 @@ site served from files alone has no app output.</p> |
| var q = new URLSearchParams(activeQuery); |
| var hadCursor = Boolean(cursor); |
| q.set("limit", "200"); |
| - if (cursor) q.set("cursor", cursor); |
| + if (cursor) { |
| + q.set("cursor", cursor); |
| + q.delete("since"); |
| + q.delete("until"); |
| + } |
| request = new AbortController(); |
| timedOut = false; |
| var thisRequest = request; |