The dashboard¶
upgradescope serve embeds a web dashboard and serves it at /. It reads
the same API as everything else, so it shows what the server has stored:
the fleet score matrix, a per-team rollup, one cluster's findings for any
target, and the add-on registry. It needs no separate deployment, and it
needs the server (serve, or the chart with server.enabled=true): the
agent alone does not serve it
(Fleet server).
Screens and routes¶
The dashboard routes by the URL hash, so every view has an address you can paste into a ticket or a chat. The server needs no route of its own for them.
| Screen | Route | What it shows |
|---|---|---|
| Fleet | #/ |
A clusters by targets matrix of readiness scores, with a summary strip, search, filters and a sort |
| Teams | #/teams[?target=1.38] |
Each team's worst score, verdict and blockers across the fleet, for one target |
| Cluster | #/cluster/{id}[?target=1.38] |
One cluster's verdict, score, trend, findings and per-team table for a target |
| Registry | #/registry |
The add-on EOL and compatibility dataset this server was built with |
Fleet¶
Each cell is a stored evaluation: the score, the verdict as a mark and as
text, and the number of blockers. A cell opens the cluster at that target.
An empty cell (—) has no stored evaluation; it opens a what-if, which the
server computes on request and does not store. n/a means the cluster
already runs that target. A cluster whose agent has stopped pushing carries
a stale badge and its last-seen time: its scores describe the cluster as it
was then. A finding whose objects carry an upgradescope.basit.engineer/ignore
annotation with its reason is accepted, as scan, the agent and /gate accept
it: it counts toward neither the cell's score, its verdict nor its blockers.
The agent's spec.ignore rules never reach the server and are not applied
(Where annotations apply).
The summary strip above the matrix counts the clusters for one target (the selector lists only the targets the matrix shows; it starts on the one most clusters can still upgrade to). Every cluster is in exactly one bucket, so the buckets add up to the number of clusters you can see:
| Bucket | A cluster is in it when |
|---|---|
| Ready | its stored verdict for the target is ready |
| Blocked | its stored verdict is blocked |
| Unknown | its stored verdict is unknown: nothing blocked, but a required check did not run. Never counted as ready |
| Stale | its agent has stopped pushing, whatever the old verdict was |
| n/a | it already runs the target or newer |
| No stored evaluation | none is stored for the target (a what-if would compute it) |
When a cluster fits more than one description, the first row it fits in this order wins: n/a, stale, no stored evaluation, then the verdict. With a team-scoped token the server returns only that scope's clusters, so the counts are for them.
Click a bucket to filter the matrix to it; click it again to clear. The toolbar has a name search (a case-insensitive substring), a quick filter (Blocked, Unknown, Stale, Has blockers, and the other buckets) and a sort: worst score first (the default), name, or last seen with the oldest first. It says "N of M clusters". When nothing matches there is a clear-filters button. The header row and the cluster column stay in view while you scroll; at most 100 rows are drawn, and Show more adds 100.
A filter looks at the targets the matrix shows, never at a target the server left out of its columns (the note under the heading says how many). Once you click a bucket, or pick a target in the summary, the target is pinned and the filter and the worst-score sort look at that target alone; until then they look at every shown target.
The toolbar and the summary's target live in the hash, so a reload or a pasted link restores them:
| Parameter | Values |
|---|---|
q |
text matched against cluster names |
filter |
blocked, unknown, stale, has-blockers, ready, na, missing |
sort |
score (default), name, seen |
target |
a target the matrix shows, such as 1.37 |
#/?q=prod&filter=blocked&sort=name&target=1.37
A value the dashboard does not know is ignored.
Cluster¶
The page shows the verdict and score for a target (the cluster's next minor
unless target is given; any minor works, and one with no stored
evaluation comes back as a what-if), the trend, and the findings grouped by
severity. The findings filters are in the hash next to the target, and a
single finding can be linked:
| Parameter | Values |
|---|---|
target |
a minor version such as 1.37 |
category |
a finding category, such as removed-api |
severity |
blocker, warning or info |
team |
a team name, or (unattributed) for findings no team owns |
q |
text matched against a finding's title, detail, namespaces and team chips |
finding |
a finding's key: scrolls to it and highlights it |
#/cluster/3?target=1.37&category=removed-api&q=ingress
#/cluster/3?target=1.37&finding=eol-addon%2Fingress-nginx
Each finding has a # link whose address is that finding: the target and
finding=<key>, with the key URL-encoded. Because routing uses the hash, the
link is a query parameter that scrolls to and highlights the finding, not an
element anchor. Picking another target drops the
filters, because a category that exists for one target may not for the next.
The line under the verdict counts the findings by severity and ends in
"N suppressed" when object annotations accepted N findings, or objects of
findings. What they accept is left out of the findings list, the score and
the verdict (a finding only some of whose objects are accepted stays, with
the others); GET /api/v1/clusters/{id}/report lists them in suppressed with
each reason, and the auditor exports list them too.
Fresh data¶
Each view loads once when you open it and does not poll. The page head has an "updated HH:MM:SS" stamp and a Refresh button. A tab that becomes visible again refetches when what it shows is more than 60 seconds old. While a refetch runs, the old data stays on screen with a small spinner; a failed refresh keeps it and says so next to the button.
Reading with a token¶
When the server asks for a read token (serve --read-token, or a minted
one), the page shows an Unauthorized message until you set it with the
set token button in the header. The token is sent as a bearer header on
every API call. It is kept in sessionStorage, so it is gone when the tab
closes, unless you tick Remember on this device, which keeps it in this
browser's localStorage instead. It is never in both. Saving it with the box
unticked, or clearing it, removes the remembered copy.
Read access has the kinds of read token, and Tenancy and access control says what each credential may do. Neither protects the dashboard's static files, which hold no data. To put the dashboard behind single sign-on, see Putting the dashboard behind SSO.
The team-scoped banner¶
A read token minted for teams (tokens create --read --teams payments)
reads only those teams' clusters, findings and scores. The server filters
the data; the dashboard shows a banner under the header that names the
teams ("Showing team payments only: this view does not include other
teams' clusters, findings or scores") so a short list is not mistaken for a
small fleet. A cluster outside the scope answers 404, and the error says it
may belong to another team.
Serving under a path prefix¶
The dashboard works behind a reverse proxy at a path prefix, for example
https://ops.example.com/upgradescope/, when three things hold:
- Open the URL with the trailing slash. Asset and API URLs are
relative. Without the slash the browser resolves them against the parent
path (
/assets/...instead of/upgradescope/assets/...), and the page comes up blank with its assets answering 404. - The proxy strips the prefix before it forwards to
serve. The server knows nothing of the prefix: it sees/,/assets/...and/api/.... - The proxy's Host is allowed.
serveanswers an unknown Host with421; the name the proxy forwards must be in--allowed-host(the chart does this forserver.ingress.host; see The Host check).
Because the URLs are relative, the server serves the page itself only where
they resolve: at /, /index.html and one extensionless segment such as
/teams. The dashboard routes by hash (#/cluster/3), so it has no other
path of its own. At a nested path such as /cluster/3, /cluster/3/ or
/foo/ (a link someone typed or pasted) the server answers 302 with a
relative Location (../#/cluster/3), which the browser resolves against
the URL it asked for, so the dashboard opens at the root of the prefix on
that route. The redirect is written by hand and relative on purpose: an
absolute one would drop the proxy's prefix. A path with a file extension that
is not a file, and anything under /assets/, is a JSON 404.
An nginx location that does this (the trailing slash on proxy_pass is what
strips the prefix):
location = /upgradescope { return 301 /upgradescope/; }
location /upgradescope/ {
proxy_pass http://upgradescope-server.upgradescope.svc:8080/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# Pass Location headers through untouched: they are relative.
proxy_redirect off;
}
The same with Kubernetes Ingress objects, for a controller that supports the
ingress-nginx rewrite annotation (ingress-nginx itself reached end of life in
March 2026; on another controller use its own strip-prefix feature, for
example a Traefik StripPrefix middleware):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: upgradescope
namespace: upgradescope
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- host: ops.example.com
http:
paths:
- path: /upgradescope(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: upgradescope-server
port:
number: 8080
These snippets are examples; they are not run in CI. What the server does behind a prefix-stripping proxy (the redirects, and every asset and API URL resolving on the same prefix) is tested; see DB-05 in the claims ledger.
If the dashboard is blank or its assets answer 404, see Dashboard blank or assets 404 behind a proxy.