Skip to content

feat(admin): expose keyviz heatmap via /admin/api/v1/keyviz/matrix#660

Merged
bootjp merged 3 commits intomainfrom
feat/keyviz-admin-binary-handler
Apr 26, 2026
Merged

feat(admin): expose keyviz heatmap via /admin/api/v1/keyviz/matrix#660
bootjp merged 3 commits intomainfrom
feat/keyviz-admin-binary-handler

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 26, 2026

Summary

Bridges the in-process keyviz.MemSampler into the in-node admin HTTP listener (Layer C, internal/admin) so the SPA can render the heatmap without an extra hop through the gRPC Admin service. The SPA was merged in #649 but has no KeyViz page yet — this PR is the backend half; the SPA page will follow as a separate slice.

  • New KeyVizSource interface in internal/admin (Snapshot only) so *keyviz.MemSampler satisfies it without a wrapper. Tests pass an in-memory fake.
  • KeyVizHandler serves GET /admin/api/v1/keyviz/matrix. Query parameters: series (default writes per design §4.1), from_unix_ms, to_unix_ms, rows (capped at keyVizRowBudgetCap=1024 per design §4.1).
  • Returns 503 keyviz_disabled when the source is nil so the SPA renders an explicit "feature off" state instead of an empty matrix.
  • Pivots column-major MatrixColumn into row-major JSON, mirroring the adapter (gRPC) handler from PR feat(adapter): expose keyviz heatmap via AdminServer.GetKeyVizMatrix #646. The duplication is intentional for now — extracting a shared pivot helper is a future cleanup.
  • ServerDeps.KeyViz field; buildAPIMux always registers the route (even when source is nil) so 503 is served instead of 404.
  • main.go threads *keyviz.MemSampler through startAdminFromFlagsstartAdminServerbuildAdminHTTPServer; keyvizSourceFromSampler converts the typed-nil to interface-nil so the handler's "is keyviz disabled" check fires correctly.

Test plan

  • TestKeyVizHandlerReturnsServiceUnavailableWhenNoSource — 503 + keyviz_disabled code.
  • TestKeyVizHandlerRejectsNonGet — 405.
  • TestKeyVizHandlerPivotsMatrix — two-column, two-route fixture; verifies missing-row-becomes-zero contract.
  • TestKeyVizHandlerSeriesParam — table-driven across all four enum values.
  • TestKeyVizHandlerSeriesParamRejectsUnknown — 400 invalid_query for typo'd series.
  • TestKeyVizHandlerHonorsRowsBudget?rows=2 returns top-2 by activity, sorted by Start.
  • TestKeyVizHandlerEncodesAggregateBucket — virtual-bucket layout with route_count from MemberRoutesTotal and route_ids_truncated flag.
  • go test ./internal/admin/... . clean.
  • golangci-lint run . ./internal/admin/... clean.

Summary by CodeRabbit

  • New Features
    • Added /admin/api/v1/keyviz/matrix API endpoint for retrieving key visualization matrix data
    • Supports filtering by metric type: reads, writes, read_bytes, and write_bytes
    • Supports time-range filtering with Unix millisecond precision
    • Supports row limiting and activity-based sorting
    • Returns 503 status when key visualization sampling is disabled

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

Warning

Rate limit exceeded

@bootjp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 49 minutes and 44 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 49 minutes and 44 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ca2a806-6483-421e-b4df-8b6d6c3cea69

📥 Commits

Reviewing files that changed from the base of the PR and between 25ac777 and 7db3c87.

📒 Files selected for processing (3)
  • internal/admin/keyviz_handler.go
  • internal/admin/keyviz_handler_test.go
  • internal/admin/server.go
📝 Walkthrough

Walkthrough

This pull request introduces a new KeyViz HTTP API endpoint at /admin/api/v1/keyviz/matrix that serves sampled key-value access patterns as JSON. The handler validates HTTP method and request parameters, fetches column-major snapshot data from a configurable source, pivots it into row-major format with row budgeting, and integrates the endpoint into the admin server's routing layer.

Changes

Cohort / File(s) Summary
KeyViz Handler Implementation
internal/admin/keyviz_handler.go
Defines KeyVizSource interface with Snapshot method, implements KeyVizHandler HTTP handler with request validation (GET-only, 503 on disabled), query parameter parsing (series, time bounds, row limits), column-to-row-major pivot transformation, deterministic row sorting with activity-based truncation, and JSON encoding with cache headers.
KeyViz Handler Tests
internal/admin/keyviz_handler_test.go
Comprehensive test suite verifying handler behavior: 503 response when source is nil, 405 for non-GET methods, correct matrix pivoting and bucketing, series parameter mapping, row truncation logic, aggregate row construction, and deterministic tie-breaking during row-budget truncation.
Admin Server Integration
internal/admin/server.go
Adds KeyViz KeyVizSource field to ServerDeps, updates buildAPIMux signature to accept keyviz handler, and extends route dispatch logic to include dedicated /admin/api/v1/keyviz/matrix switch case.
Main Entry Point & Wiring
main.go, main_admin.go
Threads *keyviz.MemSampler from node startup through startAdminFromFlagsstartAdminServerbuildAdminHTTPServer, converting sampler into admin.ServerDeps.KeyViz via keyvizSourceFromSampler helper to ensure nil-sampler → interface-nil semantics.
Admin Integration Tests
main_admin_test.go
Updates test call sites to pass nil instead of "" for the new sampler parameter in startAdminServer invocations across disabled-config and TLS-serving test cases.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant KeyVizHandler
    participant KeyVizSource
    participant JSONEncoder

    Client->>KeyVizHandler: GET /admin/api/v1/keyviz/matrix?series=writes&rows=100
    
    alt Source is nil
        KeyVizHandler-->>Client: 503 {error: keyviz_disabled}
    else Source configured
        KeyVizHandler->>KeyVizHandler: Validate method, parse params
        KeyVizHandler->>KeyVizSource: Snapshot(from, to)
        KeyVizSource-->>KeyVizHandler: []MatrixColumn (column-major)
        
        KeyVizHandler->>KeyVizHandler: Pivot to row-major format
        KeyVizHandler->>KeyVizHandler: Sort & truncate rows by activity
        KeyVizHandler->>KeyVizHandler: Construct BucketIDs & aggregate
        
        KeyVizHandler->>JSONEncoder: KeyVizMatrix
        JSONEncoder-->>KeyVizHandler: JSON bytes
        KeyVizHandler-->>Client: 200 JSON response<br/>(Cache-Control: no-store)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A sampler spins its ring of keys,

