@zintrust/workers 0.4.48 → 0.4.60

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.
@@ -4,7 +4,7 @@
4
4
  * Sealed namespace for immutability
5
5
  */
6
6
  import * as ZintrustCoreModule from '@zintrust/core';
7
- import { Cloudflare, createRedisConnection, databaseConfig, Env, ErrorFactory, generateUuid, getBullMQSafeQueueName, isFunction, isNonEmptyString, isObject, JobStateTracker, Logger, NodeSingletons, queueConfig, registerDatabasesFromRuntimeConfig, useEnsureDbConnected, workersConfig, ZintrustLang, } from '@zintrust/core';
7
+ import { Cloudflare, createRedisConnection, databaseConfig, DatabaseConnectionRegistry, Env, ErrorFactory, generateUuid, getBullMQSafeQueueName, isFunction, isNonEmptyString, isObject, JobStateTracker, Logger, NodeSingletons, queueConfig, registerDatabasesFromRuntimeConfig, useEnsureDbConnected, workersConfig, ZintrustLang, } from '@zintrust/core';
8
8
  import { Worker } from 'bullmq';
9
9
  import { AutoScaler } from './AutoScaler.js';
10
10
  import { CanaryController } from './CanaryController.js';
@@ -23,6 +23,7 @@ import { WorkerMetrics } from './WorkerMetrics.js';
23
23
  import { WorkerRegistry } from './WorkerRegistry.js';
24
24
  import { WorkerVersioning } from './WorkerVersioning.js';
25
25
  import { keyPrefix } from './config/workerConfig.js';
26
+ import { recordQueueMonitorJob } from './queueMonitorHistory.js';
26
27
  import { DbWorkerStore, InMemoryWorkerStore, RedisWorkerStore, } from './storage/WorkerStore.js';
27
28
  const path = NodeSingletons.path;
28
29
  const isNodeRuntime = () => typeof process !== 'undefined' && Boolean(process.versions?.node);
@@ -63,7 +64,7 @@ const resolveRuntimeBridgeUrl = (specifier) => {
63
64
  const filePath = path.join(dir, `${safeName}.bridge.mjs`);
64
65
  const exportLines = Object.keys(bridgeModule)
65
66
  .filter((key) => key !== 'default' && isValidBridgeExportName(key))
66
- .sort()
67
+ .sort((a, b) => a.localeCompare(b))
67
68
  .map((key) => `export const ${key} = bridge[${JSON.stringify(key)}];`);
68
69
  const code = [
69
70
  'const bridgeMap = globalThis.__zintrustProcessorPackageBridges__;',
@@ -1544,19 +1545,25 @@ const resolvePersistenceConfig = (config) => {
1544
1545
  throw ErrorFactory.createConfigError('WORKER_PERSISTENCE_DRIVER must be one of memory, redis, or database');
1545
1546
  };
1546
1547
  const resolveDbClientFromEnv = async (connectionName = 'default') => {
1547
- const connect = async () => await useEnsureDbConnected(undefined, connectionName);
1548
- try {
1549
- return await connect();
1550
- }
1551
- catch (error) {
1552
- Logger.error('Worker persistence failed to resolve database connection', error);
1548
+ // Eagerly populate the registry when the requested connection is not yet
1549
+ // registered. On both the Cloudflare Workers runtime and Node, the registry
1550
+ // may be empty when called from the workers-persistence path because
1551
+ // registerDatabasesFromRuntimeConfig has not yet run for this connection. The
1552
+ // old pattern of connecting first (which always fails) then registering as a
1553
+ // fallback produced spurious [DEBUG] noise on every fresh start.
1554
+ if (DatabaseConnectionRegistry.get(connectionName) === undefined) {
1555
+ try {
1556
+ registerDatabasesFromRuntimeConfig(databaseConfig);
1557
+ }
1558
+ catch (registrationError) {
1559
+ Logger.warn(`[WorkerPersistence] Runtime database registration failed for connection '${connectionName}'`, registrationError);
1560
+ }
1553
1561
  }
1554
1562
  try {
1555
- registerDatabasesFromRuntimeConfig(databaseConfig);
1556
- return await connect();
1563
+ return await useEnsureDbConnected(undefined, connectionName);
1557
1564
  }
1558
1565
  catch (error) {
1559
- Logger.error('Worker persistence failed after registering runtime databases', error);
1566
+ Logger.error('Worker persistence failed to resolve database connection', error);
1560
1567
  throw ErrorFactory.createConfigError(`Worker persistence requires a database client. Register connection '${connectionName}' or pass infrastructure.persistence.client.`);
1561
1568
  }
1562
1569
  };
@@ -1899,10 +1906,15 @@ const initializeDatacenter = (config) => {
1899
1906
  },
1900
1907
  });
1901
1908
  };
