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.
- tkati_dashboard-0.4.0/PKG-INFO +172 -0
- tkati_dashboard-0.4.0/README.md +159 -0
- tkati_dashboard-0.4.0/pyproject.toml +38 -0
- tkati_dashboard-0.4.0/setup.cfg +4 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/__init__.py +9 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/__main__.py +4 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/_kafka_metadata.py +31 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/app.py +155 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/dataflow.py +168 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/flows.py +54 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/lag.py +78 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/main.py +76 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/py.typed +0 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/snapshot.py +111 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/static/index.html +1410 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard/topic_stats.py +95 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/PKG-INFO +172 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/SOURCES.txt +26 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/dependency_links.txt +1 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/entry_points.txt +2 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/requires.txt +6 -0
- tkati_dashboard-0.4.0/src/tkati_dashboard.egg-info/top_level.txt +1 -0
- tkati_dashboard-0.4.0/tests/test_app.py +248 -0
- tkati_dashboard-0.4.0/tests/test_dataflow.py +230 -0
- tkati_dashboard-0.4.0/tests/test_flows.py +68 -0
- tkati_dashboard-0.4.0/tests/test_lag.py +72 -0
- tkati_dashboard-0.4.0/tests/test_snapshot.py +45 -0
- 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,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
|