Review date: 2026-04-18 (amended 2026-04-19 with G11; G11 phase 1 merged to main via PR #23. Further amended 2026-04-19 with strikethroughs for A2/A4/A7/A8/A9/A10 as those tickets shipped in PRs #25, #26, #27, #29, #30, #31.)
This document is a holistic review of the Kotte project (a web visualizer for Apache AGE), not a line-by-line code review. It frames where the product sits today, the structural gaps that matter, and a prioritized improvement plan. Pair this with docs/ROADMAP.md (the ticketed implementation plan derived from this review) and docs/ARCHITECTURE.md (system design).
- Where Kotte sits today
- The structural gaps
- Proposed roadmap (4 milestones)
- If you only do three things
| Dimension | Reality |
|---|---|
| Product positioning | A Cypher console + light D3 SVG explorer for Apache AGE. Closer in spirit to a "psql for AGE with a graph view" than to Neo4j Browser/Bloom. |
| Backend | Solid bones: per-session pool, parameterized cypher() invocation, agtype parser, structured errors, Prometheus metrics, JSON logs. The backlog (P0–P4) cleaned up real correctness issues. |
| Frontend | Vite + React + Zustand + Tailwind. Multi-tab editor, metadata sidebar, several layouts, PNG/SVG export hooks. Editor is a plain <textarea>. |
| Ops | Dev-grade docker-compose. No multi-stage images, no production frontend image, no CI for tests/lint, no migrations system, no Helm/K8s manifests despite a planning doc. |
| Docs | Voluminous and well structured, but several docs describe planned behavior as if shipped (credential backends, K8s, "Next Steps" in QUICKSTART). |
The product is feature-complete as a v0.1. Most of the "shortcomings" are not bugs — they are product depth, production-readiness, and wiring gaps between things that exist but aren't connected.
These are the issues that change how to plan the next 1–2 quarters, not just bug-fix tickets.
You're competing for mindshare against people who have used Neo4j Browser. GraphView.tsx is SVG-only, single force layout actually animated, no arrowheads, no curved/parallel edges, no self-loop rendering, no minimap, no LOD/canvas/WebGL fallback, and no client-side enforcement of maxNodesForGraph / maxEdgesForGraph. A debug banner is permanently visible — search Resolved (ROADMAP A4, PR #25): the marker is now gated on GraphView.tsx for GraphView marker: to find it (currently around line 358).import.meta.env.DEV so it never ships in production builds.
The store has togglePinNode / toggleHideNode but no UI calls them (Pin/Hide on canvas — still tracked as ROADMAP A3). There is a tab pin button but it can never unpin — Resolved (ROADMAP A2, PR #26): TabBar doesn't accept the onTabUnpin prop the workspace passes.TabBar.Props now declares onTabUnpin and the pin button is conditionally rendered as tab.pinned ? onTabUnpin : onTabPin, so a parent supplying only one direction no longer renders a no-op button.
No Cypher highlighting, no autocomplete from discovered labels/properties, no error squiggles, no parameter inspector beyond a "paste JSON" textarea. that silently falls back to Param-error surfacing resolved (ROADMAP A10, PR #31): {} on parse failure (QueryEditor.tsx:208-214).getQueryParams now returns a { ok: true; value } | { ok: false; error } discriminated union; the editor shows an inline role="alert" caption + red border under the params textarea, disables Execute (with tooltip + ARIA), blocks Shift+Enter, and renders a small red dot on the collapsed Parameters toggle so the disabled state has a visible cause. The deeper "textarea → real Cypher editor with highlighting / autocomplete" gap remains tracked under Milestone C. (Note: a code-review bot also flagged that JSON.parse accepts arrays, primitives, and null — adding an object-shape guard in getQueryParams is captured as a follow-up on PR #31; not yet merged.) For a tool that markets itself on Cypher exploration, this is the second-biggest UX gap after G1.
SettingsModal is wired but never opened — WorkspacePage declares useState(false) and never flips it (pages/WorkspacePage.tsx:48,425-429). That means theme, defaultViewMode, queryHistoryLimit, autoExecuteQuery, maxNodesForGraph, maxEdgesForGraph, export format choice, and table page size are mostly inert. The workspace is hard-coded dark (bg-zinc-950) regardless of the theme setting. Two color systems for labels coexist — D3 schemeCategory10 in graph view vs a fixed palette in nodeColors.ts — so the same label can be different colors in the sidebar pill and the graph circle.
api/v1/query_stream.py chunks NDJSON and even paginates with auto-injected SKIP/LIMIT, but if the user query already has LIMIT/SKIP it materialises the full window in memory then chunks (query_stream.py:144-229). The frontend queryAPI.stream exists, TableView accepts streaming / onLoadMore props, but ResultTab never enables them. So large-result UX is functionally identical to the non-streaming path. The streaming fetch also has no AbortSignal, so it cannot be cancelled cleanly from the UI.
MetadataService.get_graph_metadata fans out asyncio.gather over every vlabel and elabel for property discovery (api/v1/graph.py:123-179). On a graph with many labels this becomes many concurrent keys(n) queries; with db_pool_max_size=10 you saturate that user's pool before discovery completes. Property discovery is also Cypher LIMIT 500 sampling (clamped from a default of 1000 — services/metadata.py:66-75), so reported properties are probabilistic, which is not surfaced in the UI.
/deployment/frontend.Dockerfilerunsnpm run dev(Vite dev server)/deployment/docker-compose.ymlsetsENVIRONMENT: developmentfor the backendNo multi-stage builds— resolved in ROADMAP B4+B5+B6:deployment/backend.Dockerfileis now a hardened multi-stage build (PR #38);deployment/frontend.Dockerfile.prodbuilds the vite SPA and serves it via nginx with SPA fallback + gzip + cache headers + per-location security headers (PR #38); anddeployment/docker-compose.ymlhas been split (PR #39) intodocker-compose.dev.yml(source mounts,uvicorn --reload, vite HMR,ENVIRONMENT=development) anddocker-compose.prod.yml(prod Dockerfiles,ENVIRONMENT=production,restart: unless-stopped, per-servicemem_limit/cpus, read-only nginx with tmpfs, secrets viaenv_file).docs/KUBERNETES_DEPLOYMENT.mdis a plan, not artifacts. Nodeployment/k8s/, no Helm chart.- No migrations system.
init-db.sqlonly runsCREATE EXTENSION age. The "indices migration" is a one-shot Python script. No— resolved in ROADMAP A9 (PR #30): Apache-2.0LICENSE, noCHANGELOG, nobackend/.env.example(referenced by QUICKSTART)LICENSE+NOTICE, Keep-a-ChangelogCHANGELOG.mdseeded with 0.1.0, andbackend/.env.exampleenumerating everySettingskey +ADMIN_PASSWORD. Nopre-commitconfig yet (Milestone B)..github/workflows/only containsdocs.yml— markdown lint, link check, spell check. There is no CI for pytest, vitest, ruff/black/mypy/eslint, container builds, or security scans. This is the single most urgent ops gap.
The data model imagines users (there's services/user.py, api/v1/auth.py, login flow), but in practice a single built-in admin from ADMIN_PASSWORD is the only path. There is no user CRUD route, no roles, no per-user resource quotas, no audit trail beyond log lines. The per-user rate limiter used to read request.scope["session"]["user_id"], but the login flow stores session_id / csrf_token in that signed cookie session and not user_id — so per-user rate limiting was unreachable. Resolved (ROADMAP A8, PR #29): the middleware now resolves the user via session_manager.get_user_id(session_id), so the cookie can never silently drift from SessionManager. (One follow-up remains: when the cap fires, BaseHTTPMiddleware raising APIException doesn't reach FastAPI's exception handlers, so the response currently surfaces as a 500 instead of a clean 429 JSON — tracked separately.)
Per-session pools amplify this: every connect builds an AsyncConnectionPool with max_size=10, so 50 concurrent users = up to 500 PostgreSQL connections, and stale sessions don't aggressively call DatabaseConnection.disconnect() on idle expiry — that is a real connection-leak risk in long-running processes.
cypher_return_columnsinfers RETURN arity by tokenizing user input. When it can't parse (unbalanced brackets, weird subqueries), it falls back to a singleresultcolumn. AGE will error if real RETURN arity ≠ AS clause arity. The unit tests document the fallback but it's a real footgun.agtype.pyis JSON + regex normalization, not a real agtype parser. It does heuristic key-quoting and;→,rewrites (services/agtype.py:48-77). Anything exotic — temporal types, geometry, certain numeric edge cases — is not first-class.Resolved (ROADMAP A7, PR #27). A re-read of the on-disk code showed the live query wasexpand_nodefordepth != 1returns aRETURN DISTINCT n, m, relwherenis no longer in scope afterWITH DISTINCT path, m— that path is likely broken at runtime (api/v1/graph.py:339-346).RETURN DISTINCT m, rel(no scope error) — but it had a worse, quieter bug: depth-2 paths returned the endpoint and both relationships but dropped the intermediate hop, so the canvas drew floating edges. Fixed by switching toWITH n, path, thenUNWIND nodes(path)+UNWIND relationships(path), andRETURN DISTINCT n, pn, rel. Regression test inbackend/tests/integration/test_graph.py::test_expand_node_depth_two_returns_intermediate_nodes.- Safe mode is a regex over the uppercased query (
api/v1/query.py:124-136) — comments and string literals containingCREATEwill trip it; conversely, a clever cyrillic mix won't. It is not a parser-based read-only mode. - The docs imply
cancelis universal, butquery_stream.pydoesn't show the same registration withquery_tracker, so streaming queries may not be cancellable.
After P1.1 the backend cache uses prefix-based invalidation under a lock (good). The frontend cache invalidates every /graphs and /queries GET on any mutation (services/api.ts:52-56). That's safe but blunt: importing 10k rows nukes the metadata for every other graph in the same workspace.
CONFIGURATION.mdclaimsCREDENTIAL_STORAGE_TYPEsupportssqlite,postgresql,redis— only encrypted JSON file is implemented.QUICKSTART.md"Next Steps" still says things like "Implement D3.js visualization" (line 119+).ARCHITECTURE.mdreferencesapp/core/security.pybut that file doesn't exist (concerns are split acrossauth.py/middleware.py/credentials.py).KUBERNETES_DEPLOYMENT.mdreads as if it ships with the repo; it doesn't.
Every comparable tool — Neo4j Browser, Apache AGE Viewer (Bitnine), Memgraph Lab, Linkurious, Bloom — treats the canvas as a growing scratch space: double-click adds a node's first-hop neighbours to whatever is already on screen, preserving positions, pins, and prior expansions. Until very recently Kotte had no double-click affordance at all.
A first attempt to add one (on the now-discarded double-click branch) introduced a destructive handleFocusNode that wiped the canvas in two phases — first by filtering the existing graph_elements to only the clicked node's incident edges, then by replacing the entire result with the API expansion's nodes/edges. That code never landed on main. The lesson it leaves behind is worth recording, because it informs the design of the additive replacement:
- Wiping the user's prior context (query result, earlier expansions, drag positions, pin state) on every double-click is intolerable in a graph explorer — there is no undo, and re-running the original query will not restore positions.
- A two-phase rebuild creates a visible flicker (sparse "clicked node + visible neighbours" → API result, ~50–500 ms apart).
- A celebrity node with 5,000 neighbours silently shows 100 random ones when the
limitcap is not surfaced. - An untyped, undirected expansion (
(n)-[rel]-(m)) gives the user no control over relationship type or direction. - A
useReffor camera focus that lives outside React's reactive flow makes the camera land in the wrong place on rebuilds.
The correct primitive already existed: handleExpandNode (the right-click "Expand neighborhood" handler) uses mergeGraphElements on queryStore and is purely additive. The right move was to make double-click reuse that flow, not to duplicate its data-fetch path with destructive semantics.
Status (2026-04-19, merged to main via PR #23): Phase 1 of the additive design has shipped.
- A pure helper
frontend/src/utils/graphMerge.tsnow owns the merge contract (node dedup by id, edge dedup by(source, target, label), no input mutation, returns the ids of newly-added elements). queryStore.mergeGraphElementsdelegates to that helper, fixing a pre-existing edge-dedup bug that was silently producing duplicate links every time the right-click expand path was used.GraphViewexposesonNodeDoubleClick;ResultTabandWorkspacePagewire it to the same flow as the right-click expand. Double-click is now additive from day one.d3-zoom's default dblclick-to-zoom is suppressed on nodes viaevent.stopPropagation().
Phases 2 and 3 (camera focus animation, and an explicit reversible "isolate" mode in the context menu) are tracked as separate PRs against main — see ROADMAP §A11.
Group the improvements into four chunks. Each milestone is roughly 2–4 weeks of focused work for one engineer.
Cheap, high-value fixes that close the gap between what's wired and what users see.
- Wire the Settings modal — add a gear button in
Layout/WorkspacePageand consumetheme,defaultViewMode,queryHistoryLimit,autoExecuteQuery, viz limits, table page size, default layout, export format. - Fix tab pin/unpin — done in ROADMAP A2 (PR #26):
TabBar.Propsnow acceptsonTabUnpin; the pin button pickstab.pinned ? onTabUnpin : onTabPinso a parent passing only one direction no longer renders a no-op button. - Add Pin / Hide UI for nodes — extend
NodeContextMenuto calltogglePinNode/toggleHideNodealready in the store. - Remove the debug marker from
GraphView— done in ROADMAP A4 (PR #25): theGraphView marker:overlay is now gated onimport.meta.env.DEV(with Vite ambient types added infrontend/src/vite-env.d.ts), so production builds never render it. - Enforce
maxNodesForGraph/maxEdgesForGraphinWorkspacePagebefore passing data intoGraphView; fall back toTableViewwith a banner. - Unify color palette — make
nodeColorsandgraphStylesshare one source of truth so sidebar pills match graph circles. - Fix
expand_nodefordepth != 1(api/v1/graph.py:339) — done in ROADMAP A7 (PR #27): the real bug was missing intermediate nodes, not the originally-flagged scope error; switched tonodes(path)so depth-2 expansions are no longer truncated. - Fix per-user rate limit — done in ROADMAP A8 (PR #29):
RateLimitMiddlewarenow resolves the user viasession_manager.get_user_id(session_id)instead of reading a non-existentuser_idoff the cookie session. Per-user 429 is reachable;rate_limit_per_useris a real knob. - Add
LICENSE,CHANGELOG.md,backend/.env.example— done in ROADMAP A9 (PR #30): Apache-2.0LICENSE+NOTICE, Keep-a-ChangelogCHANGELOG.mdseeded with 0.1.0, fullbackend/.env.examplecovering everySettingskey +ADMIN_PASSWORD. Verifiedcp backend/.env.example backend/.envboots cleanly. Caught aCORS_ORIGINSdoc/code drift along the way (env requires JSON-array form, not the comma-separated formCONFIGURATION.mdshows) — recorded as a follow-up. - Add additive double-click expand (G11) — wire the double-click handler to the existing
mergeGraphElementsprimitive (✅ shipped via PR #23 tomain); animate camera focus on the newly-added neighbourhood (ROADMAP A11 Phase 2); add an explicit reversible "show only this & neighbourhood" context-menu action with a one-step undo (ROADMAP A11 Phase 3).
The biggest credibility gap. None of these need product changes.
- GitHub Actions — add workflows for
pytest(with a postgres+age service container for integration tests gated on a label/branch),vitest run,ruff/black/mypy,eslint, frontend production build, and a SAST/dependency scan (e.g.pip-audit+npm audit+trivyon built images). - Multi-stage Dockerfiles — backend: builder + slim runtime, non-root, healthcheck. Frontend:
node:alpinebuilder →nginx:alpineservingdist/with SPA fallback and gzip/brotli. - Compose split —
docker-compose.dev.yml(current behavior) anddocker-compose.prod.yml(nginx frontend,ENVIRONMENT=production, secrets from env-file, restart policies, resource limits, no source mounts). - Migrations — adopt Alembic for any backend-managed schema (sessions table when you move off in-memory, future user table) and ship
init-db.sqlupgrades through a versioned bootstrap script. Convertmigrate_add_indices.pyinto a migration. - K8s manifests or a Helm chart — at minimum a working Deployment + Service + Ingress for backend and a Deployment + Service for nginx-frontend. Then either build the CloudNativePG path or remove
KUBERNETES_DEPLOYMENT.mduntil you do. - Pre-commit —
ruff --fix,black,prettieron changed files; reuse the cspell config for commit messages. - Reconcile docs — drop unimplemented credential backends from
CONFIGURATION.md, updateQUICKSTART.md"Next Steps", correct the security file path inARCHITECTURE.md.
What moves Kotte from "feature complete v0.1" to something a user would prefer over psql + Cytoscape.
- Replace the textarea with CodeMirror 6 + a Cypher mode. Add schema-aware autocomplete by feeding it the metadata sidebar's labels/properties. Show JSON-parameter validation errors instead of swallowing them.
- Visualization upgrades in
GraphView:- Arrowheads, curved edges for parallel edges, and self-loop rendering.
- Canvas / WebGL renderer (e.g. PixiJS or Sigma.js) selected when nodes > ~1500. Keep SVG for ≤1500.
- Minimap + fit-to-selection.
- Node sizing by property (mirrors edge width which already exists).
- Lasso / box multi-select + bulk actions (hide, pin, expand).
- Edge bundling or incremental layout (foreground new neighbors when expanding) instead of restarting the simulation.
- Wire streaming end-to-end —
ResultTabopts intoqueryAPI.streamfor large results,TableViewvirtualises rows, both exposeAbortControllerto cancel from the UI. Server-side, switch to a real PostgreSQL named cursor insidequery_stream.pyinstead of materialising the LIMIT/SKIP window. - Schema sidebar 2.0 — show per-label property lists (the API already returns them but the UI hides them —
MetadataSidebar.tsx:133-173), property types, indexed flag, sample values; allow drag-to-canvas. - Saved queries / templates UI —
services/query_templates.pyalready exists on the backend; surface it as a library panel with parameter forms. - Read-only mode that's actually safe — replace the regex safe-mode with a Cypher AST parser (e.g. a lightweight grammar) or a server-side
SET TRANSACTION READ ONLYplus a separate mutating endpoint behind explicit confirmation. - Expand options popover + truncation indicator (depends on the Milestone A double-click fix) — extend the expand endpoint with edge-type, direction, and limit options; surface a "100 of 1,247" badge on truncated expansions.
Only if you intend Kotte to be deployed beyond a single analyst.
- Real user model — DB-backed users (Alembic migration), bcrypt + optional OIDC/OAuth, roles (admin/editor/viewer), per-user saved-connection ACLs.
- Move sessions out of process memory — Redis-backed
SessionManagerandquery_tracker. This unblocks horizontal scaling and resolves the "cookie session sees 401 but pool may linger" leak. - Per-tenant pool budgeting — instead of
min=1, max=10per session, a shared pool per(user, host, database)tuple with a global cap. Sessions reuse the pool; idle pools are reaped on a timer with explicitdisconnect(). - OpenTelemetry — add tracing on the FastAPI middleware, propagate trace IDs into
request_id, and instrument psycopg. Prometheus metrics are a good base; tracing is the missing dimension when you need to debug a slow query. - Audit log — first-class table (or append-only file) for connect/disconnect, query execute, mutation events, with user, IP, request ID. The current
SECURITY:log prefix is informal. - Rate-limit and idempotency for
csv_importer— currently reads the whole file into memory, splits by,(no RFC-4180 quoting), and embeds JSON property payloads directly into Cypher strings (api/v1/csv_importer.py:206-214). Move to streamed parsing (csvmodule orpandas.read_csv), parameterized inserts, andCOPY ... FROMinto a staging table for large files. Add idempotency keys so re-runs don't double-load.
- CI + production-grade Docker images + LICENSE/CHANGELOG (Milestone B core). Without this the project doesn't look maintained, no matter how good the code is.
- CodeMirror Cypher editor + schema-aware autocomplete + canvas renderer for >1500 nodes. These two changes do more for perceived quality than anything else.
- Wire the existing settings, finish the rest of the Milestone A wiring fixes, and reconcile the docs.
Pin/unpin(A2),(A7),expand_nodedepth-2per-user rate limit(A8),debug marker(A4),JSON-param error surfacing(A10), andLICENSE / CHANGELOG /(A9) are all shipped or in flight; the still-open Milestone A diffs are the Settings modal wiring (A1), Pin / Hide UI on the canvas (A3), the viz-limit guard (A5), and the colour-palette unification (A6). Tiny diffs that close the gap between "what we built" and "what the user can find.".env.example