diff --git a/tasktiger_admin/__init__.py b/tasktiger_admin/__init__.py index 0ea0b01..abbad5b 100644 --- a/tasktiger_admin/__init__.py +++ b/tasktiger_admin/__init__.py @@ -6,5 +6,6 @@ __all__ = ["TaskTigerView", "tasktiger_admin"] tasktiger_admin = Blueprint( - "tasktiger_admin", __name__, template_folder="templates" -) + "tasktiger_admin", __name__, template_folder="templates", + static_folder="assets" +) \ No newline at end of file diff --git a/tasktiger_admin/graph.py b/tasktiger_admin/graph.py new file mode 100644 index 0000000..59b7ca0 --- /dev/null +++ b/tasktiger_admin/graph.py @@ -0,0 +1,140 @@ +from __future__ import annotations +from typing import Dict, List +from tasktiger import Task, TaskNotFound +from tasktiger._internal import COMPLETED +import textwrap + +WRAP_MAX_CHARS = 40 + +class Graph: + def __init__(self): + """ + Generate vis-network graph for task workflow display + """ + + def generate(self, tiger, queue, state, task_id) -> VisData: + nodes: Dict[str, VisNode] = {} + edges: Dict[str, VisEdge] = {} + visited: set[str] = set() + try: + task: Task = Task.from_id(tiger, queue, state, task_id) + except: + # check completion state + if state != COMPLETED: + task: Task = Task.from_id(tiger, queue, COMPLETED, task_id) + self.generate_node_edges(task, nodes, edges, visited) + + visData: VisData = VisData(list(nodes.values()), list(edges.values())) + return visData + + def generate_node_edges( + self, + task: Task, + nodes: Dict[str, VisNode], + edges: Dict[str, VisNode], + visited: set[str], + level: int = 0 + ): + if not task: + return + + node: VisNode | None = None + + if task.id in visited: + return + label = self.get_label(task) + # colors for nodes can be set in groups on the js side + node: VisNode = VisNode(task.id, label) + nodes[task.id] = node + node.level = level + visited.add(task.id) + if task.state: + node.group = task.state + else: + node.group = "unknown" + nodes + if task.depends: + dep_tasks: List[Task] = task.get_dependencies() + for dep_task in dep_tasks: + self.generate_node_edges(dep_task, nodes, edges, visited, level + 1) + edge_id: str = dep_task.id + "->" + node.id + if edge_id not in edges: + edge = VisEdge(edge_id, "", dep_task.id, node.id, arrows=Arrows()) + # colors cannot be set in groups so we set here + edge.color = Color("#6466f3") + edges[edge_id] = edge + + def get_label(self, task: Task): + label = "ID: " + task.id[0:6] + "\n" + if task.state: + label += "Run At: " + task.ts.strftime("%Y-%m-%d %H:%M:%S") + "\n" + label += "Queue: " + task.queue + "\n" + label += "State: " + task.state + "\n" + label += "Func: " + task.serialized_func + "\n" + label += "args: " + self.wrap(str(task.args)) + "\n" + label += "kwargs: " + self.wrap(str(task.kwargs)) + "\n" + else: + label += "Not Found" + "\n" + return label + + def wrap(self, text: str): + return textwrap.fill(text, width=WRAP_MAX_CHARS) + +class VisData: + def __init__(self, nodes: List[VisNode], edges: List[VisEdge]): + self.nodes = nodes + self.edges = edges + + +class VisNode: + def __init__(self, id: str, label: str, group: str = None): + self.id: str = id + self.label: str = label + self.level: int = 0 + if group: + self.group: str = group + + +# cannot use from and to for attributes +# so we implement edges as a dict instead +class VisEdge(Dict): + def __init__( + self, + id: str, + label: str, + From: str, + To: str, + arrows: Arrows | None = None, + color: Color | None = None, + ): + self["id"] = id + self["label"] = label + self["from"] = From + self["to"] = To + if arrows: + self["arrows"] = arrows + if color: + self["color"] = color + self["smooth"] = False + + +class Arrows: + def __init__(self): + self.to = {"enabled": True, "type": "arrow"} + + +class Color: + def __init__( + self, + color: str | None, + highlight: str | None = None, + hover: str | None = None, + opacity: float = 0, + ): + self.color = color + if highlight: + self.highlight = highlight + if hover: + self.hover = hover + if opacity: + self.opacity = opacity diff --git a/tasktiger_admin/static/css/main.css b/tasktiger_admin/static/css/main.css new file mode 100644 index 0000000..e69de29 diff --git a/tasktiger_admin/static/js/graph.js b/tasktiger_admin/static/js/graph.js new file mode 100644 index 0000000..08795c1 --- /dev/null +++ b/tasktiger_admin/static/js/graph.js @@ -0,0 +1,114 @@ +function load_graph() { + url = `/tasktiger/${task_data["queue"]}/${task_data["state"]}/${task_data["id"]}/graph`; + fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }).then(async response => { + load_graph_data(await response.json()); + }).catch(error => { + console.error('Error:', error); + }); +} + +function load_graph_data(data) { + console.log(data); + nodes = new vis.DataSet(data.nodes); + edges = new vis.DataSet(data.edges); + var visData = { + nodes: nodes, + edges: edges, + }; + var container = document.getElementById("graph"); + var options = { + nodes: { + mass: 4, + font: { + face: 'monospace', + size: 14, + color: 'black', + align: 'left' + } + }, + edges: { + physics: false, + }, + layout: { + randomSeed: 0, + improvedLayout: true, + hierarchical: getTreeLayout() + }, + physics: { + enabled: false + }, + groups: { + completed: { + color: { background: "#8ed1f0" }, + borderWidth: 2, + shape: 'box', + mass: 2 + }, + active: { + color: { background: "#8ef0a3" }, + borderWidth: 2, + shape: 'box', + mass: 2 + }, + waiting: { + color: { background: "#d3e473" }, + borderWidth: 2, + shape: 'box', + mass: 2 + }, + scheduled: { + color: { background: "#b173e4" }, + borderWidth: 2, + shape: 'box', + mass: 2 + }, + queued: { + color: { background: "#73e4de" }, + borderWidth: 2, + shape: 'box', + mass: 2 + }, + error: { + color: { background: "#f07272" }, + borderWidth: 2, + shape: 'box', + mass: 2 + }, + unknown: { + color: { background: "#d43b3b" }, + borderWidth: 2, + shape: 'box', + mass: 2 + } + } + }; + var network = new vis.Network(container, visData, options); + + network.once('afterDrawing', (ctx) => { + // workaround for resizing + container.style.height = '300px'; + }); +} + +function getTreeLayout() { + return { + direction: "RL", + sortMethod: 'directed', + parentCentralization: true, + edgeMinimization: true, + levelSeparation: 600, + nodeSpacing: 300, + treeSpacing: 600, + blockShifting: true, + shakeTowards: 'leaves' + }; +} + +window.addEventListener("load", event => { + load_graph(); +}); \ No newline at end of file diff --git a/tasktiger_admin/templates/tasktiger_admin/tasktiger.html b/tasktiger_admin/templates/tasktiger_admin/tasktiger.html index 7ca1c6b..46cb77a 100644 --- a/tasktiger_admin/templates/tasktiger_admin/tasktiger.html +++ b/tasktiger_admin/templates/tasktiger_admin/tasktiger.html @@ -19,6 +19,8 @@
| ID | Run At | Func | Args | @@ -20,7 +21,8 @@|||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }} | +{{ task.id[:6] }} | +{{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }} | {{ task.serialized_func }} | {{ task.args }} {{ task.kwargs }} | {% if task.executions %}{{ task.executions.0.exception_name }}{% endif %}
diff --git a/tasktiger_admin/templates/tasktiger_admin/tasktiger_task_detail.html b/tasktiger_admin/templates/tasktiger_admin/tasktiger_task_detail.html
index 03c261b..b02bc06 100644
--- a/tasktiger_admin/templates/tasktiger_admin/tasktiger_task_detail.html
+++ b/tasktiger_admin/templates/tasktiger_admin/tasktiger_task_detail.html
@@ -12,6 +12,21 @@
cursor: pointer;
}
+
+
+
+
+
+
{% endblock %}
{% block body %}
@@ -36,6 +51,37 @@ TaskTiger – {{ queue }} ({{ state }
| |||||||||||
| Dependencies | +
+
+
+ Show/Hide+ {% for task in task_dependencies %} +
+ {{ task.id[:6] }}
+ {% if task.state %}
+ | {{ task.state }}
+ | {{ task.ts.strftime("%Y-%m-%d %H:%M:%S") }}
+ | {{ task.serialized_func }}
+ | {{ task.args }} {{ task.kwargs }}
+ {% endif %}
+
+ {% endfor %}
+ |
+ |||||||||||||||
| Workflow | +
+
+
+ Show/Hide+
+
+
+
+ |
+ |||||||||||||||
| Run At | diff --git a/tasktiger_admin/utils.py b/tasktiger_admin/utils.py index 1f96e67..1133a84 100644 --- a/tasktiger_admin/utils.py +++ b/tasktiger_admin/utils.py @@ -12,8 +12,9 @@ @click.option("-p", "--port", help="Redis server port") @click.option("-a", "--password", help="Redis password") @click.option("-n", "--db", help="Redis database number") +@click.option("-b", "--bind", help="bind addr, default=127.0.0.1") @click.option("-l", "--listen", help="Admin port to listen on") -def run_admin(host, port, db, password, listen): +def run_admin(host, port, db, password, bind, listen): conn = redis.Redis( host, int(port or 6379), int(db or 0), password, decode_responses=True ) @@ -23,4 +24,4 @@ def run_admin(host, port, db, password, listen): admin.add_view( TaskTigerView(tiger, name="TaskTiger", endpoint="tasktiger") ) - app.run(debug=True, port=int(listen or 5000)) + app.run(debug=True, host=(bind or "127.0.0.1"), port=int(listen or 5000)) diff --git a/tasktiger_admin/views.py b/tasktiger_admin/views.py index 3d68de4..905d8ec 100644 --- a/tasktiger_admin/views.py +++ b/tasktiger_admin/views.py @@ -5,6 +5,7 @@ from flask import abort, redirect, url_for from flask_admin import BaseView, expose from tasktiger import Task, TaskNotFound +from tasktiger_admin.graph import Graph, VisData from .integrations import generate_integrations @@ -99,6 +100,7 @@ def task_detail(self, queue, state, task_id): self.integration_config.get("INTEGRATION_LINKS", []), task, None ) + task_deps = task.get_dependencies() return self.render( "tasktiger_admin/tasktiger_task_detail.html", queue=queue, @@ -108,8 +110,21 @@ def task_detail(self, queue, state, task_id): task_data_dumped=json.dumps(task.data, indent=2, sort_keys=True), executions_dumped=reversed(executions_dumped), integrations=integrations, + task_dependencies=task_deps, ) + @expose("/