Now served as matrices, if you please!

Rows pivot, pivot, sorted with care,

Deterministic buckets hop everywhere! 🥕

The matrix hops forth in JSON so bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the primary change: exposing keyviz heatmap data via a new admin HTTP endpoint.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/keyviz-admin-binary-handler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the KeyVizHandler and wires it into the admin server to expose heatmap matrix data via a new API endpoint. The changes include logic for pivoting data, parsing query parameters, and applying row-based truncation. Review feedback recommends optimizing the row-budgeting logic by pre-calculating activity totals to improve sorting efficiency and adding a tie-breaker to ensure deterministic results.

Comment on lines +67 to +76
type KeyVizRow struct {
BucketID string `json:"bucket_id"`
Start []byte `json:"start"`
End []byte `json:"end"`
Aggregate bool `json:"aggregate"`
RouteIDs []uint64 `json:"route_ids,omitempty"`
RouteIDsTruncated bool `json:"route_ids_truncated,omitempty"`
RouteCount uint64 `json:"route_count"`
Values []uint64 `json:"values"`
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The KeyVizRow struct is used for JSON serialization but also serves as the intermediate object for sorting and truncation. To improve the performance of applyKeyVizRowBudget, consider adding a Total field with a json:"-" tag. This allows pre-calculating the activity sum during the pivot phase, avoiding redundant O(M) iterations over the Values slice during the O(N log N) sort comparisons.

Suggested change
type KeyVizRow struct {
BucketID string `json:"bucket_id"`
Start []byte `json:"start"`
End []byte `json:"end"`
Aggregate bool `json:"aggregate"`
RouteIDs []uint64 `json:"route_ids,omitempty"`
RouteIDsTruncated bool `json:"route_ids_truncated,omitempty"`
RouteCount uint64 `json:"route_count"`
Values []uint64 `json:"values"`
}
type KeyVizRow struct {
BucketID string "json:\"bucket_id\""
Start []byte "json:\"start\""
End []byte "json:\"end\""
Aggregate bool "json:\"aggregate\""
RouteIDs []uint64 "json:\"route_ids,omitempty\""
RouteIDsTruncated bool "json:\"route_ids_truncated,omitempty\""
RouteCount uint64 "json:\"route_count\""
Values []uint64 "json:\"values\""
// Total is the sum of Values, used for activity-based truncation.
Total uint64 "json:\"-\""
}

Comment thread internal/admin/keyviz_handler.go Outdated
rowsByID[mr.RouteID] = row
order = append(order, mr.RouteID)
}
row.Values[j] = pick(mr)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accumulate the total activity for the row here to avoid re-calculating it during the sorting phase.

val := pick(mr)
			row.Values[j] = val
			row.Total += val