1902
- const setupWorkerEventListeners = (worker, workerName, workerVersion, features) => {
1909
+ const setupWorkerEventListeners = (worker, workerName, queueName, workerVersion, features) => {
1903
1910
  worker.on('completed', (job) => {
1904
1911
  try {
1905
1912
  Logger.debug(`Job completed: ${workerName}`, { jobId: job.id });
1913
+ void recordQueueMonitorJob({
1914
+ queueName,
1915
+ status: 'completed',
1916
+ job,
1917
+ });
1906
1918
  if (features?.observability === true) {
1907
1919
  Observability.incrementCounter('worker.jobs.completed', 1, {
1908
1920
  worker: workerName,
@@ -1918,6 +1930,14 @@ const setupWorkerEventListeners = (worker, workerName, workerVersion, features)
1918
1930
  worker.on('failed', (job, error) => {
1919
1931
  try {
1920
1932
  Logger.error(`Job failed: ${workerName}`, { error, jobId: job?.id }, 'workers');
1933
+ if (job) {
1934
+ void recordQueueMonitorJob({
1935
+ queueName,
1936
+ status: 'failed',
1937
+ job,
1938
+ error,
1939
+ });
1940
+ }
1921
1941
  if (features?.observability === true) {
1922
1942
  Observability.incrementCounter('worker.jobs.failed', 1, {
1923
1943
  worker: workerName,
@@ -2081,7 +2101,7 @@ export const WorkerFactory = Object.freeze({
2081
2101
  // Create BullMQ worker
2082
2102
  const resolvedOptions = resolveWorkerOptions(config, autoStart);
2083
2103
  const worker = new Worker(queueName, enhancedProcessor, resolvedOptions);
2084
- setupWorkerEventListeners(worker, name, workerVersion, features);
2104
+ setupWorkerEventListeners(worker, name, queueName, workerVersion, features);
2085
2105
  // Update status to "starting"
2086
2106
  await store.update(name, {
2087
2107
  status: WorkerCreationStatus.STARTING,
@@ -0,0 +1,12 @@
1
+ type DurableObjectState = {
2
+ storage: {
3
+ get: (key: string) => Promise<unknown>;
4
+ put: (key: string, value: unknown) => Promise<void>;
5
+ };
6
+ };
7
+ export declare class ZinTrustWorkerShutdownDurableObject {
8
+ private readonly state;
9
+ constructor(state: DurableObjectState);
10
+ fetch(request: Request): Promise<Response>;
11
+ }
12
+ export {};
@@ -0,0 +1,41 @@
1
+ import { Logger } from '@zintrust/core';
2
+ const loadState = async (state) => {
3
+ const stored = (await state.storage.get('shutdown'));
4
+ return stored ?? { shuttingDown: false };
5
+ };
6
+ const saveState = async (state, value) => {
7
+ await state.storage.put('shutdown', value);
8
+ };
9
+ // eslint-disable-next-line no-restricted-syntax
10
+ export class ZinTrustWorkerShutdownDurableObject {
11
+ state;
12
+ constructor(state) {
13
+ this.state = state;
14
+ }
15
+ async fetch(request) {
16
+ const url = new URL(request.url);
17
+ const path = url.pathname;
18
+ if (request.method === 'GET' && path === '/status') {
19
+ const current = await loadState(this.state);
20
+ return new Response(JSON.stringify(current), {
21
+ status: 200,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+ if (request.method === 'POST' && path === '/shutdown') {
26
+ const payload = (await request.json().catch(() => ({})));
27
+ const next = {
28
+ shuttingDown: true,
29
+ startedAt: new Date().toISOString(),
30
+ reason: payload.reason ?? 'manual',
31
+ };
32
+ await saveState(this.state, next);
33
+ Logger.info('Worker shutdown requested via Durable Object', next);
34
+ return new Response(JSON.stringify({ ok: true }), {
35
+ status: 202,
36
+ headers: { 'content-type': 'application/json' },
37
+ });
38
+ }
39
+ return new Response('Not Found', { status: 404 });
40
+ }
41
+ }
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@zintrust/workers",
3
- "version": "0.4.48",
4
- "buildDate": "2026-04-03T10:00:24.111Z",
3
+ "version": "0.4.60",
4
+ "buildDate": "2026-04-05T07:03:43.939Z",
5
5
  "buildEnvironment": {
6
- "node": "v20.20.1",
7
- "platform": "linux",
8
- "arch": "x64"
6
+ "node": "v22.22.1",
7
+ "platform": "darwin",
8
+ "arch": "arm64"
9
9
  },
10
10
  "git": {
11
- "commit": "0ac23637",
12
- "branch": "master"
11
+ "commit": "be058c1d",
12
+ "branch": "release"
13
13
  },
14
14
  "package": {
15
15
  "engines": {
@@ -178,8 +178,8 @@
178
178
  "sha256": "3869f960c87260588e40941ff91bffcfa0757be7a04815fd28b57dd4840c51df"
179
179
  },
180
180
  "WorkerFactory.js": {
181
- "size": 104787,
182
- "sha256": "2ff4782577a8e15d28c5989f5bfe16af84ecdbdc4ecc860c716d0119dcc891e4"
181
+ "size": 105839,
182
+ "sha256": "b26135593d6e3849f5540d74d097082d8186b50c72a456722f83d624141c42c8"
183
183
  },
184
184
  "WorkerInit.d.ts": {
185
185
  "size": 3284,
@@ -213,6 +213,14 @@
213
213
  "size": 6643,
214
214
  "sha256": "bef3a37ebc8292f4f548d5f66890ef7d6dd386e39c4c1cc61db058fd8815c927"
215
215
  },
216
+ "WorkerShutdownDurableObject.d.ts": {
217
+ "size": 354,
218
+ "sha256": "521df11172067bc036bc7b95ff6adf40b7c594afa0454742963317ddfe776d37"
219
+ },
220
+ "WorkerShutdownDurableObject.js": {
221
+ "size": 1547,
222
+ "sha256": "9c7298133dfc4073fda80d3fe9a37ac00d31cacf500ce10c1dd43a70c3a8ddd7"
223
+ },
216
224
  "WorkerVersioning.d.ts": {
217
225
  "size": 2881,
218
226
  "sha256": "a3f1f9e518e8a46201b181679bb5d7a484da7c021b4b7cfd5653cf98fe36e995"
@@ -221,6 +229,10 @@
221
229
  "size": 10953,
222
230
  "sha256": "8af20d462270e7044c6ea983821f5b6e6ce8a5caf39b6e8fefff07c9a0bf071e"
223
231
  },
232
+ "build-manifest.json": {
233
+ "size": 19594,
234
+ "sha256": "7402721e5f5a98fb988fa78f5ee49b6e82f2f3fab1c3cfb8ca2bf8116012169f"
235
+ },
224
236
  "config/workerConfig.d.ts": {
225
237
  "size": 132,
226
238
  "sha256": "577486dd9e0ef5b5c27d070e0f6a383337d9d68725fae0f0bad258254b828a3b"
@@ -234,8 +246,8 @@
234
246
  "sha256": "dacd49f6c112eba439bdd9bb457eea90daedbf32efc381cd3189ce562fa5b0a8"
235
247
  },
236
248
  "createQueueWorker.js": {
237
- "size": 14702,
238
- "sha256": "69bf07658c185ad5b4bafd064bfc64ea257c769809b6d4811a274020b4a5a8e7"
249
+ "size": 15594,
250
+ "sha256": "0710f2fb6936a326cdbe6fa6a2b8acfb60f7c6b27acb2aed0b98bf2402fc67f4"
239
251
  },
240
252
  "dashboard/index.d.ts": {
241
253
  "size": 109,
@@ -258,8 +270,8 @@
258
270
  "sha256": "8e0e04329e1119d8ae835dd4458efead084293bcc2c263c09dd5a19d467e5ca4"
259
271
  },
260
272
  "dashboard/workers-api.js": {
261
- "size": 28207,
262
- "sha256": "ae1ff0e962b1f64e6d200a35c3b8b1de968341d8df6283625a3efce50a23826c"
273
+ "size": 28568,
274
+ "sha256": "c28f0d4b8134337a2cc71f1ce2f1d6195f44ccd9bb0b8fa26a4edaed88ee4fc0"
263
275
  },
264
276
  "helper/index.d.ts": {
265
277
  "size": 159,
@@ -403,7 +415,15 @@
403
415
  },
404
416
  "index.js": {
405
417
  "size": 2337,
406
- "sha256": "ec0e7e4ee51acd850c41f6120d54d4600cd1e462d0dd5c1696b16bc3517177e0"
418
+ "sha256": "85a789d0f2ed71dde649f59658827ff16eaf2c1bc77df99b2297c7eb7e340108"
419
+ },
420
+ "queueMonitorHistory.d.ts": {
421
+ "size": 433,
422
+ "sha256": "1ba25cf47b0cad83e92c7b6481d12de974b921d0e01fc4cf17fc833acdf8b8c8"
423
+ },
424
+ "queueMonitorHistory.js": {
425
+ "size": 2194,
426
+ "sha256": "03d406adc046e18c3526837a654fae93b112fb6010ddc1163b129d10e934c4d4"
407
427
  },
408
428
  "register.d.ts": {
409
429
  "size": 256,
@@ -524,6 +544,22 @@
524
544
  "ui/types/worker-ui.js": {
525
545
  "size": 94,
526
546
  "sha256": "7e9f752c2a8eb29dab365b0c836bac90fa7d3567aaf961537d3aa782e85c884e"
547
+ },
548
+ "ui/workers/index.html": {
549
+ "size": 7940,
550
+ "sha256": "a71e384de568f720ff23bf5c90dca60cc97d30c0bbb4ccf5c3b026a8086381b4"
551
+ },
552
+ "ui/workers/main.js": {
553
+ "size": 60723,
554
+ "sha256": "8437771f28963e1b039bee0b1721a8e83a8c65bed19dbd89af0e37d5a2882bb4"
555
+ },
556
+ "ui/workers/styles.css": {
557
+ "size": 22652,
558
+ "sha256": "6de91f206251b0b69ca1a41b71b454b00e3b1213a9762abd8fd8f0ff0381556b"
559
+ },
560
+ "ui/workers/zintrust.svg": {
561
+ "size": 1035,
562
+ "sha256": "9b791269aad740b4191b0ae9f005b08bb28a3407a79a8ed38c11d225fa443f69"
527
563
  }
528
564
  }
529
565
  }
@@ -1,5 +1,6 @@
1
1
  import * as Core from '@zintrust/core';
2
2
  import { Env, Logger, Queue } from '@zintrust/core';
3
+ import { recordQueueMonitorJob } from './queueMonitorHistory.js';
3
4
  const TypedQueue = Queue;
4
5
  const RETRY_BASE_DELAY_MS = 1000;
5
6
  const RETRY_MAX_DELAY_MS = 30000;
@@ -168,6 +169,18 @@ const checkAndRequeueIfNotDue = async (options, queueName, driverName, message,
168
169
  };
169
170
  const onProcessSuccess = async (input) => {
170
171
  await TypedQueue.ack(input.queueName, input.message.id, input.driverName);
172
+ await recordQueueMonitorJob({
173
+ queueName: input.queueName,
174
+ status: 'completed',
175
+ job: {
176
+ id: input.message.id,
177
+ name: `${input.queueName}-job`,
178
+ data: input.message.payload,
179
+ attemptsMade: getAttemptsFromMessage(input.message),
180
+ processedOn: input.startedAtMs,
181
+ finishedOn: Date.now(),
182
+ },
183
+ });
171
184
  if (typeof input.trackerApi.completed === 'function') {
172
185
  await input.trackerApi.completed({
173
186
  queueName: input.queueName,
@@ -217,6 +230,20 @@ const onProcessFailure = async (input) => {
217
230
  });
218
231
  }
219
232
  await TypedQueue.ack(input.queueName, input.message.id, input.driverName);
233
+ await recordQueueMonitorJob({
234
+ queueName: input.queueName,
235
+ status: 'failed',
236
+ job: {
237
+ id: input.message.id,
238
+ name: `${input.queueName}-job`,
239
+ data: input.message.payload,
240
+ attemptsMade: nextAttempts,
241
+ failedReason: failure.message,
242
+ processedOn: Date.now(),
243
+ finishedOn: Date.now(),
244
+ },
245
+ error: failure,
246
+ });
220
247
  await removeHeartbeatIfSupported(input.queueName, input.message.id);
221
248
  if (typeof input.trackerApi.failed === 'function') {
222
249
  await input.trackerApi.failed({
@@ -457,6 +457,12 @@ async function getRedisQueueData() {
457
457
  throw ErrorFactory.createConfigError('Redis driver not configured');
458
458
  }
459
459
  const monitor = QueueMonitor.create({
460
+ knownQueues: async () => {
461
+ const records = await WorkerFactory.listPersistedRecords();
462
+ return Array.from(new Set(records
463
+ .map((record) => record.queueName)
464
+ .filter((queueName) => typeof queueName === 'string'))).sort((left, right) => left.localeCompare(right));
465
+ },
460
466
  redis: {
461
467
  host: redisConfig.host || 'localhost',
462
468
  port: redisConfig.port || 6379,
@@ -0,0 +1,17 @@
1
+ type QueueMonitorStatus = 'completed' | 'failed';
2
+ type QueueMonitorJobLike = {
3
+ id?: string;
4
+ name?: string;
5
+ data?: unknown;
6
+ attemptsMade?: number;
7
+ failedReason?: string;
8
+ processedOn?: number;
9
+ finishedOn?: number;
10
+ };
11
+ export declare const recordQueueMonitorJob: (input: {
12
+ queueName: string;
13
+ status: QueueMonitorStatus;
14
+ job: QueueMonitorJobLike;
15
+ error?: Error;
16
+ }) => Promise<void>;
17
+ export {};
@@ -0,0 +1,62 @@
1
+ import { isNonEmptyString, isObject, Logger, queueConfig } from '@zintrust/core';
2
+ let queueMonitorMetricsPromise = null;
3
+ const toFiniteInteger = (value, fallback) => {
4
+ if (typeof value === 'number' && Number.isFinite(value)) {
5
+ return Math.floor(value);
6
+ }
7
+ if (typeof value === 'string' && value.trim() !== '') {
8
+ const parsed = Number(value);
9
+ if (Number.isFinite(parsed)) {
10
+ return Math.floor(parsed);
11
+ }
12
+ }
13
+ return fallback;
14
+ };
15
+ const resolveQueueMonitorRedisConfig = () => {
16
+ const redisConfig = queueConfig?.drivers?.redis;
17
+ if (!isObject(redisConfig) || redisConfig['driver'] !== 'redis') {
18
+ return null;
19
+ }
20
+ const host = isNonEmptyString(redisConfig['host']) ? redisConfig['host'].trim() : '127.0.0.1';
21
+ const port = toFiniteInteger(redisConfig['port'], 6379);
22
+ const db = toFiniteInteger(redisConfig['database'], 0);
23
+ const password = isNonEmptyString(redisConfig['password']) ? redisConfig['password'] : undefined;
24
+ return { host, port, password, db };
25
+ };
26
+ const loadQueueMonitorMetrics = async () => {
27
+ const redisConfig = resolveQueueMonitorRedisConfig();
28
+ if (redisConfig === null) {
29
+ return null;
30
+ }
31
+ try {
32
+ const module = (await import('@zintrust/queue-monitor'));
33
+ if (typeof module.createMetrics !== 'function') {
34
+ return null;
35
+ }
36
+ return module.createMetrics(redisConfig);
37
+ }
38
+ catch (error) {
39
+ Logger.debug('Queue monitor metrics are unavailable for worker history recording', error);
40
+ return null;
41
+ }
42
+ };
43
+ const getQueueMonitorMetrics = async () => {
44
+ queueMonitorMetricsPromise ??= loadQueueMonitorMetrics();
45
+ return queueMonitorMetricsPromise;
46
+ };
47
+ export const recordQueueMonitorJob = async (input) => {
48
+ const metrics = await getQueueMonitorMetrics();
49
+ if (metrics === null) {
50
+ return;
51
+ }
52
+ try {
53
+ await metrics.recordJob(input.queueName, input.status, input.job, input.error);
54
+ }
55
+ catch (error) {
56
+ Logger.debug('Queue monitor history write failed', {
57
+ queueName: input.queueName,
58
+ status: input.status,
59
+ error,
60
+ });
61
+ }
62
+ };
@@ -0,0 +1,202 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ZinTrust Workers Dashboard</title>
7
+ <link rel="stylesheet" href="workers/styles.css" />
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <div class="header">
12
+ <div class="header-top">
13
+ <div style="display: flex; align-items: center; gap: 16px">
14
+ <div class="logo-frame">
15
+ <img src="workers/zintrust.svg" alt="ZinTrust" class="logo-img" />
16
+ </div>
17
+ <h1>ZinTrust Workers</h1>
18
+ </div>
19
+ <div class="header-actions">
20
+ <button id="theme-toggle" class="theme-toggle">
21
+ <svg class="icon" viewBox="0 0 24 24">
22
+ <circle cx="12" cy="12" r="5" />
23
+ <path
24
+ d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
25
+ />
26
+ </svg>
27
+ Theme
28
+ </button>
29
+ <button id="auto-refresh-toggle" class="btn" onclick="toggleAutoRefresh()">
30
+ <svg id="auto-refresh-icon" class="icon" viewBox="0 0 24 24">
31
+ <polygon points="5 3 19 12 5 21 5 3" />
32
+ </svg>
33
+ <span id="auto-refresh-label">Auto Refresh</span>
34
+ </button>
35
+ <button class="btn" onclick="fetchData()">
36
+ <svg class="icon" viewBox="0 0 24 24">
37
+ <path d="M23 4v6h-6" />
38
+ <path d="M1 20v-6h6" />
39
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
40
+ </svg>
41
+ Refresh
42
+ </button>
43
+ <button class="btn btn-primary" onclick="showAddWorkerModal()">
44
+ <svg class="icon" viewBox="0 0 24 24">
45
+ <line x1="12" y1="5" x2="12" y2="19" />
46
+ <line x1="5" y1="12" x2="19" y2="12" />
47
+ </svg>
48
+ Add Worker
49
+ </button>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="nav-bar">
54
+ <nav class="nav-links">
55
+ <a href="/queue-monitor" class="nav-link">Queue Monitor</a>
56
+ <a href="/telemetry" class="nav-link">Telemetry</a>
57
+ <a href="/metrics" class="nav-link">Metrics</a>
58
+ </nav>
59
+ </div>
60
+
61
+ <div class="filters-bar">
62
+ <div class="filter-group">
63
+ <span>Status:</span>
64
+ <select id="status-filter">
65
+ <option value="">All Status</option>
66
+ <option value="running">Running</option>
67
+ <option value="stopped">Stopped</option>
68
+ <option value="error">Error</option>
69
+ <option value="paused">Paused</option>
70
+ </select>
71
+ </div>
72
+ <div class="filter-group">
73
+ <span>Driver:</span>
74
+ <select id="driver-filter">
75
+ <option value="">All Drivers</option>
76
+ </select>
77
+ </div>
78
+ <div class="filter-group">
79
+ <span>Sort:</span>
80
+ <select id="sort-select">
81
+ <option value="name">Sort by Name</option>
82
+ <option value="status" selected>Sort by Status</option>
83
+ <option value="driver">Sort by Driver</option>
84
+ <option value="health">Sort by Health</option>
85
+ <option value="version">Sort by Version</option>
86
+ <option value="processed">Sort by Performance</option>
87
+ </select>
88
+ </div>
89
+ <div style="flex-grow: 1"></div>
90
+ <div class="search-box">
91
+ <svg class="search-icon" viewBox="0 0 24 24">
92
+ <circle cx="11" cy="11" r="8"></circle>
93
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
94
+ </svg>
95
+ <input type="text" id="search-input" placeholder="Search workers..." />
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <div id="loading" style="text-align: center; padding: 40px; color: var(--muted)">
101
+ <div>Loading workers...</div>
102
+ </div>
103
+
104
+ <div
105
+ id="error"
106
+ style="display: none; text-align: center; padding: 40px; color: var(--danger)"
107
+ >
108
+ <div>Failed to load workers data</div>
109
+ <button class="btn" onclick="fetchData()" style="margin-top: 16px">Retry</button>
110
+ </div>
111
+
112
+ <div id="workers-content" style="display: none">
113
+ <div class="summary-bar" id="queue-summary">
114
+ <div class="summary-item">
115
+ <span class="summary-label">Queue Driver</span>
116
+ <span class="summary-value" id="queue-driver">-</span>
117
+ </div>
118
+ <div class="summary-item">
119
+ <span class="summary-label">Queues</span>
120
+ <span class="summary-value" id="queue-total">0</span>
121
+ </div>
122
+ <div class="summary-item">
123
+ <span class="summary-label">Jobs</span>
124
+ <span class="summary-value" id="queue-jobs">0</span>
125
+ </div>
126
+ <div class="summary-item">
127
+ <span class="summary-label">Processing</span>
128
+ <span class="summary-value" id="queue-processing">0</span>
129
+ </div>
130
+ <div class="summary-item">
131
+ <span class="summary-label">Failed</span>
132
+ <span class="summary-value" id="queue-failed">0</span>
133
+ </div>
134
+ <div class="summary-item">
135
+ <span class="summary-label">Drivers</span>
136
+ <div class="drivers-list" id="drivers-list"></div>
137
+ </div>
138
+ </div>
139
+ <div class="table-container">
140
+ <div class="table-wrapper">
141
+ <table>
142
+ <thead>
143
+ <tr>
144
+ <th style="width: 250px">Worker</th>
145
+ <th style="width: 120px">Status</th>
146
+ <th style="width: 120px">Health</th>
147
+ <th style="width: 100px">Driver</th>
148
+ <th style="width: 100px">Version</th>
149
+ <th style="width: 320px">Performance</th>
150
+ <th style="width: 180px">Actions</th>
151
+ </tr>
152
+ </thead>
153
+ <tbody id="workers-tbody">
154
+ <!-- Workers will be populated here -->
155
+ </tbody>
156
+ </table>
157
+ </div>
158
+
159
+ <div class="pagination">
160
+ <div class="pagination-info" id="pagination-info">Showing 0-0 of 0 workers</div>
161
+ <div class="pagination-controls">
162
+ <button class="page-btn" id="prev-btn" onclick="loadPage('prev')" disabled>
163
+ <svg
164
+ viewBox="0 0 24 24"
165
+ fill="none"
166
+ stroke="currentColor"
167
+ stroke-linecap="round"
168
+ stroke-linejoin="round"
169
+ >
170
+ <polyline points="15 18 9 12 15 6"></polyline>
171
+ </svg>
172
+ </button>
173
+ <div id="page-numbers" style="display: flex; gap: 8px"></div>
174
+ <button class="page-btn" id="next-btn" onclick="loadPage('next')" disabled>
175
+ <svg
176
+ viewBox="0 0 24 24"
177
+ fill="none"
178
+ stroke="currentColor"
179
+ stroke-linecap="round"
180
+ stroke-linejoin="round"
181
+ >
182
+ <polyline points="9 18 15 12 9 6"></polyline>
183
+ </svg>
184
+ </button>
185
+
186
+ <div class="page-size-selector">
187
+ <span>Show:</span>
188
+ <select id="limit-select" onchange="changeLimit(this.value)">
189
+ <option value="10">10</option>
190
+ <option value="25">25</option>
191
+ <option value="50">50</option>
192
+ <option value="100">100</option>
193
+ </select>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ <script src="workers/main.js"></script>
201
+ </body>
202
+ </html>