Aggregated lens
+Pods
+0 cached rows
+| Context | +Namespace | +Name | +Ready | +Status | +Restarts | +Observed | +
|---|
diff --git a/README.md b/README.md index ea03ffa..8f17c52 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Sith -**Status: Slice 3 local fleet client.** The CLI discovers every context resolved by client-go, -hydrates a local in-memory fleet cache through per-context watches, serves coverage-honest fleet -search, and provides explicit-context logs, exec, port-forward, describe, and YAML view/edit. +**Status: Slice 4 local fleet client.** The CLI and embedded browser IDE discover every context +resolved by client-go, hydrate one local in-memory fleet cache through per-context watches, serve +coverage-honest fleet search/correlation, and provide explicit-context logs, exec, port-forward, +describe, and YAML view/edit. Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**. It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster @@ -29,6 +30,7 @@ make build ./bin/sith exec api --context kind-dev -n apps -it -- /bin/sh ./bin/sith port-forward service/api --context kind-dev -n apps :http ./bin/sith edit configmap/api-settings --context kind-dev -n apps +./bin/sith ui # loopback-only embedded fleet IDE ``` `sith clusters` follows standard client-go loading rules: set `KUBECONFIG` to an OS path-list or @@ -54,6 +56,15 @@ active one. The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or component dependency enters the binary. +`sith ui` serves a build-free frontend embedded in the same Go binary. It binds to +`127.0.0.1` on an available port by default; `--address` accepts loopback addresses only and +`--no-open` suppresses browser launch. The browser renders the same cache, lenses, ordering, +coverage, search/correlation grammar, and per-resource operations as the CLI/TUI. Its local HTTP +boundary requires an exact Host/Origin and a per-process capability header, uses a restrictive +Content Security Policy, and loads no remote assets. YAML apply additionally requires a short-lived, +single-use server preview token bound to the exact target and manifest; Secret edit requires an +explicit reveal-and-edit confirmation before unredacted data enters the browser. + Local resource operations always require or derive one explicit cached context and use that context's existing kubeconfig identity directly. They are deliberately separate from Sith's governed Intent/PEP action model. Secret YAML is redacted unless `--show-secrets` is explicit; @@ -74,10 +85,11 @@ make ci ``` The real multi-cluster gate creates two temporary kind clusters with a digest-pinned node image, -checks one additional unreachable context, and proves context-isolated logs, exec, YAML/Secret -handling, describe/events, dry-run edit, and loopback TCP forwarding against a scratch fixture -image. It removes both clusters afterward. The gate requires a running Docker engine and kind -v0.32.0, and consumes additional CI time, disk, and memory: +checks one additional unreachable context, and proves CLI plus web-IDE context isolation for +search/correlation, logs, exec, YAML/Secret handling, describe/events, preview-gated edit, and +loopback TCP forwarding against a scratch fixture image. It removes both clusters afterward. The +gate requires a running Docker engine and kind v0.32.0, and consumes additional CI time, disk, +and memory: ```bash make e2e-kind diff --git a/go.mod b/go.mod index 114a2b0..af2fc79 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 + k8s.io/streaming v0.36.2 sigs.k8s.io/yaml v1.6.0 ) @@ -61,7 +62,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/streaming v0.36.2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a6a45cd..1ab40ed 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -179,10 +179,10 @@ func TestClustersUsesInjectedSource(t *testing.T) { } } -func TestUIStub(t *testing.T) { - stdout, _, exitCode := runCLI(t, []string{"ui"}, fleet.StubSource{}) - if exitCode != 0 || stdout != "sith ui: not yet implemented — see F11.3 (#34).\n" { - t.Fatalf("exit/stdout = %d/%q", exitCode, stdout) +func TestUIRequiresLocalBackend(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"ui", "--no-open"}, fleet.StubSource{}) + if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "requires a Kubernetes reader") { + t.Fatalf("exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) } } diff --git a/internal/cli/local_test.go b/internal/cli/local_test.go index f994f43..2574983 100644 --- a/internal/cli/local_test.go +++ b/internal/cli/local_test.go @@ -14,6 +14,7 @@ import ( "sync" "testing" + "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/fleet" "github.com/ArdurAI/sith/internal/localops" ) @@ -129,6 +130,24 @@ func TestEditSurfacesServerDryRunRejectionWithoutApply(t *testing.T) { } } +func TestUIRefusesExternalBindAndStartsOnLoopback(t *testing.T) { + reader := &cacheReader{} + client := &fakeLocalClient{} + _, stderr, exitCode := runUICLI(context.Background(), t, []string{ + "ui", "--address", "0.0.0.0", "--no-open", + }, reader, client) + if exitCode == 0 || !strings.Contains(stderr, "not loopback") { + t.Fatalf("external bind exit/stderr = %d/%q", exitCode, stderr) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + stdout, stderr, exitCode := runUICLI(ctx, t, []string{"ui", "--port", "0", "--no-open"}, reader, client) + if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "sith ui listening on http://127.0.0.1:") { + t.Fatalf("loopback UI exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + func runLocalCLI( t *testing.T, args []string, @@ -146,6 +165,22 @@ func runLocalCLI( return stdoutBuffer.String(), stderrBuffer.String(), exitCode } +func runUICLI( + ctx context.Context, + t *testing.T, + args []string, + reader connector.Reader, + client localops.Client, +) (stdout, stderr string, exitCode int) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var stdoutBuffer, stderrBuffer bytes.Buffer + exitCode = executeBackendContext(ctx, args, backend{ + source: connector.AsSource(reader), reader: reader, local: client, tuiInput: strings.NewReader(""), + }, &stdoutBuffer, &stderrBuffer) + return stdoutBuffer.String(), stderrBuffer.String(), exitCode +} + type fakeLocalClient struct { mu sync.Mutex callCount int diff --git a/internal/cli/root.go b/internal/cli/root.go index 124d713..0311048 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -140,7 +140,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { commands := []*cobra.Command{ newVersionCommand(options), newClustersCommand(options, runtime.source), - newUICommand(), + newUICommand(runtime.reader, runtime.local), newHubCommand(), } if runtime.reader != nil { diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 6cecd34..6901b23 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -3,21 +3,132 @@ package cli import ( + "context" + "errors" "fmt" + "net" + "net/http" + "os/exec" + "runtime" + "strconv" + "time" "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" ) -func newUICommand() *cobra.Command { - return &cobra.Command{ +type uiOptions struct { + address string + port int + noOpen bool +} + +func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command { + options := &uiOptions{address: "127.0.0.1"} + command := &cobra.Command{ Use: "ui", - Short: "Start the local fleet IDE", + Short: "Start the loopback-only local fleet IDE", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if _, err := fmt.Fprintln(command.OutOrStdout(), "sith ui: not yet implemented — see F11.3 (#34)."); err != nil { - return fmt.Errorf("write ui status: %w", err) + if reader == nil || local == nil { + return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client") } - return nil + return runWebUI(command.Context(), command, reader, local, options) }, } + command.Flags().StringVar(&options.address, "address", options.address, "loopback listen address") + command.Flags().IntVar(&options.port, "port", 0, "loopback listen port; 0 selects an available port") + command.Flags().BoolVar(&options.noOpen, "no-open", false, "do not open the system browser") + return command +} + +func runWebUI( + ctx context.Context, + command *cobra.Command, + reader connector.Reader, + local localops.Client, + options *uiOptions, +) error { + if err := webui.ValidateLoopbackAddress(options.address); err != nil { + return err + } + if options.port < 0 || options.port > 65535 { + return fmt.Errorf("local web UI port must be between 0 and 65535") + } + listener, err := net.Listen("tcp", net.JoinHostPort(options.address, strconv.Itoa(options.port))) + if err != nil { + return fmt.Errorf("listen for local fleet UI: %w", err) + } + defer func() { _ = listener.Close() }() + tcpAddress, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return fmt.Errorf("local fleet UI listener returned an unexpected address type") + } + origin := "http://" + net.JoinHostPort(options.address, strconv.Itoa(tcpAddress.Port)) + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store) + if err != nil { + return err + } + application, err := webui.New(ctx, store, hydrator, local) + if err != nil { + return err + } + defer func() { _ = application.Close() }() + handler, err := application.Handler(origin) + if err != nil { + return err + } + server := &http.Server{ + Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 0, + IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 32 << 10, + } + serverErrors := make(chan error, 1) + go func() { serverErrors <- server.Serve(listener) }() + go func() { _ = hydrator.Run(ctx) }() + if _, err := fmt.Fprintf(command.OutOrStdout(), "sith ui listening on %s\n", origin); err != nil { + return fmt.Errorf("write local fleet UI address: %w", err) + } + if !options.noOpen { + go func() { + if err := openBrowser(origin); err != nil { + _, _ = fmt.Fprintf(command.ErrOrStderr(), "warning: open browser: %v\n", err) + } + }() + } + var serveErr error + select { + case <-ctx.Done(): + case serveErr = <-serverErrors: + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + shutdownErr := server.Shutdown(shutdownCtx) + if errors.Is(serveErr, http.ErrServerClosed) { + serveErr = nil + } + return errors.Join(serveErr, shutdownErr) +} + +func openBrowser(url string) error { + var name string + var arguments []string + switch runtime.GOOS { + case "darwin": + name, arguments = "open", []string{url} + case "windows": + name, arguments = "rundll32", []string{"url.dll,FileProtocolHandler", url} + default: + name, arguments = "xdg-open", []string{url} + } + // #nosec G204 -- executable names are fixed above and the URL is a generated loopback origin. + if err := exec.Command(name, arguments...).Start(); err != nil { + return err + } + return nil } diff --git a/internal/webui/api.go b/internal/webui/api.go new file mode 100644 index 0000000..ebc6929 --- /dev/null +++ b/internal/webui/api.go @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pmezard/go-difflib/difflib" + + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" +) + +const ( + maxAPIRequestBytes = 10 << 20 + maxExecOutputBytes = 1 << 20 + maxExecArguments = 64 + maxForwardPorts = 16 +) + +func (application *Application) registerAPI(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/meta", application.handleMeta) + mux.HandleFunc("GET /api/v1/snapshot", application.handleSnapshot) + mux.HandleFunc("POST /api/v1/sync", application.handleSync) + mux.HandleFunc("GET /api/v1/object", application.handleObject) + mux.HandleFunc("GET /api/v1/logs", application.handleLogs) + mux.HandleFunc("POST /api/v1/exec", application.handleExec) + mux.HandleFunc("POST /api/v1/edit/preview", application.handleEditPreview) + mux.HandleFunc("POST /api/v1/edit/apply", application.handleEditApply) + mux.HandleFunc("GET /api/v1/port-forwards", application.handleListForwards) + mux.HandleFunc("POST /api/v1/port-forwards", application.handleStartForward) + mux.HandleFunc("DELETE /api/v1/port-forwards/{id}", application.handleDeleteForward) +} + +func (application *Application) handleMeta(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, map[string]any{ + "mode": application.mode, "account_required": false, "telemetry": false, + "lenses": hydrate.TierOneKinds(), + "operations": []string{"describe", "yaml", "logs", "exec", "port-forward", "edit"}, + }) +} + +func (application *Application) handleSnapshot(response http.ResponseWriter, request *http.Request) { + query := fleetcache.Query{ + Kind: request.URL.Query().Get("kind"), + Limit: 5000, + } + if scopes := splitNonEmpty(request.URL.Query().Get("scopes"), ","); len(scopes) > 0 { + query.Scopes = scopes + } + expression := strings.TrimSpace(request.URL.Query().Get("q")) + if expression != "" { + var parsed fleetcache.Query + var err error + if request.URL.Query().Get("correlate") == "true" { + parsed, err = fleetcache.ParseCorrelation(expression) + } else { + parsed, err = fleetcache.ParseSearch(expression) + } + if err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if request.URL.Query().Get("all") != "true" && parsed.Kind == "" { + parsed.Kind = query.Kind + } + if len(query.Scopes) > 0 { + parsed.Scopes = query.Scopes + } + parsed.Limit = query.Limit + query = parsed + } + writeJSON(response, http.StatusOK, application.store.Query(query)) +} + +type syncRequest struct { + Kinds []string `json:"kinds"` +} + +func (application *Application) handleSync(response http.ResponseWriter, request *http.Request) { + payload := syncRequest{} + if request.ContentLength != 0 { + if err := decodeJSON(response, request, 64<<10, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + } + kinds := append([]string(nil), payload.Kinds...) + if !application.refreshing.CompareAndSwap(false, true) { + writeJSON(response, http.StatusAccepted, map[string]string{"status": "refresh already running"}) + return + } + go func() { + defer application.refreshing.Store(false) + if len(kinds) == 0 { + _ = application.syncer.SyncOnce(application.ctx) + return + } + _ = application.syncer.SyncKinds(application.ctx, kinds...) + }() + writeJSON(response, http.StatusAccepted, map[string]string{"status": "refresh scheduled"}) +} + +type objectResponse struct { + Target localops.Target `json:"target"` + YAML string `json:"yaml"` + Events []json.RawMessage `json:"events"` +} + +func (application *Application) handleObject(response http.ResponseWriter, request *http.Request) { + target, err := targetFromQuery(request) + if err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if request.URL.Query().Get("describe") == "true" { + description, err := application.local.Describe(request.Context(), target) + if err != nil { + writeOperationError(response, err) + return + } + events := make([]json.RawMessage, 0, len(description.Events)) + for _, event := range description.Events { + events = append(events, append(json.RawMessage(nil), event.Observed...)) + } + writeJSON(response, http.StatusOK, objectResponse{Target: target, YAML: string(description.Object.YAML), Events: events}) + return + } + reveal := request.URL.Query().Get("reveal_secrets") == "true" + view, err := application.local.View(request.Context(), target, reveal) + if err != nil { + writeOperationError(response, err) + return + } + writeJSON(response, http.StatusOK, objectResponse{Target: target, YAML: string(view.YAML), Events: []json.RawMessage{}}) +} + +func (application *Application) handleLogs(response http.ResponseWriter, request *http.Request) { + target, err := targetFromQuery(request) + if err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + target.Kind = "Pod" + tail := int64(200) + if value := request.URL.Query().Get("tail"); value != "" { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < -1 || parsed > 10000 { + writeAPIError(response, http.StatusBadRequest, "tail must be between -1 and 10000") + return + } + tail = parsed + } + since := time.Duration(0) + if value := request.URL.Query().Get("since"); value != "" { + since, err = time.ParseDuration(value) + if err != nil || since < 0 { + writeAPIError(response, http.StatusBadRequest, "since must be a non-negative duration") + return + } + } + stream, err := application.local.Logs(request.Context(), target, localops.LogOptions{ + Container: request.URL.Query().Get("container"), Follow: request.URL.Query().Get("follow") == "true", + Previous: request.URL.Query().Get("previous") == "true", Timestamps: true, TailLines: &tail, Since: since, + }) + if err != nil { + writeOperationError(response, err) + return + } + defer func() { _ = stream.Close() }() + response.Header().Set("Content-Type", "text/plain; charset=utf-8") + response.WriteHeader(http.StatusOK) + flusher, _ := response.(http.Flusher) + buffer := make([]byte, 16<<10) + for { + count, readErr := stream.Read(buffer) + if count > 0 { + if _, err := response.Write(buffer[:count]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + if readErr != nil { + return + } + } +} + +type execRequest struct { + Target localops.Target `json:"target"` + Container string `json:"container,omitempty"` + Command []string `json:"command"` +} + +type execResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Truncated bool `json:"truncated"` +} + +func (application *Application) handleExec(response http.ResponseWriter, request *http.Request) { + payload := execRequest{} + if err := decodeJSON(response, request, maxAPIRequestBytes, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if len(payload.Command) == 0 || len(payload.Command) > maxExecArguments { + writeAPIError(response, http.StatusBadRequest, "command must contain between 1 and 64 arguments") + return + } + for _, argument := range payload.Command { + if len(argument) > 4096 { + writeAPIError(response, http.StatusBadRequest, "each command argument must be at most 4096 bytes") + return + } + } + payload.Target.Kind = "Pod" + stdout, stderr := &boundedWriter{limit: maxExecOutputBytes}, &boundedWriter{limit: maxExecOutputBytes} + err := application.local.Exec(request.Context(), payload.Target, localops.ExecOptions{ + Container: payload.Container, Command: append([]string(nil), payload.Command...), + }, localops.Streams{Stdout: stdout, Stderr: stderr}) + if err != nil { + writeOperationError(response, err) + return + } + writeJSON(response, http.StatusOK, execResponse{ + Stdout: stdout.String(), Stderr: stderr.String(), Truncated: stdout.truncated || stderr.truncated, + }) +} + +type editRequest struct { + Target localops.Target `json:"target"` + Manifest string `json:"manifest"` + PreviewToken string `json:"preview_token,omitempty"` +} + +type editPreviewResponse struct { + Current string `json:"current"` + DryRun string `json:"dry_run"` + Diff string `json:"diff"` + PreviewToken string `json:"preview_token"` +} + +func (application *Application) handleEditPreview(response http.ResponseWriter, request *http.Request) { + payload, ok := decodeEditRequest(response, request) + if !ok { + return + } + preview, err := application.local.PreviewApply(request.Context(), payload.Target, []byte(payload.Manifest)) + if err != nil { + writeOperationError(response, err) + return + } + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(string(preview.CurrentYAML)), B: difflib.SplitLines(string(preview.DryRunYAML)), + FromFile: payload.Target.Kind + "/" + payload.Target.Name + " (current)", + ToFile: payload.Target.Kind + "/" + payload.Target.Name + " (server dry-run)", Context: 3, + }) + if err != nil { + writeAPIError(response, http.StatusInternalServerError, "build server dry-run diff: "+err.Error()) + return + } + token, err := application.previews.issue(payload.Target, payload.Manifest) + if err != nil { + writeAPIError(response, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(response, http.StatusOK, editPreviewResponse{ + Current: string(preview.CurrentYAML), DryRun: string(preview.DryRunYAML), Diff: diff, PreviewToken: token, + }) +} + +func (application *Application) handleEditApply(response http.ResponseWriter, request *http.Request) { + payload, ok := decodeEditRequest(response, request) + if !ok { + return + } + if err := application.previews.consume(payload.PreviewToken, payload.Target, payload.Manifest); err != nil { + writeAPIError(response, http.StatusConflict, err.Error()) + return + } + evidence, err := application.local.Apply(request.Context(), payload.Target, []byte(payload.Manifest)) + if err != nil { + writeOperationError(response, err) + return + } + writeJSON(response, http.StatusOK, evidence) +} + +func decodeEditRequest(response http.ResponseWriter, request *http.Request) (editRequest, bool) { + payload := editRequest{} + if err := decodeJSON(response, request, maxAPIRequestBytes, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return editRequest{}, false + } + if len(payload.Manifest) == 0 || len(payload.Manifest) > maxAPIRequestBytes { + writeAPIError(response, http.StatusBadRequest, "manifest must contain between 1 byte and 10 MiB") + return editRequest{}, false + } + return payload, true +} + +type startForwardRequest struct { + Target localops.Target `json:"target"` + Ports []string `json:"ports"` +} + +func (application *Application) handleListForwards(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, application.forwards.list()) +} + +func (application *Application) handleStartForward(response http.ResponseWriter, request *http.Request) { + payload := startForwardRequest{} + if err := decodeJSON(response, request, 64<<10, &payload); err != nil { + writeAPIError(response, http.StatusBadRequest, err.Error()) + return + } + if len(payload.Ports) == 0 || len(payload.Ports) > maxForwardPorts { + writeAPIError(response, http.StatusBadRequest, "ports must contain between 1 and 16 mappings") + return + } + if err := application.forwards.reserve(); err != nil { + writeAPIError(response, http.StatusTooManyRequests, err.Error()) + return + } + reserved := true + defer func() { + if reserved { + application.forwards.releaseReservation() + } + }() + session, err := application.local.PortForward(application.ctx, localops.ForwardRequest{ + Target: payload.Target, Ports: append([]string(nil), payload.Ports...), + }) + if err != nil { + writeOperationError(response, err) + return + } + timer := time.NewTimer(20 * time.Second) + defer timer.Stop() + select { + case <-session.Ready(): + forward, err := application.forwards.add(payload.Target, session) + if err != nil { + _ = session.Close() + writeOperationError(response, err) + return + } + reserved = false + writeJSON(response, http.StatusCreated, forward) + case err := <-session.Done(): + _ = session.Close() + if err == nil { + err = fmt.Errorf("port-forward ended before becoming ready") + } + writeOperationError(response, err) + case <-timer.C: + _ = session.Close() + writeAPIError(response, http.StatusGatewayTimeout, "port-forward did not become ready within 20 seconds") + case <-request.Context().Done(): + _ = session.Close() + } +} + +func (application *Application) handleDeleteForward(response http.ResponseWriter, request *http.Request) { + if err := application.forwards.close(request.PathValue("id")); err != nil { + writeAPIError(response, http.StatusNotFound, err.Error()) + return + } + response.WriteHeader(http.StatusNoContent) +} + +func targetFromQuery(request *http.Request) (localops.Target, error) { + target := localops.Target{ + Context: request.URL.Query().Get("context"), Namespace: request.URL.Query().Get("namespace"), + Kind: request.URL.Query().Get("kind"), Name: request.URL.Query().Get("name"), + } + if err := target.Validate(); err != nil { + return localops.Target{}, err + } + return target, nil +} + +func splitNonEmpty(value, separator string) []string { + parts := strings.Split(value, separator) + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func writeOperationError(response http.ResponseWriter, err error) { + writeAPIError(response, http.StatusUnprocessableEntity, err.Error()) +} + +type boundedWriter struct { + buffer bytes.Buffer + limit int + truncated bool +} + +func (writer *boundedWriter) Write(payload []byte) (int, error) { + original := len(payload) + remaining := writer.limit - writer.buffer.Len() + if remaining <= 0 { + writer.truncated = writer.truncated || original > 0 + return original, nil + } + if len(payload) > remaining { + payload = payload[:remaining] + writer.truncated = true + } + _, _ = writer.buffer.Write(payload) + return original, nil +} + +func (writer *boundedWriter) String() string { return writer.buffer.String() } + +var _ io.Writer = (*boundedWriter)(nil) diff --git a/internal/webui/assets/app.css b/internal/webui/assets/app.css new file mode 100644 index 0000000..71e399b --- /dev/null +++ b/internal/webui/assets/app.css @@ -0,0 +1,315 @@ +:root { + --storm: #101820; + --storm-soft: #24313a; + --fog: #edf1f3; + --paper: #f8faf9; + --white: #ffffff; + --cobalt: #2155a3; + --cobalt-pale: #dce7f7; + --orange: #e56b3f; + --mint: #2d8b77; + --amber: #c58a24; + --alert: #b33a3a; + --line: #cbd4d8; + --muted: #63727b; + --display: ui-rounded, "Avenir Next", "Segoe UI", sans-serif; + --body: "Avenir Next", "Segoe UI", Helvetica, Arial, sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-family: var(--body); + color: var(--storm); + background: var(--fog); + font-synthesis: none; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + linear-gradient(90deg, rgba(16, 24, 32, 0.035) 1px, transparent 1px) 0 0 / 24px 24px, + var(--fog); +} + +button, input, textarea { font: inherit; } +button { color: inherit; } + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid rgba(33, 85, 163, 0.36); + outline-offset: 2px; +} + +.boot-scan { + position: fixed; + z-index: 100; + inset: 0 0 auto; + height: 3px; + pointer-events: none; + background: var(--orange); + transform-origin: left; + animation: boot-scan 850ms cubic-bezier(.22, .9, .3, 1) both; +} + +@keyframes boot-scan { + 0% { transform: scaleX(0); opacity: 1; } + 78% { transform: scaleX(1); opacity: 1; } + 100% { transform: scaleX(1); opacity: 0; } +} + +.masthead { + display: grid; + grid-template-columns: minmax(230px, 0.8fr) minmax(320px, 1.5fr) auto; + align-items: center; + min-height: 88px; + padding: 14px 22px; + color: var(--paper); + background: var(--storm); + border-bottom: 4px solid var(--orange); +} + +.identity { display: flex; align-items: center; gap: 12px; } +.identity-mark { + display: grid; + width: 46px; + aspect-ratio: 1; + place-items: center; + border: 1px solid #52616b; + font: 700 25px/1 var(--display); + color: var(--orange); + clip-path: polygon(0 0, 82% 0, 100% 18%, 100% 100%, 0 100%); +} + +.eyebrow { + margin: 0 0 4px; + font: 700 10px/1.2 var(--mono); + letter-spacing: .16em; + text-transform: uppercase; + color: var(--muted); +} + +.masthead .eyebrow { color: #9caab2; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 0; font: 700 26px/1 var(--display); letter-spacing: -.02em; } +h1 span { font: 500 14px/1 var(--mono); color: #aebac0; } +h2 { margin-bottom: 0; font: 650 21px/1.15 var(--display); letter-spacing: -.02em; } + +.coverage-summary { + display: flex; + align-items: baseline; + gap: 18px; + padding: 0 24px; + border-left: 1px solid #39464f; +} + +#coverage-count { font: 650 17px/1.2 var(--display); } +#coverage-detail { color: #aebac0; font-size: 12px; } +.mast-actions { display: flex; gap: 8px; justify-content: flex-end; } + +.primary-action, .quiet-action, .mode-switch, .operation-grid button, .dialog-actions button, .close-action { + min-height: 38px; + border: 1px solid currentColor; + border-radius: 2px; + padding: 8px 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: .02em; + cursor: pointer; + background: transparent; +} + +.primary-action { color: var(--storm); background: var(--orange); border-color: var(--orange); } +.quiet-action { color: var(--paper); border-color: #52616b; } +.quiet-action span { color: var(--orange); font-family: var(--mono); } +.primary-action:hover { background: #f27a4f; } +.quiet-action:hover { border-color: var(--paper); } + +.query-deck { + display: grid; + grid-template-columns: minmax(410px, 1.1fr) minmax(330px, 1fr) auto; + gap: 14px; + align-items: center; + padding: 12px 22px; + background: var(--paper); + border-bottom: 1px solid var(--line); +} + +.lens-switcher { display: flex; gap: 4px; overflow-x: auto; } +.lens-switcher button { + border: 0; + border-bottom: 2px solid transparent; + padding: 9px 10px 7px; + background: transparent; + color: var(--muted); + font: 700 11px/1 var(--mono); + text-transform: uppercase; + cursor: pointer; +} +.lens-switcher button[aria-current="true"] { color: var(--storm); border-color: var(--orange); } + +.search-field { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 0 10px; + border: 1px solid var(--line); + background: var(--white); +} +.search-field span { font: 700 10px/1 var(--mono); color: var(--muted); text-transform: uppercase; } +.search-field input { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--storm); } +.search-field kbd { font: 600 10px/1 var(--mono); color: var(--muted); border: 1px solid var(--line); padding: 4px 6px; } +.mode-switch { color: var(--cobalt); border-color: var(--cobalt); background: var(--white); } +.mode-switch[aria-pressed="true"] { color: var(--white); background: var(--cobalt); } + +.workspace { + display: grid; + grid-template-columns: 224px minmax(520px, 1fr) 340px; + min-height: calc(100vh - 155px); +} + +.context-rail, .inspector { background: var(--paper); } +.context-rail { padding: 22px 18px; border-right: 1px solid var(--line); } +.rail-heading, .board-heading { display: flex; align-items: flex-end; justify-content: space-between; } +.context-list { position: relative; margin: 25px 0 18px; padding-left: 0; } +.context-list::before { + content: ""; + position: absolute; + top: 18px; + bottom: 18px; + left: 13px; + width: 1px; + background: var(--line); +} +.context-node { + position: relative; + display: grid; + grid-template-columns: 27px minmax(0, 1fr); + gap: 10px; + width: 100%; + padding: 8px 4px 8px 0; + border: 0; + text-align: left; + background: transparent; + cursor: pointer; +} +.context-node::before { + content: ""; + z-index: 1; + width: 9px; + height: 9px; + margin: 4px 0 0 9px; + border: 3px solid var(--paper); + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 0 1px var(--mint); +} +.context-node[data-state="stale"]::before { background: var(--amber); box-shadow: 0 0 0 1px var(--amber); } +.context-node[data-state="down"]::before { background: var(--alert); box-shadow: 0 0 0 1px var(--alert); } +.context-node[aria-current="true"] { color: var(--cobalt); } +.context-node strong { display: block; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.context-node small { display: block; margin-top: 2px; color: var(--muted); font: 10px/1.2 var(--mono); } +.rail-note { color: var(--muted); font-size: 11px; line-height: 1.5; } + +.fleet-board { min-width: 0; padding: 22px; } +.board-heading { margin-bottom: 15px; } +.result-count { margin: 0; color: var(--muted); font: 11px/1.2 var(--mono); } +.table-frame { position: relative; min-height: 420px; overflow: auto; border: 1px solid var(--line); background: var(--white); } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +thead { position: sticky; z-index: 2; top: 0; background: var(--storm); color: var(--paper); } +th { padding: 10px 12px; text-align: left; font: 650 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +td { max-width: 250px; padding: 10px 12px; border-bottom: 1px solid #e4e9eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +tbody tr { cursor: pointer; } +tbody tr:hover { background: #f1f5f7; } +tbody tr[aria-selected="true"] { background: var(--cobalt-pale); box-shadow: inset 4px 0 var(--cobalt); } +.cell-context, .cell-age, .cell-restarts { font-family: var(--mono); font-size: 10px; } +.status-pill { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; } +.status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--mint); } +.status-pill[data-tone="warn"]::before { background: var(--amber); } +.status-pill[data-tone="bad"]::before { background: var(--alert); } +.status-pill[data-tone="muted"]::before { background: var(--muted); } +.empty-state { display: grid; gap: 8px; place-items: center; padding: 80px 20px; color: var(--muted); text-align: center; } +.empty-state[hidden] { display: none; } +.coverage-line { padding: 11px 3px 0; color: var(--muted); font: 11px/1.4 var(--mono); } + +.inspector { padding: 22px; border-left: 1px solid var(--line); } +.inspector-empty { margin-top: 33vh; transform: translateY(-50%); } +.inspector-empty p:last-child { color: var(--muted); font-size: 12px; line-height: 1.55; } +.resource-address { margin: 7px 0 22px; color: var(--cobalt); font: 10px/1.4 var(--mono); overflow-wrap: anywhere; } +.fact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0 0 22px; background: var(--line); border: 1px solid var(--line); } +.fact-grid div { min-width: 0; padding: 10px; background: var(--white); } +.fact-grid dt { color: var(--muted); font: 700 9px/1 var(--mono); text-transform: uppercase; } +.fact-grid dd { margin: 5px 0 0; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } +.operation-grid button { border-color: var(--line); background: var(--white); text-align: left; } +.operation-grid button:hover { border-color: var(--cobalt); color: var(--cobalt); } +.identity-boundary { margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font: 9px/1.5 var(--mono); text-transform: uppercase; } + +.action-dialog { + width: min(920px, calc(100vw - 40px)); + max-height: calc(100vh - 40px); + padding: 0; + border: 1px solid var(--storm); + border-radius: 2px; + color: var(--storm); + background: var(--paper); + box-shadow: 0 24px 80px rgba(16, 24, 32, .34); +} +.action-dialog::backdrop { background: rgba(16, 24, 32, .62); backdrop-filter: blur(2px); } +.action-dialog > header { display: flex; justify-content: space-between; align-items: center; padding: 17px 20px; border-bottom: 1px solid var(--line); } +.close-action { min-height: 32px; color: var(--muted); border-color: var(--line); } +.dialog-body { min-height: 180px; max-height: calc(100vh - 210px); overflow: auto; padding: 20px; } +.dialog-body pre { margin: 0; padding: 16px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #dce7e9; background: var(--storm); font: 11px/1.55 var(--mono); tab-size: 2; } +.dialog-body textarea, .dialog-body input { + width: 100%; border: 1px solid var(--line); border-radius: 0; padding: 12px; color: var(--storm); background: var(--white); font: 12px/1.5 var(--mono); +} +.dialog-body textarea { min-height: 390px; resize: vertical; } +.dialog-body label { display: grid; gap: 7px; margin-bottom: 14px; color: var(--muted); font: 700 10px/1.3 var(--mono); text-transform: uppercase; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 20px; border-top: 1px solid var(--line); } +.dialog-actions button { color: var(--cobalt); border-color: var(--cobalt); background: var(--white); } +.dialog-actions button[data-primary="true"] { color: var(--white); background: var(--cobalt); } +.event-list, .forward-list { display: grid; gap: 8px; margin-top: 15px; } +.event-entry, .forward-entry { padding: 12px; border-left: 3px solid var(--orange); background: var(--white); } +.event-entry strong, .forward-entry strong { display: block; font-size: 12px; } +.event-entry span, .forward-entry span { color: var(--muted); font-size: 11px; } +.loading-state { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 12px; color: var(--muted); } +.loading-state span { width: 34px; height: 3px; background: var(--orange); animation: loading 800ms ease-in-out infinite alternate; } +@keyframes loading { to { transform: scaleX(.3); opacity: .45; } } + +.toast-region { position: fixed; z-index: 200; right: 20px; bottom: 20px; display: grid; gap: 8px; width: min(360px, calc(100vw - 40px)); } +.toast { padding: 12px 14px; color: var(--paper); background: var(--storm); border-left: 4px solid var(--mint); box-shadow: 0 8px 28px rgba(16,24,32,.2); font-size: 12px; } +.toast[data-tone="error"] { border-color: var(--alert); } + +@media (max-width: 1100px) { + .masthead { grid-template-columns: 1fr auto; } + .coverage-summary { grid-column: 1 / -1; grid-row: 2; padding: 10px 0 0; border: 0; } + .query-deck { grid-template-columns: 1fr auto; } + .lens-switcher { grid-column: 1 / -1; } + .workspace { grid-template-columns: 190px minmax(480px, 1fr); } + .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); } + .inspector-empty { margin: 0; transform: none; } +} + +@media (max-width: 760px) { + .masthead { grid-template-columns: 1fr; gap: 12px; } + .mast-actions { justify-content: flex-start; } + .coverage-summary { display: grid; gap: 4px; } + .query-deck { grid-template-columns: 1fr; } + .lens-switcher, .search-field { grid-column: auto; } + .workspace { display: block; } + .context-rail { border-right: 0; border-bottom: 1px solid var(--line); } + .context-list { display: flex; overflow-x: auto; gap: 7px; margin: 14px 0; } + .context-list::before { display: none; } + .context-node { flex: 0 0 170px; border: 1px solid var(--line); padding: 9px; } + .fleet-board, .inspector { padding: 16px; } + .table-frame { min-height: 330px; } + .operation-grid { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } +} diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js new file mode 100644 index 0000000..0d3742b --- /dev/null +++ b/internal/webui/assets/app.js @@ -0,0 +1,413 @@ +"use strict"; + +const csrfToken = document.querySelector('meta[name="sith-csrf-token"]').content; +const state = { + meta: null, + snapshot: null, + lens: "Pod", + scope: "", + query: "", + correlate: false, + selected: null, + logAbort: null, + viewKey: "", +}; + +const dom = Object.fromEntries([ + "coverage-count", "coverage-detail", "lens-switcher", "fleet-search", "search-label", + "query-mode", "context-list", "board-heading", "board-kicker", "result-count", "fleet-rows", + "empty-state", "coverage-line", "inspector-empty", "inspector-content", "inspector-kind", + "inspector-name", "inspector-address", "inspector-facts", "operation-grid", "refresh-button", + "forwards-button", "forward-count", "toast-region", "action-dialog", "dialog-title", + "dialog-kicker", "dialog-body", "dialog-actions", "dialog-close", "loading-template", +].map((id) => [id, document.getElementById(id)])); + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + headers.set("X-Sith-CSRF", csrfToken); + if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + const response = await fetch(path, {...options, headers}); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { message = (await response.json()).error || message; } catch (_) { /* response was not JSON */ } + throw new Error(message); + } + return response; +} + +function node(tag, className = "", text = "") { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== "") element.textContent = text; + return element; +} + +function replaceChildren(parent, children = []) { parent.replaceChildren(...children); } + +function ageLabel(value) { + if (!value || value.startsWith("0001-")) return "never"; + const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000)); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + return `${Math.floor(seconds / 3600)}h`; +} + +function coverageText(coverage = {}) { + const requested = coverage.requested || 0; + const reachable = coverage.reachable || 0; + const parts = [`covered ${reachable}/${requested} clusters`]; + if ((coverage.stale || []).length) parts.push(`${coverage.stale.length} stale (${coverage.stale.join(", ")})`); + if ((coverage.unreachable || []).length) parts.push(`${coverage.unreachable.length} unreachable (${coverage.unreachable.join(", ")})`); + return parts.join(" · "); +} + +function statusTone(status = "") { + const normalized = status.toLowerCase(); + if (["running", "ready", "healthy", "normal", "succeeded"].some((word) => normalized.includes(word))) return "good"; + if (["progress", "pending", "unknown"].some((word) => normalized.includes(word))) return "warn"; + if (["fail", "error", "crash", "degraded", "notready", "backoff"].some((word) => normalized.includes(word))) return "bad"; + return "muted"; +} + +function renderMeta() { + replaceChildren(dom["lens-switcher"]); + for (const lens of state.meta.lenses) { + const button = node("button", "", `${lens}s`); + button.type = "button"; + button.setAttribute("role", "listitem"); + button.setAttribute("aria-current", String(state.lens === lens)); + button.addEventListener("click", () => { state.lens = lens; state.selected = null; loadSnapshot(); renderMeta(); }); + dom["lens-switcher"].append(button); + } +} + +function renderSnapshot() { + const snapshot = state.snapshot; + const coverage = snapshot.coverage || {}; + dom["coverage-count"].textContent = `${coverage.reachable || 0} of ${coverage.requested || 0} contexts answering`; + dom["coverage-detail"].textContent = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + dom["coverage-line"].textContent = coverageText(coverage); + dom["board-heading"].textContent = state.correlate || state.query ? "Fleet results" : `${state.lens}s`; + dom["board-kicker"].textContent = state.correlate ? "Correlation answer" : state.query ? "Filtered cache" : "Aggregated lens"; + dom["result-count"].textContent = `${snapshot.records.length} cached row${snapshot.records.length === 1 ? "" : "s"}`; + renderContexts(snapshot.scopes || [], coverage); + renderRows(snapshot.records || []); + if (state.selected) { + const identity = recordIdentity(state.selected); + state.selected = snapshot.records.find((record) => recordIdentity(record) === identity) || null; + } + renderInspector(); +} + +function renderContexts(scopes, coverage) { + const entries = []; + const all = node("button", "context-node"); + all.type = "button"; + all.setAttribute("aria-current", String(!state.scope)); + all.dataset.state = (coverage.unreachable || []).length ? "stale" : "fresh"; + all.append(contextLabel("All contexts", compactCoverage(coverage))); + all.addEventListener("click", () => { state.scope = ""; loadSnapshot(); }); + entries.push(all); + for (const scope of scopes) { + const button = node("button", "context-node"); + button.type = "button"; + const isDown = !scope.reachable; + const isStale = (coverage.stale || []).includes(scope.name); + button.dataset.state = isDown ? "down" : isStale ? "stale" : "fresh"; + button.setAttribute("aria-current", String(state.scope === scope.name)); + button.append(contextLabel(scope.name, `${isDown ? "unreachable" : isStale ? "stale" : "reachable"} · ${ageLabel(scope.observed_at)}`)); + button.addEventListener("click", () => { state.scope = state.scope === scope.name ? "" : scope.name; loadSnapshot(); }); + entries.push(button); + } + replaceChildren(dom["context-list"], entries); +} + +function compactCoverage(coverage) { + return `${coverage.reachable || 0}/${coverage.requested || 0} covered · ${(coverage.stale || []).length} stale · ${(coverage.unreachable || []).length} down`; +} + +function contextLabel(name, detail) { + const wrapper = node("span"); + wrapper.append(node("strong", "", name), node("small", "", detail)); + return wrapper; +} + +function renderRows(records) { + const rows = records.map((record) => { + const row = node("tr"); + row.tabIndex = 0; + row.setAttribute("aria-selected", String(state.selected && recordIdentity(state.selected) === recordIdentity(record))); + row.append( + cell(record.cluster, "cell-context"), cell(record.namespace || "—"), cell(record.name), + cell(record.ready || "—"), statusCell(record.status || record.reason || "Unknown"), + cell(String(record.restarts || 0), "cell-restarts"), cell(ageLabel(record.observed_at), "cell-age"), + ); + const select = () => { state.selected = record; renderRows(records); renderInspector(); }; + row.addEventListener("click", select); + row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } }); + return row; + }); + replaceChildren(dom["fleet-rows"], rows); + dom["empty-state"].hidden = records.length !== 0; +} + +function cell(text, className = "") { return node("td", className, text); } +function statusCell(text) { + const td = node("td"); + const status = node("span", "status-pill", text); + status.dataset.tone = statusTone(text); + td.append(status); + return td; +} +function recordIdentity(record) { return [record.cluster, record.kind, record.namespace, record.name].join("/"); } + +function renderInspector() { + const record = state.selected; + dom["inspector-empty"].hidden = Boolean(record); + dom["inspector-content"].hidden = !record; + if (!record) return; + dom["inspector-kind"].textContent = record.kind; + dom["inspector-name"].textContent = record.name; + dom["inspector-address"].textContent = `${record.cluster} / ${record.namespace || "cluster-scoped"}`; + const facts = [ + ["Status", record.status || record.reason || "Unknown"], ["Ready", record.ready || "—"], + ["Restarts", String(record.restarts || 0)], ["Node", record.node || "—"], + ["Observed", ageLabel(record.observed_at)], ["Image", (record.images || []).join(", ") || "—"], + ]; + replaceChildren(dom["inspector-facts"], facts.map(([label, value]) => { + const wrapper = node("div"); wrapper.append(node("dt", "", label), node("dd", "", value)); return wrapper; + })); + const operations = [ + ["Describe", () => showObject(true, false)], ["View YAML", () => showObject(false, false)], + ]; + if (record.kind === "Pod") operations.push(["Follow logs", showLogs], ["Run command", showExec], ["Forward port", showForward]); + if (record.kind === "Service") operations.push(["Forward port", showForward]); + operations.push(["Edit YAML", () => showEdit(false)]); + replaceChildren(dom["operation-grid"], operations.map(([label, action]) => { + const button = node("button", "", label); button.type = "button"; button.addEventListener("click", action); return button; + })); +} + +function selectedTarget() { + const record = state.selected; + return {context: record.cluster, namespace: record.namespace, kind: record.kind, name: record.name}; +} + +function targetQuery(target) { + return new URLSearchParams({context: target.context, namespace: target.namespace || "", kind: target.kind, name: target.name}); +} + +function openDialog(title, kicker = "Local operation") { + dom["dialog-title"].textContent = title; + dom["dialog-kicker"].textContent = kicker; + replaceChildren(dom["dialog-body"], [dom["loading-template"].content.cloneNode(true)]); + replaceChildren(dom["dialog-actions"]); + if (!dom["action-dialog"].open) dom["action-dialog"].showModal(); +} + +function dialogButton(label, action, primary = false) { + const button = node("button", "", label); button.type = "button"; button.dataset.primary = String(primary); button.addEventListener("click", action); return button; +} + +function showPre(content) { replaceChildren(dom["dialog-body"], [node("pre", "", content)]); } + +async function showObject(describe, reveal) { + const target = selectedTarget(); + openDialog(`${describe ? "Describe" : "YAML"} · ${target.name}`, `${target.context} / ${target.kind}`); + try { + const query = targetQuery(target); query.set("describe", String(describe)); query.set("reveal_secrets", String(reveal)); + const payload = await (await api(`/api/v1/object?${query}`)).json(); + if (!describe) { + showPre(payload.yaml); + const actions = []; + if (target.kind === "Secret" && !reveal) actions.push(dialogButton("Reveal Secret data", () => showObject(false, true))); + actions.push(dialogButton("Edit YAML", () => showEdit(false), true)); + replaceChildren(dom["dialog-actions"], actions); + return; + } + const container = node("div"); + container.append(node("pre", "", payload.yaml)); + const events = node("div", "event-list"); + if (!payload.events.length) events.append(node("div", "event-entry", "No related events.")); + for (const event of payload.events) { + const entry = node("div", "event-entry"); + entry.append(node("strong", "", `${event.type || "Event"} · ${event.reason || "Unknown"}`), node("span", "", event.message || "No message")); + events.append(entry); + } + container.append(events); replaceChildren(dom["dialog-body"], [container]); + } catch (error) { showDialogError(error); } +} + +async function showLogs() { + const target = selectedTarget(); + openDialog(`Logs · ${target.name}`, `${target.context} / follow`); + const output = node("pre", "", "Opening stream…\n"); + replaceChildren(dom["dialog-body"], [output]); + state.logAbort?.abort(); + state.logAbort = new AbortController(); + replaceChildren(dom["dialog-actions"], [dialogButton("Stop following", () => state.logAbort?.abort(), true)]); + try { + const query = targetQuery(target); query.set("follow", "true"); query.set("tail", "200"); + const response = await api(`/api/v1/logs?${query}`, {signal: state.logAbort.signal}); + output.textContent = ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const {done, value} = await reader.read(); + if (done) break; + output.textContent += decoder.decode(value, {stream: true}); + output.scrollTop = output.scrollHeight; + } + } catch (error) { + if (error.name !== "AbortError") showDialogError(error); + } +} + +function showExec() { + const target = selectedTarget(); + openDialog(`Run command · ${target.name}`, `${target.context} / exact argv`); + const wrapper = node("div"); + const label = node("label", "", "Command arguments (JSON array)"); + const input = node("textarea"); input.value = '["uname", "-a"]'; input.style.minHeight = "110px"; label.append(input); wrapper.append(label); + replaceChildren(dom["dialog-body"], [wrapper]); + replaceChildren(dom["dialog-actions"], [dialogButton("Run command", async () => { + try { + const command = JSON.parse(input.value); + if (!Array.isArray(command) || command.some((part) => typeof part !== "string")) throw new Error("Command must be a JSON array of strings."); + replaceChildren(dom["dialog-actions"]); + const payload = await (await api("/api/v1/exec", {method: "POST", body: JSON.stringify({target, command})})).json(); + showPre(`${payload.stdout}${payload.stderr ? `\n[stderr]\n${payload.stderr}` : ""}${payload.truncated ? "\n[output truncated]" : ""}`); + } catch (error) { showDialogError(error); } + }, true)]); +} + +async function showEdit(revealSecret) { + const target = selectedTarget(); + if (target.kind === "Secret" && !revealSecret) { + openDialog(`Edit Secret · ${target.name}`, `${target.context} / explicit disclosure`); + replaceChildren(dom["dialog-body"], [node("div", "event-entry", "Editing this Secret reveals its data in the browser until the dialog closes.")]); + replaceChildren(dom["dialog-actions"], [dialogButton("Reveal and edit Secret", () => showEdit(true), true)]); + return; + } + openDialog(`Edit YAML · ${target.name}`, `${target.context} / server-validated`); + try { + const query = targetQuery(target); query.set("reveal_secrets", String(target.kind === "Secret" && revealSecret)); + const payload = await (await api(`/api/v1/object?${query}`)).json(); + const textarea = node("textarea"); textarea.value = payload.yaml; textarea.setAttribute("aria-label", "Resource YAML"); + replaceChildren(dom["dialog-body"], [textarea]); + let previewedManifest = ""; + let previewToken = ""; + const preview = dialogButton("Preview changes", async () => { + try { + const manifest = textarea.value; + const result = await (await api("/api/v1/edit/preview", {method: "POST", body: JSON.stringify({target, manifest})})).json(); + previewedManifest = manifest; + previewToken = result.preview_token; + const diff = node("pre", "", result.diff || "No changes."); + replaceChildren(dom["dialog-body"], [textarea, diff]); + replaceChildren(dom["dialog-actions"], [preview, dialogButton("Apply previewed YAML", async () => { + if (textarea.value !== previewedManifest) { toast("YAML changed after preview. Preview it again.", "error"); return; } + try { + await api("/api/v1/edit/apply", {method: "POST", body: JSON.stringify({target, manifest: previewedManifest, preview_token: previewToken})}); + toast(`${target.kind}/${target.name} updated in ${target.context}.`); + dom["action-dialog"].close(); loadSnapshot(); + } catch (error) { showDialogError(error); } + }, true)]); + } catch (error) { showDialogError(error); } + }); + replaceChildren(dom["dialog-actions"], [preview]); + } catch (error) { showDialogError(error); } +} + +function showForward() { + const target = selectedTarget(); + openDialog(`Forward port · ${target.name}`, `${target.context} / loopback only`); + const label = node("label", "", "Port mapping"); + const input = node("input"); input.value = ":8080"; input.placeholder = "LOCAL:REMOTE or :REMOTE"; label.append(input); + replaceChildren(dom["dialog-body"], [label]); + replaceChildren(dom["dialog-actions"], [dialogButton("Start loopback forward", async () => { + try { + const payload = await (await api("/api/v1/port-forwards", {method: "POST", body: JSON.stringify({target, ports: input.value.trim().split(/\s+/)})})).json(); + toast(`Forward ready: ${payload.ports.map((port) => `${port.local}→${port.remote}`).join(", ")}`); + dom["action-dialog"].close(); refreshForwardCount(); + } catch (error) { showDialogError(error); } + }, true)]); +} + +async function showForwards() { + openDialog("Active port-forwards", "Loopback session manager"); + try { + const forwards = await (await api("/api/v1/port-forwards")).json(); + const list = node("div", "forward-list"); + if (!forwards.length) list.append(node("div", "forward-entry", "No active port-forwards.")); + for (const forward of forwards) { + const entry = node("div", "forward-entry"); + entry.append(node("strong", "", `${forward.target.context} / ${forward.target.name}`), node("span", "", forward.ports.map((port) => `${port.local}→${port.remote}`).join(", "))); + const close = dialogButton("Close forward", async () => { await api(`/api/v1/port-forwards/${encodeURIComponent(forward.id)}`, {method: "DELETE"}); showForwards(); refreshForwardCount(); }); + entry.append(close); list.append(entry); + } + replaceChildren(dom["dialog-body"], [list]); + } catch (error) { showDialogError(error); } +} + +function showDialogError(error) { + const message = error instanceof Error ? error.message : String(error); + replaceChildren(dom["dialog-body"], [node("div", "event-entry", message)]); + replaceChildren(dom["dialog-actions"]); + toast(message, "error"); +} + +function toast(message, tone = "ok") { + const item = node("div", "toast", message); item.dataset.tone = tone; dom["toast-region"].append(item); + setTimeout(() => item.remove(), 4800); +} + +async function refreshForwardCount() { + try { const forwards = await (await api("/api/v1/port-forwards")).json(); dom["forward-count"].textContent = String(forwards.filter((entry) => !entry.done).length); } catch (_) { /* main view reports API failures */ } +} + +async function loadSnapshot() { + const query = new URLSearchParams({kind: state.lens}); + if (state.scope) query.set("scopes", state.scope); + if (state.query) { query.set("q", state.query); query.set("all", String(!state.correlate)); } + if (state.correlate) query.set("correlate", "true"); + try { + const next = await (await api(`/api/v1/snapshot?${query}`)).json(); + const viewKey = query.toString(); + if (state.snapshot && state.viewKey === viewKey && state.snapshot.version === next.version && + state.snapshot.state === next.state && JSON.stringify(state.snapshot.coverage) === JSON.stringify(next.coverage)) return; + state.snapshot = next; + state.viewKey = viewKey; + renderSnapshot(); + } catch (error) { toast(error.message, "error"); } +} + +let searchTimer = 0; +dom["fleet-search"].addEventListener("input", () => { + clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.query = dom["fleet-search"].value.trim(); state.selected = null; loadSnapshot(); }, 180); +}); +dom["query-mode"].addEventListener("click", () => { + state.correlate = !state.correlate; + dom["query-mode"].setAttribute("aria-pressed", String(state.correlate)); + dom["query-mode"].textContent = state.correlate ? "Correlation mode" : "Search mode"; + dom["search-label"].textContent = state.correlate ? "Correlate the fleet" : "Search the fleet"; + dom["fleet-search"].placeholder = state.correlate ? "deploy/payments status!=Healthy" : "payments status:CrashLoopBackOff"; + state.selected = null; loadSnapshot(); +}); +dom["refresh-button"].addEventListener("click", async () => { try { await api("/api/v1/sync", {method: "POST", body: "{}"}); toast("Fleet refresh scheduled."); } catch (error) { toast(error.message, "error"); } }); +dom["forwards-button"].addEventListener("click", showForwards); +dom["dialog-close"].addEventListener("click", () => dom["action-dialog"].close()); +dom["action-dialog"].addEventListener("close", () => { state.logAbort?.abort(); state.logAbort = null; }); +document.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); dom["fleet-search"].focus(); } +}); + +(async function start() { + try { + state.meta = await (await api("/api/v1/meta")).json(); + renderMeta(); + await Promise.all([loadSnapshot(), refreshForwardCount()]); + setInterval(loadSnapshot, 3000); + setInterval(refreshForwardCount, 5000); + } catch (error) { toast(`Fleet UI could not start: ${error.message}`, "error"); } +})(); diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html new file mode 100644 index 0000000..dfe89c7 --- /dev/null +++ b/internal/webui/assets/index.html @@ -0,0 +1,120 @@ + + +
+ + + + +Fleet plotting board
+Aggregated lens
+0 cached rows
+| Context | +Namespace | +Name | +Ready | +Status | +Restarts | +Observed | +
|---|
Reading selected context…