Comment on lines +327 to +329
sort.Slice(rows, func(i, j int) bool {
return rowActivityTotal(rows[i]) > rowActivityTotal(rows[j])
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current sorting logic in applyKeyVizRowBudget is inefficient because it calls rowActivityTotal (an O(M) operation) inside the sort comparator (O(N log N) calls). Additionally, it lacks a tie-breaker, which makes the truncation non-deterministic when multiple rows have the same total activity. This can lead to inconsistent UI states upon refresh. Using a pre-calculated Total field and adding BucketID as a tie-breaker ensures both performance and determinism.

sort.Slice(rows, func(i, j int) bool {
		if rows[i].Total != rows[j].Total {
			return rows[i].Total > rows[j].Total
		}
		return rows[i].BucketID < rows[j].BucketID
	})
References
  1. Avoid silently dropping entries or using non-deterministic normalization to ensure state consistency and prevent UI inconsistencies.
  2. Apply fixed bounds to collections that grow from external requests to prevent unbounded memory growth.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

bootjp added a commit that referenced this pull request Apr 26, 2026
Round-1 review fixes for PR #660 (Gemini medium):

The rows-budget sort previously called rowActivityTotal inside the
comparator, paying O(M) per pair × O(N log N) comparisons —
quadratic-ish in column count for the cost of one HTTP request.
Worse, the lack of a tie-breaker made truncation non-deterministic:
two refreshes against identical data could return different row
sets, surfacing as flicker in the SPA heatmap.

Accumulate a precomputed row.total during the pivot phase (json:"-"
field, internal only) and sort on it in the budget step. Add
BucketID as the tie-breaker so identical data → identical response.

Test TestKeyVizHandlerRowsBudgetTieBreakDeterministic stages three
routes with equal Writes, requests rows=2 three times in a row, and
asserts the same two routes (lowest BucketID) survive every time.
@bootjp bootjp force-pushed the feat/keyviz-admin-binary-handler branch from 50d38d0 to 5237296 Compare April 26, 2026 10:23
@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 3m 2s —— View job


Code Review

  • Gather context and understand request
  • Read changed files (keyviz_handler.go, keyviz_handler_test.go, server.go, main.go, main_admin.go, main_admin_test.go)
  • Review implementation
  • Post review

Overall the implementation is clean and well-structured. The pivot logic mirrors the gRPC adapter correctly, the nil-source → 503 path is load-bearing and handled properly, and the Gemini round-1 suggestions (pre-computed total, BucketID tie-breaker, determinism test) were already addressed before the review landed. A few issues worth addressing:


Medium priority

1. Stale route layout comment in buildAPIMux (server.go:169–178)

The layout comment documents all registered routes but is missing the new one:

//	GET    /admin/api/v1/keyviz/matrix              (auth required)

Minor doc drift, but this comment is the only place the full route table is described in one place.

Fix this →


2. Undocumented zero-value behavior of parseUnixMs (keyviz_handler.go:238–239)

if n == 0 {
    return time.Time{}, nil
}

?from_unix_ms=0 silently becomes "unbounded lower bound" (same as omitting the parameter), while a caller might expect Unix epoch (1970-01-01T00:00:00Z). This is consistent with how ringBuffer.Range treats a zero time.Time, so the behavior is correct — but the handler's doc comment for from_unix_ms / to_unix_ms doesn't mention it. Worth adding a one-line note: // 0 is treated as "unbounded" on that side.


3. RouteCount for aggregate rows with MemberRoutesTotal == 0 (keyviz_handler.go:299–303)

total := mr.MemberRoutesTotal
if !mr.Aggregate && total == 0 {
    total = 1  // correct: non-aggregate rows always have 1 route
}

The non-aggregate guard is correct, but an aggregate row with MemberRoutesTotal == 0 would be serialized as "route_count": 0 — a virtual bucket claiming zero constituent routes. If the sampler can produce this (e.g., a just-coalesced bucket before a flush completes), the SPA may misrender it. A defensive fallback of len(mr.MemberRoutes) for the aggregate case would handle this edge.


Low priority

4. No test coverage for from_unix_ms / to_unix_ms parameters

All seven handler tests use zero-time bounds. The parseUnixMs code path — including the n == 0 edge case and the negative-value pass-through — is exercised by no handler test. A small table-driven test for parseUnixMs alone (or a handler-level test that passes ?from_unix_ms=<past>&to_unix_ms=<future> and verifies column filtering) would close this gap.

5. TestKeyVizHandlerSeriesParam subtests are not t.Parallel()

The parent test is parallel but the table-driven inner loop isn't. Low impact but inconsistent with the rest of the file.


Already addressed (Gemini round-1)

The PR already implements what Gemini requested:

  • ✅ Pre-computed total uint64 (unexported, accumulated during pivot at keyviz_handler.go:272–273) — O(1) comparator, not O(M).
  • BucketID tie-breaker in applyKeyVizRowBudget (keyviz_handler.go:343) — deterministic truncation.
  • TestKeyVizHandlerRowsBudgetTieBreakDeterministic — three-iteration loop pins the determinism contract.

Positive observations

  • keyvizSourceFromSampler (main_admin.go:590–595) — The typed-nil → interface-nil conversion is load-bearing and correctly documented. Without it, h.source == nil would never fire for a nil *MemSampler, silently returning an empty matrix instead of 503.
  • Auth on the keyviz routekeyvizChain := protect(keyvizHandler) (server.go:237) applies session + CSRF + audit consistently with other admin endpoints.
  • Cache-Control: no-store (keyviz_handler.go:150) — Prevents a proxy or browser from serving a stale heatmap snapshot.
  • cloneColumn + fakeKeyVizSource deep copy — The mutability contract (Snapshot returns rows the caller can mutate freely) is respected in both the production sampler and the test fake.
  • keyVizRowBudgetCap = 1024 — Mirrors the gRPC cap; prevents the JSON path from becoming a memory-pressure vector for large route tables.

bootjp added 2 commits April 26, 2026 20:15
Bridges the in-process keyviz.MemSampler into the in-node admin
HTTP listener (Layer C) so the SPA can render the heatmap without
an extra hop through the gRPC Admin service. The SPA was merged in
PR #649 but has no KeyViz page yet — this PR is the backend half;
the SPA page is task B in the design's Phase 2 follow-up.

- New KeyVizSource interface in internal/admin (Snapshot only) so
  *keyviz.MemSampler satisfies it without a wrapper. Tests pass an
  in-memory fake.
- KeyVizHandler serves GET /admin/api/v1/keyviz/matrix. Query
  parameters: series (default writes per design §4.1),
  from_unix_ms, to_unix_ms, rows (capped at keyVizRowBudgetCap=1024
  per design §4.1).
- Returns 503 keyviz_disabled when the source is nil so the SPA
  renders an explicit "feature off" state instead of an empty matrix.
- Pivots column-major MatrixColumn into row-major JSON, mirroring
  the adapter (gRPC) handler from PR #646. The duplication is
  intentional for now — extracting a shared pivot helper is a
  future cleanup.
- ServerDeps.KeyViz field; buildAPIMux always registers the route
  (even when source is nil) so 503 is served instead of 404.
- main.go threads *keyviz.MemSampler through startAdminFromFlags →
  startAdminServer → buildAdminHTTPServer; keyvizSourceFromSampler
  converts the typed-nil to interface-nil so the handler's "is
  keyviz disabled" check fires correctly.

Tests:
- TestKeyVizHandlerReturnsServiceUnavailableWhenNoSource
- TestKeyVizHandlerRejectsNonGet
- TestKeyVizHandlerPivotsMatrix (two-column, two-route fixture)
- TestKeyVizHandlerSeriesParam (table-driven across all four enums)
- TestKeyVizHandlerSeriesParamRejectsUnknown (400 invalid_query)
- TestKeyVizHandlerHonorsRowsBudget
- TestKeyVizHandlerEncodesAggregateBucket
Round-1 review fixes for PR #660 (Gemini medium):

The rows-budget sort previously called rowActivityTotal inside the
comparator, paying O(M) per pair × O(N log N) comparisons —
quadratic-ish in column count for the cost of one HTTP request.
Worse, the lack of a tie-breaker made truncation non-deterministic:
two refreshes against identical data could return different row
sets, surfacing as flicker in the SPA heatmap.

Accumulate a precomputed row.total during the pivot phase (json:"-"
field, internal only) and sort on it in the budget step. Add
BucketID as the tie-breaker so identical data → identical response.

Test TestKeyVizHandlerRowsBudgetTieBreakDeterministic stages three
routes with equal Writes, requests rows=2 three times in a row, and
asserts the same two routes (lowest BucketID) survive every time.
@bootjp bootjp force-pushed the feat/keyviz-admin-binary-handler branch from 5237296 to 25ac777 Compare April 26, 2026 11:15
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 16s —— View job


Claude finished @bootjp's task


Code Review

  • Gather context and understand request
  • Read changed files (keyviz_handler.go, keyviz_handler_test.go, server.go, main.go, main_admin.go, main_admin_test.go)
  • Review implementation across all 5 lenses (CLAUDE.md)
  • Post review

The implementation is clean and well-structured. I've reviewed it against all five CLAUDE.md lenses. Summary upfront: no data-loss or correctness blockers; a few medium-priority issues and one new finding.


Lens 1 — Data loss

No writes in this path. The handler is GET-only; the 405 guard (keyviz_handler.go:132) fires before any other processing. The fakeKeyVizSource.Snapshot deep-copies Start/End/MemberRoutes correctly at keyviz_handler_test.go:36–48, honoring the mutability contract from the KeyVizSource doc.

No issues.


Lens 2 — Concurrency / distributed failures

KeyVizHandler fields (source, now, logger) are set at construction and never mutated concurrently with ServeHTTP. The WithClock/WithLogger builder methods mutate directly, but they're called only at startup before the listener is live. pivotKeyVizColumns is purely functional (no shared state). keyvizSourceFromSampler correctly converts a typed-nil *MemSampler to interface-nil (main_admin.go:655–660) — this is load-bearing and correctly documented.

No issues.


Lens 3 — Performance

  • applyKeyVizRowBudget uses the precomputed row.total field (accumulated during pivot at keyviz_handler.go:272–273), making the sort comparator O(1). Good.
  • keyVizRowBudgetCap = 1024 mirrors the gRPC cap. Good.
  • json.NewEncoder(w).Encode(matrix) allocates a fresh encoder per request. Admin endpoints aren't hot paths so this is acceptable.
  • make(map[uint64]*KeyVizRow) per request: fine for an admin endpoint.

No issues.


Lens 4 — Data consistency

Medium: buildAPIMux route-table comment is stale (server.go:202–215)

The layout comment documents all registered routes but is missing the new one. It's the only place the full route table is described in one place, so drift here misleads operators reading the source.

//	GET    /admin/api/v1/keyviz/matrix              (auth required)

Fix this →


Medium: parseUnixMs(0) is undocumented in the handler's param doc (keyviz_handler.go:238–239)

if n == 0 {
    return time.Time{}, nil   // treated as "unbounded" — same as omitting the param
}

?from_unix_ms=0 silently becomes an unbounded lower bound rather than Unix epoch (1970-01-01). The behavior is internally consistent (zero time.Time means unbounded in ringBuffer.Range) but a caller would reasonably expect 0 to mean the epoch. The handler's query-param doc block (keyviz_handler.go:87–92) doesn't mention this. A one-line note — // 0 is treated as "unbounded" on that side — would make the contract explicit.

Fix this →


Medium: Aggregate row with MemberRoutesTotal == 0 serializes as "route_count": 0 (keyviz_handler.go:299–303)

total := mr.MemberRoutesTotal
if !mr.Aggregate && total == 0 {
    total = 1  // correct for non-aggregate rows
}

The non-aggregate guard is correct. But an aggregate row with MemberRoutesTotal == 0 serializes route_count: 0 — a virtual bucket claiming zero constituent routes. If MemSampler can produce this (e.g., a just-coalesced bucket before the next flush), the SPA will misrender it. A defensive fallback of uint64(len(mr.MemberRoutes)) for the aggregate zero case would be safe:

if mr.Aggregate && total == 0 {
    total = uint64(len(mr.MemberRoutes))
}

Fix this →


Lens 5 — Test coverage

Low: No test coverage for from_unix_ms / to_unix_ms parameters

All seven handler tests use zero-time bounds; the time-range filtering code path in parseUnixMs — including the n == 0 edge case, negative values, and a non-zero timestamp that filters columns — is exercised by no test. A small table-driven test for parseUnixMs in isolation, plus one handler-level test that passes ?from_unix_ms=<past>&to_unix_ms=<future> and verifies the column subset returned, would close this gap.

Low: TestKeyVizHandlerSeriesParam inner subtests missing t.Parallel() (keyviz_handler_test.go:158)

The parent is t.Parallel() but the table-driven subtests inside t.Run(...) don't call t.Parallel(). This is inconsistent with the rest of the file and prevents subtests from running concurrently within the suite.


Positive observations

  • keyvizSourceFromSampler (main_admin.go:655–660) — typed-nil → interface-nil conversion is load-bearing and correctly explained with the "why" in the comment. This is exactly the failure mode that would cause silent regression if the comment were ever removed.
  • Auth middlewarekeyvizChain := protect(keyvizHandler) (server.go:274) applies session + CSRF + audit consistently with other admin endpoints. No gaps in the auth chain.
  • Cache-Control: no-store (keyviz_handler.go:150) — correct; a proxy or browser must not cache a heatmap snapshot.
  • Gemini round-1 already addressed — pre-computed total, BucketID tie-break, and TestKeyVizHandlerRowsBudgetTieBreakDeterministic were all landed before this review.
  • writeJSONError for 503 — the keyviz_disabled code lets the SPA distinguish "feature off" from "no data yet" (empty matrix on 200); this is the correct layering.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
internal/admin/keyviz_handler.go (3)

261-274: Per-row metadata is frozen from the first column the route appears in.

Start, End, Aggregate, RouteCount, and RouteIDsTruncated are populated by newKeyVizRowFrom(mr, ...) only on the first sighting of mr.RouteID. If the same RouteID appears in a later column with a changed range (e.g. a split/merge mid-window) or, for an aggregate bucket, a different MemberRoutesTotal/MemberRoutes, those updates are silently dropped — only Values[j] and total continue to track. In practice route identity is stable within a sampling window so this is fine, but it's a subtle behavioral choice worth a short comment so a future "why is the matrix showing the old range?" debug session is shorter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/admin/keyviz_handler.go` around lines 261 - 274, Per-row metadata
(Start, End, Aggregate, RouteCount, RouteIDsTruncated) is only set when a route
first appears by newKeyVizRowFrom and later sightings only update Values[j] and
total, so add a short explanatory comment in the loop (near the rowsByID lookup
inside the for j, col := range cols block) clarifying that route-level metadata
is intentionally frozen on first sighting (or, if you prefer to keep metadata
mutable, update the existing row's
Start/End/Aggregate/RouteCount/RouteIDsTruncated when mr differs from the stored
row), referencing newKeyVizRowFrom, rowsByID, pick, matrix.ColumnUnixMs and
order so future readers know why only Values and total are updated.

284-297: Minor: default arm is unreachable.

pivotKeyVizColumns is only ever called after parseKeyVizSeries validates the enum, so the default case is dead code. Consider folding it into keyVizSeriesWrites or panicking with "unreachable: " + string(series) to make the invariant explicit and silence any future "I added a series and forgot to update the picker" drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/admin/keyviz_handler.go` around lines 284 - 297, The default arm in
keyVizSeriesPicker is unreachable because parseKeyVizSeries already validates
KeyVizSeries; update keyVizSeriesPicker to make this invariant explicit by
replacing the default return with a panic (e.g., panic(fmt.Sprintf("unreachable:
%v", series))) or by removing the default and returning the keyVizSeriesWrites
handler directly; ensure you reference the KeyVizSeries enum and keep the
function signature of keyVizSeriesPicker unchanged so callers (e.g.,
pivotKeyVizColumns) are unaffected.

167-183: Optional: tighten from_unix_ms / to_unix_ms validation.

strconv.ParseInt(raw, 10, 64) accepts negative values, so e.g. ?from_unix_ms=-1 is silently turned into a pre-epoch time.Time rather than a 400 invalid_query. Likewise there is no from <= to check, so a reversed range just produces an empty matrix indistinguishable from "no activity." Both surface as confusing-looking 200 OK responses to a misbehaving client; rejecting them keeps the wire contract crisp.

♻️ Suggested validation tweaks
 func parseUnixMs(name, raw string) (time.Time, error) {
 	n, err := strconv.ParseInt(raw, 10, 64)
 	if err != nil {
 		return time.Time{}, errors.New(name + " must be an integer (unix milliseconds)")
 	}
+	if n < 0 {
+		return time.Time{}, errors.New(name + " must be non-negative")
+	}
 	if n == 0 {
 		return time.Time{}, nil
 	}
 	return time.UnixMilli(n).UTC(), nil
 }

And after both bounds are parsed in parseKeyVizParams:

 	if err := setKeyVizTimeParam(&p.to, "to_unix_ms", q.Get("to_unix_ms")); err != nil {
 		return keyVizParams{}, err
 	}
+	if !p.from.IsZero() && !p.to.IsZero() && p.from.After(p.to) {
+		return keyVizParams{}, errors.New("from_unix_ms must be <= to_unix_ms")
+	}

Also applies to: 233-242

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/admin/keyviz_handler.go` around lines 167 - 183, Reject
negative/unix-ms timestamps and enforce from<=to: modify setKeyVizTimeParam so
it treats parsed int64 < 0 as an error (returning the same invalid_query-style
error) instead of producing a pre-epoch time, and then in parseKeyVizParams
(after calling setKeyVizTimeParam for p.from and p.to) add a check that p.from
<= p.to and return an invalid_query error if not; apply the same negative-value
and range check fixes to the other time-parsing block in this file that mirrors
these calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/admin/keyviz_handler.go`:
- Around line 261-274: Per-row metadata (Start, End, Aggregate, RouteCount,
RouteIDsTruncated) is only set when a route first appears by newKeyVizRowFrom
and later sightings only update Values[j] and total, so add a short explanatory
comment in the loop (near the rowsByID lookup inside the for j, col := range
cols block) clarifying that route-level metadata is intentionally frozen on
first sighting (or, if you prefer to keep metadata mutable, update the existing
row's Start/End/Aggregate/RouteCount/RouteIDsTruncated when mr differs from the
stored row), referencing newKeyVizRowFrom, rowsByID, pick, matrix.ColumnUnixMs
and order so future readers know why only Values and total are updated.
- Around line 284-297: The default arm in keyVizSeriesPicker is unreachable
because parseKeyVizSeries already validates KeyVizSeries; update
keyVizSeriesPicker to make this invariant explicit by replacing the default
return with a panic (e.g., panic(fmt.Sprintf("unreachable: %v", series))) or by
removing the default and returning the keyVizSeriesWrites handler directly;
ensure you reference the KeyVizSeries enum and keep the function signature of
keyVizSeriesPicker unchanged so callers (e.g., pivotKeyVizColumns) are
unaffected.
- Around line 167-183: Reject negative/unix-ms timestamps and enforce from<=to:
modify setKeyVizTimeParam so it treats parsed int64 < 0 as an error (returning
the same invalid_query-style error) instead of producing a pre-epoch time, and
then in parseKeyVizParams (after calling setKeyVizTimeParam for p.from and p.to)
add a check that p.from <= p.to and return an invalid_query error if not; apply
the same negative-value and range check fixes to the other time-parsing block in
this file that mirrors these calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b6b82b9-10c3-4db3-ae35-5f39b9afad65

📥 Commits

Reviewing files that changed from the base of the PR and between 5e4fe67 and 25ac777.

📒 Files selected for processing (6)
  • internal/admin/keyviz_handler.go
  • internal/admin/keyviz_handler_test.go
  • internal/admin/server.go
  • main.go
  • main_admin.go
  • main_admin_test.go

Round-2 review fixes for PR #660 (Claude bot):

- buildAPIMux layout comment was missing the keyviz route entry —
  add it next to the s3 routes so the documented surface matches
  the actual mux dispatch.
- KeyVizHandler godoc now spells out that from_unix_ms / to_unix_ms
  treat 0 (or omitted) as "unbounded on that side" — NOT the Unix
  epoch. The behaviour was internally consistent with
  ringBuffer.Range but a caller could reasonably read 0 as 1970.
- newKeyVizRowFrom now has a defensive fallback for an aggregate
  row with MemberRoutesTotal == 0: render route_count =
  len(MemberRoutes) instead of 0, so a virtual bucket never
  reports "0 routes" to the SPA. Should never happen with the
  current sampler, but the guard costs nothing.

Tests:
- TestKeyVizHandlerTimeBoundsParam — exercises a real time pair
  (parsed and forwarded to Snapshot via a capturingKeyVizSource),
  the 0 → unbounded contract, and the non-numeric → 400
  invalid_query branch.
- TestKeyVizHandlerAggregateFallbackWhenTotalZero — pins the
  aggregate-row defensive fallback.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 29s —— View job


Code Review

  • Gather context and understand request
  • Read changed files (keyviz_handler.go, keyviz_handler_test.go, server.go, main.go, main_admin.go, main_admin_test.go)
  • Review implementation across all 5 lenses (CLAUDE.md)
  • Post review

All medium-priority issues from the two prior rounds have been addressed in commits 25ac777 and 7db3c87. The implementation is solid and the test suite is now comprehensive. Three minor items from CodeRabbit remain unaddressed — none are blockers, but two are worth the one-liner fix.


Resolved since last round ✅

Issue Status
Stale route layout comment in buildAPIMux Fixed — server.go:211
parseUnixMs(0) undocumented in godoc Fixed — keyviz_handler.go:88-90
Aggregate row MemberRoutesTotal == 0 → route_count: 0 Fixed — keyviz_handler.go:307-314
No from_unix_ms / to_unix_ms test coverage Fixed — TestKeyVizHandlerTimeBoundsParam + capturingKeyVizSource
Missing TestKeyVizHandlerAggregateFallbackWhenTotalZero Fixed — keyviz_handler_test.go:357-389
Gemini round-1: precomputed total, BucketID tie-break, determinism test Fixed in 25ac777

Still open (CodeRabbit nitpicks)

1. parseUnixMs accepts negative values (keyviz_handler.go:234-242)

?from_unix_ms=-1 parses without error and silently becomes a pre-epoch time.Time, which is almost certainly not what the caller intended. The same errors.New pattern used for rows < 0 (keyviz_handler.go:215) applies here:

if n < 0 {
    return time.Time{}, errors.New(name + " must be non-negative")
}

And after parsing both bounds in parseKeyVizParams, a from > to pair currently returns an empty matrix on 200, indistinguishable from "no activity." Rejecting it with 400 invalid_query makes the contract crisp:

if !p.from.IsZero() && !p.to.IsZero() && p.from.After(p.to) {
    return keyVizParams{}, errors.New("from_unix_ms must be <= to_unix_ms")
}

Fix this →

2. default arm in keyVizSeriesPicker is unreachable dead code (keyviz_handler.go:295-296)

parseKeyVizSeries (keyviz_handler.go:225-232) is the only call site and validates the enum before keyVizSeriesPicker is called, so the default arm can never fire. Replacing it with a panic makes the invariant explicit and prevents a future new-series addition from silently degrading to writes:

default:
    panic("unreachable: keyVizSeriesPicker called with invalid series: " + string(series))

Fix this →

3. Per-row metadata silently frozen on first column sighting (keyviz_handler.go:261-274)

Start, End, Aggregate, RouteCount, and RouteIDsTruncated are set only on first sighting of a RouteID; subsequent columns for the same route only update Values[j] and total. This is fine because route identity is stable within a sampling window, but the loop has no comment explaining why, so a future reader debugging "stale range in the matrix" won't immediately understand the invariant. A one-liner is enough:

// Route-level metadata (Start/End/Aggregate/RouteCount) is frozen
// on first sighting; route identity is stable within a window.

Fix this →


Lens summary

Lens Result
Data loss No issues — handler is GET-only; 405 guard fires before any processing; deep-copy contract honored in both production sampler and fakeKeyVizSource
Concurrency No issues — KeyVizHandler fields are read-only after construction; pivotKeyVizColumns is purely functional; keyvizSourceFromSampler typed-nil conversion is load-bearing and correctly documented
Performance No issues — precomputed row.total makes the sort comparator O(1); keyVizRowBudgetCap = 1024 mirrors the gRPC cap; json.NewEncoder per request is fine for an admin path
Data consistency parseUnixMs accepts negative timestamps (minor, item 1 above)
Test coverage TestKeyVizHandlerTimeBoundsParam + TestKeyVizHandlerAggregateFallbackWhenTotalZero close the gaps from round 2; sequential subtests in TestKeyVizHandlerSeriesParam are intentional (see comment at keyviz_handler_test.go:160)

This is ready to merge. The three remaining items are all one-liners and can land in a follow-up if preferred.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7db3c8746c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +211 to +213
if raw == "" {
return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce default row budget when rows is absent

When the query omits rows, this branch leaves p.rows at its zero value, and later applyKeyVizRowBudget treats budget <= 0 as "no cap," so /admin/api/v1/keyviz/matrix returns every tracked row instead of respecting the intended 1024-row limit. In practice, a normal SPA poll without rows can trigger large sorts and oversized payloads (up to all tracked routes), which undermines the endpoint’s resource guard.

Useful? React with 👍 / 👎.

@bootjp bootjp merged commit ee521be into main Apr 26, 2026
11 checks passed
@bootjp bootjp deleted the feat/keyviz-admin-binary-handler branch April 26, 2026 12:02
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 19s —— View job


Code Review

  • Gather context and understand request
  • Read changed files (keyviz_handler.go, keyviz_handler_test.go, server.go, main_admin.go, main_admin_test.go)
  • Review across all 5 CLAUDE.md lenses
  • Post review

This is the third review round. Two previous rounds caught and confirmed fixes for the major issues. Reviewing the current merged state against all five CLAUDE.md lenses.


What's already in good shape ✅

  • keyvizSourceFromSampler typed-nil → interface-nil (main_admin.go:655–660) — load-bearing and correct
  • Auth chain: keyvizChain := protect(keyvizHandler) — session + CSRF + audit applied uniformly
  • Cache-Control: no-store (keyviz_handler.go:151) — correct for a heatmap snapshot
  • row.total precomputed during pivot (keyviz_handler.go:273) — O(1) sort comparator
  • BucketID tie-break in applyKeyVizRowBudget (keyviz_handler.go:351–355) — deterministic truncation
  • newKeyVizRowFrom aggregate fallback when MemberRoutesTotal == 0 (keyviz_handler.go:307–314) — defensive, correct
  • Route layout comment in buildAPIMux (server.go:211) — includes the keyviz route
  • from_unix_ms / to_unix_ms godoc notes 0 = unbounded (keyviz_handler.go:89–90)
  • 9 tests covering all major branches including time bounds, aggregate fallback, tie-break determinism

Still open — follow-up items

P1: Default row budget bypasses keyVizRowBudgetCap entirely (keyviz_handler.go:210–222, 347–348)

This is the Codex P1 item. When ?rows is omitted, p.rows stays at its zero value (0), and applyKeyVizRowBudget then takes the budget <= 0 fast-path and returns all rows untruncated. So a plain SPA poll to /admin/api/v1/keyviz/matrix — with no ?rows= parameter — can return every tracked route, up to the full ring-buffer population.

The constant's own comment says "SPA cannot force unbounded payloads via the JSON path" and the PR description says "capped at 1024 per design §4.1." The current behavior contradicts both. The godoc for the rows parameter says // 0 means no cap, capped at 1024 (default: 0) — which is internally inconsistent ("no cap" but "capped at 1024"). One of these two things should be true: either rows=0 / omitted means "apply the 1024 cap by default," or the cap is advisory only. Right now neither is true — there is no cap unless the caller opts in.

Fix: in parseKeyVizParams, after setKeyVizRowsParam, apply the cap when rows was omitted:

if p.rows == 0 {
    p.rows = keyVizRowBudgetCap
}

And update the godoc: // rows - row budget; 0 or omitted defaults to 1024, capped at 1024.

Fix this →


Medium: parseUnixMs silently accepts negative timestamps (keyviz_handler.go:234–242)

?from_unix_ms=-1 returns 200 OK with a pre-epoch time window rather than 400 invalid_query. Since setKeyVizRowsParam already rejects negative rows with the same errors.New pattern (keyviz_handler.go:215), the same guard is natural here:

if n < 0 {
    return time.Time{}, errors.New(name + " must be non-negative")
}

Bonus: a from > to pair also currently returns an empty 200 indistinguishable from "no activity." Rejecting it with 400 invalid_query in parseKeyVizParams keeps the wire contract crisp.

Fix this →


Minor: default arm in keyVizSeriesPicker is dead code (keyviz_handler.go:295–296)

keyVizSeriesPicker is only ever called after parseKeyVizSeries validates the enum, so the default arm returning a writes picker can never fire. This means a future engineer who adds a new KeyVizSeries constant and forgets to add a case here will silently get writes metrics — not a compile error, not a panic, just wrong data.

Replace with a panic to make the invariant explicit:

default:
    panic("unreachable: keyVizSeriesPicker called with invalid series: " + string(series))

Fix this →


Minor: Per-row metadata freeze is undocumented (keyviz_handler.go:264–274)

Start, End, Aggregate, RouteCount, and RouteIDsTruncated are set only on first sighting of a RouteID; later columns for the same route only update Values[j] and total. The rationale — route identity is stable within a sampling window — is not stated, so a future "why does the matrix show the old range?" debugger won't find the answer locally.

// Route-level metadata (Start/End/Aggregate/RouteCount) is frozen on
// first sighting; route identity is stable within a sampling window.

Fix this →


Lens summary

Lens Result
Data loss No issues — GET-only, 405 guard fires before any processing, fakeKeyVizSource deep-copy contract honored
Concurrency No issues — handler fields read-only after construction, pivot is purely functional, typed-nil conversion is load-bearing and correct
Performance P1 above — no-rows path returns unbounded rows contrary to cap intent; all other hot-path allocations are acceptable for an admin endpoint
Data consistency Negative timestamp inputs silently produce pre-epoch windows; inverted from > to range produces empty 200
Test coverage 9 tests cover all major branches; TestKeyVizHandlerTimeBoundsParam does not yet cover the negative-timestamp or inverted-range cases

The P1 (default rows behavior) is worth a follow-up fix before the SPA page lands — once the SPA polls this endpoint without ?rows=N, it will get untruncated payloads and potentially trigger large JSON serializations for clusters with many routes.

bootjp added a commit that referenced this pull request Apr 26, 2026
…up) (#672)

## Summary

Follow-up to PR #660, which was merged at the round-2 commit before this
Codex P1 fix propagated. The orphaned change:

**Codex round-3 P1**: `?rows=` was optional and parsed to `0` when
omitted; `applyKeyVizRowBudget` then treats `budget <= 0` as "no cap"
and returns every tracked route in one payload — defeating the 1024-row
resource guard the cap was supposed to provide. A normal SPA poll
without `?rows=` would fall into this trap.

- `parseKeyVizParams` now pre-seeds `rows = keyVizRowBudgetCap` so an
omitted query parameter inherits the cap.
- `setKeyVizRowsParam` collapses an explicit `rows=0` to the cap so
callers cannot disable the budget by passing 0.
- Godoc updated: omitted / 0 / negative all yield the cap; explicit
values above the cap are silently clamped down.

## Test plan

- [x] `TestKeyVizHandlerOmittedRowsAppliesDefaultCap` — stages
`keyVizRowBudgetCap+5` routes and confirms both the omitted (`""`) and
`?rows=0` forms truncate down to `keyVizRowBudgetCap`.
- [x] Existing `TestKeyVizHandlerHonorsRowsBudget` and
`TestKeyVizHandlerClampsRowsBudgetToCap` still pass — the change is
additive at the default end.
- [x] `go test -race -count=1 ./internal/admin/...` clean.
- [x] `golangci-lint run ./internal/admin/...` clean.
bootjp added a commit that referenced this pull request Apr 26, 2026
## Summary

Phase 2-B of the Key Visualizer design
(`docs/admin_ui_key_visualizer_design.md`): integrate the heatmap into
the existing `web/admin/` SPA rather than building a parallel dashboard.
Doc + implementation in one PR (doc commit first, per CLAUDE.md
design-doc-first workflow).

- New page at `/keyviz` — canvas heatmap polling
`/admin/api/v1/keyviz/matrix` with series picker (writes / reads /
write_bytes / read_bytes), row-budget input (clamped at 1024), and off /
5 s / 30 s auto-refresh.
- Cold cells (value 0) render as the page background, not a faint blue —
spotting actually-cold routes stays the dominant visual signal.
- Row-detail flyout on hover: bucket_id, start, end, aggregate,
route_count, route_ids (with truncation indicator). `start` / `end`
decode through a printable-or-hex preview so binary keys do not render
as mojibake.
- Dependency-free: hand-rolled five-stop colour ramp in
`lib/colorRamp.ts`. No d3, no ECharts. Bundle grew from ~155 kB to ~208
kB (raw), 64 kB gzipped.

Backend is unchanged. The handler at `/admin/api/v1/keyviz/matrix` and
its sampler wiring already shipped under Phase 2-A (PRs #639 / #645 /
#646 / #647 / #651 / #660 / #661 / #672).

## Design

`docs/design/2026_04_27_proposed_keyviz_spa_integration.md` — proposed
status, lands with this PR. Parent design §12 phase table is split into
2-A (server, shipped) / 2-B (SPA, this PR) / 2-C (cluster fan-out,
open).

## Five-lens self-review

1. **Data loss** — n/a; SPA is read-only against an existing handler.
2. **Concurrency / distributed** — n/a; single browser tab polling a
single handler. Sampler concurrency was already covered by Phase 2-A
tests.
3. **Performance** — Canvas + per-cell `fillRect` runs under §10 budget
at 1024 × 500. Auto-refresh defaults off; 5-second cadence is the lower
bound (sampler flush is 1 s).
4. **Data consistency** — SPA renders whatever the handler returns;
consistency guarantees come from the existing leader-issued counters in
the sampler.
5. **Test coverage** — `tsc -b --noEmit` clean; `vite build` clean; `go
build ./internal/admin/...` clean (embed glob unaffected); `go test
./internal/admin/...` clean. Manual verification documented in the
design doc §5.

## Test plan

- [x] `npm run lint` (`tsc -b --noEmit`) — clean
- [x] `npm run build` (Vite) — clean, output goes to
`internal/admin/dist`
- [x] `go build ./internal/admin/...` — clean
- [x] `go test ./internal/admin/...` — clean
- [ ] Manual: `make run` + `make client`, navigate to `/keyviz`, see hot
routes light up red within ~5 s of write traffic
- [ ] Manual: series picker swaps the displayed counter; row-budget
input clamps at 1024; auto-refresh polls without flicker

## Out of scope

- **Cluster fan-out** — handler is currently node-local. Phase 2-C will
add a cross-node admin RPC; this PR will pick up the aggregate view
automatically once that ships.
- **Drill-down per-route sparkline** — Phase 3.
- **Routes / Raft Groups correlation** — Phase 1 SPA pages not yet
built; correlation lands when those pages do.
- **`localStorage` for series / rows / refresh** — punt to follow-up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant