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.
@@ -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,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,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