Recent updates dialogue #89
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
update_checker.gofrom MeshGo/api/v1/infoOpen questions:
Concrete shape for this — the body is well-scoped (4 bullets + 3 open trigger-placement questions), so the work is mostly picking a small, useful slice and answering the trigger question.
Two phases, two PRs
update_checker.gopattern frommeshgo/internal/app/into a similarinternal/updates/package; cache perupdate_check_period; expose viaGET /api/v1/info(or a newupdateskey)I'd suggest landing phase 1 first, even with no UI, because:
git.skobk.in/api/v1/repos/.../releases?limit=5on every page load)./api/v1/infoshape is the wire contract for any future consumer — even a future CLI / health-check probe will want it.The port — what to copy from
meshgo/internal/app/update_checker.goUpdateCheckerstruct holding endpoint URL, interval, current version,*http.Clientgit.skobk.in/api/v1/repos/skobkin/meshmap-lite/releases?draft=false&pre-release=false&limit=5Start(ctx)goroutine that ticks onintervalLastResult()returns the cachedUpdateSnapshot/api/v1/infoor/api/v1/updatesweb.map.topology_cache_ttlper #78 — keep naming consistent)The meshgo version is small (single file, no deps beyond
net/http+encoding/json). A direct port should be ~150 LoC.Trigger placement (the three open questions, with one answer each)
The three options you listed are all real, but they boil down to "where's the affordance on mobile vs. desktop". A small variant usually beats a new dedicated control:
That keeps the trigger placement decision out of the new feature and reuses an existing UX hook. Concrete shape:
?ori) → make it a small dropdown / popover with two entries: "Site Information" and "Recent updates".The cache invalidation question
Meshgo's update_checker caches for
interval(12h by default). For meshmap-lite I'd suggest the same, with one tweak: also invalidate when the user opens the dialog. The user opening the dialog is a strong signal they care about the current state, and a refresh-on-open costs one HTTP call. Implementation:GET /api/v1/updates?force=trueskips the cache. The frontend hits the forced endpoint on dialog open; the regular polling tick still hits the cached endpoint.What to do about the "version comparison" edge case
If the user is on a build that doesn't match any release tag (e.g.
masterbetween releases, or a fork),current_versionis unknown and the comparison logic shouldn't claim "you're up to date" or "an update is available" — it should just say "current version unknown" and link to the releases page. One extra branch in the comparison; ~10 LoC.Smallest first PR
internal/updates/updates.gowith the ported checker. Register anUpdateSnapshotendpoint on the existing router (probablyinternal/api/http/router.go).config.example.yamlwithweb.updates.endpoint,web.updates.interval, andweb.updates.cache_ttlkeys. Document them inopenapi.yaml.httptestfor the cache (fresh entry within TTL is served from cache; expired entry re-fetches; forced entry always re-fetches).One open question to settle before phase 1 lands
Should the same
UpdateSnapshotalso report meshmap-lite's own server version (the running build's commit SHA or build date), so the dialog can show "you're running commitabc1234, latest isdef5678"? Meshgo does this. If yes, the endpoint needs access to aversionvariable populated at build time via-ldflags "-X main.version=$(git rev-parse --short HEAD)". If no, the "current version" is always blank for self-hosters. I'd say yes — it's two lines of build config and makes the dialog actually useful for self-hosters.Implementation plan
Context
End users of a self-hosted
meshmap-liteinstance have no way to learn what changed in the app since they last used it. They aren't the operator, so "a new version is out" isn't directly actionable — but they're often curious about new features and want to discover release notes when they feel like it. The goal is a subtle, opt-in "what's new" surface that mirrors the existing Site Information dialog's role and re-uses its component, but is never auto-pushed onto the user: the dialog opens only on explicit click, and the only signal of new content is a small number badge on the existing "i" button.The backend is being designed from day one as a generic multi-source release-checker: the same component will later power Meshtastic firmware update notifications (a separate source on GitHub), so the design introduces a
Sourceinterface and per-platform implementations. The cache is centralised at the manager level (one snapshot per registered source) so we hit upstream APIs at most once per interval regardless of how many users are connected.Approach
Backend (Go) — new
internal/updatecheck/packageFile layout (per-platform sources are isolated sub-packages for context management):
Types (
types.go):ReleaseInfo{Version, Body, HTMLURL, PublishedAt}— single release from any platform.UpdateSnapshot{CurrentVersion string, Latest ReleaseInfo, Releases []ReleaseInfo, UpdateAvailable bool, CheckedAt time.Time, SourceHash string}— what the cache stores and what/api/v1/updatesreturns.SourceSpec— config-side description of a registered source (name, current version source, label, etc.).Interface (
source.go):The interface is intentionally tiny: it returns releases plus a couple of display facts the frontend needs. Current-version comparison, hashing, and
UpdateAvailablecomputation are the Manager's responsibility (so a source stays a pure data fetcher with no opinion about "what counts as newer"). Failed fetches don't poison the cache — only successful fetches overwrite the snapshot.ReleasesPageURLis built by the platform adapter frombase_url+repository(with a sensible default for GitHub), so the frontend never has to know which platform a source came from.Cache (
cache.go): own file, holds:The Manager is the only writer; HTTP handlers are readers. Snapshot is held in memory only — no SQLite persistence, no restart durability. This is exactly what the issue means by "cache its results for the update check period" and matches the meshgo reference.
Manager (
manager.go):Manager{sources map[string]registeredSource, cache *Cache, interval time.Duration, httpClient *http.Client, logger *slog.Logger}.registeredSource{Source, CurrentVersion string}— current version can be empty when the platform has no concept of "what's installed" (e.g. Meshtastic firmware, where the user is just informed of latest).Register(name string, src Source, currentVersion string) error— called at startup.Start(ctx context.Context) error— spawns one goroutine per source. Each goroutine does an immediate fetch, then ticks oninterval. On each successful fetch: computeSourceHash(SHA-256 ofLatest.Version || "\n" || Latest.PublishedAt), computeUpdateAvailable(semver compare if both versions are valid; "current is dev" → alwaystrue; "current is empty/empty-target" → omitUpdateAvailable), callcache.Set(name, snap).Snapshot(name string) (UpdateSnapshot, bool)— read-through accessor for the HTTP handler.Names() []string— for/api/v1/metato advertise available sources.Per-platform implementations:
sources/forgejo/forgejo.go— port ofmeshgo/internal/app/update_checker.go(the sameforgejoReleaseJSON DTO,Accept: application/json,?limit=from config, semver compare viagolang.org/x/mod/semver). The adapter constructs the URL frombase_url + /api/v1/repos/<repository>/releases?draft=false&pre-release=false&limit=<limit>and exposesReleasesPageURLasbase_url + /<repository>/releases.sources/github/github.go— stub package for MVP. Single file with theSourceinterface implemented butFetchReleasesreturnserrors.New("github source not implemented"). The package compiles, the registration site is reachable, and the Meshtastic firmware follow-up can flesh it out without touching the manager.ReleasesPageURLfor GitHub ishttps://github.com/<repository>/releases(regardless ofbase_url, which is only used for the API endpoint).Config (
internal/config/types.go+defaults.go+validate.go)The platform adapter (forgejo, github, …) owns the URL shape. Users only set
base_url+repository(+limit); the adapter composes the API endpoint at runtime.base_urlis required forforgejo; forgithubit defaults tohttps://api.github.com.current_version_sourcevalues:buildinfo(useinternal/buildinfo.Version),none(no comparison, the snapshot just shows the latest), or a literal string. Defaults:enabled=true,interval=12h,timeout=15s,limit=15.Default registration: when
update_check.enabled=trueand nomeshmap-litesource is declared in config, the wiring layer auto-registers ameshmap-litesource with the defaults shown above (label: Map,type: forgejo,base_url: https://git.skobk.in,repository: skobkin/meshmap-lite,current_version_source: buildinfo,limit: 15). Declaring a source withname: meshmap-litein config (or via ENV override) replaces the default entry entirely. To turn the feature off, setupdate_check.enabled: false(or setupdate_check.sources: []).Validation: at least one source required when
enabled=true; source names must be unique; sourcetypemust match a registered sub-package.Wiring (
internal/app/app.go)updatecheck.NewCache(),updatecheck.NewManager(...).sources/forgejo.New(...)orsources/github.New(...)) and callmanager.Register(name, src, currentVersion).current_version_source: buildinfotobuildinfo.Versionat registration time.manager.Start(ctx)alongside the MQTT client.*updatecheck.Managerto the HTTP server viahttpapi.Config.Updates *updatecheck.Manager.HTTP API
GET /api/v1/updates?source=meshmap-lite&format=html|markdown(internal/api/http/handlers.go):sourcedefaults to the only/first registered source.format=html(default) → each release'sbodyis pre-rendered by goldmark (reusesiteinfo.RenderMarkdown).format=markdown→ raw markdown bodies.{format, source_hash, releases: [{version, published_at, html_url, body}]}.update_check_not_configuredwhenupdate_check.enabled=false.update_check_source_not_foundwhen the named source isn't registered.update_check_not_readywhen the source has not completed its first fetch yet./api/v1/metaextension (internal/api/http/dto.go): replace the simple booleans with aupdate_check_sources []SourceSummaryarray, each entry{name, source_hash, current_version, latest_version, update_available}. Add a top-levelupdate_check_available boolfor "is the feature on at all". Omit the array (or sendnull) when disabled.internal/apidocs/assets/openapi.yaml): document the new endpoint, the?source=and?format=params, and the meta extension.Frontend (TypeScript / Preact)
InfoModal→HtmlModalinweb/src/components/InfoModal.tsx:<h2>Site information</h2>with aheaderslot (ortabsslot, see step 8) controlled by props.content(pre-rendered HTML),error,loading,showUpdatedNotice,onClose,onDismiss— these stay. Add an optionaltitleprop for non-tab use.AppModalwrapper inweb/src/components/AppModal.tsxthat renders the tabbed version:[tab bar centered] [close button right]. No<h2>.meta.update_check_sources(one per source, labeled with the source'slabelconfig — e.g. Map, and in the future Meshtastic). For MVP there are 2 tabs; once the Meshtastic firmware source is added, the dialog shows 3 tabs without any further wiring.web/src/utils/updatesCookie.ts:meshmap-lite.updates.<sourceName>.dismissed_published_at— stores the highestpublished_atISO timestamp the user has marked as read (default empty).infoCookie.ts(10y Max-Age,SameSite=Lax,Path=/).meshmap-liteis registered, so the helper only ever needs to round-trip that one name — but the design accommodates future sources.web/src/api/types.ts: ExtendMetawithupdate_check_available: booleanandupdate_check_sources?: SourceSummary[]. The meta'sreleasesarray carries justversion+published_atper release — enough to render a version/date list and to compute the unread count, but without the markdown body, so the page-load payload stays light. The full bodies are fetched on-demand fromapi.updates(source).web/src/api/client.ts— addapi.updates(source, format = 'html')taking the source name.web/src/stores/meta.ts— surface the new fields, including the per-sourcereleasesmetadata.web/src/stores/updates.ts— a small per-source map. Keyed by source name, value holds:{ releases: ReleaseInfo[] (with body), loading, error }. Populated lazily when the user opens a tab. Cookie reads/writes also happen here.App.tsxwiring:metaload, ifupdate_check_availableandupdate_check_sourcesis non-empty, do not pre-fetch bodies. Just compute the unread count per source frommeta.update_check_sources[*].releases+ the per-source cookie, sum them for the header badge, and stash the cookie values instores/updates.ts.api.updates(source, 'html')and stash the full response instores/updates.ts. Subsequent opens of the same tab use the cached response.appModalOpen,appModalActiveTab('information' | <sourceName>).meshmap-lite.updates.<sourceName>.dismissed_published_at) when a source tab is active. "×" close never marks anything as read.#/infokeeps the current behavior (opens the dialog on Information). Add#/updates/<sourceName>for explicit deep-link to a source tab (e.g.#/updates/meshmap-lite).web/src/components/Header.tsx:<a href="#/info">anchor.<span class="header-badge">{n}</span>absolutely positioned over the icon (Pico--pico-del-coloror similar).aria-labelbecomes "Site information (N new updates)" when the badge is shown, withNbeing the cross-source total.sum(source.unreadCount for source in meta.update_check_sources). When the second source is added, the badge automatically reflects it.web/src/components/UpdatesPanel.tsx:source: { name, label, releasesPageUrl },releases(with body, from the store),dismissedPublishedAt(from cookie),onDismiss,loading,error.labelshows as the panel's<h2>(or as a small caption above the version list); the "View all" link points atsource.releasesPageUrl(e.g. "View all releases on Forgejo →" / "View all releases on GitHub →"), and theonDismisscallback writes the source's own cookie.<section class="updates-release">with<h3>version</h3>,<time>(localized), and a<div class="updates-body" dangerouslySetInnerHTML>.published_at <= dismissedPublishedAt; if found and it isn't the last in the list, render a visible separator after it (<hr class="updates-separator">plus a small caption "You last read up to vX.Y.Z" — only when the user is "behind" by at least one release).dismissedPublishedAtget a subtle "new" visual treatment (e.g., a small "NEW" pill next to the version).source.releasesPageUrl) and the "Got it" button (which callsonDismiss).web/src/styles.css— new rules:.app-modal-tabs,.app-modal-tab,.app-modal-tab[aria-current="page"]— tab bar layout, centered, equal-width buttons, focus rings, dark-theme parity viacolor-mix(consistent with the recentshow-all-topologydark-theme work)..header-badge— small red circle, absolutely positioned top-right of the icon, with min-width for double digits..updates-release,.updates-separator,.updates-new-pill— release list styling, narrow-viewport friendly.update_checker_test.go→ split intocache_test.go(Get/Set/concurrency) andmanager_test.go(ticker + multi-source registration +UpdateAvailablecases). Addsources/forgejo/forgejo_test.go(HTTP fetch + DTO decode, URL construction frombase_url+repository+limit). Handler tests for/api/v1/updates(200, 404 disabled, 404 source, 503 not ready, format=markdown) and the meta extension. Amanagertest that registers a stubSource(not the GitHub package) to verify the orchestrator's behavior is platform-agnostic.InfoModal.test.tsxto cover the tabbed variant; newUpdatesPanel.test.tsxfor separator / "new" / "Got it" / "View all" behavior; new test for the header badge rendering (with multi-source total).Decisions (from brainstorm)
limit: 15, configurable). Visible separator after the latest release the user has already read, with a "you last read up to vX.Y.Z" caption.base_url+repository(or a GitHub default).api.updates(source)when the user opens that source's tab.meshmap-lite.info.dismissed_source_hash(unchanged) and a new per-sourcemeshmap-lite.updates.<name>.dismissed_published_at(10y Max-Age,SameSite=Lax).InfoModalis generalized toHtmlModal(adds header slot, keeps existing props). NewAppModalis the tabbed variant.Sourceinterface (Name,Label,FetchReleases,ReleasesPageURL) + per-platform sub-packages.Cacheis centralised at the manager level (one snapshot per source, separatecache.gofile). The MVP ships only theforgejosource implementation;githubis a stub package so the follow-up Meshtastic firmware checker can plug in without manager changes.interval. Cache lives incache.go(own file) and is owned by the Manager. Per-source failure doesn't poison the cache. Manager does an immediate fetch on startup so the cache is warm for the first request.current_version_source: buildinfo→buildinfo.Version.current_version_source: none→ no comparison, the snapshot just exposes the latest.label,type,base_url(required for forgejo, optional for github),repository,current_version_source,limit. The platform adapter owns the URL construction — no fullendpointfield.meshmap-liteis auto-registered with sensible defaults (label: Map,type: forgejo,base_url: https://git.skobk.in,repository: skobkin/meshmap-lite,current_version_source: buildinfo,limit: 15) whenupdate_check.enabled=trueand the source is absent from config. Declaring it in config (or via ENV override) replaces the default.Files to modify
internal/updatecheck/— new package (see file layout above)internal/config/types.go,internal/config/defaults.go,internal/config/validate.go— newUpdateCheckConfigblock withsourcesarrayinternal/app/app.go— wire the manager, pass to HTTP serverinternal/api/http/server.go,internal/api/http/handlers.go,internal/api/http/dto.go,internal/api/http/routes.go— new endpoint, meta extension, routeinternal/apidocs/assets/openapi.yaml— document the new endpoint,?source=,?format=, and meta fieldsweb/src/components/InfoModal.tsx→ rename / generalize toHtmlModal.tsx(header slot prop)web/src/components/AppModal.tsx— new tabbed wrapperweb/src/components/UpdatesPanel.tsx— new content componentweb/src/components/Header.tsx— render the badge on the "i" buttonweb/src/utils/updatesCookie.ts— new per-source cookie helper (mirror ofinfoCookie.ts)web/src/stores/updates.ts— new small store (snapshot of the latest fetched response, unread count)web/src/stores/meta.ts— surface the new meta fieldsweb/src/api/types.ts,web/src/api/client.ts— types + client methodweb/src/App.tsx— modal state, auto-open rules, dismiss handlers, URL fragmentsweb/src/styles.css— new rules for tabs, badge, updates panelweb/src/components/InfoModal.test.tsx→ split intoHtmlModal.test.tsx+AppModal.test.tsxweb/src/components/UpdatesPanel.test.tsx— new (generic per source, not meshmap-lite specific)internal/api/http/handlers_test.go— extend with/api/v1/updatescasesinternal/config/defaults.go— add the auto-registration of themeshmap-litedefault source when the config block is emptyinternal/config/validate.go— whenenabled=true, ifsourcesis empty, silently fall back to the default; if it has entries, the user-provided entries win. Reject duplicate names. Reject unknowntypevalues.Verification
go test ./...(backend) andnpm test(frontend) — all pass.Sourcethat returns canned releases, verify (a) the ticker triggers fetches, (b) the cache holds the last successful snapshot, (c) a failing source leaves the previous snapshot intact, (d) two sources don't share state, (e) the manager does an immediate fetch on startup so the first request after boot is served from cache.update_check.enabled: trueand nosourcesblock, themeshmap-litesource is registered with the documented defaults. Adding asources:block (with or without ameshmap-liteentry) overrides; settingupdate_check.enabled: false(orsources: []) disables it.git.skobk.in/skobkin/meshmap-lite:update_check.enabled=true(no explicit sources block).<= cookie.published_at(skip this on first visit — no separator).meshmap-lite.updates.meshmap-lite.dismissed_published_atcookie is written, badge clears on next load.web.info.file(or trigger a hash change), reload → dialog auto-opens on Information tab with the existing "updated since you last dismissed it" notice. The "i" badge for updates is unaffected.githubsource added to the config and a real in-process test handler returning 200, the SPA'smeta.update_check_sourcesshould contain bothmeshmap-liteandmeshtastic-firmwareentries, the dialog renders three tabs (Information, Map, Meshtastic), andGET /api/v1/updates?source=meshtastic-firmwarereturns the stub's data without the manager having to be re-architected. (The stub GitHubFetchReleasesis expected to fail for MVP; this verifies the failure-doesn't-poison-cache contract, not real GitHub behavior.)2N, while each tab badge shows its ownN.GET /api/v1/meta— noGET /api/v1/updatesuntil the user opens a source tab. Confirmed via the browser DevTools network tab.update_check.enabled=false, the "i" button shows no badge,meta.update_check_availableisfalse, and/api/v1/updatesreturns 404update_check_not_configured. Withenabled=truebut a never-completed check, the endpoint returns 503update_check_not_readyand the "i" button shows no badge.Plan refinements
Follow-up to the original plan in #6077. Same overall shape — four targeted edits to make the backend library-extractable and to align the dialog copy with the new tabbed layout.
1.
Sourceinterface — dropLabel(), keep it library-shapedLabel()is an app-specific concern. The platform adapter shouldn't know what the user-facing tab is called. Labels move to the Manager (populated from thelabel:field of each config entry at registration time) so the interface stays pure data-fetching and the wholeinternal/updatecheck/package can be lifted into a Go module without dragging anymeshmap-liteUI knowledge along with it.2.
Manager— own the update schedule and let the app subscribeAdd a small broker modeled on
internal/api/ws/hub.go(R/W mutex +map[name][]chan+ non-blocking snapshot-then-fan-out):Subscribe(name string) (<-chan UpdateSnapshot, func())— buffered channel (cap 4) per source; returned func unsubscribes and closes the channel.SubscribeAll() (<-chan NamedSnapshot, func())— single firehose channel carrying{Name, Snapshot}from every source. Lets the meta handler (or a future WebSocket emitter) react once per fetch.Labels() map[string]string— exposes labels keyed by source name for/api/v1/meta.Registernow takes a struct:Register(spec SourceSpec) errorwhereSourceSpec = {Name, Label, Source, CurrentVersion}— keeps the registration call stable as new metadata is added.registeredSource{Source, CurrentVersion string, Label string}—Labelis the user-facing tab name taken from config.A slow consumer must not block the Manager's fetch loop — drop the snapshot for that subscriber and continue.
3. Dialog
<h2>— "Information" (the first tab's name)Replace the hardcoded "Site information" with just "Information" when the tabbed layout is active. The dialog is no longer site-information-only; the
<h2>is now the first tab's name. The non-tabbedHtmlModalstandalone use gets atitleprop defaulting to"Information".4. Cookie helper — generalize to a
makeCookie(name)factoryRefactor
web/src/utils/infoCookie.tsto a small factory that serves both flows. The two free functions become instances of one shape; the existingcookie?arg seam used by the test is preserved.Why a flat updates cookie (
meshmap-lite.updates.dismissed_published_at, not per-source) for MVP:published_atalready covers the cross-source "you've seen everything up to T" case.makeCookie('meshmap-lite.updates', 'meshmap-lite').dismissed_published_at. The factory shape is intentionally easy to extend.The separate
web/src/utils/updatesCookie.tsfrom the original plan goes away — both cookies now live in the refactoredinfoCookie.ts. ExistinginfoCookie.test.tskeeps passing under the renamedinfoDismissedCookieexport; a new test coversupdatesDismissedCookie.Updated decisions table (replaces the corresponding rows in #6077)
Sourceinterface (Name,FetchReleases,ReleasesPageURL) + per-platform sub-packages — no UI concerns, so the package is library-extractable. Labels live in the Manager (registeredSource.Labelfrom config), not onSource.Cacheis centralised at the manager level (one snapshot per source, separatecache.gofile). The MVP ships only theforgejosource implementation;githubis a stub package so the follow-up Meshtastic firmware checker can plug in without manager changes.interval. Cache lives incache.go(own file) and is owned by the Manager. Per-source failure doesn't poison the cache. Manager does an immediate fetch on startup so the cache is warm for the first request.Subscribe(name) (<-chan UpdateSnapshot, func())andSubscribeAll() (<-chan NamedSnapshot, func())so consumers (HTTP handlers, future WebSocket push) react to new snapshots without polling the cache. Modeled oninternal/api/ws/hub.go(R/W mutex +map[name][]chan+ non-blocking snapshot-then-fan-out, drop failing subs).current_version_source: buildinfo→buildinfo.Version.current_version_source: none→ no comparison, the snapshot just exposes the latest.label,type,base_url(required for forgejo, optional for github),repository,current_version_source,limit. The platform adapter owns the URL construction — no fullendpointfield.meshmap-liteis auto-registered with sensible defaults (label: Map,type: forgejo,base_url: https://git.skobk.in,repository: skobkin/meshmap-lite,current_version_source: buildinfo,limit: 15) whenupdate_check.enabled=trueand the source is absent from config. Declaring it in config (or via ENV override) replaces the default.meshmap-lite.info.dismissed_source_hash(unchanged) and a new flatmeshmap-lite.updates.dismissed_published_at(10y Max-Age,SameSite=Lax). Both come from a singlemakeCookie(name)factory inweb/src/utils/infoCookie.ts.InfoModalis generalized toHtmlModal(adds header slot, keeps existing props; new default<h2>text is "Information"). NewAppModalis the tabbed variant.Files to modify (delta vs. #6077)
web/src/utils/updatesCookie.tsweb/src/utils/infoCookie.ts— refactored to amakeCookie(name)factory exportinginfoDismissedCookie(renamed existing) andupdatesDismissedCookie(new, flat)web/src/components/InfoModal.tsx— default<h2>text becomes "Information";titleprop added for non-tab useVerification (additions)
Sourceregistered with the Manager delivers new snapshots to aSubscribe(name)channel within one tick, theSubscribeAll()channel reports the source name, unsubscribing closes the channel cleanly, and a slow consumer does not block the Manager's fetch loop.go vet ./internal/updatecheck/...passes with no imports frommeshmap-lite/...outside ofinternal/app/app.go(i.e. the package compiles in isolation once wired).Labels()returns a map keyed by source name, populated from each registeredSourceSpec.Label.Everything else in #6077 stands as-is.