tkati-dashboard 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. tkati_dashboard-0.4.0/PKG-INFO +172 -0
  2. tkati_dashboard-0.4.0/README.md +159 -0
  3. tkati_dashboard-0.4.0/pyproject.toml +38 -0
  4. tkati_dashboard-0.4.0/setup.cfg +4 -0
  5. tkati_dashboard-0.4.0/src/tkati_dashboard/__init__.py +9 -0
  6. tkati_dashboard-0.4.0/src/tkati_dashboard/__main__.py +4 -0
  7. tkati_dashboard-0.4.0/src/tkati_dashboard/_kafka_metadata.py +31 -0
  8. tkati_dashboard-0.4.0/src/tkati_dashboard/app.py +155 -0
  9. tkati_dashboard-0.4.0/src/tkati_dashboard/dataflow.py +168 -0
  10. tkati_dashboard-0.4.0/src/tkati_dashboard/flows.py +54 -0
  11. tkati_dashboard-0.4.0/src/tkati_dashboard/lag.py +78 -0
  12. tkati_dashboard-0.4.0/src/tkati_dashboard/main.py +76 -0
  13. tkati_dashboard-0.4.0/src/tkati_dashboard/py.typed +0 -0
  14. tkati_dashboard-0.4.0/src/tkati_dashboard/snapshot.py +111 -0
  15. tkati_dashboard-0.4.0/src/tkati_dashboard/static/index.html +1410 -0
  16. tkati_dashboard-0.4.0/src/tkati_dashboard/topic_stats.py +95 -0
  17. tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/PKG-INFO +172 -0
  18. tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/SOURCES.txt +26 -0
  19. tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/dependency_links.txt +1 -0
  20. tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/entry_points.txt +2 -0
  21. tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/requires.txt +6 -0
  22. tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/top_level.txt +1 -0
  23. tkati_dashboard-0.4.0/tests/test_app.py +248 -0
  24. tkati_dashboard-0.4.0/tests/test_dataflow.py +230 -0
  25. tkati_dashboard-0.4.0/tests/test_flows.py +68 -0
  26. tkati_dashboard-0.4.0/tests/test_lag.py +72 -0
  27. tkati_dashboard-0.4.0/tests/test_snapshot.py +45 -0
  28. tkati_dashboard-0.4.0/tests/test_topic_stats.py +31 -0
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: tkati-dashboard
3
+ Version: 0.4.0
4
+ Summary: Local web dashboard that renders a serialized tkati dataflow directory as a graph
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: tkati-core==0.4.0
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: uvicorn>=0.34.0
10
+ Requires-Dist: confluent-kafka>=2.11.0
11
+ Requires-Dist: orjson>=3.9.0
12
+ Requires-Dist: pyyaml>=6.0
13
+
14
+ # tkati-dashboard — dataflow graph viewer
15
+
16
+ Reads a serialized tkati dataflow directory (see
17
+ [docs/dataflow-serialization.md](../../docs/dataflow-serialization.md)) and serves a local web page
18
+ rendering it as a graph — no live runtime process required, no manifest to maintain, just a
19
+ directory of `*.json`/`*.yaml`/`*.yml` fragment files (freely mixable — both encodings merge into
20
+ the same graph identically).
21
+
22
+ ## Usage
23
+
24
+ ```sh
25
+ tkati-dashboard path/to/dataflow-dir
26
+ ```
27
+
28
+ Then open `http://127.0.0.1:8000/` in a browser. Pass more than one directory (or
29
+ `--flows-root`, see [Multiple flows](#multiple-flows) below) to observe several dataflows from
30
+ one dashboard instead of just this one. The page fetches the list of flows from `/api/flows`,
31
+ then the selected one's graph from `/api/flows/{id}/graph`, and renders it with
32
+ [React Flow](https://reactflow.dev), laid out by [dagre](https://github.com/dagrejs/dagre) using
33
+ each node's real measured box (from its label text, via canvas measurement — no DOM mount needed)
34
+ rather than a flat grid, so a node with a long `broker`/`topic` string pushes its neighbors aside
35
+ instead of overlapping them. The graph always lays out left-to-right.
36
+
37
+ A node consuming a stream with a consumer group doesn't get that edge's `group`/`lag` as a
38
+ floating label — it gets its own stacked row inside the *consuming* node instead, right below the
39
+ node's own header, one row per such input (so a fan-in node like a sessionizer joining two topics
40
+ shows two rows, each fed by its own arrow landing directly on its row). An edge with nothing extra
41
+ to show beyond its `kind` (no consumer group) stays a plain, unlabeled-beyond-`kind` line into the
42
+ node's header. Source/sink nodes (`kafka-topic`, `clickhouse-table`) and processing nodes are
43
+ colored differently. Click a node to fill the always-visible inspector panel on the right — the
44
+ whole node box glows blue while selected, and every edge into or out of it (in the graph itself)
45
+ turns the same blue and draws on top of any edge it crosses, so what a node actually talks to is
46
+ easy to trace at a glance — with its full connection/config/schema details, plus every stream
47
+ edge touching it and that edge's live consumer lag; drag the panel's left edge to
48
+ resize it (the width is remembered across reloads).
49
+
50
+ ### Try it with the bundled example
51
+
52
+ [`examples/simple-pipeline`](examples/simple-pipeline) is the smallest interesting dataflow: two
53
+ Kafka topics and one processing node (`raw-events` → `dedup` → `deduped-events`). Its `connection`
54
+ blocks point at `localhost:9092`, so if you have a broker there, seed it with sample events first:
55
+
56
+ ```sh
57
+ uv run python packages/tkati-dashboard/examples/simple-pipeline/seed_kafka.py
58
+ uv run tkati-dashboard packages/tkati-dashboard/examples/simple-pipeline
59
+ ```
60
+
61
+ Then open <http://127.0.0.1:8000/>, click the `raw-events` or `deduped-events` node, and click
62
+ "Load latest events" in the side panel to see the real messages the seed script just produced
63
+ (`raw-events` includes two intentional duplicate `event_id`s so you can see what the `dedup` node
64
+ in between is for).
65
+
66
+ For a bigger graph exercising fragment merging across `topics.json`, `tables.json`, `nodes.json`,
67
+ and `edges.json` (two raw topics → dedup → a sessionize/enrich node → a ClickHouse table, plus a
68
+ side branch straight to another table), see [`examples/analytics-pipeline`](examples/analytics-pipeline)
69
+ — its topics aren't seeded with data, so "Latest events" there will error unless you produce to
70
+ them yourself.
71
+
72
+ Since `examples/` itself contains both as sibling directories, it also doubles as a ready-made
73
+ `--flows-root` demo — `tkati-dashboard --flows-root packages/tkati-dashboard/examples` serves both
74
+ as separate flows, switchable from the ☰ menu (see [Multiple flows](#multiple-flows)).
75
+
76
+ Options:
77
+
78
+ - `--host` (default `127.0.0.1`)
79
+ - `--port` (default `8000`)
80
+ - `--flows-root DIR` — auto-discover flows (see below); repeatable
81
+
82
+ A dataflow directory is re-read on every request to its `/api/flows/{id}/graph`, so editing the
83
+ fragments and refreshing the browser picks up the change without restarting the server.
84
+
85
+ ## Multiple flows
86
+
87
+ A real deployment usually runs more than one dataflow per environment, and one dashboard
88
+ instance can observe all of them at once instead of one directory per instance:
89
+
90
+ ```sh
91
+ # name each flow directory explicitly (flow id = each directory's own basename)
92
+ tkati-dashboard path/to/orders-flow path/to/clicks-flow
93
+
94
+ # or point at a parent directory and let every fragment-containing subdirectory become a flow
95
+ tkati-dashboard --flows-root path/to/env/flows
96
+ ```
97
+
98
+ The two forms can be combined, and `--flows-root` is repeatable. Either way, a flow's id is its
99
+ directory's own basename — passing two directories with the same name is a startup error asking
100
+ you to rename one of them. Unlike the explicit directories, a `--flows-root`'s subdirectories are
101
+ rescanned on every request to `/api/flows`, so adding or removing a flow directory there shows up
102
+ without restarting the server; a subdirectory with no fragments in it (a stray `README`, say) is
103
+ silently skipped rather than treated as an error.
104
+
105
+ With more than one flow configured, the graph page gets a ☰ menu in its top-left corner naming the
106
+ currently selected flow — click it to switch to another one. It stays collapsed to that single
107
+ button otherwise, so the canvas keeps its space once a flow is picked.
108
+ The current flow is also reflected in the page's URL as `?flow=<id>`, so a link to a specific
109
+ flow's view can be bookmarked or shared. With only one flow configured (the common case, and the
110
+ default if you pass a single directory as before), the menu doesn't appear at all — the page looks
111
+ exactly as it always has.
112
+
113
+ ## Node panel
114
+
115
+ The panel on the right is always there, like an inspector rather than a popup — it shows a
116
+ placeholder until you click a node, then shows that node's full metadata: connection settings,
117
+ `config`, and `schema` (field → type). Clicking empty canvas clears the selection back to the
118
+ placeholder. Every section has a clickable header to collapse/expand it — a collapsed section
119
+ stays mounted, just hidden, so collapsing a live-fetching section and reopening it doesn't
120
+ re-fetch. For a `kafka-topic` node, the panel also fetches two more, live views:
121
+
122
+ - `GET /api/flows/{flow_id}/nodes/{id}/snapshot` connects to `connection.broker`/`connection.topic` and shows the
123
+ most recent messages on that topic (newest last), parsed as JSON, using a throwaway consumer
124
+ group that never commits offsets. It only fetches on demand — click "Load latest events" — and
125
+ a "↻ Refresh" button afterward pulls a fresh batch on request rather than automatically. Each
126
+ message renders as its own pretty-printed JSON block (fields reordered to match `schema`, when
127
+ there is one) rather than a table — real messages commonly carry 5-20 fields, too many to lay
128
+ out sensibly as table columns in a side panel — with a small header showing that message's
129
+ Kafka-level `partition`, `offset`, and timestamp, alongside its parsed body.
130
+ - `GET /api/flows/{flow_id}/nodes/{id}/topic-stats` shows the topic's partitioning and replication (per-partition
131
+ leader/replicas/in-sync-replicas, flagging any under-replicated partition) and its topic-level
132
+ config — `retention.ms`, `retention.bytes`, `cleanup.policy`, `segment.bytes`,
133
+ `compression.type`, `max.message.bytes` — via `tkati_dashboard.topic_stats`, marking each value
134
+ that differs from the broker default. Partition/replica info comes from the same topic metadata
135
+ lookup as the snapshot/lag features (`_kafka_metadata.py`); the config comes from a separate
136
+ read-only `AdminClient.describe_configs()` call.
137
+
138
+ These are two of the places `tkati-dashboard` talks to a live broker rather than just the
139
+ serialized directory — best-effort conveniences for the panel, not something the graph view itself
140
+ depends on: a broker that's unreachable, or a topic that doesn't exist, shows an inline error in
141
+ that section instead of breaking the page.
142
+
143
+ ## Consumer lag
144
+
145
+ For every stream edge whose `consumer.group_id` is set and whose source is a `kafka-topic`, the
146
+ page also fetches `GET /api/flows/{flow_id}/nodes/{topic_id}/consumer-lag?group_id=...` and shows the result in
147
+ two places: the `group`/`lag` lines in that edge's stacked row inside the consuming node (see
148
+ above), and, for either node that edge touches, the inspector panel's "Consumer lag" section
149
+ (`← other-node (group_id)` for an edge consumed by the selected node, `→ other-node (group_id)`
150
+ for one where it's the topic being consumed). This is another place `tkati-dashboard` talks to a
151
+ live broker: it looks up `group_id`'s committed offset with `Consumer.committed()` and compares
152
+ it to the topic's high watermark — it never subscribes or polls as that group, so it can't join
153
+ it, trigger a rebalance, or otherwise disturb a real pipeline's consumer. A group that has never
154
+ committed an offset is reported as fully behind (lag = the topic's full size); an unreachable
155
+ broker shows `lag: n/a` instead of failing the page.
156
+
157
+ Lag is time-sensitive, so it doesn't just get fetched once. A ☰-style control in the canvas's
158
+ top-right corner (shown whenever the graph has at least one such edge) works like Grafana's
159
+ refresh picker: a "↻" button re-fetches every visible edge's lag immediately, and a dropdown next
160
+ to it sets an auto-refresh interval (Off/5s/15s/30s/1m/5m, persisted across reloads, paused while
161
+ the browser tab isn't visible). The inspector's "Consumer lag" section additionally has its own
162
+ per-row "↻" to refresh just the one edge you're looking at, without waiting for the next tick or
163
+ refreshing every other edge in the graph.
164
+
165
+ ## Validation
166
+
167
+ `tkati_dashboard.dataflow.load_dataflow` enforces the rules from the serialization doc: the
168
+ directory must contain at least one `*.json`/`*.yaml`/`*.yml` fragment, node ids must be unique
169
+ (or identically redefined) across fragments, edges must reference existing nodes, and a node's `schema`, when
170
+ present, must use field types known to `tkati_core.type_mapping` — `schema` itself is always
171
+ optional, since it isn't always on hand for a real-world node. A validation failure surfaces as
172
+ an HTTP 422 with the error message, shown inline on the page instead of a blank graph.
@@ -0,0 +1,159 @@
1
+ # tkati-dashboard — dataflow graph viewer
2
+
3
+ Reads a serialized tkati dataflow directory (see
4
+ [docs/dataflow-serialization.md](../../docs/dataflow-serialization.md)) and serves a local web page
5
+ rendering it as a graph — no live runtime process required, no manifest to maintain, just a
6
+ directory of `*.json`/`*.yaml`/`*.yml` fragment files (freely mixable — both encodings merge into
7
+ the same graph identically).
8
+
9
+ ## Usage
10
+
11
+ ```sh
12
+ tkati-dashboard path/to/dataflow-dir
13
+ ```
14
+
15
+ Then open `http://127.0.0.1:8000/` in a browser. Pass more than one directory (or
16
+ `--flows-root`, see [Multiple flows](#multiple-flows) below) to observe several dataflows from
17
+ one dashboard instead of just this one. The page fetches the list of flows from `/api/flows`,
18
+ then the selected one's graph from `/api/flows/{id}/graph`, and renders it with
19
+ [React Flow](https://reactflow.dev), laid out by [dagre](https://github.com/dagrejs/dagre) using
20
+ each node's real measured box (from its label text, via canvas measurement — no DOM mount needed)
21
+ rather than a flat grid, so a node with a long `broker`/`topic` string pushes its neighbors aside
22
+ instead of overlapping them. The graph always lays out left-to-right.
23
+
24
+ A node consuming a stream with a consumer group doesn't get that edge's `group`/`lag` as a
25
+ floating label — it gets its own stacked row inside the *consuming* node instead, right below the
26
+ node's own header, one row per such input (so a fan-in node like a sessionizer joining two topics
27
+ shows two rows, each fed by its own arrow landing directly on its row). An edge with nothing extra
28
+ to show beyond its `kind` (no consumer group) stays a plain, unlabeled-beyond-`kind` line into the
29
+ node's header. Source/sink nodes (`kafka-topic`, `clickhouse-table`) and processing nodes are
30
+ colored differently. Click a node to fill the always-visible inspector panel on the right — the
31
+ whole node box glows blue while selected, and every edge into or out of it (in the graph itself)
32
+ turns the same blue and draws on top of any edge it crosses, so what a node actually talks to is
33
+ easy to trace at a glance — with its full connection/config/schema details, plus every stream
34
+ edge touching it and that edge's live consumer lag; drag the panel's left edge to
35
+ resize it (the width is remembered across reloads).
36
+
37
+ ### Try it with the bundled example
38
+
39
+ [`examples/simple-pipeline`](examples/simple-pipeline) is the smallest interesting dataflow: two
40
+ Kafka topics and one processing node (`raw-events` → `dedup` → `deduped-events`). Its `connection`
41
+ blocks point at `localhost:9092`, so if you have a broker there, seed it with sample events first:
42
+
43
+ ```sh
44
+ uv run python packages/tkati-dashboard/examples/simple-pipeline/seed_kafka.py
45
+ uv run tkati-dashboard packages/tkati-dashboard/examples/simple-pipeline
46
+ ```
47
+
48
+ Then open <http://127.0.0.1:8000/>, click the `raw-events` or `deduped-events` node, and click
49
+ "Load latest events" in the side panel to see the real messages the seed script just produced
50
+ (`raw-events` includes two intentional duplicate `event_id`s so you can see what the `dedup` node
51
+ in between is for).
52
+
53
+ For a bigger graph exercising fragment merging across `topics.json`, `tables.json`, `nodes.json`,
54
+ and `edges.json` (two raw topics → dedup → a sessionize/enrich node → a ClickHouse table, plus a
55
+ side branch straight to another table), see [`examples/analytics-pipeline`](examples/analytics-pipeline)
56
+ — its topics aren't seeded with data, so "Latest events" there will error unless you produce to
57
+ them yourself.
58
+
59
+ Since `examples/` itself contains both as sibling directories, it also doubles as a ready-made
60
+ `--flows-root` demo — `tkati-dashboard --flows-root packages/tkati-dashboard/examples` serves both
61
+ as separate flows, switchable from the ☰ menu (see [Multiple flows](#multiple-flows)).
62
+
63
+ Options:
64
+
65
+ - `--host` (default `127.0.0.1`)
66
+ - `--port` (default `8000`)
67
+ - `--flows-root DIR` — auto-discover flows (see below); repeatable
68
+
69
+ A dataflow directory is re-read on every request to its `/api/flows/{id}/graph`, so editing the
70
+ fragments and refreshing the browser picks up the change without restarting the server.
71
+
72
+ ## Multiple flows
73
+
74
+ A real deployment usually runs more than one dataflow per environment, and one dashboard
75
+ instance can observe all of them at once instead of one directory per instance:
76
+
77
+ ```sh
78
+ # name each flow directory explicitly (flow id = each directory's own basename)
79
+ tkati-dashboard path/to/orders-flow path/to/clicks-flow
80
+
81
+ # or point at a parent directory and let every fragment-containing subdirectory become a flow
82
+ tkati-dashboard --flows-root path/to/env/flows
83
+ ```
84
+
85
+ The two forms can be combined, and `--flows-root` is repeatable. Either way, a flow's id is its
86
+ directory's own basename — passing two directories with the same name is a startup error asking
87
+ you to rename one of them. Unlike the explicit directories, a `--flows-root`'s subdirectories are
88
+ rescanned on every request to `/api/flows`, so adding or removing a flow directory there shows up
89
+ without restarting the server; a subdirectory with no fragments in it (a stray `README`, say) is
90
+ silently skipped rather than treated as an error.
91
+
92
+ With more than one flow configured, the graph page gets a ☰ menu in its top-left corner naming the
93
+ currently selected flow — click it to switch to another one. It stays collapsed to that single
94
+ button otherwise, so the canvas keeps its space once a flow is picked.
95
+ The current flow is also reflected in the page's URL as `?flow=<id>`, so a link to a specific
96
+ flow's view can be bookmarked or shared. With only one flow configured (the common case, and the
97
+ default if you pass a single directory as before), the menu doesn't appear at all — the page looks
98
+ exactly as it always has.
99
+
100
+ ## Node panel
101
+
102
+ The panel on the right is always there, like an inspector rather than a popup — it shows a
103
+ placeholder until you click a node, then shows that node's full metadata: connection settings,
104
+ `config`, and `schema` (field → type). Clicking empty canvas clears the selection back to the
105
+ placeholder. Every section has a clickable header to collapse/expand it — a collapsed section
106
+ stays mounted, just hidden, so collapsing a live-fetching section and reopening it doesn't
107
+ re-fetch. For a `kafka-topic` node, the panel also fetches two more, live views:
108
+
109
+ - `GET /api/flows/{flow_id}/nodes/{id}/snapshot` connects to `connection.broker`/`connection.topic` and shows the
110
+ most recent messages on that topic (newest last), parsed as JSON, using a throwaway consumer
111
+ group that never commits offsets. It only fetches on demand — click "Load latest events" — and
112
+ a "↻ Refresh" button afterward pulls a fresh batch on request rather than automatically. Each
113
+ message renders as its own pretty-printed JSON block (fields reordered to match `schema`, when
114
+ there is one) rather than a table — real messages commonly carry 5-20 fields, too many to lay
115
+ out sensibly as table columns in a side panel — with a small header showing that message's
116
+ Kafka-level `partition`, `offset`, and timestamp, alongside its parsed body.
117
+ - `GET /api/flows/{flow_id}/nodes/{id}/topic-stats` shows the topic's partitioning and replication (per-partition
118
+ leader/replicas/in-sync-replicas, flagging any under-replicated partition) and its topic-level
119
+ config — `retention.ms`, `retention.bytes`, `cleanup.policy`, `segment.bytes`,
120
+ `compression.type`, `max.message.bytes` — via `tkati_dashboard.topic_stats`, marking each value
121
+ that differs from the broker default. Partition/replica info comes from the same topic metadata
122
+ lookup as the snapshot/lag features (`_kafka_metadata.py`); the config comes from a separate
123
+ read-only `AdminClient.describe_configs()` call.
124
+
125
+ These are two of the places `tkati-dashboard` talks to a live broker rather than just the
126
+ serialized directory — best-effort conveniences for the panel, not something the graph view itself
127
+ depends on: a broker that's unreachable, or a topic that doesn't exist, shows an inline error in
128
+ that section instead of breaking the page.
129
+
130
+ ## Consumer lag
131
+
132
+ For every stream edge whose `consumer.group_id` is set and whose source is a `kafka-topic`, the
133
+ page also fetches `GET /api/flows/{flow_id}/nodes/{topic_id}/consumer-lag?group_id=...` and shows the result in
134
+ two places: the `group`/`lag` lines in that edge's stacked row inside the consuming node (see
135
+ above), and, for either node that edge touches, the inspector panel's "Consumer lag" section
136
+ (`← other-node (group_id)` for an edge consumed by the selected node, `→ other-node (group_id)`
137
+ for one where it's the topic being consumed). This is another place `tkati-dashboard` talks to a
138
+ live broker: it looks up `group_id`'s committed offset with `Consumer.committed()` and compares
139
+ it to the topic's high watermark — it never subscribes or polls as that group, so it can't join
140
+ it, trigger a rebalance, or otherwise disturb a real pipeline's consumer. A group that has never
141
+ committed an offset is reported as fully behind (lag = the topic's full size); an unreachable
142
+ broker shows `lag: n/a` instead of failing the page.
143
+
144
+ Lag is time-sensitive, so it doesn't just get fetched once. A ☰-style control in the canvas's
145
+ top-right corner (shown whenever the graph has at least one such edge) works like Grafana's
146
+ refresh picker: a "↻" button re-fetches every visible edge's lag immediately, and a dropdown next
147
+ to it sets an auto-refresh interval (Off/5s/15s/30s/1m/5m, persisted across reloads, paused while
148
+ the browser tab isn't visible). The inspector's "Consumer lag" section additionally has its own
149
+ per-row "↻" to refresh just the one edge you're looking at, without waiting for the next tick or
150
+ refreshing every other edge in the graph.
151
+
152
+ ## Validation
153
+
154
+ `tkati_dashboard.dataflow.load_dataflow` enforces the rules from the serialization doc: the
155
+ directory must contain at least one `*.json`/`*.yaml`/`*.yml` fragment, node ids must be unique
156
+ (or identically redefined) across fragments, edges must reference existing nodes, and a node's `schema`, when
157
+ present, must use field types known to `tkati_core.type_mapping` — `schema` itself is always
158
+ optional, since it isn't always on hand for a real-world node. A validation failure surfaces as
159
+ an HTTP 422 with the error message, shown inline on the page instead of a blank graph.
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "tkati-dashboard"
3
+ version = "0.4.0"
4
+ description = "Local web dashboard that renders a serialized tkati dataflow directory as a graph"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "tkati-core==0.4.0",
9
+ "fastapi>=0.115.0",
10
+ "uvicorn>=0.34.0",
11
+ "confluent-kafka>=2.11.0",
12
+ "orjson>=3.9.0",
13
+ "pyyaml>=6.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ tkati-dashboard = "tkati_dashboard.main:main"
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=9.0.1", "httpx>=0.28.0"]
21
+
22
+ [build-system]
23
+ requires = ["setuptools", "wheel"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+ include = ["tkati_dashboard*"]
29
+
30
+ [tool.setuptools.package-data]
31
+ tkati_dashboard = ["static/*.html"]
32
+
33
+ [tool.uv]
34
+ package = true
35
+
36
+ [tool.uv-workspace-codegen]
37
+ generate = true
38
+ template_type = ["test", "publish"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from tkati_dashboard.app import create_app
2
+ from tkati_dashboard.dataflow import Dataflow, DataflowValidationError, load_dataflow
3
+
4
+ __all__ = [
5
+ "Dataflow",
6
+ "DataflowValidationError",
7
+ "create_app",
8
+ "load_dataflow",
9
+ ]
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,31 @@
1
+ """Shared "resolve a live topic's metadata" helpers for snapshot.py, lag.py, and topic_stats.py."""
2
+
3
+ from typing import Any
4
+
5
+ from confluent_kafka import Consumer
6
+
7
+
8
+ def resolve_topic_metadata(
9
+ consumer: Consumer, broker: str, topic: str, timeout_sec: float
10
+ ) -> Any:
11
+ """Return `topic`'s TopicMetadata (partitions, each with leader/replicas/isrs), or raise
12
+ RuntimeError with a message naming `broker`/`topic` if the broker is unreachable or the
13
+ topic doesn't exist.
14
+ """
15
+ try:
16
+ metadata = consumer.list_topics(topic, timeout=timeout_sec)
17
+ except Exception as e:
18
+ raise RuntimeError(f"Could not reach broker {broker!r}: {e}") from e
19
+
20
+ topic_metadata = metadata.topics.get(topic)
21
+ if topic_metadata is None or topic_metadata.error is not None:
22
+ raise RuntimeError(f"Topic {topic!r} not found on {broker!r}")
23
+
24
+ return topic_metadata
25
+
26
+
27
+ def resolve_partitions(
28
+ consumer: Consumer, broker: str, topic: str, timeout_sec: float
29
+ ) -> list[int]:
30
+ """Return the partition ids of `topic`. See resolve_topic_metadata for error behavior."""
31
+ return list(resolve_topic_metadata(consumer, broker, topic, timeout_sec).partitions)
@@ -0,0 +1,155 @@
1
+ from collections.abc import Callable
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from fastapi import FastAPI, HTTPException
6
+ from fastapi.responses import FileResponse
7
+
8
+ from tkati_dashboard import lag, snapshot, topic_stats
9
+ from tkati_dashboard.dataflow import (
10
+ SOURCE_SINK_TYPES,
11
+ DataflowValidationError,
12
+ NodeDef,
13
+ load_dataflow,
14
+ )
15
+ from tkati_dashboard.flows import FlowConfigError
16
+
17
+ STATIC_DIR = Path(__file__).parent / "static"
18
+
19
+
20
+ def _list_flows(list_flows: Callable[[], dict[str, Path]]) -> dict[str, Path]:
21
+ try:
22
+ return list_flows()
23
+ except FlowConfigError as e:
24
+ raise HTTPException(status_code=500, detail=str(e)) from e
25
+
26
+
27
+ def _resolve_flow_dir(list_flows: Callable[[], dict[str, Path]], flow_id: str) -> Path:
28
+ directory = _list_flows(list_flows).get(flow_id)
29
+ if directory is None:
30
+ raise HTTPException(status_code=404, detail=f"Unknown flow {flow_id!r}")
31
+ return directory
32
+
33
+
34
+ def _graph_json(directory: Path) -> dict[str, Any]:
35
+ dataflow = load_dataflow(directory)
36
+
37
+ nodes = [
38
+ {
39
+ "id": node_id,
40
+ "label": node.name or node_id,
41
+ "type": node.type,
42
+ "group": "source-sink"
43
+ if node.type in SOURCE_SINK_TYPES
44
+ else "processing-node",
45
+ "schema": node.schema,
46
+ "connection": node.connection,
47
+ "config": node.config,
48
+ }
49
+ for node_id, node in dataflow.nodes.items()
50
+ ]
51
+ edges = [
52
+ {
53
+ "from": edge.from_,
54
+ "to": edge.to,
55
+ "kind": edge.kind,
56
+ "consumer": edge.consumer,
57
+ }
58
+ for edge in dataflow.edges
59
+ ]
60
+ return {"name": dataflow.name, "nodes": nodes, "edges": edges}
61
+
62
+
63
+ def _get_node(directory: Path, node_id: str) -> NodeDef:
64
+ dataflow = load_dataflow(directory)
65
+ node = dataflow.nodes.get(node_id)
66
+ if node is None:
67
+ raise HTTPException(status_code=404, detail=f"Unknown node {node_id!r}")
68
+ return node
69
+
70
+
71
+ def _require_kafka_connection(
72
+ node_id: str, node: NodeDef, feature: str
73
+ ) -> tuple[str, str]:
74
+ """Common gate for the two live-Kafka endpoints: node must be a kafka-topic with a
75
+ broker/topic to connect to. Returns (broker, topic) or raises HTTPException."""
76
+ if node.type != "kafka-topic":
77
+ raise HTTPException(
78
+ status_code=404,
79
+ detail=f"No {feature} available for node type {node.type!r}",
80
+ )
81
+ connection = node.connection or {}
82
+ broker, topic = connection.get("broker"), connection.get("topic")
83
+ if not broker or not topic:
84
+ raise HTTPException(
85
+ status_code=422,
86
+ detail=f"Node {node_id!r} is missing connection.broker/connection.topic",
87
+ )
88
+ return broker, topic
89
+
90
+
91
+ def create_app(list_flows: Callable[[], dict[str, Path]]) -> FastAPI:
92
+ app = FastAPI(title="tkati-dashboard")
93
+
94
+ @app.get("/")
95
+ def index() -> FileResponse:
96
+ return FileResponse(STATIC_DIR / "index.html")
97
+
98
+ @app.get("/api/flows")
99
+ def flows() -> list[dict[str, str]]:
100
+ resolved = _list_flows(list_flows)
101
+ return [{"id": flow_id, "name": flow_id} for flow_id in sorted(resolved, key=str.lower)]
102
+
103
+ @app.get("/api/flows/{flow_id}/graph")
104
+ def graph(flow_id: str) -> dict[str, Any]:
105
+ directory = _resolve_flow_dir(list_flows, flow_id)
106
+ try:
107
+ return _graph_json(directory)
108
+ except DataflowValidationError as e:
109
+ raise HTTPException(status_code=422, detail=str(e)) from e
110
+
111
+ @app.get("/api/flows/{flow_id}/nodes/{node_id}/snapshot")
112
+ def node_snapshot(flow_id: str, node_id: str) -> dict[str, Any]:
113
+ directory = _resolve_flow_dir(list_flows, flow_id)
114
+ try:
115
+ node = _get_node(directory, node_id)
116
+ except DataflowValidationError as e:
117
+ raise HTTPException(status_code=422, detail=str(e)) from e
118
+ broker, topic = _require_kafka_connection(node_id, node, "live snapshot")
119
+
120
+ try:
121
+ events = snapshot.fetch_kafka_snapshot(broker, topic)
122
+ except snapshot.SnapshotError as e:
123
+ raise HTTPException(status_code=502, detail=str(e)) from e
124
+
125
+ return {"events": events}
126
+
127
+ @app.get("/api/flows/{flow_id}/nodes/{node_id}/consumer-lag")
128
+ def node_consumer_lag(flow_id: str, node_id: str, group_id: str) -> dict[str, Any]:
129
+ directory = _resolve_flow_dir(list_flows, flow_id)
130
+ try:
131
+ node = _get_node(directory, node_id)
132
+ except DataflowValidationError as e:
133
+ raise HTTPException(status_code=422, detail=str(e)) from e
134
+ broker, topic = _require_kafka_connection(node_id, node, "consumer lag")
135
+
136
+ try:
137
+ return lag.fetch_consumer_lag(broker, topic, group_id)
138
+ except lag.LagError as e:
139
+ raise HTTPException(status_code=502, detail=str(e)) from e
140
+
141
+ @app.get("/api/flows/{flow_id}/nodes/{node_id}/topic-stats")
142
+ def node_topic_stats(flow_id: str, node_id: str) -> dict[str, Any]:
143
+ directory = _resolve_flow_dir(list_flows, flow_id)
144
+ try:
145
+ node = _get_node(directory, node_id)
146
+ except DataflowValidationError as e:
147
+ raise HTTPException(status_code=422, detail=str(e)) from e
148
+ broker, topic = _require_kafka_connection(node_id, node, "topic stats")
149
+
150
+ try:
151
+ return topic_stats.fetch_topic_stats(broker, topic)
152
+ except topic_stats.TopicStatsError as e:
153
+ raise HTTPException(status_code=502, detail=str(e)) from e
154
+
155
+ return app