fix(audit,web,deploy): audit ids, web dry-run view, hardened chart, docs

Audit: correlation event_ids include stage and response code so the
multi-stage lines of one request no longer collapse under dedup; match
scans are bounded to a time window and a page instead of the object's full
history.

Web: dry-run results are read from dry_run_result (nested) so the verdict is
rendered correctly and conflicts/errors/skipped are shown; the plans view no
longer POSTs an unsolicited plan on page load; coverage and streams surface
API errors instead of showing a misleading empty state and follow cursor
pagination so they are not stuck on the oldest page; the diff path tokenizer
handles backslash-escaped dotted keys.

Deploy/CI: the chart no longer grants the query server a cluster-wide read
ClusterRole, runs as non-root with a read-only root filesystem, adds
liveness/readiness probes, wires the ConfigMap as env (STORE_PATH,
LISTEN_ADDR), defaults the journal to a PVC instead of an ephemeral
emptyDir, and adds imagePullSecrets; the replay ClusterRole drops the
unused update verb; a Dockerfile builds a static distroless image; GitHub
actions are pinned by commit SHA, jobs set least-privilege permissions, the
Vercel deploy skips fork PRs, CI passes the Makefile test timeouts, and
make lint runs a real web syntax check.

Docs: event-schema/consistency no longer describe an ingest_sequence field,
an observed-time-based event_id, or restart-from-checkpoint; the threat
model documents the unauthenticated HTTP API surface and the chart's RBAC
change; replay-safety matches the enforced dry-run gate.
This commit is contained in:
lakshit verma 2026-08-06 06:57:11 +05:30
parent 44fbd878a1
commit 9f4b6c2c5a
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
23 changed files with 251 additions and 96 deletions

View file

@ -13,10 +13,12 @@ jobs:
go:
name: build and test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.26.x"
cache: true
@ -31,10 +33,37 @@ jobs:
run: go test ./...
- name: Integration tests (fake apiserver)
run: go test -tags integration ./test/integration/...
run: go test -tags integration ./test/integration/... -timeout 20m
- name: End-to-end tests
run: go test -tags e2e ./test/e2e/...
run: go test -tags e2e ./test/e2e/... -timeout 30m
web:
name: build web ui
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Install
working-directory: web
run: npm ci
- name: Lint
working-directory: web
run: npm run lint
- name: Build
working-directory: web
run: npm run build
- name: Verify dist
run: test -d web/dist && test -f web/dist/index.html
web:
name: build web ui

View file

@ -19,15 +19,21 @@ jobs:
deploy:
name: deploy web ui
runs-on: ubuntu-latest
# The vercel-action receives a deploy token, so only runs on pushes where
# the repository secrets are available and readable content. Fork PRs are
# skipped because they never receive repository secrets.
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# Requires repository secrets:
# VERCEL_TOKEN personal access token (vercel.com/account/tokens)
# VERCEL_ORG_ID org id (vercel.com -> account settings)
# VERCEL_PROJECT_ID project id (vercel.com -> project settings)
- name: Deploy to Vercel
uses: amondnet/vercel-action@v20
uses: amondnet/vercel-action@2d78157ee070f28ff89dd4da74e0369fc26d3b34 # v20
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}

5
.gitignore vendored
View file

@ -10,3 +10,8 @@ web/dist/
.DS_Store
coverage.out
tmp/
.env
*.kubeconfig
*.pem
*.key
deploy/compose/data/

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
# Build krply-server as a static, CGO-free binary and run it as the non-root
# distroless user (65532). The SQLite journal must be mounted writable at
# /data (see deploy/helm and deploy/compose).
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/krply-server ./cmd/krply-server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/krply-server /usr/local/bin/krply-server
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/krply-server"]

View file

@ -28,7 +28,7 @@ test-e2e:
lint:
go vet ./...
cd web && npm run lint 2>/dev/null || true
cd web && npm run lint
vet:
go vet ./...

View file

