Skip to content

Commit eb1ec90

Browse files
authored
docs: add admin UI and key visualizer design (#545)
Proposes a standalone cmd/elastickv-admin binary and a TiKV-style key visualizer heatmap. Avoids the Prometheus client dependency in the initial phases by adding an in-process LiveSummary alongside the existing observers, and keeps sampler hot-path overhead below the benchmark noise floor via adaptive 1-in-N sampling with a ≥95% capture SLO. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a comprehensive design spec for the admin Web UI and Key Visualizer (architecture, sampling, persistence, UI interactions, phased plan). * **New Features** * Standalone admin HTTP server with cluster-overview API, membership discovery, CLI controls, and graceful shutdown. * Token-protected Admin gRPC API exposing cluster, raft, adapter, key-visualizer, route detail, and live event streams. * Key Visualizer: sampled heatmap, live column updates, drill-down, uncertainty/hatched rendering, and continuity across splits/merges. * **Tests** * Extensive unit and integration tests covering admin HTTP/gRPC, discovery/fanout, auth, client caching, token loading, and startup wiring. * **Chores** * Protobuf generation updated to include the new admin service. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 466e554 + 492b22d commit eb1ec90

15 files changed

Lines changed: 5907 additions & 40 deletions

adapter/admin_grpc.go

Lines changed: 479 additions & 0 deletions
Large diffs are not rendered by default.

adapter/admin_grpc_test.go

Lines changed: 632 additions & 0 deletions
Large diffs are not rendered by default.

cmd/elastickv-admin/main.go

Lines changed: 1024 additions & 0 deletions
Large diffs are not rendered by default.

cmd/elastickv-admin/main_test.go

Lines changed: 903 additions & 0 deletions
Large diffs are not rendered by default.

docs/admin_ui_key_visualizer_design.md

Lines changed: 331 additions & 0 deletions
Large diffs are not rendered by default.

internal/grpc.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,16 @@ func GRPCDialOptions() []grpc.DialOption {
2727
),
2828
}
2929
}
30+
31+
// GRPCCallOptions returns the per-call message-size cap dial option used by
32+
// callers that supply their own transport credentials (e.g. the admin
33+
// binary's TLS-aware fanout). Without this, gRPC-Go's default ~4 MiB recv
34+
// cap would silently fail RPCs once aggregated cluster-overview / matrix
35+
// admin payloads exceed 4 MiB even though node servers (GRPCServerOptions)
36+
// are configured for 64 MiB.
37+
func GRPCCallOptions() grpc.DialOption {
38+
return grpc.WithDefaultCallOptions(
39+
grpc.MaxCallRecvMsgSize(GRPCMaxMessageBytes),
40+
grpc.MaxCallSendMsgSize(GRPCMaxMessageBytes),
41+
)
42+
}

internal/raftengine/etcd/fsm_snapshot_file.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919

