tkati-dashboard 0.4.0a1__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.0a1/PKG-INFO +92 -0
- tkati_dashboard-0.4.0a1/README.md +80 -0
- tkati_dashboard-0.4.0a1/pyproject.toml +37 -0
- tkati_dashboard-0.4.0a1/setup.cfg +4 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/__init__.py +9 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/__main__.py +4 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/_kafka_metadata.py +21 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/app.py +117 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/dataflow.py +121 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/lag.py +78 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/main.py +29 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/py.typed +0 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/snapshot.py +89 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard/static/index.html +371 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard.egg-info/PKG-INFO +92 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard.egg-info/SOURCES.txt +22 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard.egg-info/dependency_links.txt +1 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard.egg-info/entry_points.txt +2 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard.egg-info/requires.txt +5 -0
- tkati_dashboard-0.4.0a1/src/tkati_dashboard.egg-info/top_level.txt +1 -0
- tkati_dashboard-0.4.0a1/tests/test_app.py +152 -0
- tkati_dashboard-0.4.0a1/tests/test_dataflow.py +44 -0
- tkati_dashboard-0.4.0a1/tests/test_lag.py +72 -0
- tkati_dashboard-0.4.0a1/tests/test_snapshot.py +40 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tkati-dashboard
|
|
3
|
+
Version: 0.4.0a1
|
|
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.0a1
|
|
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
|
+
|
|
13
|
+
# tkati-dashboard — dataflow graph viewer
|
|
14
|
+
|
|
15
|
+
Reads a serialized tkati dataflow directory (see
|
|
16
|
+
[docs/dataflow-serialization.md](../../docs/dataflow-serialization.md)) and serves a local web page
|
|
17
|
+
rendering it as a graph — no live runtime process required, no manifest to maintain, just a
|
|
18
|
+
directory of `*.json` fragment files.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
tkati-dashboard path/to/dataflow-dir
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then open `http://127.0.0.1:8000/` in a browser. The page fetches `/api/graph` and renders it
|
|
27
|
+
top-to-bottom with [React Flow](https://reactflow.dev); source/sink nodes (`kafka-topic`,
|
|
28
|
+
`clickhouse-table`) and processing nodes are colored differently, and stream edges are labeled by
|
|
29
|
+
`kind` plus, for a Kafka consumer edge, its `group_id` and live lag. Click a node to open a side
|
|
30
|
+
panel with its full connection/config/schema details.
|
|
31
|
+
|
|
32
|
+
### Try it with the bundled example
|
|
33
|
+
|
|
34
|
+
[`examples/simple-pipeline`](examples/simple-pipeline) is the smallest interesting dataflow: two
|
|
35
|
+
Kafka topics and one processing node (`raw-events` → `dedup` → `deduped-events`). Its `connection`
|
|
36
|
+
blocks point at `localhost:9092`, so if you have a broker there, seed it with sample events first:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv run python packages/tkati-dashboard/examples/simple-pipeline/seed_kafka.py
|
|
40
|
+
uv run tkati-dashboard packages/tkati-dashboard/examples/simple-pipeline
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then open <http://127.0.0.1:8000/> and click the `raw-events` or `deduped-events` node — the
|
|
44
|
+
"Latest events" section in the side panel shows the real messages the seed script just produced
|
|
45
|
+
(`raw-events` includes two intentional duplicate `event_id`s so you can see what the `dedup` node
|
|
46
|
+
in between is for).
|
|
47
|
+
|
|
48
|
+
For a bigger graph exercising fragment merging across `topics.json`, `tables.json`, `nodes.json`,
|
|
49
|
+
and `edges.json` (two raw topics → dedup → a sessionize/enrich node → a ClickHouse table, plus a
|
|
50
|
+
side branch straight to another table), see [`examples/analytics-pipeline`](examples/analytics-pipeline)
|
|
51
|
+
— its topics aren't seeded with data, so "Latest events" there will error unless you produce to
|
|
52
|
+
them yourself.
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
|
|
56
|
+
- `--host` (default `127.0.0.1`)
|
|
57
|
+
- `--port` (default `8000`)
|
|
58
|
+
|
|
59
|
+
The dataflow directory is re-read on every request to `/api/graph`, so editing the fragments and
|
|
60
|
+
refreshing the browser picks up the change without restarting the server.
|
|
61
|
+
|
|
62
|
+
## Node panel
|
|
63
|
+
|
|
64
|
+
Clicking a node opens a side panel with its full metadata: connection settings, `config`, and
|
|
65
|
+
`schema` (field → type). For a `kafka-topic` node, the panel also fetches
|
|
66
|
+
`GET /api/nodes/{id}/snapshot`, which connects live to `connection.broker`/`connection.topic` and
|
|
67
|
+
shows the most recent messages on that topic (newest last), parsed as JSON. This is one of two
|
|
68
|
+
places `tkati-dashboard` talks to a live broker rather than just the serialized directory — it's a
|
|
69
|
+
best-effort convenience for the panel, not something the graph view itself depends on: a broker
|
|
70
|
+
that's unreachable, or a topic that doesn't exist, shows an inline error in that section instead of
|
|
71
|
+
breaking the page. It uses a throwaway consumer group and never commits offsets, so it never
|
|
72
|
+
interferes with a real pipeline's consumers.
|
|
73
|
+
|
|
74
|
+
## Consumer lag
|
|
75
|
+
|
|
76
|
+
For every stream edge whose `consumer.group_id` is set and whose source is a `kafka-topic`, the
|
|
77
|
+
page also fetches `GET /api/nodes/{topic_id}/consumer-lag?group_id=...` and appends the result to
|
|
78
|
+
the edge's label (e.g. `stream · group: orders-dedup · lag: 4`). This is the other place
|
|
79
|
+
`tkati-dashboard` talks to a live broker: it looks up `group_id`'s committed offset with
|
|
80
|
+
`Consumer.committed()` and compares it to the topic's high watermark — it never subscribes or
|
|
81
|
+
polls as that group, so it can't join it, trigger a rebalance, or otherwise disturb a real
|
|
82
|
+
pipeline's consumer. A group that has never committed an offset is reported as fully behind (lag
|
|
83
|
+
= the topic's full size); an unreachable broker shows `lag: n/a` on that edge instead of failing
|
|
84
|
+
the page.
|
|
85
|
+
|
|
86
|
+
## Validation
|
|
87
|
+
|
|
88
|
+
`tkati_dashboard.dataflow.load_dataflow` enforces the rules from the serialization doc: the
|
|
89
|
+
directory must contain at least one `*.json` fragment, node ids must be unique (or identically
|
|
90
|
+
redefined) across fragments, edges must reference existing nodes, and source/sink schemas must use
|
|
91
|
+
field types known to `tkati_core.type_mapping`. A validation failure surfaces as an HTTP 422 with
|
|
92
|
+
the error message, shown inline on the page instead of a blank graph.
|
|
@@ -0,0 +1,80 @@
|
|
|
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` fragment files.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
tkati-dashboard path/to/dataflow-dir
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then open `http://127.0.0.1:8000/` in a browser. The page fetches `/api/graph` and renders it
|
|
15
|
+
top-to-bottom with [React Flow](https://reactflow.dev); source/sink nodes (`kafka-topic`,
|
|
16
|
+
`clickhouse-table`) and processing nodes are colored differently, and stream edges are labeled by
|
|
17
|
+
`kind` plus, for a Kafka consumer edge, its `group_id` and live lag. Click a node to open a side
|
|
18
|
+
panel with its full connection/config/schema details.
|
|
19
|
+
|
|
20
|
+
### Try it with the bundled example
|
|
21
|
+
|
|
22
|
+
[`examples/simple-pipeline`](examples/simple-pipeline) is the smallest interesting dataflow: two
|
|
23
|
+
Kafka topics and one processing node (`raw-events` → `dedup` → `deduped-events`). Its `connection`
|
|
24
|
+
blocks point at `localhost:9092`, so if you have a broker there, seed it with sample events first:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv run python packages/tkati-dashboard/examples/simple-pipeline/seed_kafka.py
|
|
28
|
+
uv run tkati-dashboard packages/tkati-dashboard/examples/simple-pipeline
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then open <http://127.0.0.1:8000/> and click the `raw-events` or `deduped-events` node — the
|
|
32
|
+
"Latest events" section in the side panel shows the real messages the seed script just produced
|
|
33
|
+
(`raw-events` includes two intentional duplicate `event_id`s so you can see what the `dedup` node
|
|
34
|
+
in between is for).
|
|
35
|
+
|
|
36
|
+
For a bigger graph exercising fragment merging across `topics.json`, `tables.json`, `nodes.json`,
|
|
37
|
+
and `edges.json` (two raw topics → dedup → a sessionize/enrich node → a ClickHouse table, plus a
|
|
38
|
+
side branch straight to another table), see [`examples/analytics-pipeline`](examples/analytics-pipeline)
|
|
39
|
+
— its topics aren't seeded with data, so "Latest events" there will error unless you produce to
|
|
40
|
+
them yourself.
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
|
|
44
|
+
- `--host` (default `127.0.0.1`)
|
|
45
|
+
- `--port` (default `8000`)
|
|
46
|
+
|
|
47
|
+
The dataflow directory is re-read on every request to `/api/graph`, so editing the fragments and
|
|
48
|
+
refreshing the browser picks up the change without restarting the server.
|
|
49
|
+
|
|
50
|
+
## Node panel
|
|
51
|
+
|
|
52
|
+
Clicking a node opens a side panel with its full metadata: connection settings, `config`, and
|
|
53
|
+
`schema` (field → type). For a `kafka-topic` node, the panel also fetches
|
|
54
|
+
`GET /api/nodes/{id}/snapshot`, which connects live to `connection.broker`/`connection.topic` and
|
|
55
|
+
shows the most recent messages on that topic (newest last), parsed as JSON. This is one of two
|
|
56
|
+
places `tkati-dashboard` talks to a live broker rather than just the serialized directory — it's a
|
|
57
|
+
best-effort convenience for the panel, not something the graph view itself depends on: a broker
|
|
58
|
+
that's unreachable, or a topic that doesn't exist, shows an inline error in that section instead of
|
|
59
|
+
breaking the page. It uses a throwaway consumer group and never commits offsets, so it never
|
|
60
|
+
interferes with a real pipeline's consumers.
|
|
61
|
+
|
|
62
|
+
## Consumer lag
|
|
63
|
+
|
|
64
|
+
For every stream edge whose `consumer.group_id` is set and whose source is a `kafka-topic`, the
|
|
65
|
+
page also fetches `GET /api/nodes/{topic_id}/consumer-lag?group_id=...` and appends the result to
|
|
66
|
+
the edge's label (e.g. `stream · group: orders-dedup · lag: 4`). This is the other place
|
|
67
|
+
`tkati-dashboard` talks to a live broker: it looks up `group_id`'s committed offset with
|
|
68
|
+
`Consumer.committed()` and compares it to the topic's high watermark — it never subscribes or
|
|
69
|
+
polls as that group, so it can't join it, trigger a rebalance, or otherwise disturb a real
|
|
70
|
+
pipeline's consumer. A group that has never committed an offset is reported as fully behind (lag
|
|
71
|
+
= the topic's full size); an unreachable broker shows `lag: n/a` on that edge instead of failing
|
|
72
|
+
the page.
|
|
73
|
+
|
|
74
|
+
## Validation
|
|
75
|
+
|
|
76
|
+
`tkati_dashboard.dataflow.load_dataflow` enforces the rules from the serialization doc: the
|
|
77
|
+
directory must contain at least one `*.json` fragment, node ids must be unique (or identically
|
|
78
|
+
redefined) across fragments, edges must reference existing nodes, and source/sink schemas must use
|
|
79
|
+
field types known to `tkati_core.type_mapping`. A validation failure surfaces as an HTTP 422 with
|
|
80
|
+
the error message, shown inline on the page instead of a blank graph.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tkati-dashboard"
|
|
3
|
+
version = "0.4.0a1"
|
|
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.0a1",
|
|
9
|
+
"fastapi>=0.115.0",
|
|
10
|
+
"uvicorn>=0.34.0",
|
|
11
|
+
"confluent-kafka>=2.11.0",
|
|
12
|
+
"orjson>=3.9.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
tkati-dashboard = "tkati_dashboard.main:main"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = ["pytest>=9.0.1", "httpx>=0.28.0"]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["setuptools", "wheel"]
|
|
23
|
+
build-backend = "setuptools.build_meta"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
include = ["tkati_dashboard*"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-data]
|
|
30
|
+
tkati_dashboard = ["static/*.html"]
|
|
31
|
+
|
|
32
|
+
[tool.uv]
|
|
33
|
+
package = true
|
|
34
|
+
|
|
35
|
+
[tool.uv-workspace-codegen]
|
|
36
|
+
generate = true
|
|
37
|
+
template_type = ["test", "publish"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared "resolve a live topic's partitions" helper for snapshot.py and lag.py."""
|
|
2
|
+
|
|
3
|
+
from confluent_kafka import Consumer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_partitions(
|
|
7
|
+
consumer: Consumer, broker: str, topic: str, timeout_sec: float
|
|
8
|
+
) -> list[int]:
|
|
9
|
+
"""Return the partition ids of `topic`, or raise RuntimeError with a message naming
|
|
10
|
+
`broker`/`topic` if the broker is unreachable or the topic doesn't exist.
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
metadata = consumer.list_topics(topic, timeout=timeout_sec)
|
|
14
|
+
except Exception as e:
|
|
15
|
+
raise RuntimeError(f"Could not reach broker {broker!r}: {e}") from e
|
|
16
|
+
|
|
17
|
+
topic_metadata = metadata.topics.get(topic)
|
|
18
|
+
if topic_metadata is None or topic_metadata.error is not None:
|
|
19
|
+
raise RuntimeError(f"Topic {topic!r} not found on {broker!r}")
|
|
20
|
+
|
|
21
|
+
return list(topic_metadata.partitions)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, HTTPException
|
|
5
|
+
from fastapi.responses import FileResponse
|
|
6
|
+
|
|
7
|
+
from tkati_dashboard import lag, snapshot
|
|
8
|
+
from tkati_dashboard.dataflow import (
|
|
9
|
+
SOURCE_SINK_TYPES,
|
|
10
|
+
DataflowValidationError,
|
|
11
|
+
NodeDef,
|
|
12
|
+
load_dataflow,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _graph_json(directory: Path) -> dict[str, Any]:
|
|
19
|
+
dataflow = load_dataflow(directory)
|
|
20
|
+
|
|
21
|
+
nodes = [
|
|
22
|
+
{
|
|
23
|
+
"id": node_id,
|
|
24
|
+
"label": node.name or node_id,
|
|
25
|
+
"type": node.type,
|
|
26
|
+
"group": "source-sink"
|
|
27
|
+
if node.type in SOURCE_SINK_TYPES
|
|
28
|
+
else "processing-node",
|
|
29
|
+
"schema": node.schema,
|
|
30
|
+
"connection": node.connection,
|
|
31
|
+
"config": node.config,
|
|
32
|
+
}
|
|
33
|
+
for node_id, node in dataflow.nodes.items()
|
|
34
|
+
]
|
|
35
|
+
edges = [
|
|
36
|
+
{
|
|
37
|
+
"from": edge.from_,
|
|
38
|
+
"to": edge.to,
|
|
39
|
+
"kind": edge.kind,
|
|
40
|
+
"consumer": edge.consumer,
|
|
41
|
+
}
|
|
42
|
+
for edge in dataflow.edges
|
|
43
|
+
]
|
|
44
|
+
return {"name": dataflow.name, "nodes": nodes, "edges": edges}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_node(directory: Path, node_id: str) -> NodeDef:
|
|
48
|
+
dataflow = load_dataflow(directory)
|
|
49
|
+
node = dataflow.nodes.get(node_id)
|
|
50
|
+
if node is None:
|
|
51
|
+
raise HTTPException(status_code=404, detail=f"Unknown node {node_id!r}")
|
|
52
|
+
return node
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _require_kafka_connection(
|
|
56
|
+
node_id: str, node: NodeDef, feature: str
|
|
57
|
+
) -> tuple[str, str]:
|
|
58
|
+
"""Common gate for the two live-Kafka endpoints: node must be a kafka-topic with a
|
|
59
|
+
broker/topic to connect to. Returns (broker, topic) or raises HTTPException."""
|
|
60
|
+
if node.type != "kafka-topic":
|
|
61
|
+
raise HTTPException(
|
|
62
|
+
status_code=404,
|
|
63
|
+
detail=f"No {feature} available for node type {node.type!r}",
|
|
64
|
+
)
|
|
65
|
+
connection = node.connection or {}
|
|
66
|
+
broker, topic = connection.get("broker"), connection.get("topic")
|
|
67
|
+
if not broker or not topic:
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=422,
|
|
70
|
+
detail=f"Node {node_id!r} is missing connection.broker/connection.topic",
|
|
71
|
+
)
|
|
72
|
+
return broker, topic
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_app(dataflow_dir: Path) -> FastAPI:
|
|
76
|
+
app = FastAPI(title="tkati-dashboard")
|
|
77
|
+
|
|
78
|
+
@app.get("/")
|
|
79
|
+
def index() -> FileResponse:
|
|
80
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
81
|
+
|
|
82
|
+
@app.get("/api/graph")
|
|
83
|
+
def graph() -> dict[str, Any]:
|
|
84
|
+
try:
|
|
85
|
+
return _graph_json(dataflow_dir)
|
|
86
|
+
except DataflowValidationError as e:
|
|
87
|
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
|
88
|
+
|
|
89
|
+
@app.get("/api/nodes/{node_id}/snapshot")
|
|
90
|
+
def node_snapshot(node_id: str) -> dict[str, Any]:
|
|
91
|
+
try:
|
|
92
|
+
node = _get_node(dataflow_dir, node_id)
|
|
93
|
+
except DataflowValidationError as e:
|
|
94
|
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
|
95
|
+
broker, topic = _require_kafka_connection(node_id, node, "live snapshot")
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
events = snapshot.fetch_kafka_snapshot(broker, topic)
|
|
99
|
+
except snapshot.SnapshotError as e:
|
|
100
|
+
raise HTTPException(status_code=502, detail=str(e)) from e
|
|
101
|
+
|
|
102
|
+
return {"events": events}
|
|
103
|
+
|
|
104
|
+
@app.get("/api/nodes/{node_id}/consumer-lag")
|
|
105
|
+
def node_consumer_lag(node_id: str, group_id: str) -> dict[str, Any]:
|
|
106
|
+
try:
|
|
107
|
+
node = _get_node(dataflow_dir, node_id)
|
|
108
|
+
except DataflowValidationError as e:
|
|
109
|
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
|
110
|
+
broker, topic = _require_kafka_connection(node_id, node, "consumer lag")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return lag.fetch_consumer_lag(broker, topic, group_id)
|
|
114
|
+
except lag.LagError as e:
|
|
115
|
+
raise HTTPException(status_code=502, detail=str(e)) from e
|
|
116
|
+
|
|
117
|
+
return app
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Load and validate a serialized tkati dataflow directory.
|
|
2
|
+
|
|
3
|
+
See docs/dataflow-serialization.md for the format this module implements: a directory of JSON
|
|
4
|
+
fragments merged into one graph of nodes and edges. There is no manifest file — every `*.json`
|
|
5
|
+
file directly inside the directory is a fragment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
from tkati_core.type_mapping import TYPE_MAPPING
|
|
14
|
+
|
|
15
|
+
# Node types that represent data at rest (as opposed to a processing step) and therefore require
|
|
16
|
+
# a `schema`. Kept as a heuristic, not a closed registry: an unrecognized type is still accepted,
|
|
17
|
+
# it just isn't schema-checked.
|
|
18
|
+
SOURCE_SINK_TYPES = {"kafka-topic", "clickhouse-table"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DataflowValidationError(ValueError):
|
|
22
|
+
"""A serialized dataflow directory failed validation."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NodeDef(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="allow")
|
|
27
|
+
|
|
28
|
+
type: str
|
|
29
|
+
name: str | None = None
|
|
30
|
+
schema: dict[str, str] | None = None
|
|
31
|
+
connection: dict[str, Any] | None = None
|
|
32
|
+
config: dict[str, Any] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EdgeDef(BaseModel):
|
|
36
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
37
|
+
|
|
38
|
+
from_: str = Field(alias="from")
|
|
39
|
+
to: str
|
|
40
|
+
kind: str = "stream"
|
|
41
|
+
consumer: dict[str, Any] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Dataflow(BaseModel):
|
|
45
|
+
name: str
|
|
46
|
+
nodes: dict[str, NodeDef]
|
|
47
|
+
edges: list[EdgeDef]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(path.read_text())
|
|
53
|
+
except FileNotFoundError as e:
|
|
54
|
+
raise DataflowValidationError(f"Missing dataflow file: {path}") from e
|
|
55
|
+
except json.JSONDecodeError as e:
|
|
56
|
+
raise DataflowValidationError(f"Invalid JSON in {path}: {e}") from e
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_node(node_id: str, node: NodeDef) -> None:
|
|
60
|
+
if node.type in SOURCE_SINK_TYPES:
|
|
61
|
+
if node.schema is None:
|
|
62
|
+
raise DataflowValidationError(
|
|
63
|
+
f"Node {node_id!r} of type {node.type!r} needs a schema"
|
|
64
|
+
)
|
|
65
|
+
for field_name, field_type in node.schema.items():
|
|
66
|
+
if field_type not in TYPE_MAPPING:
|
|
67
|
+
raise DataflowValidationError(
|
|
68
|
+
f"Node {node_id!r} field {field_name!r} has unknown schema type {field_type!r}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_dataflow(directory: Path) -> Dataflow:
|
|
73
|
+
"""Read every `*.json` fragment directly inside `directory`, merge, and validate them.
|
|
74
|
+
|
|
75
|
+
There is no manifest: any JSON file in the directory is a fragment contributing to the
|
|
76
|
+
graph. The dataflow's name is the directory's own name.
|
|
77
|
+
"""
|
|
78
|
+
if not directory.is_dir():
|
|
79
|
+
raise DataflowValidationError(f"Not a directory: {directory}")
|
|
80
|
+
|
|
81
|
+
fragment_paths = sorted(directory.glob("*.json"))
|
|
82
|
+
if not fragment_paths:
|
|
83
|
+
raise DataflowValidationError(
|
|
84
|
+
f"No dataflow fragments (*.json) found in {directory}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
nodes: dict[str, NodeDef] = {}
|
|
88
|
+
node_sources: dict[
|
|
89
|
+
str, str
|
|
90
|
+
] = {} # node id -> fragment it was first seen in, for error messages
|
|
91
|
+
edges: list[EdgeDef] = []
|
|
92
|
+
|
|
93
|
+
for fragment_path in fragment_paths:
|
|
94
|
+
fragment = _read_json(fragment_path)
|
|
95
|
+
|
|
96
|
+
for node_id, raw_node in fragment.get("nodes", {}).items():
|
|
97
|
+
node = NodeDef.model_validate(raw_node)
|
|
98
|
+
if node_id in nodes:
|
|
99
|
+
if nodes[node_id] != node:
|
|
100
|
+
raise DataflowValidationError(
|
|
101
|
+
f"Node {node_id!r} is defined differently in "
|
|
102
|
+
f"{node_sources[node_id]!r} and {fragment_path.name!r}"
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
nodes[node_id] = node
|
|
106
|
+
node_sources[node_id] = fragment_path.name
|
|
107
|
+
|
|
108
|
+
for raw_edge in fragment.get("edges", []):
|
|
109
|
+
edges.append(EdgeDef.model_validate(raw_edge))
|
|
110
|
+
|
|
111
|
+
for node_id, node in nodes.items():
|
|
112
|
+
_validate_node(node_id, node)
|
|
113
|
+
|
|
114
|
+
for edge in edges:
|
|
115
|
+
for endpoint in (edge.from_, edge.to):
|
|
116
|
+
if endpoint not in nodes:
|
|
117
|
+
raise DataflowValidationError(
|
|
118
|
+
f"Edge {edge.from_!r} -> {edge.to!r} references unknown node {endpoint!r}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return Dataflow(name=directory.name, nodes=nodes, edges=edges)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Live consumer-group lag lookup for a dataflow edge, for the dashboard's edge labels.
|
|
2
|
+
|
|
3
|
+
Like tkati_dashboard.snapshot, this is a best-effort, on-demand look at a live broker — it never
|
|
4
|
+
subscribes or polls as the consumer group, only reads its committed offsets via `.committed()`,
|
|
5
|
+
so it can never join the group, trigger a rebalance, or otherwise disrupt a real pipeline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from confluent_kafka import Consumer, TopicPartition
|
|
11
|
+
|
|
12
|
+
from tkati_dashboard._kafka_metadata import resolve_partitions
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT_SEC = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LagError(RuntimeError):
|
|
18
|
+
"""Consumer lag could not be computed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch_consumer_lag(
|
|
22
|
+
broker: str,
|
|
23
|
+
topic: str,
|
|
24
|
+
group_id: str,
|
|
25
|
+
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""Return per-partition and total lag of `group_id` on `topic`.
|
|
28
|
+
|
|
29
|
+
A partition `group_id` has never committed an offset on counts as fully behind (lag =
|
|
30
|
+
partition size), since that's the backlog the group would have to process from scratch.
|
|
31
|
+
"""
|
|
32
|
+
consumer = Consumer(
|
|
33
|
+
{
|
|
34
|
+
"bootstrap.servers": broker,
|
|
35
|
+
"group.id": group_id,
|
|
36
|
+
"enable.auto.commit": False,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
try:
|
|
41
|
+
partition_ids = resolve_partitions(consumer, broker, topic, timeout_sec)
|
|
42
|
+
except RuntimeError as e:
|
|
43
|
+
raise LagError(str(e)) from e
|
|
44
|
+
|
|
45
|
+
if not partition_ids:
|
|
46
|
+
return {"total_lag": 0, "partitions": []}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
committed = consumer.committed(
|
|
50
|
+
[TopicPartition(topic, pid) for pid in partition_ids],
|
|
51
|
+
timeout=timeout_sec,
|
|
52
|
+
)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise LagError(
|
|
55
|
+
f"Could not fetch committed offsets for group {group_id!r}: {e}"
|
|
56
|
+
) from e
|
|
57
|
+
|
|
58
|
+
partitions = []
|
|
59
|
+
total_lag = 0
|
|
60
|
+
for tp in committed:
|
|
61
|
+
low, high = consumer.get_watermark_offsets(
|
|
62
|
+
TopicPartition(topic, tp.partition), timeout=timeout_sec, cached=False
|
|
63
|
+
)
|
|
64
|
+
has_committed = tp.offset is not None and tp.offset >= 0
|
|
65
|
+
current = tp.offset if has_committed else low
|
|
66
|
+
partition_lag = max(high - current, 0)
|
|
67
|
+
total_lag += partition_lag
|
|
68
|
+
partitions.append(
|
|
69
|
+
{
|
|
70
|
+
"partition": tp.partition,
|
|
71
|
+
"committed_offset": tp.offset if has_committed else None,
|
|
72
|
+
"high_watermark": high,
|
|
73
|
+
"lag": partition_lag,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
return {"total_lag": total_lag, "partitions": partitions}
|
|
77
|
+
finally:
|
|
78
|
+
consumer.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import uvicorn
|
|
5
|
+
|
|
6
|
+
from tkati_dashboard.app import create_app
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="tkati-dashboard",
|
|
12
|
+
description="Serve a graph view of a serialized tkati dataflow directory",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"dataflow_dir",
|
|
16
|
+
type=Path,
|
|
17
|
+
help="Path to the dataflow directory (a directory of *.json fragments)",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
20
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
|
|
23
|
+
if not args.dataflow_dir.is_dir():
|
|
24
|
+
parser.error(f"{args.dataflow_dir} is not a directory")
|
|
25
|
+
if not any(args.dataflow_dir.glob("*.json")):
|
|
26
|
+
parser.error(f"{args.dataflow_dir} contains no dataflow fragments (*.json)")
|
|
27
|
+
|
|
28
|
+
app = create_app(args.dataflow_dir)
|
|
29
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
File without changes
|