@ -79,10 +79,11 @@ func seedDemo(ctx context.Context, store storage.Store, demoPath string) error {
func run() error {
var (
storePath = flag.String("store", "krply.db", "path to the SQLite journal")
listen = flag.String("listen", ":8080", "listen address")
demoPath = flag.String("demo", "", "seed an empty journal from this SQLite demo fixture")
showVer = flag.Bool("version", false, "print version and exit")
storePath = flag.String("store", "krply.db", "path to the SQLite journal")
listen = flag.String("listen", ":8080", "listen address")
demoPath = flag.String("demo", "", "seed an empty journal from this SQLite demo fixture")
showVer = flag.Bool("version", false, "print version and exit")
storeFlagSet bool
)
flag.Parse()
@ -97,10 +98,19 @@ func run() error {
if f.Name == "listen" {
listenFlagSet = true
}
if f.Name == "store" {
storeFlagSet = true
}
})
if !listenFlagSet && os.Getenv("PORT") != "" {
listenAddr = ":" + os.Getenv("PORT")
}
if !listenFlagSet && os.Getenv("LISTEN_ADDR") != "" {
listenAddr = os.Getenv("LISTEN_ADDR")
}
if !storeFlagSet && os.Getenv("STORE_PATH") != "" {
*storePath = os.Getenv("STORE_PATH")
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))

View file

@ -5,5 +5,5 @@ metadata:
labels:
{{- include "krply.labels" . | nindent 4 }}
data:
store-path: {{ .Values.store.path | quote }}
listen: ":{{ .Values.listen.port }}"
STORE_PATH: {{ .Values.store.path | quote }}
LISTEN_ADDR: ":{{ .Values.listen.port }}"

View file

