fairchild 0.0.4__tar.gz → 0.0.6__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.
- {fairchild-0.0.4 → fairchild-0.0.6}/PKG-INFO +1 -1
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/__init__.py +0 -2
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/cli.py +4 -12
- fairchild-0.0.6/fairchild/db/migrations/005_add_worker_tasks.sql +3 -0
- fairchild-0.0.6/fairchild/db/migrations/006_rename_recorded_to_result.sql +2 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/task.py +48 -8
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/templates/dashboard.html +32 -33
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/templates/job.html +227 -10
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/ui.py +68 -63
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/worker.py +30 -22
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/PKG-INFO +1 -1
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/SOURCES.txt +2 -2
- {fairchild-0.0.4 → fairchild-0.0.6}/pyproject.toml +1 -1
- {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_cli.py +49 -35
- {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_task.py +17 -22
- {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_web_ui.py +41 -15
- fairchild-0.0.4/fairchild/record.py +0 -22
- fairchild-0.0.4/tests/test_record.py +0 -40
- {fairchild-0.0.4 → fairchild-0.0.6}/LICENSE +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/README.md +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/context.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/__init__.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/001_initial.sql +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/002_add_parent_id.sql +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/003_remove_workflows.sql +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/004_add_workers.sql +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/fairchild.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/future.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/job.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/dependency_links.txt +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/entry_points.txt +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/requires.txt +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/top_level.txt +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/setup.cfg +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_integration.py +0 -0
- {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_job.py +0 -0
|
@@ -162,19 +162,11 @@ async def _run_workers(queue_config: dict[str, int]):
|
|
|
162
162
|
@cli.command()
|
|
163
163
|
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
|
|
164
164
|
@click.option("--port", "-p", default=4000, help="Port to bind to")
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
"-i",
|
|
168
|
-
"imports",
|
|
169
|
-
multiple=True,
|
|
170
|
-
help='Module to import for task registration, e.g., "myapp.tasks"',
|
|
171
|
-
)
|
|
172
|
-
def ui(host: str, port: int, imports: tuple[str, ...]):
|
|
173
|
-
"""Start the web UI dashboard."""
|
|
174
|
-
# Import task modules
|
|
175
|
-
for module_path in imports:
|
|
176
|
-
import_module(module_path)
|
|
165
|
+
def ui(host: str, port: int):
|
|
166
|
+
"""Start the web UI dashboard.
|
|
177
167
|
|
|
168
|
+
The UI reads task definitions from active workers, so no --import is needed.
|
|
169
|
+
"""
|
|
178
170
|
click.echo(f"Starting Fairchild UI at http://{host}:{port}")
|
|
179
171
|
asyncio.run(_run_ui(host, port))
|
|
180
172
|
|
|
@@ -15,6 +15,52 @@ def get_task(name: str) -> "Task":
|
|
|
15
15
|
return _task_registry[name]
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def get_task_schemas() -> list[dict]:
|
|
19
|
+
"""Get schemas for all registered tasks.
|
|
20
|
+
|
|
21
|
+
Returns a list of task info dicts suitable for storing in the database.
|
|
22
|
+
"""
|
|
23
|
+
tasks = []
|
|
24
|
+
for name, task in sorted(_task_registry.items()):
|
|
25
|
+
sig = inspect.signature(task.fn)
|
|
26
|
+
params = []
|
|
27
|
+
for param_name, param in sig.parameters.items():
|
|
28
|
+
if param_name in ("job", "workflow"):
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
param_info = {"name": param_name}
|
|
32
|
+
|
|
33
|
+
if param.annotation != inspect.Parameter.empty:
|
|
34
|
+
try:
|
|
35
|
+
param_info["type"] = param.annotation.__name__
|
|
36
|
+
except AttributeError:
|
|
37
|
+
param_info["type"] = str(param.annotation)
|
|
38
|
+
|
|
39
|
+
if param.default != inspect.Parameter.empty:
|
|
40
|
+
param_info["default"] = param.default
|
|
41
|
+
param_info["required"] = False
|
|
42
|
+
else:
|
|
43
|
+
param_info["required"] = True
|
|
44
|
+
|
|
45
|
+
params.append(param_info)
|
|
46
|
+
|
|
47
|
+
docstring = inspect.getdoc(task.fn)
|
|
48
|
+
|
|
49
|
+
tasks.append(
|
|
50
|
+
{
|
|
51
|
+
"name": name,
|
|
52
|
+
"queue": task.queue,
|
|
53
|
+
"priority": task.priority,
|
|
54
|
+
"max_attempts": task.max_attempts,
|
|
55
|
+
"tags": task.tags,
|
|
56
|
+
"params": params,
|
|
57
|
+
"docstring": docstring,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return tasks
|
|
62
|
+
|
|
63
|
+
|
|
18
64
|
def task(
|
|
19
65
|
queue: str = "default",
|
|
20
66
|
max_attempts: int = 3,
|
|
@@ -28,7 +74,7 @@ def task(
|
|
|
28
74
|
Usage:
|
|
29
75
|
@task(queue="default")
|
|
30
76
|
def my_task(item_id: int):
|
|
31
|
-
return
|
|
77
|
+
return {"result": item_id * 2}
|
|
32
78
|
|
|
33
79
|
# Enqueue
|
|
34
80
|
my_task.enqueue(item_id=42)
|
|
@@ -151,13 +197,7 @@ class Task:
|
|
|
151
197
|
return Future(job_id=job_id)
|
|
152
198
|
|
|
153
199
|
# Not inside a task - execute directly
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
result = self.fn(*args, **kwargs)
|
|
157
|
-
# Unwrap Record so local runs behave like resolved futures
|
|
158
|
-
if isinstance(result, Record):
|
|
159
|
-
return result.value
|
|
160
|
-
return result
|
|
200
|
+
return self.fn(*args, **kwargs)
|
|
161
201
|
|
|
162
202
|
def _serialize_args(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
163
203
|
"""Serialize arguments, converting Futures to their job IDs."""
|
|
@@ -255,6 +255,16 @@
|
|
|
255
255
|
.job-link:hover {
|
|
256
256
|
text-decoration: underline;
|
|
257
257
|
}
|
|
258
|
+
.task-args {
|
|
259
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
260
|
+
font-size: 0.75rem;
|
|
261
|
+
color: var(--text-dim);
|
|
262
|
+
margin-top: 0.25rem;
|
|
263
|
+
max-width: 300px;
|
|
264
|
+
overflow: hidden;
|
|
265
|
+
text-overflow: ellipsis;
|
|
266
|
+
white-space: nowrap;
|
|
267
|
+
}
|
|
258
268
|
|
|
259
269
|
.queues {
|
|
260
270
|
display: grid;
|
|
@@ -354,13 +364,6 @@
|
|
|
354
364
|
font-weight: 500;
|
|
355
365
|
}
|
|
356
366
|
|
|
357
|
-
#workflows-table tbody tr {
|
|
358
|
-
cursor: pointer;
|
|
359
|
-
}
|
|
360
|
-
#workflows-table tbody tr:hover {
|
|
361
|
-
background: var(--bg-hover);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
367
|
/* Enqueue Modal */
|
|
365
368
|
.modal-overlay {
|
|
366
369
|
display: none;
|
|
@@ -1214,23 +1217,10 @@
|
|
|
1214
1217
|
}
|
|
1215
1218
|
}
|
|
1216
1219
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
const tbody = document.querySelector("#workflows-table tbody");
|
|
1222
|
-
tbody.innerHTML = workflows
|
|
1223
|
-
.map(
|
|
1224
|
-
(wf) => `
|
|
1225
|
-
<tr onclick="window.location='/workflows/${wf.workflow_id}'">
|
|
1226
|
-
<td>${wf.workflow_name || wf.workflow_id.slice(0, 8)}</td>
|
|
1227
|
-
<td>${wf.completed}/${wf.total_jobs} completed</td>
|
|
1228
|
-
<td class="mono">${formatTime(wf.started_at)}</td>
|
|
1229
|
-
<td class="mono">${wf.finished_at ? formatTime(wf.finished_at) : "-"}</td>
|
|
1230
|
-
</tr>
|
|
1231
|
-
`,
|
|
1232
|
-
)
|
|
1233
|
-
.join("");
|
|
1220
|
+
function formatArgs(args) {
|
|
1221
|
+
if (!args || Object.keys(args).length === 0) return "";
|
|
1222
|
+
const str = typeof args === "string" ? args : JSON.stringify(args);
|
|
1223
|
+
return str.length > 50 ? str.slice(0, 50) + "..." : str;
|
|
1234
1224
|
}
|
|
1235
1225
|
|
|
1236
1226
|
async function fetchJobs(state = "") {
|
|
@@ -1240,18 +1230,26 @@
|
|
|
1240
1230
|
|
|
1241
1231
|
const tbody = document.querySelector("#jobs-table tbody");
|
|
1242
1232
|
tbody.innerHTML = jobs
|
|
1243
|
-
.map(
|
|
1244
|
-
(job)
|
|
1233
|
+
.map((job) => {
|
|
1234
|
+
const argsDisplay = formatArgs(job.args);
|
|
1235
|
+
const argsTitle =
|
|
1236
|
+
typeof job.args === "string"
|
|
1237
|
+
? job.args
|
|
1238
|
+
: JSON.stringify(job.args);
|
|
1239
|
+
const argsHtml = argsDisplay
|
|
1240
|
+
? `<div class="task-args" title="${argsTitle.replace(/"/g, """)}">${argsDisplay}</div>`
|
|
1241
|
+
: "";
|
|
1242
|
+
return `
|
|
1245
1243
|
<tr>
|
|
1246
1244
|
<td class="mono"><a href="/jobs/${job.id}" class="job-link">${job.id}</a></td>
|
|
1247
|
-
<td
|
|
1245
|
+
<td>${job.task_name}${argsHtml}</td>
|
|
1248
1246
|
<td>${job.queue}</td>
|
|
1249
1247
|
<td><span class="badge ${job.state}">${job.state}</span></td>
|
|
1250
1248
|
<td>${job.attempt}/${job.max_attempts}</td>
|
|
1251
1249
|
<td class="mono">${formatTime(job.inserted_at)}</td>
|
|
1252
1250
|
</tr>
|
|
1253
|
-
|
|
1254
|
-
)
|
|
1251
|
+
`;
|
|
1252
|
+
})
|
|
1255
1253
|
.join("");
|
|
1256
1254
|
}
|
|
1257
1255
|
|
|
@@ -1462,7 +1460,6 @@
|
|
|
1462
1460
|
fetchStats(),
|
|
1463
1461
|
fetchQueues(),
|
|
1464
1462
|
fetchWorkers(currentWorkerState),
|
|
1465
|
-
fetchWorkflows(),
|
|
1466
1463
|
fetchJobs(currentState),
|
|
1467
1464
|
fetchTimeseries(),
|
|
1468
1465
|
]);
|
|
@@ -1484,14 +1481,17 @@
|
|
|
1484
1481
|
}, 1000);
|
|
1485
1482
|
|
|
1486
1483
|
// Enqueue modal
|
|
1487
|
-
let tasksLoaded = false;
|
|
1488
1484
|
let taskRegistry = {};
|
|
1489
1485
|
|
|
1490
1486
|
async function loadTasks() {
|
|
1491
|
-
if (tasksLoaded) return;
|
|
1492
1487
|
const res = await fetch("/api/tasks");
|
|
1493
1488
|
const tasks = await res.json();
|
|
1494
1489
|
const select = document.getElementById("enqueue-task");
|
|
1490
|
+
|
|
1491
|
+
// Clear existing options except the placeholder
|
|
1492
|
+
select.innerHTML = '<option value="">Select a task...</option>';
|
|
1493
|
+
taskRegistry = {};
|
|
1494
|
+
|
|
1495
1495
|
tasks.forEach((task) => {
|
|
1496
1496
|
taskRegistry[task.name] = task;
|
|
1497
1497
|
const option = document.createElement("option");
|
|
@@ -1499,7 +1499,6 @@
|
|
|
1499
1499
|
option.textContent = `${task.name} (queue: ${task.queue})`;
|
|
1500
1500
|
select.appendChild(option);
|
|
1501
1501
|
});
|
|
1502
|
-
tasksLoaded = true;
|
|
1503
1502
|
}
|
|
1504
1503
|
|
|
1505
1504
|
function onTaskSelected() {
|
|
@@ -392,6 +392,64 @@
|
|
|
392
392
|
border-radius: 4px;
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
/* Task Summary Stats */
|
|
396
|
+
.task-summary {
|
|
397
|
+
margin-bottom: 1rem;
|
|
398
|
+
overflow-x: auto;
|
|
399
|
+
}
|
|
400
|
+
.task-summary table {
|
|
401
|
+
width: 100%;
|
|
402
|
+
border-collapse: collapse;
|
|
403
|
+
font-size: 0.8125rem;
|
|
404
|
+
}
|
|
405
|
+
.task-summary th,
|
|
406
|
+
.task-summary td {
|
|
407
|
+
padding: 0.5rem 0.75rem;
|
|
408
|
+
text-align: left;
|
|
409
|
+
border-bottom: 1px solid var(--border-color);
|
|
410
|
+
}
|
|
411
|
+
.task-summary th {
|
|
412
|
+
background: var(--bg-secondary);
|
|
413
|
+
color: var(--text-muted);
|
|
414
|
+
font-weight: 500;
|
|
415
|
+
text-transform: uppercase;
|
|
416
|
+
font-size: 0.6875rem;
|
|
417
|
+
}
|
|
418
|
+
.task-summary td {
|
|
419
|
+
color: var(--text-secondary);
|
|
420
|
+
}
|
|
421
|
+
.task-summary .task-name {
|
|
422
|
+
font-family: "SF Mono", Monaco, monospace;
|
|
423
|
+
font-weight: 500;
|
|
424
|
+
color: var(--text-primary);
|
|
425
|
+
}
|
|
426
|
+
.task-summary .stat-cell {
|
|
427
|
+
text-align: center;
|
|
428
|
+
min-width: 80px;
|
|
429
|
+
}
|
|
430
|
+
.task-summary .stat-cell .count {
|
|
431
|
+
font-weight: 600;
|
|
432
|
+
}
|
|
433
|
+
.task-summary .stat-cell .pct {
|
|
434
|
+
color: var(--text-dim);
|
|
435
|
+
font-size: 0.75rem;
|
|
436
|
+
margin-left: 0.25rem;
|
|
437
|
+
}
|
|
438
|
+
.task-summary .total-row {
|
|
439
|
+
font-weight: 600;
|
|
440
|
+
background: var(--bg-secondary);
|
|
441
|
+
}
|
|
442
|
+
.task-summary .total-row td {
|
|
443
|
+
border-bottom: none;
|
|
444
|
+
}
|
|
445
|
+
.task-summary .timing-cell {
|
|
446
|
+
white-space: nowrap;
|
|
447
|
+
}
|
|
448
|
+
.task-summary .timing-range {
|
|
449
|
+
color: var(--text-dim);
|
|
450
|
+
font-size: 0.7rem;
|
|
451
|
+
}
|
|
452
|
+
|
|
395
453
|
#family-table {
|
|
396
454
|
width: 100%;
|
|
397
455
|
border-collapse: collapse;
|
|
@@ -522,6 +580,9 @@
|
|
|
522
580
|
</div>
|
|
523
581
|
<div id="dag-flow"></div>
|
|
524
582
|
</div>
|
|
583
|
+
<div class="task-summary" id="task-summary" style="margin-top: 16px">
|
|
584
|
+
<!-- Populated by JS -->
|
|
585
|
+
</div>
|
|
525
586
|
<table id="family-table" style="margin-top: 16px">
|
|
526
587
|
<thead>
|
|
527
588
|
<tr>
|
|
@@ -755,10 +816,10 @@
|
|
|
755
816
|
);
|
|
756
817
|
|
|
757
818
|
// Result
|
|
758
|
-
if (job.
|
|
819
|
+
if (job.result !== null && job.result !== undefined) {
|
|
759
820
|
document.getElementById("result-card").style.display = "block";
|
|
760
821
|
document.getElementById("result-block").textContent = formatJson(
|
|
761
|
-
job.
|
|
822
|
+
job.result,
|
|
762
823
|
);
|
|
763
824
|
}
|
|
764
825
|
|
|
@@ -815,10 +876,21 @@
|
|
|
815
876
|
|
|
816
877
|
familyData = await res.json();
|
|
817
878
|
|
|
818
|
-
//
|
|
819
|
-
|
|
879
|
+
// Find the current job in the family
|
|
880
|
+
const currentJob = familyData.jobs.find((j) => j.id === jobId);
|
|
881
|
+
|
|
882
|
+
// Only show family view if:
|
|
883
|
+
// 1. Current job is the root (no parent_id) AND has children
|
|
884
|
+
// 2. Don't show for child jobs - they should just see their own info
|
|
885
|
+
const isRoot = currentJob && !currentJob.parent_id;
|
|
886
|
+
const hasChildren = familyData.jobs.some(
|
|
887
|
+
(j) => j.parent_id === jobId,
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
if (isRoot && familyData.jobs.length > 1) {
|
|
820
891
|
document.getElementById("family-card").style.display = "block";
|
|
821
892
|
renderDAG(familyData.jobs);
|
|
893
|
+
renderTaskSummary(familyData.jobs);
|
|
822
894
|
renderFamilyTable(familyData.jobs);
|
|
823
895
|
}
|
|
824
896
|
} catch (err) {
|
|
@@ -1190,6 +1262,149 @@
|
|
|
1190
1262
|
);
|
|
1191
1263
|
}
|
|
1192
1264
|
|
|
1265
|
+
function renderTaskSummary(jobs) {
|
|
1266
|
+
const container = document.getElementById("task-summary");
|
|
1267
|
+
const allStates = [
|
|
1268
|
+
"completed",
|
|
1269
|
+
"running",
|
|
1270
|
+
"available",
|
|
1271
|
+
"scheduled",
|
|
1272
|
+
"failed",
|
|
1273
|
+
"discarded",
|
|
1274
|
+
];
|
|
1275
|
+
const stateLabels = {
|
|
1276
|
+
completed: "Done",
|
|
1277
|
+
running: "Run",
|
|
1278
|
+
available: "Avail",
|
|
1279
|
+
scheduled: "Sched",
|
|
1280
|
+
failed: "Fail",
|
|
1281
|
+
discarded: "Disc",
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
function fmtDur(ms) {
|
|
1285
|
+
if (ms < 1000) return Math.round(ms) + "ms";
|
|
1286
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
|
|
1287
|
+
if (ms < 3600000) return (ms / 60000).toFixed(1) + "m";
|
|
1288
|
+
return (ms / 3600000).toFixed(1) + "h";
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const taskStats = {};
|
|
1292
|
+
let totals = { total: 0, durations: [] };
|
|
1293
|
+
allStates.forEach((s) => (totals[s] = 0));
|
|
1294
|
+
|
|
1295
|
+
jobs.forEach((job) => {
|
|
1296
|
+
const taskName = job.task_name;
|
|
1297
|
+
if (!taskStats[taskName]) {
|
|
1298
|
+
taskStats[taskName] = { total: 0, durations: [] };
|
|
1299
|
+
allStates.forEach((s) => (taskStats[taskName][s] = 0));
|
|
1300
|
+
}
|
|
1301
|
+
taskStats[taskName].total++;
|
|
1302
|
+
taskStats[taskName][job.state] =
|
|
1303
|
+
(taskStats[taskName][job.state] || 0) + 1;
|
|
1304
|
+
totals.total++;
|
|
1305
|
+
totals[job.state] = (totals[job.state] || 0) + 1;
|
|
1306
|
+
|
|
1307
|
+
// Include timing for completed, failed, and discarded jobs
|
|
1308
|
+
if (
|
|
1309
|
+
["completed", "failed", "discarded"].includes(job.state) &&
|
|
1310
|
+
job.attempted_at &&
|
|
1311
|
+
job.completed_at
|
|
1312
|
+
) {
|
|
1313
|
+
const duration =
|
|
1314
|
+
new Date(job.completed_at) - new Date(job.attempted_at);
|
|
1315
|
+
taskStats[taskName].durations.push(duration);
|
|
1316
|
+
totals.durations.push(duration);
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
// Show all state columns for consistency
|
|
1321
|
+
const states = allStates;
|
|
1322
|
+
|
|
1323
|
+
function calcDurationStats(durations) {
|
|
1324
|
+
if (durations.length === 0) return null;
|
|
1325
|
+
const sorted = durations.slice().sort((a, b) => a - b);
|
|
1326
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
1327
|
+
const p95Index = Math.floor(sorted.length * 0.95);
|
|
1328
|
+
return {
|
|
1329
|
+
avg: sum / sorted.length,
|
|
1330
|
+
p95: sorted[Math.min(p95Index, sorted.length - 1)],
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const taskNames = Object.keys(taskStats).sort();
|
|
1335
|
+
const headerCells = states
|
|
1336
|
+
.map((s) => `<th class="stat-cell">${stateLabels[s]}</th>`)
|
|
1337
|
+
.join("");
|
|
1338
|
+
|
|
1339
|
+
const rows = taskNames
|
|
1340
|
+
.map((taskName) => {
|
|
1341
|
+
const stats = taskStats[taskName];
|
|
1342
|
+
const shortName = taskName.split(".").pop();
|
|
1343
|
+
const ds = calcDurationStats(stats.durations);
|
|
1344
|
+
|
|
1345
|
+
const statCells = states
|
|
1346
|
+
.map((s) => {
|
|
1347
|
+
const count = stats[s] || 0;
|
|
1348
|
+
if (count === 0) return `<td class="stat-cell">-</td>`;
|
|
1349
|
+
const pct =
|
|
1350
|
+
stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
|
|
1351
|
+
return `<td class="stat-cell"><span class="count">${count}</span><span class="pct">(${pct}%)</span></td>`;
|
|
1352
|
+
})
|
|
1353
|
+
.join("");
|
|
1354
|
+
|
|
1355
|
+
const timing = ds
|
|
1356
|
+
? `${fmtDur(ds.avg)}<span class="timing-range"> (${fmtDur(ds.p95)})</span>`
|
|
1357
|
+
: "-";
|
|
1358
|
+
|
|
1359
|
+
return `<tr>
|
|
1360
|
+
<td class="task-name" title="${taskName}">${shortName}</td>
|
|
1361
|
+
<td class="stat-cell">${stats.total}</td>
|
|
1362
|
+
${statCells}
|
|
1363
|
+
<td class="stat-cell timing-cell">${timing}</td>
|
|
1364
|
+
</tr>`;
|
|
1365
|
+
})
|
|
1366
|
+
.join("");
|
|
1367
|
+
|
|
1368
|
+
const tds = calcDurationStats(totals.durations);
|
|
1369
|
+
const totalCells = states
|
|
1370
|
+
.map((s) => {
|
|
1371
|
+
const count = totals[s] || 0;
|
|
1372
|
+
if (count === 0) return `<td class="stat-cell">-</td>`;
|
|
1373
|
+
const pct =
|
|
1374
|
+
totals.total > 0 ? Math.round((count / totals.total) * 100) : 0;
|
|
1375
|
+
return `<td class="stat-cell"><span class="count">${count}</span><span class="pct">(${pct}%)</span></td>`;
|
|
1376
|
+
})
|
|
1377
|
+
.join("");
|
|
1378
|
+
|
|
1379
|
+
const totalTiming = tds
|
|
1380
|
+
? `${fmtDur(tds.avg)}<span class="timing-range"> (${fmtDur(tds.p95)})</span>`
|
|
1381
|
+
: "-";
|
|
1382
|
+
|
|
1383
|
+
const totalRow = `<tr class="total-row">
|
|
1384
|
+
<td>Total</td>
|
|
1385
|
+
<td class="stat-cell">${totals.total}</td>
|
|
1386
|
+
${totalCells}
|
|
1387
|
+
<td class="stat-cell timing-cell">${totalTiming}</td>
|
|
1388
|
+
</tr>`;
|
|
1389
|
+
|
|
1390
|
+
container.innerHTML = `
|
|
1391
|
+
<table>
|
|
1392
|
+
<thead>
|
|
1393
|
+
<tr>
|
|
1394
|
+
<th>Task</th>
|
|
1395
|
+
<th class="stat-cell">#</th>
|
|
1396
|
+
${headerCells}
|
|
1397
|
+
<th class="stat-cell">Avg (p95)</th>
|
|
1398
|
+
</tr>
|
|
1399
|
+
</thead>
|
|
1400
|
+
<tbody>
|
|
1401
|
+
${rows}
|
|
1402
|
+
${totalRow}
|
|
1403
|
+
</tbody>
|
|
1404
|
+
</table>
|
|
1405
|
+
`;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1193
1408
|
function renderFamilyTable(jobs) {
|
|
1194
1409
|
const tbody = document.querySelector("#family-table tbody");
|
|
1195
1410
|
|
|
@@ -1214,13 +1429,15 @@
|
|
|
1214
1429
|
}
|
|
1215
1430
|
|
|
1216
1431
|
let result = "-";
|
|
1217
|
-
if (job.
|
|
1218
|
-
const
|
|
1219
|
-
typeof job.
|
|
1220
|
-
? job.
|
|
1221
|
-
: JSON.stringify(job.
|
|
1432
|
+
if (job.result !== null && job.result !== undefined) {
|
|
1433
|
+
const resultStr =
|
|
1434
|
+
typeof job.result === "string"
|
|
1435
|
+
? job.result
|
|
1436
|
+
: JSON.stringify(job.result);
|
|
1222
1437
|
result =
|
|
1223
|
-
|
|
1438
|
+
resultStr.length > 30
|
|
1439
|
+
? resultStr.slice(0, 30) + "..."
|
|
1440
|
+
: resultStr;
|
|
1224
1441
|
}
|
|
1225
1442
|
|
|
1226
1443
|
return `
|