2020
const (
2121
fsmSnapDirName = "fsm-snap"
22+
snapFileExt = ".snap"
2223
snapshotTokenSize = 17 // 4 (magic) + 1 (version) + 8 (index) + 4 (crc32c)
2324
snapshotTokenVersion = byte(0x01)
2425

@@ -135,7 +136,7 @@ func fsmSnapPath(fsmSnapDir string, index uint64) string {
135136
// Snap files are named "{term:016x}-{index:016x}.snap".
136137
// Returns 0 on parse failure.
137138
func parseSnapFileIndex(name string) uint64 {
138-
base := strings.TrimSuffix(name, ".snap")
139+
base := strings.TrimSuffix(name, snapFileExt)
139140
idx := strings.LastIndex(base, "-")
140141
if idx < 0 {
141142
return 0
@@ -554,7 +555,7 @@ func collectLiveSnapIndexes(snapDir string) (map[uint64]bool, error) {
554555
}
555556
liveIndexes := make(map[uint64]bool, len(snapEntries))
556557
for _, e := range snapEntries {
557-
if !e.IsDir() && filepath.Ext(e.Name()) == ".snap" {
558+
if !e.IsDir() && filepath.Ext(e.Name()) == snapFileExt {
558559
if idx := parseSnapFileIndex(e.Name()); idx > 0 {
559560
liveIndexes[idx] = true
560561
}
@@ -644,7 +645,7 @@ func purgeOldSnapshotFiles(snapDir, fsmSnapDir string) error {
644645
func collectSnapNames(entries []os.DirEntry) []string {
645646
var snaps []string
646647
for _, e := range entries {
647-
if !e.IsDir() && filepath.Ext(e.Name()) == ".snap" {
648+
if !e.IsDir() && filepath.Ext(e.Name()) == snapFileExt {
648649
snaps = append(snaps, e.Name())
649650
}
650651
}

internal/tokenfile.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package internal
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/cockroachdb/errors"
12+
)
13+
14+
// LoadBearerTokenFile materialises a bearer-token file with a strict upper
15+
// bound on size so a misconfigured path (for example, pointing at a log)
16+
// cannot force an arbitrary allocation before the bearer-token check.
17+
// The file is read through an io.LimitReader bounded to maxBytes+1 so a
18+
// file that grows or is swapped between stat() and read() still cannot
19+
// sneak past the cap.
20+
//
21+
// The returned string has surrounding whitespace trimmed; an empty file (or
22+
// one that is only whitespace) is reported as an error so operators notice
23+
// the misconfiguration immediately.
24+
//
25+
// The humanName is used in error messages to distinguish token files (e.g.
26+
// "admin token" vs "node token"); callers typically pass a fixed string like
27+
// "admin token" or "node token".
28+
func LoadBearerTokenFile(path string, maxBytes int64, humanName string) (string, error) {
29+
if humanName == "" {
30+
humanName = "token"
31+
}
32+
abs, err := filepath.Abs(path)
33+
if err != nil {
34+
return "", errors.Wrapf(err, "resolve %s path", humanName)
35+
}
36+
f, err := os.Open(abs)
37+
if err != nil {
38+
return "", errors.Wrapf(err, "open %s file", humanName)
39+
}
40+
defer func() {
41+
if cerr := f.Close(); cerr != nil {
42+
log.Printf("internal: close %s file %s: %v", humanName, abs, cerr)
43+
}
44+
}()
45+
b, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
46+
if err != nil {
47+
return "", errors.Wrapf(err, "read %s file", humanName)
48+
}
49+
if int64(len(b)) > maxBytes {
50+
return "", fmt.Errorf("%s file %s exceeds maximum of %d bytes", humanName, abs, maxBytes)
51+
}
52+
tok := strings.TrimSpace(string(b))
53+
if tok == "" {
54+
return "", fmt.Errorf("%s file %s is empty", humanName, abs)
55+
}
56+
return tok, nil
57+
}

internal/tokenfile_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package internal
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestLoadBearerTokenFileHappyPath(t *testing.T) {
11+
t.Parallel()
12+
dir := t.TempDir()
13+
path := filepath.Join(dir, "tok")
14+
if err := os.WriteFile(path, []byte("\n s3cret \n"), 0o600); err != nil {
15+
t.Fatal(err)
16+
}
17+
got, err := LoadBearerTokenFile(path, 4<<10, "admin token")
18+
if err != nil {
19+
t.Fatalf("LoadBearerTokenFile: %v", err)
20+
}
21+
if got != "s3cret" {
22+
t.Fatalf("tok = %q, want s3cret", got)
23+
}
24+
}
25+
26+
func TestLoadBearerTokenFileRejectsEmpty(t *testing.T) {
27+
t.Parallel()
28+
dir := t.TempDir()
29+
path := filepath.Join(dir, "empty")
30+
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
31+
t.Fatal(err)
32+
}
33+
_, err := LoadBearerTokenFile(path, 4<<10, "admin token")
34+
if err == nil || !strings.Contains(err.Error(), "is empty") {
35+
t.Fatalf("want empty-file error, got %v", err)
36+
}
37+
}
38+
39+
func TestLoadBearerTokenFileRejectsOversize(t *testing.T) {
40+
t.Parallel()
41+
dir := t.TempDir()
42+
path := filepath.Join(dir, "huge")
43+
const cap_ = 64
44+
if err := os.WriteFile(path, []byte(strings.Repeat("x", cap_+1)), 0o600); err != nil {
45+
t.Fatal(err)
46+
}
47+
_, err := LoadBearerTokenFile(path, cap_, "admin token")
48+
if err == nil || !strings.Contains(err.Error(), "exceeds maximum") {
49+
t.Fatalf("want oversize error, got %v", err)
50+
}
51+
}
52+
53+
func TestLoadBearerTokenFileMissingFile(t *testing.T) {
54+
t.Parallel()
55+
_, err := LoadBearerTokenFile("/definitely/not/there", 4<<10, "admin token")
56+
if err == nil {
57+
t.Fatal("expected open-failure error")
58+
}
59+
}

0 commit comments

Comments
 (0)