@ -22,24 +22,50 @@ spec:
{{- include "krply.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "krply.fullname" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- --store
- {{ .Values.store.path | quote }}
- --listen
- ":{{ .Values.listen.port }}"
envFrom:
- configMapRef:
name: {{ include "krply.fullname" . }}-args
ports:
- name: http
containerPort: {{ .Values.listen.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /v1/health
port: http
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /v1/health
port: http
initialDelaySeconds: 3
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: store
mountPath: {{ .Values.store.path | dir }}
mountPath: /data
volumes:
- name: store
{{- if .Values.store.persistence.enabled }}

View file

@ -1,9 +1,14 @@
# Recorder RBAC for the krply-server ServiceAccount.
#
# Read-only: get/list/watch on the exact resources krply captures. No Secrets,
# no wildcards, no write verbs. Mirrors deploy/rbac/recorder-clusterrole.yaml.
# Disable with --set rbac.create=false if you bind an existing role instead.
{{- if .Values.rbac.create }}
{{/*
krply-server is a query API, materializer, and replay planner: it reads the
SQLite journal and, only when a replay is run, resolves the target cluster
through its own in-cluster configuration. It performs no listing or watching,
so its ServiceAccount needs no ClusterRole.
When you co-locate a `krply record` agent in this chart, bind the agent's
ServiceAccount to deploy/rbac/recorder-clusterrole.yaml instead; that role is
read-only get/list/watch on the MVP resources with no Secrets access.
*/}}
{{- if .Values.rbac.recorder.create }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
@ -20,19 +25,4 @@ rules:
- apiGroups: ["node.k8s.io"]
resources: ["runtimeclasses"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "krply.fullname" . }}-recorder
labels:
{{- include "krply.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "krply.fullname" . }}-recorder
subjects:
- kind: ServiceAccount
name: {{ include "krply.fullname" . }}
namespace: {{ .Release.Namespace }}
{{- end }}

View file

@ -5,16 +5,21 @@ image:
tag: dev
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
store:
# Path of the SQLite journal inside the container. It must live under /data,
# which is the volume mounted for the store.
path: /data/krply.db
# PersistentVolumeClaim to back the store path. If empty, an emptyDir is used.
# The journal is the primary data of krply, so a PersistentVolumeClaim is the
# default. Set enabled: false to use an emptyDir (ephemeral, lost on restart).
persistence:
enabled: false
enabled: true
storageClass: ""
size: 10Gi
size: 1Gi
listen:
port: 8080
@ -23,10 +28,12 @@ service:
type: ClusterIP
port: 8080
# Create a recorder ClusterRole/Binding for the service account. See
# deploy/rbac/recorder-clusterrole.yaml for the full reference role.
# The krply-server ServiceAccount needs no RBAC (it reads the journal and
# resolves the target cluster through its in-cluster configuration). Enable
# recorder.create only when a `krply record` agent runs in this chart.
rbac:
create: true
recorder:
create: false
replicaCount: 1

View file

@ -18,10 +18,10 @@ metadata:
rules:
- apiGroups: [""]
resources: ["namespaces", "configmaps", "services"]
verbs: ["create", "patch", "update", "get", "list"]
verbs: ["create", "patch", "get", "list"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["create", "patch", "update", "get", "list"]
verbs: ["create", "patch", "get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View file

@ -62,9 +62,10 @@ The journal write uses the write-event-then-checkpoint order:
1. Write the raw event first.
2. Update the checkpoint in the same transaction.
3. On restart, replay from the last checkpoint and deduplicate.
This gives at-least-once delivery with deterministic deduplication. If the process crashes between writing an event and its checkpoint, the event may be written again. The field event_id is a deterministic key derived from the stream, the resource, the watch type, and the observed time. This key makes re-application idempotent.
Within a session, a reconnect resumes from the last durable resource version, so nothing after it is missed. After a process restart the collector performs a fresh list and baseline rather than resuming from the stored checkpoint; the journal deduplicates any event that is redelivered, so the fresh baseline and its synthetic events coexist with the earlier history.
This gives at-least-once delivery with deterministic deduplication. If the process crashes between writing an event and its checkpoint, the event may be written again. The field event_id is a deterministic key derived from the stream, the resource, and the watch type (synthetic relist baselines also mix in the list observation time so a re-listed object is a new observation, not a duplicate). This key makes re-application idempotent.
These behaviors are NOT guaranteed:

View file

@ -9,7 +9,7 @@ Every journal record is an immutable entry. The wire format is api/event/v1. Thi
| cluster_id | Separates resource version and UID domains; one per physical cluster |
| stream_id | Identifies group, version, resource, namespace, and selector |
| event_id | Deterministic deduplication key |
| ingest_sequence | Local storage order (ascending); returned in this order |
| ingest_seq | Local storage order (ascending); returned in this order |
| observed_at | Collector observation time |
| watch_type | Original event type (ADDED, MODIFIED, DELETED, BOOKMARK, ERROR) |
| synthetic | True for baseline events |
@ -44,11 +44,11 @@ The field event_id is the deterministic deduplication key. The derivation is:
EventID(stream, resource, watchType, observedAt) -> string
```
The inputs are the stream identity, the resource reference, the watch type, and the observed time. The same key is recomputed for a redelivered event, for example after a reconnect or a crash before the checkpoint. Re-application is therefore idempotent. This makes at-least-once ingestion safe. See ../consistency/consistency.md for the ingestion guarantee.
The inputs are the stream identity, the resource reference, and the watch type. For live watch events the observed time is deliberately excluded: the same underlying API event must always produce the same key, so a duplicate delivery after a reconnect is idempotent. Collector-generated synthetic baselines pass the list observation time, so an unchanged object re-listed after a gap is treated as a new observation rather than a duplicate and survives deduplication. See ../consistency/consistency.md for the ingestion guarantee.
## object_hash
The function ObjectHash(objectJSON) returns a fast equality and deduplication hash over the raw object payload. It lets consumers detect that a MODIFIED event did not change the object. It lets the store collapse no-op writes. It is not a cryptographic commitment. Treat it as a performance and equality helper only.
The function ObjectHash(objectJSON) returns a fast equality hash over the raw object payload. It lets consumers detect that a MODIFIED event did not change the object. It is not a cryptographic commitment. Treat it as a performance and equality helper only. Deduplication is by event_id, not by object content.
## Provenance

View file

@ -11,7 +11,7 @@ Replay does NOT mean sending historical events back to a live cluster. krply rec
5. **Sort resources by dependency**. Sort namespaces first, then declarative roots.
6. **Run a server-side dry run** with the synthetic field manager.
7. **Show the plan and warnings**. Include dry-run conflicts and errors.
8. **Apply only after explicit approval**. The replay apply command requires a target context, a plan ID, a namespace allowlist, a successful dry run, and a confirmation flag.
8. **Apply only after explicit approval**. The replay apply command requires a successful dry run and a confirmation flag. A target namespace, when provided, is always honored.
9. **Observe the target without assuming convergence**. The tool does not prove that the target reached the intended state. Target controllers may still change it.
## Sanitization defaults
@ -79,8 +79,8 @@ Only these kinds can be replayed in the MVP:
The planner refuses to produce or apply a plan in these cases:
- **Coverage is incomplete**. Any stream that feeds the plan has an unresolved gap, unless the Policy flag AllowGaps is set explicitly.
- **Dry run fails**. The server-side dry run reports conflicts or errors that are not overridden.
- **No explicit approval**. The apply command is missing the target context, the plan ID, a namespace allowlist, a successful dry run, or the confirmation flag.
- **Dry run fails**. The server-side dry run reports conflicts, errors, or skipped objects, and Apply refuses unless the plan status is "dry-run-ok".
- **No explicit approval**. The apply command is missing the confirmation flag or a successful dry run. The target namespace, when given, is always honored, and objects are filtered to the source namespace first.
- **Excluded kinds requested**. A request to replay an excluded kind, such as Secrets, Pods, RBAC, PVs, webhooks, or CRDs, is refused unless the corresponding Policy allowlist flag is set.
```mermaid

View file

@ -14,7 +14,7 @@ This document lists the threats that krply is designed against, the controls tha
| Invalid owner references | Orphaned objects | Remove or remap UIDs, replay roots only | replay-safety |
| Name collision | Community confusion | Rename or coordinate | none |
Each row of the risk table maps to at least one control above. The controls are structural. The journal format, the planner policy, and the RBAC manifests enforce them. They do not depend on operator discipline alone.
Each row of the risk table maps to at least one control above. The controls are structural. The journal format, the planner policy, and the RBAC manifests enforce them. They do not depend on operator discipline alone. The exception is the HTTP API surface, which is unauthenticated by design and relies on network placement; see "HTTP API surface" below.
## Trust boundaries
@ -51,7 +51,19 @@ Rules:
- No cluster-admin.
- No write access to the source cluster.
Reference manifests: ../../deploy/rbac/. The Helm chart ships a recorder ClusterRole bound to its own ServiceAccount. See ../../deploy/helm/krply/.
Reference manifests: ../../deploy/rbac/. The Helm chart ships no ClusterRole for the krply-server ServiceAccount because the server reads only the journal and resolves the replay target through its own in-cluster configuration. See ../../deploy/helm/krply/.
## HTTP API surface
The query API served by krply-server is **unauthenticated** and is intended to run on a trusted network. This is a deliberate simplification, not a hard control:
| Surface | Risk | Mitigation (as shipped) |
|---|---|---|
| Read endpoints (`/v1/health`, clusters, streams, events, history, diff, snapshots, plans) | Anyone who can reach the port can read the journal, which may contain recorded ConfigMaps and object bodies | Bind to a trusted interface, place the server behind an authenticated reverse proxy, or restrict it to loopback; do not expose it to the public internet |
| `POST /v1/replay-plans/{id}/dry-run` and `POST /v1/replay-runs` | A network peer could trigger a dry run or an apply against the target cluster | The server resolves the target cluster only through its own kubeconfig and never accepts a client-supplied kubeconfig. Apply requires `confirm: true` and a plan whose dry run already succeeded; plans are held in server memory and are not persisted across restarts |
| CORS | A malicious web page could drive the API from a victim's browser | Set `KRPLY_CORS_ORIGINS` to an explicit allowlist. The default is `*`, so the wildcard must be overridden before the server is exposed anywhere a browser could reach it |
If the server is exposed beyond a trusted network, add authentication and TLS at the proxy before it.
## What the tool does NOT claim

View file

@ -63,12 +63,21 @@ func (c *Correlator) Match(ctx context.Context, evt AuditEvent) (string, error)
}
func (c *Correlator) matchRecord(ctx context.Context, evt AuditEvent) (*event.Record, error) {
recs, err := c.store.Events(ctx, storage.EventFilter{
// The correlated journal event must be close in time to the audit line, so
// the scan is bounded to a window and a page instead of the object's whole
// history.
f := storage.EventFilter{
ClusterID: evt.ClusterID,
Namespace: evt.Namespace,
Name: evt.Name,
RecordType: event.TypeEvent,
})
Limit: 500,
}
if !evt.Timestamp.IsZero() {
f.Since = evt.Timestamp.Add(-1 * time.Minute)
f.Until = evt.Timestamp.Add(1 * time.Minute)
}
recs, err := c.store.Events(ctx, f)
if err != nil {
return nil, err
}

View file

@ -61,11 +61,14 @@ func correlationRecord(evt AuditEvent, matched *event.Record) *event.Record {
}
}
// auditEventID derives a deterministic key for an audit event. The same
// request against the same object always produces the same ID.
// auditEventID derives a deterministic key for an audit event. Stage and
// response code are included so the multi-stage lines of one request
// (RequestReceived / ResponseStarted / ResponseComplete) do not collapse to a
// single record under the journal's event_id deduplication, which would drop
// the authoritative ResponseComplete stage.
func auditEventID(evt AuditEvent) string {
h := sha256.New()
fmt.Fprintf(h, "%s\x00%s\x00%s\x00%s\x00%s\x00%s", evt.ClusterID, evt.RequestID, evt.Namespace, evt.Name, evt.UID, evt.ResourceVersion)
fmt.Fprintf(h, "%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d", evt.ClusterID, evt.RequestID, evt.Namespace, evt.Name, evt.UID, evt.ResourceVersion, evt.Stage, evt.ResponseCode)
return "audit-" + hex.EncodeToString(h.Sum(nil))
}

View file

@ -6,6 +6,7 @@
"description": "Dependency-light static UI for krply-server",
"scripts": {
"build": "node build.mjs",
"lint": "node --check src/api.js && node --check src/app.js && node --check src/util.js && node --check src/views/coverage.js && node --check src/views/diff.js && node --check src/views/plans.js && node --check src/views/snapshots.js && node --check src/views/streams.js && node --check src/views/timeline.js",
"start": "node build.mjs && npx serve dist"
}
}

View file

@ -55,6 +55,19 @@ export const api = {
clusters: () => request('/v1/clusters'),
streams: () => request('/v1/streams'),
events: (q) => request('/v1/events?' + qs(q)),
// eventsAll follows cursor pagination so the whole journal is fetched, not
// just the first (oldest) page. Bounded to 20 pages of 1000.
eventsAll: async (q = {}) => {
const all = [];
let cursor = '';
for (let i = 0; i < 20; i++) {
const page = await api.events({ ...q, limit: 1000, ...(cursor ? { cursor } : {}) });
all.push(...asList(page.items));
if (!page.has_more || !page.next_cursor) break;
cursor = page.next_cursor;
}
return all;
},
history: (cluster, stream, ns, name, since) =>
request(`/v1/objects/${enc(objectRef({ cluster, stream, ns, name }))}/history` + (since ? '?' + qs({ since }) : '')),
diff: (q) => request('/v1/diff?' + qs(q)),
@ -67,6 +80,10 @@ export const api = {
}),
};
function asList(v) {
return Array.isArray(v) ? v : [];
}
// objectRef encodes the composite object reference as a single base64url token
// (stream IDs contain slashes and cannot be path segments).
function objectRef({ cluster, stream, ns, name }) {

View file

@ -11,9 +11,20 @@ export async function viewCoverage(mount) {
]));
mount.appendChild(el('hr', { className: 'rule' }));
const [c, s] = await Promise.all([api.clusters().catch(() => []), api.streams().catch(() => [])]);
const clusters = asList(c);
const streams = asList(s);
let clusters, streams, events;
try {
[clusters, streams, events] = await Promise.all([
api.clusters(),
api.streams(),
api.eventsAll({}),
]);
} catch (err) {
mount.appendChild(el('div', { className: 'notice error', textContent: `failed to load coverage: ${err.message || String(err)}` }));
return;
}
clusters = asList(clusters);
streams = asList(streams);
events = asList(events);
if (!clusters.length && !streams.length) {
mount.appendChild(emptyState(
@ -23,8 +34,6 @@ export async function viewCoverage(mount) {
return;
}
const events = asList(await api.events({ limit: 2000 }).catch(() => []));
const byCluster = new Map();
for (const st of streams) {
const list = byCluster.get(st.cluster_id) || [];

View file

@ -143,13 +143,38 @@ function buildTree(changes, side) {
return root;
}
// pathTokens splits a dotted path like "spec.template.spec.containers[0].image"
// into key/index tokens. The server escapes '.', '[', ']' and '\' in segment
// names with a backslash (annotation keys such as "helm.sh/hook"), which the
// tokenizer unescapes so dotted keys stay a single segment.
const pathTokens = (p) => {
const out = [];
const re = /([^\[\].]+)(?:\[(\d+)\])?/g;
let m;
while ((m = re.exec(p || ''))) {
out.push({ key: m[1], index: m[2] != null ? Number(m[2]) : null });
let key = '';
for (let i = 0; i < p.length; i++) {
const c = p[i];
if (c === '\\' && i + 1 < p.length) {
key += p[i + 1];
i++;
} else if (c === '.') {
if (key) out.push({ key, index: null });
key = '';
} else if (c === '[') {
let j = i + 1;
let idx = '';
while (j < p.length && p[j] !== ']') {
idx += p[j];
j++;
}
const index = Number(idx);
if (key) out.push({ key, index });
else if (out.length) out[out.length - 1].index = index;
key = '';
i = j;
} else {
key += c;
}
}
if (key) out.push({ key, index: null });
return out;
};

View file

@ -35,7 +35,6 @@ export async function viewPlans(mount) {
const sourceInput = el('input', { type: 'text', placeholder: 'source namespace' });
const targetInput = el('input', { type: 'text', placeholder: 'target namespace' });
const list = el('div', { className: 'plan-list' });
let bootstrapped = false;
const defCluster = pickFirst(clusterList);
const defSnapshot = pickLatestSnapshot(snapshotList);
@ -91,7 +90,6 @@ export async function viewPlans(mount) {
list.textContent = '';
if (!plans.length) {
list.appendChild(el('p', { className: 'muted', textContent: 'No plans yet — create one above.' }));
bootstrapIfEmpty();
return;
}
for (const p of plans) list.appendChild(planCard(p));
@ -101,23 +99,6 @@ export async function viewPlans(mount) {
}
}
await load();
// Demo bootstrap: with a real cluster + snapshot, auto-create one plan so a
// visitor sees a populated page without filling the form.
async function bootstrapIfEmpty() {
if (bootstrapped) return;
bootstrapped = true;
const body = {
cluster_id: clusterSel.value,
snapshot_id: snapshotSel.value,
source_namespace: sourceInput.value.trim(),
target_namespace: targetInput.value.trim(),
};
if (!body.cluster_id || !body.snapshot_id || !body.source_namespace || !body.target_namespace) return;
const plans = asList(await api.plans().catch(() => []));
if (plans.length) return;
create(body);
}
}
const itemName = (x) => (x.namespace ? `${x.namespace}/${x.name}` : x.name);
@ -178,13 +159,17 @@ function planCard(p) {
drySection.appendChild(el('p', { className: 'muted', textContent: 'running dry-run…' }));
try {
const r = await api.dryRun({ plan_id: p.id });
// The server returns the full plan with the result nested under
// dry_run_result; fall back to the top level defensively.
const dr = r.dry_run_result || r;
drySection.textContent = '';
drySection.appendChild(el('div', {}, [
el('span', { textContent: 'dry-run: ' }),
r.ok ? badge('OK', 'ok') : badge('NOT OK', 'gap'),
el('span', { textContent: ` · applied ${r.applied}` }),
dr.ok ? badge('OK', 'ok') : badge('NOT OK', 'gap'),
el('span', { textContent: ` · applied ${dr.applied}` }),
el('span', { textContent: ` · skipped ${asList(dr.skipped).length}` }),
]));
for (const [label, list] of [['conflicts', r.conflicts], ['errors', r.errors]]) {
for (const [label, list] of [['conflicts', dr.conflicts], ['errors', dr.errors], ['skipped', dr.skipped]]) {
if (asList(list).length) {
drySection.appendChild(el('div', { className: 'subsection' }, [
el('h4', { textContent: label }),

View file

@ -9,7 +9,14 @@ export async function viewStreams(mount) {
]));
mount.appendChild(el('hr', { className: 'rule' }));
const streams = asList(await api.streams());
let streams, events;
try {
streams = asList(await api.streams());
events = asList(await api.eventsAll({}));
} catch (err) {
mount.appendChild(el('div', { className: 'notice error', textContent: `failed to load streams: ${err.message || String(err)}` }));
return;
}
if (!streams.length) {
mount.appendChild(emptyState(
'No streams recorded yet.',
@ -18,7 +25,6 @@ export async function viewStreams(mount) {
return;
}
const events = asList(await api.events({ limit: 2000 }).catch(() => []));
const span = observedSpan(events);
mount.appendChild(simpleTable(