Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0183a4b
docs(fleet): spec one app across every cluster, and the matrix cell t…
dynaum Aug 3, 2026
7ec1eea
docs(fleet): implementation plan, fourteen tasks
dynaum Aug 3, 2026
43f1ad7
refactor(promotion): export tag ordering and app identity for reuse
dynaum Aug 3, 2026
9a5394b
feat(promotion): a multi-context environment stops collapsing a disag…
dynaum Aug 3, 2026
3dcade7
fix(promotion): absent/denied stop counting as drift, split priority …
dynaum Aug 3, 2026
1a5fbfa
docs(fleet): correct the plan where review proved it wrong
dynaum Aug 3, 2026
1b9535d
fix(promotion): unread cluster never collapses regardless of instance…
dynaum Aug 3, 2026
ad2fc6f
fix(promotion): read every context in an environment, not the first
dynaum Aug 3, 2026
272215b
docs(fleet): a refused read is denied, not absent
dynaum Aug 3, 2026
af66813
docs(fleet): correct the all-denied claim in the spec
dynaum Aug 3, 2026
903b55a
fix(promotion): unreachable banner only for fully-unread environments
dynaum Aug 3, 2026
aa2e5e6
fix(promotion): an unread environment renders denied, not absent
dynaum Aug 3, 2026
b34bf24
fix(promotion): pin drift/split behavior, fix cluster count, trim note
dynaum Aug 3, 2026
24ada32
feat(fleet): derive which clusters run the newest version
dynaum Aug 3, 2026
dc29396
docs(fleet): the plan promised seven tests and listed six
dynaum Aug 3, 2026
825b9e8
fix(fleet): an unorderable tag must not become the yardstick
dynaum Aug 3, 2026
24e62d3
feat(fleet): gather one app from every context
dynaum Aug 3, 2026
8521e9a
fix(fleet): absent is not denied, and one timeout per cluster
dynaum Aug 3, 2026
1d17a1a
docs(fleet): the plan's gatherer traded one conflation for its mirror
dynaum Aug 3, 2026
b698d76
feat(fleet): serve the fleet view over HTTP
dynaum Aug 3, 2026
d902bcc
feat(fleet): screen 7, one app across every cluster
dynaum Aug 4, 2026
4484d75
fix(fleet): the fleet screen called prod clusters unclassified
dynaum Aug 4, 2026
b38d244
feat(promotion): render the split cell and link it to fleet
dynaum Aug 4, 2026
bbff0b9
feat(fleet): reach the screen from the palette and app detail
dynaum Aug 4, 2026
cfe6188
test(fleet): degraded mode, extracted UI logic, and a screenshot base…
dynaum Aug 4, 2026
6d4142e
docs(fleet): record what shipped, and correct two claims that did not
dynaum Aug 4, 2026
8c0c93e
docs: answer the Argo CD question on the page, not in a comment thread
dynaum Aug 4, 2026
4d5ceaa
docs: record why there is no TUI
dynaum Aug 4, 2026
23d8619
docs: the last claim about a cluster UID kubeside never reads
dynaum Aug 4, 2026
5a5e1c2
docs: three claims the fleet branch got wrong about itself
dynaum Aug 4, 2026
03a1102
fix(promotion): an unread cluster is an unknown, not a disagreement
dynaum Aug 17, 2026
5aa1395
fix(api): update the one Promotion test #76 changed the outcome of
dynaum Aug 17, 2026
dfae3b9
fix(clusters): serialize concurrent Connect calls for one context
dynaum Aug 17, 2026
f0f51f9
fix(api): Promotion stops reading a refusal or a stale session as absent
dynaum Aug 17, 2026
dfa6a5c
test(e2e): fleet's row order and the split cell were never actually c…
dynaum Aug 17, 2026
1114616
test(web): a component test harness, so decision logic in a .tsx is r…
dynaum Aug 17, 2026
c86e1e3
test(e2e): the missing Linux screenshot baselines
dynaum Aug 17, 2026
38c4928
docs: add a changelog
dynaum Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ web/playwright-report/
.DS_Store
.env
.env.local
.superpowers/
web/tsconfig.tsbuildinfo
HANDOFF.md

Expand Down
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Changelog

All notable changes to kubeside are recorded here, in the style of
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

Release notes on GitHub are generated from commit history by GoReleaser
(`changelog: use: github` in `.goreleaser.yaml`) and remain the record of
what shipped in each tagged version. This file exists for the same
information in one place across releases, kept in plain language rather than
raw commit subjects.

## [Unreleased]

### Added
- Fleet screen (Screen 7): one app, one row per cluster, answering whether
every cluster is running the newest version. Reachable from the command
palette, the app detail screen, and a split promotion cell.
- Five distinct fleet states — present, absent, denied, unreachable,
pending — so a cluster that refused a read, one that never answered, and
one that genuinely does not run the app never render the same way.
- A denied fleet row names the RBAC rule that would fix it, not just that
access was refused.
- A component test harness (jsdom, `@testing-library/react`) for decision
logic that lives inside a `.tsx` file, where a plain unit test cannot
reach it.

### Fixed
- The promotion matrix silently discarded every context after the first in
a multi-context environment and reported one cluster's version as the
whole environment's. Every bound context is now read, and a genuine
disagreement renders as a distinct split state instead of collapsing to
a guess.
- An environment nobody could read rendered "not deployed here" instead of
naming that it was unreadable, in both the promotion matrix and, by a
related fix, when only some of its clusters answered.
- An unread cluster that produced no disagreement counted as a severe
drift the same way a genuine version disagreement did; it now renders
as an unknown, not a defect, and no longer inflates drift or the split
count.
- A refused RBAC read (an app-carrying resource kind the reader could not
list) was indistinguishable from the app simply not existing there, in
both the promotion matrix and the fleet screen.
- A context that connected once and later failed to reconnect could be
read as fully readable off a stale session; `Service.Promotion` now
checks the connection error directly rather than trusting the client
handle alone.
- Two concurrent connection attempts for the same kubeconfig context could
both dial, racing to overwrite each other's result; connections for one
context now serialize.
- The fleet screen classified environments by matching literal `qa`/`stg`/
`prod` strings, so any real cluster name (`prod-us-east`, `production`,
`staging-eks`, ...) rendered as an unclassified, unstyled row instead of
its actual risk color.

### Documentation
- Corrected multi-cluster docs that described per-context aggregation and a
cluster-UID dedupe mechanism that were never implemented; documented what
actually shipped instead.
- Added Argo CD to the tool comparison in the problem statement.
- Recorded why kubeside has no terminal UI.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ already enough of.
## Status

Released. The app list, whole-workload logs, the reconstructed timeline,
resolved configuration, cross-environment diff, the promotion matrix,
resolved configuration, cross-environment diff, the promotion matrix, fleet,
port-forward, the command palette, prod guardrails, and exec are built, tested,
and driven against real clusters.

Expand All @@ -183,7 +183,8 @@ The full site is at
[keyboard](docs/guide/09-keyboard.md) ·
[the config file](docs/guide/10-config-file.md) ·
[permissions](docs/guide/11-permissions.md) ·
[when things are missing](docs/guide/12-troubleshooting.md)
[when things are missing](docs/guide/12-troubleshooting.md) ·
[fleet, one app across every cluster](docs/guide/13-fleet.md)

**Design notes** — [the problem](docs/01-problem.md) ·
[personas](docs/02-personas.md) ·
Expand Down
14 changes: 14 additions & 0 deletions docs/01-problem.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,29 @@ Assume Radar gets topology right. The kubeside wedge is narrower and different:
the developer's four questions, resolved configuration, whole-workload logs, and
the same application compared across environments.

Argo CD came up first on the [Show HN thread](https://news.ycombinator.com/item?id=49139573)
and it is a different kind of tool, not a missed competitor. It runs 1:1 against
one cluster and shows sync state against git: is the live state what the repo
says it should be. That is a delivery question, and its reader is the person who
owns the pipeline. kubeside shows the app a developer owns across every
environment it runs in, qa, stg, and prod side by side, which is a different
question asked by a different reader. The two coexist rather than compete:
kubeside already reads the `argocd.argoproj.io/instance` label as a grouping
signal, documented in [03-product-spec.md](03-product-spec.md).

## Reference points

| Tool | Stars | Open issues | License | Note |
| --- | --- | --- | --- | --- |
| k9s | 34,182 | 135 | Apache-2.0 | Terminal, single maintainer |
| Argo CD | 23,835 | 4,318 | Apache-2.0 | GitOps delivery, 1:1 with the cluster, not a comparison |
| kubernetes/dashboard | 15,427 | 166 | Apache-2.0 | Archived 2026-01-21 |
| Headlamp | 6,920 | 834 | Apache-2.0 | SIG UI successor, auth-heavy backlog |
| Freelens | 5,311 | 190 | MIT | Electron, Lens lineage, metrics bugs |
| Kite | 2,937 | 39 | Apache-2.0 | Web, platform positioning |
| Radar | 2,645 | 50 | Apache-2.0 | Topology and timeline, direct competitor |
| KHI | 2,071 | 21 | Apache-2.0 | Google, log timeline only |

Argo CD's row was read 2026-08-04, after the rest of this research; every other
row still reflects 2026-07-22, as stated at the top of this document.
</content>
37 changes: 35 additions & 2 deletions docs/03-product-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ One sentence for the README, the launch post, and every scope argument:
6. Teach kubectl, do not replace it. Every view exposes the equivalent command.
7. Trust the numbers. A metric that could be wrong is not displayed.

## The four screens
## The screens

Nothing else ships in v1.
Nine views ship in v1. Four get a full design here, since their content
belongs to one app in one place: the app list, app detail, resolved
configuration, and logs. The rest live where their content forces them to:
promotion, fleet, and the cross-environment config diff need every
environment already modeled, so [04-multi-cluster.md](04-multi-cluster.md)
specifies them; the command palette is under Cross-cutting below, and exec is
specified with the actions it belongs to.

Nothing beyond these nine ships in v1.

### Screen 1: Apps

Expand Down Expand Up @@ -187,6 +195,31 @@ Every view exposes "show kubectl", printing the equivalent command.
Environment switching and comparison are covered in
[04-multi-cluster.md](04-multi-cluster.md).

Screen numbering, for cross-reference: 1 the app list, 2 app detail, 3
resolved configuration, 3b the cross-environment config diff, 4 logs, 5
promotion, 6 this palette, 7 fleet, and exec, which carries no number because
it is an action surface rather than a view.

### Screen 7: Fleet

One app, one row per cluster, answering a question promotion cannot phrase:
is every cluster running the latest version. Promotion compares environments
side by side; a team running prod across two regions asks about the clusters
inside one environment, or about clusters that sit outside the environment
axis entirely. One row per cluster instead of one column per environment.

Fleet declares its own five states: present, absent, denied, unreachable, and
pending. It does not share promotion's, which are comparisons between columns
rather than facts about one cluster. Only `absent` and `denied` are spelled the
same in both, and they do not mean the same thing: promotion has no
`unreachable`, so a cluster that never answered reads as denied there and as
unreachable here. What fleet does reuse from promotion is the machinery,
`CompareTags`, `Identity`, and `Orderable`, so one app is one app and one
version ordering governs both screens.

Opened from a split promotion cell or the app detail screen.
Guide: `docs/guide/13-fleet.md`.

## Explicit non-goals for v1

No node view. No PersistentVolume or StorageClass browsing. No RBAC editor. No
Expand Down
14 changes: 9 additions & 5 deletions docs/04-multi-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ Three concepts, deliberately separate.
| App | A logical service, present in zero or more environments. Identity is namespace plus name within a cluster, matched across environments by the rules below. |

One environment maps to one or more contexts. A team running prod across two
regions gets one `prod` environment holding `prod-us-east` and `prod-eu-west`,
and the promotion view aggregates them with per-context detail on expansion.
regions gets one `prod` environment holding `prod-us-east` and `prod-eu-west`.
The promotion cell for that environment collapses to one version only when
every context inside it agrees on tag and digest. When they disagree, the cell
renders `split` and names the disagreement instead of guessing at a version.
Per-context detail lives on its own screen, fleet: one app, one row per
cluster, opened from the split cell or from the app detail screen.

### An environment is not always a context

Expand Down Expand Up @@ -328,10 +332,10 @@ cluster behind a VPN never blocks the rest of the grid.
| --- | --- |
| Prod reachable only over VPN, currently off | Two states, never conflated. Connected earlier this session: in-memory snapshot with its age. Never connected this session: "nothing known yet" with a reconnect action. Other environments unaffected either way. |
| Credential plugin prompts for SSO in a browser | Inline prompt on the panel. No modal blocking the whole app. |
| Two contexts point at the same cluster | Detected by API server URL and cluster UID, merged with a notice. |
| Context renamed in kubeconfig | Match on cluster UID first, name second, so history survives a rename. |
| Two contexts point at the same cluster | Detected by API server URL alone (`config.NormalizeURL`), merged with a notice. No cluster UID exists anywhere in kubeside: reading one needs `get` on a `kube-system` namespace object, a permission kubeside does not request and a namespace-scoped developer would be refused for asking. |
| Context renamed in kubeconfig | Environment classification matches on API server URL first, context name second (`Config.Environment`), so a rename keeps its environment and guardrails. There is no stored history for a rename to survive: kubeside writes nothing to disk, and the timeline is reconstructed from the live cluster on every read. |
| Same app name, unrelated services in two environments | Config `apps.match` overrides. A mismatch warning appears when image repositories differ entirely. |
| An environment has 40 contexts | Environment panel paginates and the promotion view aggregates, with per-context detail on expansion. |
| An environment has 40 contexts | Environment panel paginates. The promotion cell for it collapses only if all 40 agree; otherwise it splits. Fleet lists all 40 with their own state. |

## Resolved questions

Expand Down
13 changes: 11 additions & 2 deletions docs/05-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,17 @@ web/ React frontend, embedded via embed.FS
permission cache, and circuit breaker. One goroutine per connection. A dead
cluster never blocks a request for another.

`contextID` derives from cluster UID first and kubeconfig name second, so a
context rename in kubeconfig preserves stored history.
`contextID` is the kubeconfig context name. Two contexts aimed at one cluster
are recognized by API server URL, normalized by `config.NormalizeURL`, which is
what the fleet view merges on.

An earlier draft said this derived from a cluster UID, and that a rename
"preserves stored history". Both were wrong. No cluster UID is read anywhere:
obtaining one means reading the `kube-system` namespace UID, a permission
kubeside does not request and a namespace-scoped developer would be refused. And
there is no stored history to preserve, which is the point of
[04-multi-cluster.md](04-multi-cluster.md)'s decision to write nothing to disk.
A rename costs the session's in-memory buffer, and nothing else.

Watch tiers, per [04-multi-cluster.md](04-multi-cluster.md):

Expand Down
24 changes: 24 additions & 0 deletions docs/guide/12-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,27 @@ manages your RBAC. See [permissions](11-permissions.html).
Look for the edge. `ended` means the container exited, `gone` means the pod was
deleted, `error` carries the reason. A log view that simply went quiet would be
the failure worth worrying about.

## Is there a TUI, like k9s?

No, and this is a decision, not an oversight. Asked on the same
[Show HN thread](https://news.ycombinator.com/item?id=49139573) that raised the
Argo CD question.

The four questions kubeside answers are layout problems before they are
anything else. A timeline needs a horizontal axis with markers on it. A
side-by-side config diff needs two columns a reader can scan against each
other. A promotion matrix needs a grid with color and drift indicators. A
monospace grid renders none of them well, and forcing them into one would
mean designing worse versions of screens that already work in a browser.

k9s also already owns the terminal, and it does that job well. Competing with
it on keyboard speed would be a fight against a tool people already like,
over ground kubeside does not need.

If your kubeconfig is not local, for example a shared cluster with no kubectl
access from your machine, `--serve` mode is the answer. It runs the same
binary and the same screens against the cluster directly, over OIDC. It is
listed under Beyond v1 in
[the roadmap](https://github.com/dynaum/kubeside/blob/main/docs/06-roadmap.md)
and has not shipped yet.
84 changes: 84 additions & 0 deletions docs/guide/13-fleet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Fleet

Is every cluster running the latest version? One app, one row per cluster.

Promotion compares environments side by side. Fleet answers a question
promotion cannot phrase: a team running prod across two regions, or any
environment holding more than one cluster, needs to know whether those
clusters agree with each other, not only whether prod agrees with stg.

## Opening it

Four ways in:

- The command palette (`cmd+k`), from any app: "*app* across every cluster".
- The "Fleet" button on the app detail screen.
- Directly, at `#fleet/<app>/<namespace>`.
- From the promotion screen, click a `split` cell. A split cell means the
clusters inside that environment disagree, and fleet is where you see which
ones.

Opening this screen wakes every context in your kubeconfig, not only the one
you were looking at. The app list and app detail connect lazily, one context at
a time, as you look at them. Fleet asks all of them at once, on open, because
"every cluster" is the question it exists to answer.

Promotion also connects every context, so fleet is not alone in that. Two
things make fleet heavier. It connects them concurrently rather than one after
another, and it reads pod status on every cluster it reaches, which promotion
does only for the apps you are comparing. Pod status is where the image digest
lives, and the digest is what catches one tag running as two different builds.

On a kubeconfig with forty contexts, that is forty connections at once, each
possibly running a credential plugin. Worth knowing before you bind the palette
command to muscle memory.

## Reading the table

| State | Means |
| --- | --- |
| `present` | The cluster runs the app. |
| `absent` | The cluster answered, and does not run it. A schedule, not a problem: the app just is not deployed there yet. |
| `denied` | The cluster refused the read. The note names the RBAC rule that would fix it, for example "cannot read Deployment here; the role needs: list deployments in team-a". |
| `unreachable` | The cluster did not answer at all. VPN, DNS, or the cluster is down. |
| `pending` | Still asking. |

The distinction that matters most is `unreachable` against `absent`. A prod
cluster reachable only over VPN, currently off, is `unreachable`: kubeside
never saw it this time, so it never claims the app is missing there. Only a
cluster that actually answered and does not run the app gets `absent`.
Conflating the two, "I could not look" read as "it is not there", is the
mistake this screen exists to prevent, and it is the reason opening fleet
wakes every context instead of trusting whatever was last connected.

A present row can also carry:

- **behind** — an older version than the newest tag another cluster in the
fleet runs.
- **mutable tag** — this cluster's tag also resolves to a different digest
somewhere else in the fleet. Two clusters agree on the version number and
are not running the same code. This outranks behind: a cluster openly a
version behind is a schedule, two clusters claiming agreement while running
different builds is a defect.

Rows sort by how much the cluster told you, worst first: mutable tag, then
unreachable, then behind, then denied, then present with no comparable
version, then pending. Absent sits with the healthy rows at the bottom,
because "not deployed here" is not a disagreement.

## Version unknown

An image pinned by digest (`myapp@sha256:...`) carries no tag. There is
nothing to compare it against, so the row reads "version unknown: the image
is pinned by digest" rather than folding it into the newest tag or treating
it as a match. Pinning by digest is not a defect; reading it as agreement
would be.

## Clusters that are really one cluster

A kubeconfig commonly holds one cluster under two context names, often
because one of them carries credentials that stopped working. Fleet detects
this by API server URL and merges the two into one row, so the same cluster
is never counted twice. The row names what it merged: "also
prod-us-east-arn" beside the context it kept. The row it keeps is whichever
context answered with the most, so a stale duplicate never hides a live one.
Loading
Loading