@zintrust/workers 0.1.31 → 0.1.43
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/dist/ClusterLock.js +3 -2
- package/dist/DeadLetterQueue.js +3 -2
- package/dist/HealthMonitor.js +24 -13
- package/dist/Observability.js +8 -0
- package/dist/WorkerFactory.d.ts +4 -0
- package/dist/WorkerFactory.js +384 -42
- package/dist/WorkerInit.js +122 -43
- package/dist/WorkerMetrics.js +5 -1
- package/dist/WorkerRegistry.js +8 -0
- package/dist/WorkerShutdown.d.ts +0 -13
- package/dist/WorkerShutdown.js +1 -44
- package/dist/build-manifest.json +99 -83
- package/dist/config/workerConfig.d.ts +1 -0
- package/dist/config/workerConfig.js +7 -1
- package/dist/createQueueWorker.js +281 -42
- package/dist/dashboard/workers-api.js +8 -1
- package/dist/http/WorkerController.js +90 -35
- package/dist/http/WorkerMonitoringService.js +29 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +0 -1
- package/dist/routes/workers.js +10 -7
- package/dist/storage/WorkerStore.d.ts +6 -3
- package/dist/storage/WorkerStore.js +16 -0
- package/dist/telemetry/api/TelemetryMonitoringService.js +29 -2
- package/dist/ui/router/ui.js +58 -29
- package/dist/ui/workers/index.html +202 -0
- package/dist/ui/workers/main.js +1952 -0
- package/dist/ui/workers/styles.css +1350 -0
- package/dist/ui/workers/zintrust.svg +30 -0
- package/package.json +5 -5
- package/src/ClusterLock.ts +13 -7
- package/src/ComplianceManager.ts +3 -2
- package/src/DeadLetterQueue.ts +6 -4
- package/src/HealthMonitor.ts +33 -17
- package/src/Observability.ts +11 -0
- package/src/WorkerFactory.ts +446 -43
- package/src/WorkerInit.ts +167 -48
- package/src/WorkerMetrics.ts +14 -8
- package/src/WorkerRegistry.ts +11 -0
- package/src/WorkerShutdown.ts +1 -69
- package/src/config/workerConfig.ts +9 -1
- package/src/createQueueWorker.ts +428 -43
- package/src/dashboard/workers-api.ts +8 -1
- package/src/http/WorkerController.ts +111 -36
- package/src/http/WorkerMonitoringService.ts +35 -2
- package/src/index.ts +2 -3
- package/src/routes/workers.ts +10 -8
- package/src/storage/WorkerStore.ts +21 -3
- package/src/telemetry/api/TelemetryMonitoringService.ts +35 -2
- package/src/types/queue-monitor.d.ts +2 -1
- package/src/ui/router/EmbeddedAssets.ts +3 -0
- package/src/ui/router/ui.ts +57 -39
- package/src/WorkerShutdownDurableObject.ts +0 -64
|
@@ -113,6 +113,9 @@ export const InMemoryWorkerStore = Object.freeze({
|
|
|
113
113
|
async remove(name) {
|
|
114
114
|
store.delete(name);
|
|
115
115
|
},
|
|
116
|
+
async close() {
|
|
117
|
+
// No-op for memory store
|
|
118
|
+
},
|
|
116
119
|
};
|
|
117
120
|
},
|
|
118
121
|
});
|
|
@@ -176,6 +179,15 @@ export const RedisWorkerStore = Object.freeze({
|
|
|
176
179
|
return;
|
|
177
180
|
await client.hset(key, ...updates);
|
|
178
181
|
},
|
|
182
|
+
async close() {
|
|
183
|
+
try {
|
|
184
|
+
// Force disconnect in Cloudflare env to avoid hanging on quit()
|
|
185
|
+
client.disconnect();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Ignore connection errors during cleanup
|
|
189
|
+
}
|
|
190
|
+
},
|
|
179
191
|
async remove(name) {
|
|
180
192
|
await client.hdel(key, name);
|
|
181
193
|
},
|
|
@@ -238,6 +250,10 @@ export const DbWorkerStore = Object.freeze({
|
|
|
238
250
|
async remove(name) {
|
|
239
251
|
await db.table(table).where('name', '=', name).delete();
|
|
240
252
|
},
|
|
253
|
+
async close() {
|
|
254
|
+
// Database clients often managed by pool, but if needed:
|
|
255
|
+
// await db.destroy?.();
|
|
256
|
+
},
|
|
241
257
|
};
|
|
242
258
|
},
|
|
243
259
|
});
|
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
import { Logger, NodeSingletons } from '@zintrust/core';
|
|
2
|
+
const createFallbackEmitter = () => {
|
|
3
|
+
const listeners = new Map();
|
|
4
|
+
return {
|
|
5
|
+
on(event, listener) {
|
|
6
|
+
const set = listeners.get(event) ?? new Set();
|
|
7
|
+
set.add(listener);
|
|
8
|
+
listeners.set(event, set);
|
|
9
|
+
},
|
|
10
|
+
off(event, listener) {
|
|
11
|
+
const set = listeners.get(event);
|
|
12
|
+
if (!set)
|
|
13
|
+
return;
|
|
14
|
+
set.delete(listener);
|
|
15
|
+
if (set.size === 0)
|
|
16
|
+
listeners.delete(event);
|
|
17
|
+
},
|
|
18
|
+
emit(event, payload) {
|
|
19
|
+
const set = listeners.get(event);
|
|
20
|
+
if (!set)
|
|
21
|
+
return false;
|
|
22
|
+
for (const listener of set)
|
|
23
|
+
listener(payload);
|
|
24
|
+
return true;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|
|
2
28
|
// Internal state for singleton service
|
|
3
|
-
const
|
|
4
|
-
emitter
|
|
29
|
+
const EventEmitterCtor = NodeSingletons?.EventEmitter;
|
|
30
|
+
const emitter = typeof EventEmitterCtor === 'function' ? new EventEmitterCtor() : createFallbackEmitter();
|
|
31
|
+
emitter.setMaxListeners?.(Infinity);
|
|
5
32
|
let interval = null;
|
|
6
33
|
let subscribers = 0;
|
|
7
34
|
let currentSettings = null;
|
package/dist/ui/router/ui.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { Cloudflare, Logger, MIME_TYPES, NodeSingletons, Router
|
|
1
|
+
import { Cloudflare, Logger, MIME_TYPES, NodeSingletons, Router } from '@zintrust/core';
|
|
2
2
|
import { INDEX_HTML, MAIN_JS, STYLES_CSS, ZINTRUST_SVG } from './EmbeddedAssets';
|
|
3
|
-
const isCloudflare =
|
|
3
|
+
const isCloudflare = (() => {
|
|
4
|
+
try {
|
|
5
|
+
return Cloudflare.getWorkersEnv() !== null;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
})();
|
|
4
11
|
const safeFileUrlToPath = (url) => {
|
|
5
12
|
if (typeof url !== 'string' || url.trim() === '')
|
|
6
13
|
return '';
|
|
@@ -44,6 +51,26 @@ const fetchAssetBytes = async (assetPath) => {
|
|
|
44
51
|
const buffer = await response.arrayBuffer();
|
|
45
52
|
return new Uint8Array(buffer);
|
|
46
53
|
};
|
|
54
|
+
const resolveEmbeddedAssetText = (assetPath) => {
|
|
55
|
+
const normalizedPath = assetPath.replace(/^\//, '');
|
|
56
|
+
if (normalizedPath === 'workers/index.html') {
|
|
57
|
+
return Buffer.from(INDEX_HTML, 'base64').toString('utf-8');
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
const resolveEmbeddedAssetBytes = (assetPath) => {
|
|
62
|
+
const normalizedPath = assetPath.replace(/^\//, '');
|
|
63
|
+
if (normalizedPath === 'workers/styles.css') {
|
|
64
|
+
return Buffer.from(STYLES_CSS, 'base64');
|
|
65
|
+
}
|
|
66
|
+
if (normalizedPath === 'workers/main.js') {
|
|
67
|
+
return Buffer.from(MAIN_JS, 'base64');
|
|
68
|
+
}
|
|
69
|
+
if (normalizedPath === 'workers/zintrust.svg') {
|
|
70
|
+
return Buffer.from(ZINTRUST_SVG, 'base64');
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
47
74
|
export const uiResolver = async (uiBasePath) => {
|
|
48
75
|
// Resolve base path for UI assets
|
|
49
76
|
// const __filename = NodeSingletons.url.fileURLToPath(import.meta.url);
|
|
@@ -51,12 +78,16 @@ export const uiResolver = async (uiBasePath) => {
|
|
|
51
78
|
const assetHtml = await fetchAssetText('/workers/index.html');
|
|
52
79
|
if (assetHtml !== '')
|
|
53
80
|
return assetHtml;
|
|
54
|
-
if (isCloudflare) {
|
|
55
|
-
return Buffer.from(INDEX_HTML, 'base64').toString('utf-8');
|
|
56
|
-
}
|
|
57
81
|
const uiPath = NodeSingletons.path.resolve(uiBasePath, 'workers/index.html');
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
try {
|
|
83
|
+
return await NodeSingletons.fs.readFile(uiPath, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
const embedded = resolveEmbeddedAssetText('/workers/index.html');
|
|
87
|
+
if (embedded !== null)
|
|
88
|
+
return embedded;
|
|
89
|
+
throw Error('workers index.html is unavailable');
|
|
90
|
+
}
|
|
60
91
|
};
|
|
61
92
|
// MIME type mapping for static files
|
|
62
93
|
const getMimeType = (filePath) => {
|
|
@@ -96,6 +127,16 @@ const getUiBase = () => {
|
|
|
96
127
|
return uiBasePath;
|
|
97
128
|
};
|
|
98
129
|
const serveStaticFile = async (req, res) => {
|
|
130
|
+
const tryServeEmbedded = (assetPath) => {
|
|
131
|
+
const bytes = resolveEmbeddedAssetBytes(assetPath);
|
|
132
|
+
if (bytes === null)
|
|
133
|
+
return false;
|
|
134
|
+
const mimeType = getMimeType(assetPath);
|
|
135
|
+
res.setHeader('Content-Type', mimeType);
|
|
136
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
137
|
+
res.send(Buffer.from(bytes));
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
99
140
|
try {
|
|
100
141
|
const filePath = req.getPath();
|
|
101
142
|
const assetBytes = await fetchAssetBytes(filePath);
|
|
@@ -107,28 +148,8 @@ const serveStaticFile = async (req, res) => {
|
|
|
107
148
|
return;
|
|
108
149
|
}
|
|
109
150
|
if (isCloudflare) {
|
|
110
|
-
|
|
111
|
-
if (normalizedPath === 'workers/styles.css') {
|
|
112
|
-
const mimeType = MIME_TYPES.CSS;
|
|
113
|
-
res.setHeader('Content-Type', mimeType);
|
|
114
|
-
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
115
|
-
res.send(Buffer.from(STYLES_CSS, 'base64'));
|
|
151
|
+
if (tryServeEmbedded(filePath))
|
|
116
152
|
return;
|
|
117
|
-
}
|
|
118
|
-
if (normalizedPath === 'workers/main.js') {
|
|
119
|
-
const mimeType = MIME_TYPES.JS;
|
|
120
|
-
res.setHeader('Content-Type', mimeType);
|
|
121
|
-
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
122
|
-
res.send(Buffer.from(MAIN_JS, 'base64'));
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (normalizedPath === 'workers/zintrust.svg') {
|
|
126
|
-
const mimeType = MIME_TYPES.SVG;
|
|
127
|
-
res.setHeader('Content-Type', mimeType);
|
|
128
|
-
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
129
|
-
res.send(Buffer.from(ZINTRUST_SVG, 'base64'));
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
153
|
res.setStatus(404);
|
|
133
154
|
res.send(Buffer.from('Not Found'));
|
|
134
155
|
return;
|
|
@@ -140,7 +161,15 @@ const serveStaticFile = async (req, res) => {
|
|
|
140
161
|
res.send(Buffer.from('Forbidden'));
|
|
141
162
|
return;
|
|
142
163
|
}
|
|
143
|
-
|
|
164
|
+
let content;
|
|
165
|
+
try {
|
|
166
|
+
content = await NodeSingletons.fs.readFile(fullPath);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
if (tryServeEmbedded(filePath))
|
|
170
|
+
return;
|
|
171
|
+
throw Error(`Missing static asset: ${filePath}`);
|
|
172
|
+
}
|
|
144
173
|
const mimeType = getMimeType(filePath);
|
|
145
174
|
res.setHeader('Content-Type', mimeType);
|
|
146
175
|
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
|
@@ -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>
|