@getworkbench/core 0.2.1 → 0.3.1
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.
- package/README.md +16 -0
- package/dist/chunk-O7DNWUZD.js +571 -0
- package/dist/chunk-O7DNWUZD.js.map +1 -0
- package/dist/hono.d.ts +43 -0
- package/dist/hono.js +32 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.d.ts +9 -673
- package/dist/index.js +191 -597
- package/dist/index.js.map +1 -1
- package/dist/workbench-Btnvit1t.d.ts +696 -0
- package/package.json +14 -2
- package/dist/ui/assets/index.css +0 -1
- package/dist/ui/assets/index.js +0 -454
- package/dist/ui/index.html +0 -32
package/dist/index.js
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UI_DIST_PATH,
|
|
3
|
+
buildRouteTable,
|
|
4
|
+
buildWorkbenchApp,
|
|
5
|
+
computeBasePath,
|
|
6
|
+
renderIndexHtml,
|
|
7
|
+
resolveBasePath,
|
|
8
|
+
serveStaticAsset
|
|
9
|
+
} from "./chunk-O7DNWUZD.js";
|
|
10
|
+
|
|
11
|
+
// src/core/discover.ts
|
|
12
|
+
import { Queue } from "bullmq";
|
|
13
|
+
import { Redis } from "ioredis";
|
|
14
|
+
async function discoverQueues(connection, prefix = "bull") {
|
|
15
|
+
const normalized = normalizeConnection(connection);
|
|
16
|
+
const client = createScanClient(normalized);
|
|
17
|
+
const firstError = captureFirstError(client);
|
|
18
|
+
try {
|
|
19
|
+
await Promise.race([client.ping(), firstError.promise]);
|
|
20
|
+
const names = await scanQueueNames(client, prefix);
|
|
21
|
+
return names.map(
|
|
22
|
+
(name) => new Queue(name, {
|
|
23
|
+
connection: { ...normalized },
|
|
24
|
+
prefix
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
} finally {
|
|
28
|
+
firstError.dispose();
|
|
29
|
+
client.disconnect();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function captureFirstError(client) {
|
|
33
|
+
let onError = null;
|
|
34
|
+
const promise = new Promise((_, reject) => {
|
|
35
|
+
onError = (err) => reject(err);
|
|
36
|
+
client.once("error", onError);
|
|
37
|
+
});
|
|
38
|
+
client.on("error", () => {
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
promise,
|
|
42
|
+
dispose: () => {
|
|
43
|
+
if (onError) client.off("error", onError);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function normalizeConnection(connection) {
|
|
48
|
+
if (typeof connection === "string") {
|
|
49
|
+
return { url: connection };
|
|
50
|
+
}
|
|
51
|
+
return { ...connection };
|
|
52
|
+
}
|
|
53
|
+
function createScanClient(opts) {
|
|
54
|
+
const { url, ...rest } = opts;
|
|
55
|
+
if (url) {
|
|
56
|
+
return new Redis(url, {
|
|
57
|
+
...rest,
|
|
58
|
+
lazyConnect: false,
|
|
59
|
+
maxRetriesPerRequest: 1
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return new Redis({ ...rest, lazyConnect: false, maxRetriesPerRequest: 1 });
|
|
63
|
+
}
|
|
64
|
+
async function scanQueueNames(client, prefix) {
|
|
65
|
+
const pattern = `${prefix}:*:meta`;
|
|
66
|
+
const names = /* @__PURE__ */ new Set();
|
|
67
|
+
let cursor = "0";
|
|
68
|
+
do {
|
|
69
|
+
const [next, batch] = await client.scan(
|
|
70
|
+
cursor,
|
|
71
|
+
"MATCH",
|
|
72
|
+
pattern,
|
|
73
|
+
"COUNT",
|
|
74
|
+
500
|
|
75
|
+
);
|
|
76
|
+
cursor = next;
|
|
77
|
+
for (const key of batch) {
|
|
78
|
+
const name = parseQueueName(key, prefix);
|
|
79
|
+
if (name) names.add(name);
|
|
80
|
+
}
|
|
81
|
+
} while (cursor !== "0");
|
|
82
|
+
return Array.from(names).sort();
|
|
83
|
+
}
|
|
84
|
+
function parseQueueName(key, prefix) {
|
|
85
|
+
const head = `${prefix}:`;
|
|
86
|
+
const tail = ":meta";
|
|
87
|
+
if (!key.startsWith(head) || !key.endsWith(tail)) return null;
|
|
88
|
+
return key.slice(head.length, key.length - tail.length);
|
|
89
|
+
}
|
|
90
|
+
|
|
1
91
|
// src/core/queue-manager.ts
|
|
2
92
|
import { FlowProducer } from "bullmq";
|
|
3
93
|
import { LRUCache } from "lru-cache";
|
|
@@ -78,7 +168,7 @@ var QueueManager = class {
|
|
|
78
168
|
if (!client) {
|
|
79
169
|
const jobs2 = await queue.getJobs([status], 0, limit * 2);
|
|
80
170
|
return jobs2.filter(
|
|
81
|
-
(job) => job.finishedOn && job.finishedOn >= startTime && job.finishedOn <= endTime
|
|
171
|
+
(job) => job != null && !!job.finishedOn && job.finishedOn >= startTime && job.finishedOn <= endTime
|
|
82
172
|
);
|
|
83
173
|
}
|
|
84
174
|
const queueKey = `bull:${queue.name}:${status}`;
|
|
@@ -101,7 +191,7 @@ var QueueManager = class {
|
|
|
101
191
|
} catch (_error) {
|
|
102
192
|
const jobs = await queue.getJobs([status], 0, limit * 2);
|
|
103
193
|
return jobs.filter(
|
|
104
|
-
(job) => job.finishedOn && job.finishedOn >= startTime && job.finishedOn <= endTime
|
|
194
|
+
(job) => job != null && !!job.finishedOn && job.finishedOn >= startTime && job.finishedOn <= endTime
|
|
105
195
|
);
|
|
106
196
|
}
|
|
107
197
|
}
|
|
@@ -175,6 +265,8 @@ var QueueManager = class {
|
|
|
175
265
|
completed: 0,
|
|
176
266
|
failed: 0,
|
|
177
267
|
delayed: 0,
|
|
268
|
+
prioritized: 0,
|
|
269
|
+
"waiting-children": 0,
|
|
178
270
|
total: 0,
|
|
179
271
|
timestamp: Date.now()
|
|
180
272
|
};
|
|
@@ -186,9 +278,11 @@ var QueueManager = class {
|
|
|
186
278
|
totals.completed += counts.completed || 0;
|
|
187
279
|
totals.failed += counts.failed || 0;
|
|
188
280
|
totals.delayed += counts.delayed || 0;
|
|
281
|
+
totals.prioritized += counts.prioritized || 0;
|
|
282
|
+
totals["waiting-children"] += counts["waiting-children"] || 0;
|
|
189
283
|
})
|
|
190
284
|
);
|
|
191
|
-
totals.total = totals.waiting + totals.active + totals.completed + totals.failed + totals.delayed;
|
|
285
|
+
totals.total = totals.waiting + totals.active + totals.completed + totals.failed + totals.delayed + totals.prioritized + totals["waiting-children"];
|
|
192
286
|
return totals;
|
|
193
287
|
});
|
|
194
288
|
}
|
|
@@ -231,6 +325,8 @@ var QueueManager = class {
|
|
|
231
325
|
completed: counts.completed || 0,
|
|
232
326
|
failed: counts.failed || 0,
|
|
233
327
|
delayed: counts.delayed || 0,
|
|
328
|
+
prioritized: counts.prioritized || 0,
|
|
329
|
+
"waiting-children": counts["waiting-children"] || 0,
|
|
234
330
|
paused: counts.paused || 0
|
|
235
331
|
},
|
|
236
332
|
isPaused
|
|
@@ -250,7 +346,7 @@ var QueueManager = class {
|
|
|
250
346
|
let activeJobs = 0;
|
|
251
347
|
let failedJobs = 0;
|
|
252
348
|
for (const queue of queues) {
|
|
253
|
-
totalJobs += queue.counts.waiting + queue.counts.active + queue.counts.delayed;
|
|
349
|
+
totalJobs += queue.counts.waiting + queue.counts.active + queue.counts.delayed + queue.counts.prioritized + queue.counts["waiting-children"];
|
|
254
350
|
activeJobs += queue.counts.active;
|
|
255
351
|
failedJobs += queue.counts.failed;
|
|
256
352
|
}
|
|
@@ -619,7 +715,15 @@ var QueueManager = class {
|
|
|
619
715
|
if (!queue) {
|
|
620
716
|
return { data: [], total: 0, hasMore: false };
|
|
621
717
|
}
|
|
622
|
-
const types = status ? [status] : [
|
|
718
|
+
const types = status ? [status] : [
|
|
719
|
+
"active",
|
|
720
|
+
"waiting",
|
|
721
|
+
"waiting-children",
|
|
722
|
+
"prioritized",
|
|
723
|
+
"completed",
|
|
724
|
+
"failed",
|
|
725
|
+
"delayed"
|
|
726
|
+
];
|
|
623
727
|
const counts = await this.getCachedJobCounts(queue);
|
|
624
728
|
const jobsWithState = [];
|
|
625
729
|
let total = 0;
|
|
@@ -826,7 +930,7 @@ var QueueManager = class {
|
|
|
826
930
|
const queueChecks = await Promise.all(
|
|
827
931
|
queueEntries.map(async ([queueName, queue]) => {
|
|
828
932
|
const counts = await this.getCachedJobCounts(queue);
|
|
829
|
-
const hasJobs = (counts.waiting || 0) > 0 || (counts.active || 0) > 0 || (counts.completed || 0) > 0 || (counts.failed || 0) > 0 || (counts.delayed || 0) > 0;
|
|
933
|
+
const hasJobs = (counts.waiting || 0) > 0 || (counts.active || 0) > 0 || (counts.completed || 0) > 0 || (counts.failed || 0) > 0 || (counts.delayed || 0) > 0 || (counts.prioritized || 0) > 0 || (counts["waiting-children"] || 0) > 0;
|
|
830
934
|
return { queueName, queue, hasJobs };
|
|
831
935
|
})
|
|
832
936
|
);
|
|
@@ -834,7 +938,15 @@ var QueueManager = class {
|
|
|
834
938
|
if (relevantQueues.length === 0) {
|
|
835
939
|
return [];
|
|
836
940
|
}
|
|
837
|
-
const types = [
|
|
941
|
+
const types = [
|
|
942
|
+
"active",
|
|
943
|
+
"waiting",
|
|
944
|
+
"waiting-children",
|
|
945
|
+
"prioritized",
|
|
946
|
+
"completed",
|
|
947
|
+
"failed",
|
|
948
|
+
"delayed"
|
|
949
|
+
];
|
|
838
950
|
const fetchLimit = Math.min(limit * 2, 50);
|
|
839
951
|
const stringifiedDataCache = /* @__PURE__ */ new WeakMap();
|
|
840
952
|
const queueResults = await Promise.allSettled(
|
|
@@ -914,16 +1026,23 @@ var QueueManager = class {
|
|
|
914
1026
|
}
|
|
915
1027
|
const perQueueFetch = Math.max(5, Math.ceil((limit + 10) / numQueues) + 2);
|
|
916
1028
|
const allTypes = [
|
|
917
|
-
"waiting",
|
|
918
1029
|
"active",
|
|
1030
|
+
"waiting",
|
|
1031
|
+
"waiting-children",
|
|
1032
|
+
"prioritized",
|
|
919
1033
|
"completed",
|
|
920
1034
|
"failed",
|
|
921
1035
|
"delayed"
|
|
922
1036
|
];
|
|
923
1037
|
const results = await Promise.all(
|
|
924
1038
|
queueEntries.map(async ([queueName, queue]) => {
|
|
925
|
-
const
|
|
926
|
-
|
|
1039
|
+
const jobArrays = await Promise.all(
|
|
1040
|
+
allTypes.map(async (type) => {
|
|
1041
|
+
const jobs = await queue.getJobs(type, 0, perQueueFetch);
|
|
1042
|
+
return jobs.map((job) => ({ job, queueName, state: type }));
|
|
1043
|
+
})
|
|
1044
|
+
);
|
|
1045
|
+
return jobArrays.flat();
|
|
927
1046
|
})
|
|
928
1047
|
);
|
|
929
1048
|
const allJobs = results.flat();
|
|
@@ -934,15 +1053,7 @@ var QueueManager = class {
|
|
|
934
1053
|
});
|
|
935
1054
|
const jobsToConvert = allJobs.slice(start, start + limit);
|
|
936
1055
|
const runInfos = await Promise.all(
|
|
937
|
-
jobsToConvert.map(async ({ job, queueName }) => {
|
|
938
|
-
let state = "waiting";
|
|
939
|
-
if (job.finishedOn) {
|
|
940
|
-
state = job.failedReason ? "failed" : "completed";
|
|
941
|
-
} else if (job.processedOn) {
|
|
942
|
-
state = "active";
|
|
943
|
-
} else if (job.delay && job.delay > 0) {
|
|
944
|
-
state = "delayed";
|
|
945
|
-
}
|
|
1056
|
+
jobsToConvert.map(async ({ job, queueName, state }) => {
|
|
946
1057
|
const info = await this.jobToInfo(job, "list", state);
|
|
947
1058
|
return { ...info, queueName };
|
|
948
1059
|
})
|
|
@@ -969,7 +1080,15 @@ var QueueManager = class {
|
|
|
969
1080
|
return this.getLatestRuns(limit, start);
|
|
970
1081
|
}
|
|
971
1082
|
const queueEntries = Array.from(this.queues.entries());
|
|
972
|
-
const types = filters?.status ? [filters.status] : [
|
|
1083
|
+
const types = filters?.status ? [filters.status] : [
|
|
1084
|
+
"active",
|
|
1085
|
+
"waiting",
|
|
1086
|
+
"waiting-children",
|
|
1087
|
+
"prioritized",
|
|
1088
|
+
"completed",
|
|
1089
|
+
"failed",
|
|
1090
|
+
"delayed"
|
|
1091
|
+
];
|
|
973
1092
|
const hasTimeRange = !!filters?.timeRange;
|
|
974
1093
|
const numQueues = Math.max(queueEntries.length, 1);
|
|
975
1094
|
if (queueEntries.length === 0) {
|
|
@@ -1197,7 +1316,15 @@ var QueueManager = class {
|
|
|
1197
1316
|
*/
|
|
1198
1317
|
async getTagValues(field, limit = 50) {
|
|
1199
1318
|
const valueMap = /* @__PURE__ */ new Map();
|
|
1200
|
-
const types = [
|
|
1319
|
+
const types = [
|
|
1320
|
+
"active",
|
|
1321
|
+
"waiting",
|
|
1322
|
+
"waiting-children",
|
|
1323
|
+
"prioritized",
|
|
1324
|
+
"completed",
|
|
1325
|
+
"failed",
|
|
1326
|
+
"delayed"
|
|
1327
|
+
];
|
|
1201
1328
|
const queueEntries = Array.from(this.queues.entries());
|
|
1202
1329
|
const queueResults = await Promise.all(
|
|
1203
1330
|
queueEntries.map(async ([, queue]) => {
|
|
@@ -1494,8 +1621,9 @@ var QueueManager = class {
|
|
|
1494
1621
|
return { queueName, jobs: waitingChildrenJobs };
|
|
1495
1622
|
}
|
|
1496
1623
|
const otherTypes = [
|
|
1497
|
-
"waiting",
|
|
1498
1624
|
"active",
|
|
1625
|
+
"waiting",
|
|
1626
|
+
"prioritized",
|
|
1499
1627
|
"completed",
|
|
1500
1628
|
"failed",
|
|
1501
1629
|
"delayed"
|
|
@@ -1689,24 +1817,56 @@ var QueueManager = class {
|
|
|
1689
1817
|
};
|
|
1690
1818
|
|
|
1691
1819
|
// src/core/workbench.ts
|
|
1692
|
-
var WorkbenchCore = class {
|
|
1820
|
+
var WorkbenchCore = class _WorkbenchCore {
|
|
1693
1821
|
options;
|
|
1694
1822
|
queueManager;
|
|
1695
|
-
|
|
1823
|
+
discovery;
|
|
1824
|
+
constructor(options, discovery = null) {
|
|
1696
1825
|
const opts = Array.isArray(options) ? { queues: options } : options;
|
|
1697
1826
|
this.options = {
|
|
1698
1827
|
title: "Workbench",
|
|
1699
1828
|
readonly: false,
|
|
1700
1829
|
...opts
|
|
1701
1830
|
};
|
|
1702
|
-
|
|
1831
|
+
this.discovery = discovery;
|
|
1832
|
+
const explicit = this.options.queues ?? [];
|
|
1833
|
+
const allowEmpty = !!this.options.redis;
|
|
1834
|
+
if (explicit.length === 0 && !allowEmpty) {
|
|
1703
1835
|
throw new Error(
|
|
1704
1836
|
"Workbench requires at least one queue. Pass queues directly or provide a redis connection for auto-discovery."
|
|
1705
1837
|
);
|
|
1706
1838
|
}
|
|
1707
|
-
this.queueManager = new QueueManager(
|
|
1708
|
-
|
|
1709
|
-
|
|
1839
|
+
this.queueManager = new QueueManager(explicit, this.options.tags || []);
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Async factory: build a `WorkbenchCore` from `WorkbenchOptions`, performing
|
|
1843
|
+
* BullMQ queue auto-discovery via `SCAN <prefix>:*:meta` when `queues` is
|
|
1844
|
+
* not provided.
|
|
1845
|
+
*
|
|
1846
|
+
* - When `queues` is set explicitly, behaves like `new WorkbenchCore(opts)`.
|
|
1847
|
+
* - When only `redis` is set, scans the connection for queues, caps at
|
|
1848
|
+
* `maxQueues` (default 100) to avoid connection storms with very large
|
|
1849
|
+
* deployments, and constructs the core with the resulting list.
|
|
1850
|
+
* - When no queues are discovered, the core is constructed with an empty
|
|
1851
|
+
* queue map so the dashboard can render an "empty" state instead of
|
|
1852
|
+
* erroring out.
|
|
1853
|
+
*/
|
|
1854
|
+
static async fromOptions(opts) {
|
|
1855
|
+
if (opts.queues?.length) {
|
|
1856
|
+
return new _WorkbenchCore(opts);
|
|
1857
|
+
}
|
|
1858
|
+
if (!opts.redis) {
|
|
1859
|
+
throw new Error(
|
|
1860
|
+
"WorkbenchCore.fromOptions requires either `queues` or `redis`"
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
const prefix = opts.prefix ?? "bull";
|
|
1864
|
+
const cap = opts.maxQueues ?? 100;
|
|
1865
|
+
const all = await discoverQueues(opts.redis, prefix);
|
|
1866
|
+
const queues = all.slice(0, cap);
|
|
1867
|
+
return new _WorkbenchCore(
|
|
1868
|
+
{ ...opts, queues },
|
|
1869
|
+
{ total: all.length, capped: all.length > cap, cap }
|
|
1710
1870
|
);
|
|
1711
1871
|
}
|
|
1712
1872
|
/**
|
|
@@ -1737,577 +1897,12 @@ var WorkbenchCore = class {
|
|
|
1737
1897
|
logo: this.options.logo,
|
|
1738
1898
|
readonly: this.options.readonly,
|
|
1739
1899
|
queues: this.queueManager.getQueueNames(),
|
|
1740
|
-
tags: this.queueManager.getTagFields()
|
|
1900
|
+
tags: this.queueManager.getTagFields(),
|
|
1901
|
+
discovery: this.discovery
|
|
1741
1902
|
};
|
|
1742
1903
|
}
|
|
1743
1904
|
};
|
|
1744
1905
|
|
|
1745
|
-
// src/server/hono-app.ts
|
|
1746
|
-
import { Hono as Hono2 } from "hono";
|
|
1747
|
-
import { basicAuth } from "hono/basic-auth";
|
|
1748
|
-
import { cors } from "hono/cors";
|
|
1749
|
-
|
|
1750
|
-
// src/api/router.ts
|
|
1751
|
-
import { Hono } from "hono";
|
|
1752
|
-
|
|
1753
|
-
// src/api/handlers.ts
|
|
1754
|
-
function parseSort(sort) {
|
|
1755
|
-
if (!sort) return void 0;
|
|
1756
|
-
const [field, dir] = sort.split(":");
|
|
1757
|
-
if (!field) return void 0;
|
|
1758
|
-
return {
|
|
1759
|
-
field,
|
|
1760
|
-
direction: dir === "asc" ? "asc" : "desc"
|
|
1761
|
-
};
|
|
1762
|
-
}
|
|
1763
|
-
var readonlyError = {
|
|
1764
|
-
status: 403,
|
|
1765
|
-
body: { error: "Dashboard is in readonly mode" }
|
|
1766
|
-
};
|
|
1767
|
-
function buildRouteTable(core) {
|
|
1768
|
-
const qm = core.queueManager;
|
|
1769
|
-
const isReadonly = () => !!core.options.readonly;
|
|
1770
|
-
return [
|
|
1771
|
-
{
|
|
1772
|
-
method: "post",
|
|
1773
|
-
path: "/refresh",
|
|
1774
|
-
handler: async () => {
|
|
1775
|
-
qm.clearCache();
|
|
1776
|
-
return { status: 200, body: { success: true } };
|
|
1777
|
-
}
|
|
1778
|
-
},
|
|
1779
|
-
{
|
|
1780
|
-
method: "get",
|
|
1781
|
-
path: "/overview",
|
|
1782
|
-
handler: async () => ({
|
|
1783
|
-
status: 200,
|
|
1784
|
-
body: await qm.getOverview()
|
|
1785
|
-
})
|
|
1786
|
-
},
|
|
1787
|
-
{
|
|
1788
|
-
method: "get",
|
|
1789
|
-
path: "/counts",
|
|
1790
|
-
handler: async () => ({
|
|
1791
|
-
status: 200,
|
|
1792
|
-
body: await qm.getQuickCounts()
|
|
1793
|
-
})
|
|
1794
|
-
},
|
|
1795
|
-
{
|
|
1796
|
-
method: "get",
|
|
1797
|
-
path: "/runs",
|
|
1798
|
-
handler: async ({ query }) => {
|
|
1799
|
-
const limit = Number(query.limit) || 50;
|
|
1800
|
-
const cursor = query.cursor;
|
|
1801
|
-
const start = cursor ? Number(cursor) : 0;
|
|
1802
|
-
const sort = parseSort(query.sort);
|
|
1803
|
-
const status = query.status;
|
|
1804
|
-
const q = query.q;
|
|
1805
|
-
const from = query.from;
|
|
1806
|
-
const to = query.to;
|
|
1807
|
-
const tagsParam = query.tags;
|
|
1808
|
-
let tags;
|
|
1809
|
-
if (tagsParam) {
|
|
1810
|
-
try {
|
|
1811
|
-
tags = JSON.parse(tagsParam);
|
|
1812
|
-
} catch {
|
|
1813
|
-
const tagPairs = tagsParam.split(",");
|
|
1814
|
-
tags = {};
|
|
1815
|
-
for (const pair of tagPairs) {
|
|
1816
|
-
const [key, value] = pair.split(":");
|
|
1817
|
-
if (key && value) {
|
|
1818
|
-
tags[key.trim()] = value.trim();
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
let timeRange;
|
|
1824
|
-
if (from && to) {
|
|
1825
|
-
timeRange = {
|
|
1826
|
-
start: Number(from),
|
|
1827
|
-
end: Number(to)
|
|
1828
|
-
};
|
|
1829
|
-
}
|
|
1830
|
-
let text;
|
|
1831
|
-
if (q) {
|
|
1832
|
-
if (!q.includes(":")) {
|
|
1833
|
-
text = q;
|
|
1834
|
-
} else {
|
|
1835
|
-
const parts = q.split(" ");
|
|
1836
|
-
const textParts = parts.filter((p) => !p.includes(":"));
|
|
1837
|
-
if (textParts.length > 0) {
|
|
1838
|
-
text = textParts.join(" ");
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
const filters = status || tags || text || timeRange ? {
|
|
1843
|
-
status,
|
|
1844
|
-
tags,
|
|
1845
|
-
text,
|
|
1846
|
-
timeRange
|
|
1847
|
-
} : void 0;
|
|
1848
|
-
return {
|
|
1849
|
-
status: 200,
|
|
1850
|
-
body: await qm.getAllRuns(limit, start, sort, filters)
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
},
|
|
1854
|
-
{
|
|
1855
|
-
method: "get",
|
|
1856
|
-
path: "/schedulers",
|
|
1857
|
-
handler: async ({ query }) => {
|
|
1858
|
-
const repeatableSort = parseSort(query.repeatableSort);
|
|
1859
|
-
const delayedSort = parseSort(query.delayedSort);
|
|
1860
|
-
return {
|
|
1861
|
-
status: 200,
|
|
1862
|
-
body: await qm.getSchedulers(repeatableSort, delayedSort)
|
|
1863
|
-
};
|
|
1864
|
-
}
|
|
1865
|
-
},
|
|
1866
|
-
{
|
|
1867
|
-
method: "post",
|
|
1868
|
-
path: "/test",
|
|
1869
|
-
handler: async ({ body }) => {
|
|
1870
|
-
if (isReadonly()) return readonlyError;
|
|
1871
|
-
const req = body;
|
|
1872
|
-
if (!req?.queueName || !req.jobName) {
|
|
1873
|
-
return {
|
|
1874
|
-
status: 400,
|
|
1875
|
-
body: { error: "queueName and jobName are required" }
|
|
1876
|
-
};
|
|
1877
|
-
}
|
|
1878
|
-
try {
|
|
1879
|
-
const result = await qm.enqueueJob(req);
|
|
1880
|
-
return { status: 200, body: result };
|
|
1881
|
-
} catch (e) {
|
|
1882
|
-
return { status: 400, body: { error: e.message } };
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
},
|
|
1886
|
-
{
|
|
1887
|
-
method: "get",
|
|
1888
|
-
path: "/queue-names",
|
|
1889
|
-
handler: async () => ({
|
|
1890
|
-
status: 200,
|
|
1891
|
-
body: qm.getQueueNames()
|
|
1892
|
-
})
|
|
1893
|
-
},
|
|
1894
|
-
{
|
|
1895
|
-
method: "get",
|
|
1896
|
-
path: "/queues",
|
|
1897
|
-
handler: async () => ({
|
|
1898
|
-
status: 200,
|
|
1899
|
-
body: await qm.getQueues()
|
|
1900
|
-
})
|
|
1901
|
-
},
|
|
1902
|
-
{
|
|
1903
|
-
method: "get",
|
|
1904
|
-
path: "/metrics",
|
|
1905
|
-
handler: async () => ({
|
|
1906
|
-
status: 200,
|
|
1907
|
-
body: await qm.getMetrics()
|
|
1908
|
-
})
|
|
1909
|
-
},
|
|
1910
|
-
{
|
|
1911
|
-
method: "get",
|
|
1912
|
-
path: "/activity",
|
|
1913
|
-
handler: async () => ({
|
|
1914
|
-
status: 200,
|
|
1915
|
-
body: await qm.getActivityStats()
|
|
1916
|
-
})
|
|
1917
|
-
},
|
|
1918
|
-
{
|
|
1919
|
-
method: "get",
|
|
1920
|
-
path: "/queues/:name/jobs",
|
|
1921
|
-
handler: async ({ params, query }) => {
|
|
1922
|
-
const name = params.name;
|
|
1923
|
-
const status = query.status;
|
|
1924
|
-
const limit = Number(query.limit) || 50;
|
|
1925
|
-
const cursor = query.cursor;
|
|
1926
|
-
const start = cursor ? Number(cursor) : 0;
|
|
1927
|
-
const sort = parseSort(query.sort);
|
|
1928
|
-
return {
|
|
1929
|
-
status: 200,
|
|
1930
|
-
body: await qm.getJobs(name, status, limit, start, sort)
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
1933
|
-
},
|
|
1934
|
-
{
|
|
1935
|
-
method: "get",
|
|
1936
|
-
path: "/jobs/:queue/:id",
|
|
1937
|
-
handler: async ({ params }) => {
|
|
1938
|
-
const job = await qm.getJob(params.queue, params.id);
|
|
1939
|
-
if (!job) {
|
|
1940
|
-
return { status: 404, body: { error: "Job not found" } };
|
|
1941
|
-
}
|
|
1942
|
-
return { status: 200, body: job };
|
|
1943
|
-
}
|
|
1944
|
-
},
|
|
1945
|
-
{
|
|
1946
|
-
method: "post",
|
|
1947
|
-
path: "/jobs/:queue/:id/retry",
|
|
1948
|
-
handler: async ({ params }) => {
|
|
1949
|
-
if (isReadonly()) return readonlyError;
|
|
1950
|
-
const success = await qm.retryJob(params.queue, params.id);
|
|
1951
|
-
if (!success) {
|
|
1952
|
-
return { status: 400, body: { error: "Failed to retry job" } };
|
|
1953
|
-
}
|
|
1954
|
-
return { status: 200, body: { success: true } };
|
|
1955
|
-
}
|
|
1956
|
-
},
|
|
1957
|
-
{
|
|
1958
|
-
method: "post",
|
|
1959
|
-
path: "/jobs/:queue/:id/remove",
|
|
1960
|
-
handler: async ({ params }) => {
|
|
1961
|
-
if (isReadonly()) return readonlyError;
|
|
1962
|
-
const success = await qm.removeJob(params.queue, params.id);
|
|
1963
|
-
if (!success) {
|
|
1964
|
-
return { status: 400, body: { error: "Failed to remove job" } };
|
|
1965
|
-
}
|
|
1966
|
-
return { status: 200, body: { success: true } };
|
|
1967
|
-
}
|
|
1968
|
-
},
|
|
1969
|
-
{
|
|
1970
|
-
method: "post",
|
|
1971
|
-
path: "/jobs/:queue/:id/promote",
|
|
1972
|
-
handler: async ({ params }) => {
|
|
1973
|
-
if (isReadonly()) return readonlyError;
|
|
1974
|
-
const success = await qm.promoteJob(params.queue, params.id);
|
|
1975
|
-
if (!success) {
|
|
1976
|
-
return { status: 400, body: { error: "Failed to promote job" } };
|
|
1977
|
-
}
|
|
1978
|
-
return { status: 200, body: { success: true } };
|
|
1979
|
-
}
|
|
1980
|
-
},
|
|
1981
|
-
{
|
|
1982
|
-
method: "get",
|
|
1983
|
-
path: "/search",
|
|
1984
|
-
handler: async ({ query }) => {
|
|
1985
|
-
const q = query.q || "";
|
|
1986
|
-
const limit = Number(query.limit) || 20;
|
|
1987
|
-
if (!q) return { status: 200, body: { results: [] } };
|
|
1988
|
-
const results = await qm.search(q, limit);
|
|
1989
|
-
return { status: 200, body: { results } };
|
|
1990
|
-
}
|
|
1991
|
-
},
|
|
1992
|
-
{
|
|
1993
|
-
method: "get",
|
|
1994
|
-
path: "/tags/:field/values",
|
|
1995
|
-
handler: async ({ params, query }) => {
|
|
1996
|
-
const field = params.field;
|
|
1997
|
-
const limit = Number(query.limit) || 50;
|
|
1998
|
-
const tagFields = qm.getTagFields();
|
|
1999
|
-
if (tagFields.length > 0 && !tagFields.includes(field)) {
|
|
2000
|
-
return {
|
|
2001
|
-
status: 400,
|
|
2002
|
-
body: {
|
|
2003
|
-
error: `Field "${field}" is not a configured tag field`
|
|
2004
|
-
}
|
|
2005
|
-
};
|
|
2006
|
-
}
|
|
2007
|
-
const values = await qm.getTagValues(field, limit);
|
|
2008
|
-
return { status: 200, body: { field, values } };
|
|
2009
|
-
}
|
|
2010
|
-
},
|
|
2011
|
-
{
|
|
2012
|
-
method: "post",
|
|
2013
|
-
path: "/queues/:name/clean",
|
|
2014
|
-
handler: async ({ params, body }) => {
|
|
2015
|
-
if (isReadonly()) return readonlyError;
|
|
2016
|
-
const req = body;
|
|
2017
|
-
if (!req) {
|
|
2018
|
-
return { status: 400, body: { error: "Body required" } };
|
|
2019
|
-
}
|
|
2020
|
-
const count = await qm.cleanJobs(
|
|
2021
|
-
params.name,
|
|
2022
|
-
req.status,
|
|
2023
|
-
req.grace || 0
|
|
2024
|
-
);
|
|
2025
|
-
return { status: 200, body: { removed: count } };
|
|
2026
|
-
}
|
|
2027
|
-
},
|
|
2028
|
-
{
|
|
2029
|
-
method: "post",
|
|
2030
|
-
path: "/bulk/retry",
|
|
2031
|
-
handler: async ({ body }) => {
|
|
2032
|
-
if (isReadonly()) return readonlyError;
|
|
2033
|
-
const req = body;
|
|
2034
|
-
if (!req?.jobs) {
|
|
2035
|
-
return { status: 400, body: { error: "jobs is required" } };
|
|
2036
|
-
}
|
|
2037
|
-
return { status: 200, body: await qm.bulkRetry(req.jobs) };
|
|
2038
|
-
}
|
|
2039
|
-
},
|
|
2040
|
-
{
|
|
2041
|
-
method: "post",
|
|
2042
|
-
path: "/bulk/delete",
|
|
2043
|
-
handler: async ({ body }) => {
|
|
2044
|
-
if (isReadonly()) return readonlyError;
|
|
2045
|
-
const req = body;
|
|
2046
|
-
if (!req?.jobs) {
|
|
2047
|
-
return { status: 400, body: { error: "jobs is required" } };
|
|
2048
|
-
}
|
|
2049
|
-
return { status: 200, body: await qm.bulkDelete(req.jobs) };
|
|
2050
|
-
}
|
|
2051
|
-
},
|
|
2052
|
-
{
|
|
2053
|
-
method: "post",
|
|
2054
|
-
path: "/bulk/promote",
|
|
2055
|
-
handler: async ({ body }) => {
|
|
2056
|
-
if (isReadonly()) return readonlyError;
|
|
2057
|
-
const req = body;
|
|
2058
|
-
if (!req?.jobs) {
|
|
2059
|
-
return { status: 400, body: { error: "jobs is required" } };
|
|
2060
|
-
}
|
|
2061
|
-
return { status: 200, body: await qm.bulkPromote(req.jobs) };
|
|
2062
|
-
}
|
|
2063
|
-
},
|
|
2064
|
-
{
|
|
2065
|
-
method: "post",
|
|
2066
|
-
path: "/queues/:name/pause",
|
|
2067
|
-
handler: async ({ params }) => {
|
|
2068
|
-
if (isReadonly()) return readonlyError;
|
|
2069
|
-
try {
|
|
2070
|
-
await qm.pauseQueue(params.name);
|
|
2071
|
-
return { status: 200, body: { success: true, paused: true } };
|
|
2072
|
-
} catch (error) {
|
|
2073
|
-
return {
|
|
2074
|
-
status: 404,
|
|
2075
|
-
body: {
|
|
2076
|
-
error: error instanceof Error ? error.message : "Failed to pause queue"
|
|
2077
|
-
}
|
|
2078
|
-
};
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
},
|
|
2082
|
-
{
|
|
2083
|
-
method: "post",
|
|
2084
|
-
path: "/queues/:name/resume",
|
|
2085
|
-
handler: async ({ params }) => {
|
|
2086
|
-
if (isReadonly()) return readonlyError;
|
|
2087
|
-
try {
|
|
2088
|
-
await qm.resumeQueue(params.name);
|
|
2089
|
-
return { status: 200, body: { success: true, paused: false } };
|
|
2090
|
-
} catch (error) {
|
|
2091
|
-
return {
|
|
2092
|
-
status: 404,
|
|
2093
|
-
body: {
|
|
2094
|
-
error: error instanceof Error ? error.message : "Failed to resume queue"
|
|
2095
|
-
}
|
|
2096
|
-
};
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
},
|
|
2100
|
-
{
|
|
2101
|
-
method: "get",
|
|
2102
|
-
path: "/flows",
|
|
2103
|
-
handler: async ({ query }) => {
|
|
2104
|
-
const limit = Number(query.limit) || 50;
|
|
2105
|
-
const flows = await qm.getFlows(limit);
|
|
2106
|
-
return { status: 200, body: { flows } };
|
|
2107
|
-
}
|
|
2108
|
-
},
|
|
2109
|
-
{
|
|
2110
|
-
method: "get",
|
|
2111
|
-
path: "/flows/:queueName/:jobId",
|
|
2112
|
-
handler: async ({ params }) => {
|
|
2113
|
-
const flow = await qm.getFlow(params.queueName, params.jobId);
|
|
2114
|
-
if (!flow) {
|
|
2115
|
-
return { status: 404, body: { error: "Flow not found" } };
|
|
2116
|
-
}
|
|
2117
|
-
return { status: 200, body: flow };
|
|
2118
|
-
}
|
|
2119
|
-
},
|
|
2120
|
-
{
|
|
2121
|
-
method: "post",
|
|
2122
|
-
path: "/flows",
|
|
2123
|
-
handler: async ({ body }) => {
|
|
2124
|
-
if (isReadonly()) return readonlyError;
|
|
2125
|
-
const req = body;
|
|
2126
|
-
if (!req?.name || !req.queueName || !req.children?.length) {
|
|
2127
|
-
return {
|
|
2128
|
-
status: 400,
|
|
2129
|
-
body: { error: "name, queueName, and children are required" }
|
|
2130
|
-
};
|
|
2131
|
-
}
|
|
2132
|
-
try {
|
|
2133
|
-
const result = await qm.createFlow(req);
|
|
2134
|
-
return { status: 200, body: result };
|
|
2135
|
-
} catch (e) {
|
|
2136
|
-
return { status: 400, body: { error: e.message } };
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
];
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
// src/api/router.ts
|
|
2144
|
-
function createApiRoutes(core) {
|
|
2145
|
-
const app = new Hono();
|
|
2146
|
-
for (const route of buildRouteTable(core)) {
|
|
2147
|
-
app[route.method](route.path, async (c) => {
|
|
2148
|
-
let body;
|
|
2149
|
-
const method = c.req.method;
|
|
2150
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
2151
|
-
const contentType = c.req.header("content-type") ?? "";
|
|
2152
|
-
if (contentType.includes("application/json")) {
|
|
2153
|
-
try {
|
|
2154
|
-
body = await c.req.json();
|
|
2155
|
-
} catch {
|
|
2156
|
-
body = void 0;
|
|
2157
|
-
}
|
|
2158
|
-
}
|
|
2159
|
-
}
|
|
2160
|
-
const input = {
|
|
2161
|
-
params: c.req.param(),
|
|
2162
|
-
query: c.req.query(),
|
|
2163
|
-
body
|
|
2164
|
-
};
|
|
2165
|
-
const result = await route.handler(input);
|
|
2166
|
-
return new Response(JSON.stringify(result.body), {
|
|
2167
|
-
status: result.status,
|
|
2168
|
-
headers: { "Content-Type": "application/json" }
|
|
2169
|
-
});
|
|
2170
|
-
});
|
|
2171
|
-
}
|
|
2172
|
-
return app;
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
// src/server/base-path.ts
|
|
2176
|
-
var CLIENT_ROUTES = [
|
|
2177
|
-
/\/queues\/[^/]+\/jobs\/[^/]+\/?$/,
|
|
2178
|
-
/\/queues\/[^/]+\/?$/,
|
|
2179
|
-
/\/flows\/[^/]+\/[^/]+\/?$/,
|
|
2180
|
-
/\/schedulers\/?$/,
|
|
2181
|
-
/\/flows\/?$/,
|
|
2182
|
-
/\/metrics\/?$/,
|
|
2183
|
-
/\/test\/?$/
|
|
2184
|
-
];
|
|
2185
|
-
function computeBasePath(pathname) {
|
|
2186
|
-
let basePath = pathname;
|
|
2187
|
-
for (const route of CLIENT_ROUTES) {
|
|
2188
|
-
basePath = basePath.replace(route, "");
|
|
2189
|
-
}
|
|
2190
|
-
if (!basePath.endsWith("/")) {
|
|
2191
|
-
basePath = `${basePath}/`;
|
|
2192
|
-
}
|
|
2193
|
-
return basePath;
|
|
2194
|
-
}
|
|
2195
|
-
function resolveBasePath(override, pathname) {
|
|
2196
|
-
if (override) {
|
|
2197
|
-
return override.endsWith("/") ? override : `${override}/`;
|
|
2198
|
-
}
|
|
2199
|
-
return computeBasePath(pathname);
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
// src/server/static-assets.ts
|
|
2203
|
-
import { existsSync, readFileSync } from "fs";
|
|
2204
|
-
import { join as join2 } from "path";
|
|
2205
|
-
|
|
2206
|
-
// src/ui-dist.ts
|
|
2207
|
-
import { dirname, join } from "path";
|
|
2208
|
-
import { fileURLToPath } from "url";
|
|
2209
|
-
var UI_DIST_PATH = join(dirname(fileURLToPath(import.meta.url)), "ui");
|
|
2210
|
-
|
|
2211
|
-
// src/server/static-assets.ts
|
|
2212
|
-
function serveStaticAsset(filename) {
|
|
2213
|
-
const filePath = join2(UI_DIST_PATH, "assets", filename);
|
|
2214
|
-
if (!existsSync(filePath)) {
|
|
2215
|
-
return { status: 404, body: null, contentType: "text/plain" };
|
|
2216
|
-
}
|
|
2217
|
-
const body = readFileSync(filePath);
|
|
2218
|
-
const contentType = filename.endsWith(".js") ? "application/javascript" : filename.endsWith(".css") ? "text/css" : filename.endsWith(".svg") ? "image/svg+xml" : filename.endsWith(".png") ? "image/png" : filename.endsWith(".woff2") ? "font/woff2" : "application/octet-stream";
|
|
2219
|
-
return { status: 200, body, contentType };
|
|
2220
|
-
}
|
|
2221
|
-
function renderIndexHtml(basePath, title) {
|
|
2222
|
-
const indexPath = join2(UI_DIST_PATH, "index.html");
|
|
2223
|
-
if (existsSync(indexPath)) {
|
|
2224
|
-
let html = readFileSync(indexPath, "utf-8");
|
|
2225
|
-
html = html.replace("<head>", `<head>
|
|
2226
|
-
<base href="${basePath}">`);
|
|
2227
|
-
return { body: html, contentType: "text/html; charset=utf-8" };
|
|
2228
|
-
}
|
|
2229
|
-
return {
|
|
2230
|
-
body: fallbackHtml(title, basePath),
|
|
2231
|
-
contentType: "text/html; charset=utf-8"
|
|
2232
|
-
};
|
|
2233
|
-
}
|
|
2234
|
-
function fallbackHtml(title, basePath) {
|
|
2235
|
-
return `<!DOCTYPE html>
|
|
2236
|
-
<html lang="en">
|
|
2237
|
-
<head>
|
|
2238
|
-
<base href="${basePath}">
|
|
2239
|
-
<meta charset="UTF-8" />
|
|
2240
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2241
|
-
<title>${title}</title>
|
|
2242
|
-
<style>
|
|
2243
|
-
body {
|
|
2244
|
-
font-family: system-ui, sans-serif;
|
|
2245
|
-
display: flex;
|
|
2246
|
-
align-items: center;
|
|
2247
|
-
justify-content: center;
|
|
2248
|
-
min-height: 100vh;
|
|
2249
|
-
margin: 0;
|
|
2250
|
-
background: #0a0a0a;
|
|
2251
|
-
color: #fafafa;
|
|
2252
|
-
}
|
|
2253
|
-
.message {
|
|
2254
|
-
text-align: center;
|
|
2255
|
-
padding: 2rem;
|
|
2256
|
-
}
|
|
2257
|
-
code {
|
|
2258
|
-
background: #1a1a1a;
|
|
2259
|
-
padding: 0.5rem 1rem;
|
|
2260
|
-
border-radius: 0.5rem;
|
|
2261
|
-
display: block;
|
|
2262
|
-
margin-top: 1rem;
|
|
2263
|
-
}
|
|
2264
|
-
</style>
|
|
2265
|
-
</head>
|
|
2266
|
-
<body>
|
|
2267
|
-
<div class="message">
|
|
2268
|
-
<h1>${title}</h1>
|
|
2269
|
-
<p>UI assets not found. Build @getworkbench/core first:</p>
|
|
2270
|
-
<code>bun run --filter=@getworkbench/core build</code>
|
|
2271
|
-
</div>
|
|
2272
|
-
</body>
|
|
2273
|
-
</html>`;
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
// src/server/hono-app.ts
|
|
2277
|
-
function buildWorkbenchApp(core) {
|
|
2278
|
-
const app = new Hono2();
|
|
2279
|
-
app.use("/api/*", cors());
|
|
2280
|
-
if (core.requiresAuth()) {
|
|
2281
|
-
app.use(
|
|
2282
|
-
"*",
|
|
2283
|
-
basicAuth({
|
|
2284
|
-
username: core.options.auth.username,
|
|
2285
|
-
password: core.options.auth.password
|
|
2286
|
-
})
|
|
2287
|
-
);
|
|
2288
|
-
}
|
|
2289
|
-
app.route("/api", createApiRoutes(core));
|
|
2290
|
-
app.get("/config", (c) => c.json(core.getConfig()));
|
|
2291
|
-
app.get("/assets/:file", (c) => {
|
|
2292
|
-
const fileName = c.req.param("file");
|
|
2293
|
-
const asset = serveStaticAsset(fileName);
|
|
2294
|
-
if (asset.status === 404 || !asset.body) {
|
|
2295
|
-
return c.text("Not found", 404);
|
|
2296
|
-
}
|
|
2297
|
-
return new Response(new Uint8Array(asset.body), {
|
|
2298
|
-
status: 200,
|
|
2299
|
-
headers: { "Content-Type": asset.contentType }
|
|
2300
|
-
});
|
|
2301
|
-
});
|
|
2302
|
-
app.get("*", (c) => {
|
|
2303
|
-
const url = new URL(c.req.url);
|
|
2304
|
-
const basePath = resolveBasePath(core.options.basePath, url.pathname);
|
|
2305
|
-
const html = renderIndexHtml(basePath, core.options.title || "Workbench");
|
|
2306
|
-
return c.html(html.body);
|
|
2307
|
-
});
|
|
2308
|
-
return app;
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
1906
|
// src/api/fetch-handler.ts
|
|
2312
1907
|
function createFetchHandler(options) {
|
|
2313
1908
|
const core = new WorkbenchCore(options);
|
|
@@ -2380,11 +1975,10 @@ export {
|
|
|
2380
1975
|
UI_DIST_PATH,
|
|
2381
1976
|
WorkbenchCore,
|
|
2382
1977
|
buildRouteTable,
|
|
2383
|
-
buildWorkbenchApp,
|
|
2384
1978
|
checkBasicAuth,
|
|
2385
1979
|
computeBasePath,
|
|
2386
|
-
createApiRoutes,
|
|
2387
1980
|
createFetchHandler,
|
|
1981
|
+
discoverQueues,
|
|
2388
1982
|
renderIndexHtml,
|
|
2389
1983
|
resolveBasePath,
|
|
2390
1984
|
serveStaticAsset
|