fleet-framework 0.1.0__py3-none-any.whl

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.
Files changed (85) hide show
  1. fleet/__init__.py +1 -0
  2. fleet/cli.py +290 -0
  3. fleet/core/__init__.py +69 -0
  4. fleet/core/automation.py +125 -0
  5. fleet/core/backend.py +736 -0
  6. fleet/core/config.py +38 -0
  7. fleet/core/context.py +102 -0
  8. fleet/core/contract.py +87 -0
  9. fleet/core/country_presets.py +50 -0
  10. fleet/core/events.py +55 -0
  11. fleet/core/logging.py +97 -0
  12. fleet/core/memory_backend.py +492 -0
  13. fleet/core/metrics.py +61 -0
  14. fleet/core/otel.py +97 -0
  15. fleet/core/primitives.py +310 -0
  16. fleet/core/protocol.py +171 -0
  17. fleet/core/proxy.py +166 -0
  18. fleet/core/reconcile.py +75 -0
  19. fleet/core/sqlite_backend.py +1117 -0
  20. fleet/core/store.py +104 -0
  21. fleet/master/__init__.py +3 -0
  22. fleet/master/api.py +324 -0
  23. fleet/master/app.py +105 -0
  24. fleet/master/auth.py +132 -0
  25. fleet/master/broadcaster.py +37 -0
  26. fleet/master/dashboard/__init__.py +4 -0
  27. fleet/master/dashboard/router.py +36 -0
  28. fleet/master/dashboard/static/style.css +97 -0
  29. fleet/master/dashboard/templates/index.html +372 -0
  30. fleet/master/metrics_route.py +141 -0
  31. fleet/master/ratelimit.py +55 -0
  32. fleet/master/ws_router.py +142 -0
  33. fleet/worker/__init__.py +3 -0
  34. fleet/worker/agent.py +173 -0
  35. fleet/worker/reconcile_loop.py +246 -0
  36. fleet/worker/slot_runner.py +256 -0
  37. fleet/worker/ws_client.py +164 -0
  38. fleet_browser/__init__.py +21 -0
  39. fleet_browser/browser.py +277 -0
  40. fleet_browser/cert.py +68 -0
  41. fleet_browser/fingerprint.py +327 -0
  42. fleet_browser/humanizer.py +157 -0
  43. fleet_browser/pool.py +241 -0
  44. fleet_browser/proxy_extension.py +122 -0
  45. fleet_browser/solver.py +51 -0
  46. fleet_browser/stealth.py +80 -0
  47. fleet_cloudflare/__init__.py +22 -0
  48. fleet_cloudflare/bypasser.py +168 -0
  49. fleet_cloudflare/harvest.py +266 -0
  50. fleet_cloudflare/replay.py +82 -0
  51. fleet_cloudflare/solver.py +28 -0
  52. fleet_content/__init__.py +24 -0
  53. fleet_content/automation.py +43 -0
  54. fleet_content/contracts.py +76 -0
  55. fleet_detect/__init__.py +26 -0
  56. fleet_detect/contracts.py +67 -0
  57. fleet_detect/detect.py +126 -0
  58. fleet_framework-0.1.0.dist-info/METADATA +160 -0
  59. fleet_framework-0.1.0.dist-info/RECORD +85 -0
  60. fleet_framework-0.1.0.dist-info/WHEEL +5 -0
  61. fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
  62. fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
  63. fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
  64. fleet_headers/__init__.py +28 -0
  65. fleet_headers/profiles.py +131 -0
  66. fleet_jobs/__init__.py +28 -0
  67. fleet_jobs/automation.py +34 -0
  68. fleet_jobs/contracts.py +143 -0
  69. fleet_marketplace/__init__.py +33 -0
  70. fleet_marketplace/automation.py +32 -0
  71. fleet_marketplace/contracts.py +151 -0
  72. fleet_news/__init__.py +21 -0
  73. fleet_news/automation.py +51 -0
  74. fleet_news/contracts.py +59 -0
  75. fleet_place/__init__.py +33 -0
  76. fleet_place/automation.py +37 -0
  77. fleet_place/contracts.py +156 -0
  78. fleet_provider_dataimpulse/__init__.py +82 -0
  79. fleet_provider_evomi/__init__.py +76 -0
  80. fleet_serp/__init__.py +30 -0
  81. fleet_serp/automation.py +47 -0
  82. fleet_serp/contracts.py +100 -0
  83. fleet_social/__init__.py +34 -0
  84. fleet_social/automation.py +44 -0
  85. fleet_social/contracts.py +172 -0
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+ from fastapi import WebSocket
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Broadcaster:
15
+ # in-memory worker_id -> set[WebSocket]. one WS per (automation_type, worker_id).
16
+ # used to push ConfigChanged frames after a PATCH.
17
+ def __init__(self) -> None:
18
+ self._sockets: dict[tuple[str, str], set[WebSocket]] = defaultdict(set)
19
+ self._lock = asyncio.Lock()
20
+
21
+ async def attach(self, automation_type: str, worker_id: str, ws: WebSocket) -> None:
22
+ async with self._lock:
23
+ self._sockets[(automation_type, worker_id)].add(ws)
24
+
25
+ async def detach(self, automation_type: str, worker_id: str, ws: WebSocket) -> None:
26
+ async with self._lock:
27
+ self._sockets.get((automation_type, worker_id), set()).discard(ws)
28
+
29
+ async def publish(self, automation_type: str, worker_id: str, frame: dict[str, Any]) -> None:
30
+ payload = json.dumps(frame)
31
+ async with self._lock:
32
+ socks = list(self._sockets.get((automation_type, worker_id), set()))
33
+ for ws in socks:
34
+ try:
35
+ await ws.send_text(payload)
36
+ except Exception:
37
+ logger.debug("ws send failed for %s/%s", automation_type, worker_id)
@@ -0,0 +1,4 @@
1
+ # dashboard templates + static + router lives here.
2
+ from fleet.master.dashboard.router import make_dashboard_router
3
+
4
+ __all__ = ["make_dashboard_router"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, Request
6
+ from fastapi.responses import HTMLResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.templating import Jinja2Templates
9
+
10
+ from fleet.core.automation import get_registry
11
+
12
+ _HERE = Path(__file__).parent
13
+
14
+
15
+ def make_dashboard_router() -> APIRouter:
16
+ router = APIRouter()
17
+ templates = Jinja2Templates(directory=str(_HERE / "templates"))
18
+
19
+ @router.get("/", response_class=HTMLResponse)
20
+ async def index(request: Request) -> HTMLResponse:
21
+ reg = get_registry()
22
+ automations = [
23
+ {
24
+ "type": name,
25
+ "class": cls.__name__,
26
+ "kind": cls.__mro__[1].__name__,
27
+ "schema": cls.Config.model_json_schema(),
28
+ }
29
+ for name, cls in reg.items()
30
+ ]
31
+ return templates.TemplateResponse(
32
+ request, "index.html", {"automations": automations}
33
+ )
34
+
35
+ router.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
36
+ return router
@@ -0,0 +1,97 @@
1
+ :root {
2
+ --bg: #0f0f10;
3
+ --fg: #e5e5e5;
4
+ --muted: #8a8a8a;
5
+ --accent: #f7931a;
6
+ --line: #2a2a2a;
7
+ --panel: #181819;
8
+ }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0; padding: 2rem;
12
+ background: var(--bg); color: var(--fg);
13
+ font: 14px/1.5 -apple-system, ui-sans-serif, system-ui, sans-serif;
14
+ }
15
+ header { margin-bottom: 1.5rem; }
16
+ h1 { margin: 0; font-size: 1.3rem; color: var(--accent); }
17
+ .muted { color: var(--muted); }
18
+ .tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--line); margin-bottom: 1rem; }
19
+ .tab {
20
+ background: transparent; color: var(--muted);
21
+ border: 0; border-bottom: 2px solid transparent;
22
+ padding: 0.5rem 1rem; cursor: pointer; font: inherit;
23
+ }
24
+ .tab:hover { color: var(--fg); }
25
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
26
+ .pane { padding: 1rem 0; }
27
+ h2 { font-size: 1rem; margin: 0 0 1rem 0; }
28
+ h3 { font-size: 0.85rem; color: var(--muted); margin: 1.5rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
29
+ table { width: 100%; border-collapse: collapse; }
30
+ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; }
31
+ th { color: var(--muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; }
32
+ pre.schema { background: var(--panel); padding: 1rem; border-radius: 6px; overflow: auto; font-size: 12px; }
33
+
34
+ .graph-wrap {
35
+ background: var(--panel); padding: 1rem; border-radius: 6px;
36
+ overflow: auto; max-height: 75vh;
37
+ }
38
+ .graph-wrap svg { max-width: 100%; height: auto; }
39
+ .submit-form { background: var(--panel); padding: 1rem; border-radius: 6px; }
40
+ .task-form .fields {
41
+ display: grid;
42
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
43
+ gap: 0.5rem 1rem;
44
+ margin-bottom: 0.75rem;
45
+ }
46
+ .task-form label {
47
+ display: flex; flex-direction: column;
48
+ font-size: 0.8rem; color: var(--muted);
49
+ gap: 0.25rem;
50
+ }
51
+ .task-form label.bool { flex-direction: row; align-items: center; }
52
+ .task-form input[type="text"],
53
+ .task-form input[type="number"],
54
+ .task-form select,
55
+ .task-form textarea {
56
+ background: var(--bg); color: var(--fg);
57
+ border: 1px solid var(--line); border-radius: 4px;
58
+ padding: 0.4rem 0.5rem; font: inherit;
59
+ }
60
+ .task-form input:focus, .task-form select:focus, .task-form textarea:focus {
61
+ outline: none; border-color: var(--accent);
62
+ }
63
+ .task-form button {
64
+ background: var(--accent); color: #111;
65
+ border: 0; border-radius: 4px; padding: 0.5rem 1rem;
66
+ font-weight: 600; cursor: pointer;
67
+ }
68
+ .task-form button:hover { filter: brightness(1.1); }
69
+ .task-form .submit-result {
70
+ margin-top: 0.75rem; padding: 0.5rem;
71
+ background: var(--bg); border-radius: 4px; font-size: 12px;
72
+ min-height: 1.2em; white-space: pre-wrap;
73
+ }
74
+ .task-form details.advanced { margin: 0.5rem 0; }
75
+ .task-form details.advanced summary {
76
+ cursor: pointer; color: var(--muted); font-size: 0.8rem;
77
+ padding: 0.25rem 0;
78
+ }
79
+
80
+ .inspector { background: var(--panel); padding: 0.75rem; border-radius: 6px; }
81
+ .inspector button {
82
+ background: transparent; color: var(--muted);
83
+ border: 1px solid var(--line); border-radius: 4px;
84
+ padding: 0.25rem 0.6rem; font: inherit; cursor: pointer;
85
+ margin-right: 0.4rem;
86
+ }
87
+ .inspector button:hover { color: var(--fg); border-color: var(--accent); }
88
+ .inspector button.danger { color: #ff7070; }
89
+ .inspector button.danger:hover { border-color: #ff7070; }
90
+ .inspector pre {
91
+ margin: 0.5rem 0 0; padding: 0.5rem;
92
+ background: var(--bg); border-radius: 4px; font-size: 12px;
93
+ max-height: 240px; overflow: auto; white-space: pre-wrap;
94
+ }
95
+ .pool-card { padding: 0.5rem 0; border-bottom: 1px solid var(--line); }
96
+ .pool-card:last-child { border-bottom: 0; }
97
+ .pool-card strong { color: var(--fg); margin-right: 0.5rem; }
@@ -0,0 +1,372 @@
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" />
6
+ <title>fleet</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>fleet</h1>
12
+ <p class="muted">
13
+ {{ automations|length }} automation{{ '' if automations|length == 1 else 's' }} installed
14
+ </p>
15
+ </header>
16
+
17
+ <nav class="tabs" id="tabs">
18
+ <button class="tab" data-type="__graph">graph</button>
19
+ {% for a in automations %}
20
+ <button class="tab" data-type="{{ a.type }}">{{ a.type }}</button>
21
+ {% endfor %}
22
+ </nav>
23
+
24
+ <main id="panes">
25
+ <section class="pane" data-type="__graph" hidden>
26
+ <h2>contracts <span class="muted">(catalog DAG)</span></h2>
27
+ <div class="graph-wrap"><div id="graph" class="mermaid">loading…</div></div>
28
+ </section>
29
+ {% for a in automations %}
30
+ <section class="pane" data-type="{{ a.type }}" hidden>
31
+ <h2>{{ a.type }} <span class="muted">({{ a.kind }})</span></h2>
32
+ <div class="workers" id="workers-{{ a.type }}">loading…</div>
33
+
34
+ <h3>submit task</h3>
35
+ <div class="submit-form" id="submit-{{ a.type }}">
36
+ <p class="muted">loading schema…</p>
37
+ </div>
38
+
39
+ <h3>queue</h3>
40
+ <div class="inspector" id="queue-{{ a.type }}">
41
+ <button class="refresh">refresh</button>
42
+ <pre class="muted">loading…</pre>
43
+ </div>
44
+
45
+ <h3>dead-letter queue</h3>
46
+ <div class="inspector" id="dlq-{{ a.type }}">
47
+ <button class="refresh">refresh</button>
48
+ <button class="danger">drain</button>
49
+ <pre class="muted">loading…</pre>
50
+ </div>
51
+
52
+ <h3>pools</h3>
53
+ <div class="inspector" id="pools-{{ a.type }}">loading…</div>
54
+
55
+ <h3>config schema</h3>
56
+ <pre class="schema">{{ a.schema | tojson(indent=2) }}</pre>
57
+ </section>
58
+ {% endfor %}
59
+ </main>
60
+
61
+ <script type="module">
62
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
63
+ mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
64
+ window.__mermaid = mermaid;
65
+ </script>
66
+ <script>
67
+ const ADMIN_TOKEN = localStorage.getItem('admin_token') || prompt('admin token:');
68
+ if (ADMIN_TOKEN) localStorage.setItem('admin_token', ADMIN_TOKEN);
69
+ const AUTH = { 'Authorization': 'Bearer ' + ADMIN_TOKEN };
70
+
71
+ const loadedForms = new Set();
72
+ let graphLoaded = false;
73
+
74
+ document.querySelectorAll('.tab').forEach(btn => {
75
+ btn.addEventListener('click', () => {
76
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
77
+ btn.classList.add('active');
78
+ document.querySelectorAll('.pane').forEach(p => p.hidden = true);
79
+ const t = btn.dataset.type;
80
+ document.querySelector(`.pane[data-type="${t}"]`).hidden = false;
81
+ if (t === '__graph') {
82
+ if (!graphLoaded) { loadGraph(); graphLoaded = true; }
83
+ return;
84
+ }
85
+ loadWorkers(t);
86
+ if (!loadedForms.has(t)) {
87
+ loadSubmitForm(t);
88
+ wireInspectors(t);
89
+ loadInspectors(t);
90
+ loadedForms.add(t);
91
+ }
92
+ });
93
+ });
94
+ document.querySelector('.tab')?.click();
95
+
96
+ function wireInspectors(type) {
97
+ const qBox = document.getElementById('queue-' + type);
98
+ const dlqBox = document.getElementById('dlq-' + type);
99
+ qBox.querySelector('.refresh').addEventListener('click', () => loadQueue(type));
100
+ dlqBox.querySelector('.refresh').addEventListener('click', () => loadDlq(type));
101
+ dlqBox.querySelector('.danger').addEventListener('click', async () => {
102
+ if (!confirm(`drain DLQ for ${type}?`)) return;
103
+ const r = await fetch(`/api/v1/automations/${type}/dead`, {
104
+ method: 'DELETE', headers: AUTH,
105
+ });
106
+ const data = await r.json().catch(() => ({}));
107
+ alert(`[${r.status}] ${JSON.stringify(data)}`);
108
+ loadDlq(type);
109
+ });
110
+ }
111
+
112
+ async function loadInspectors(type) {
113
+ await Promise.all([loadQueue(type), loadDlq(type), loadPools(type)]);
114
+ }
115
+
116
+ async function loadQueue(type) {
117
+ const out = document.querySelector('#queue-' + CSS.escape(type) + ' pre');
118
+ try {
119
+ const r = await fetch(`/api/v1/automations/${type}/queue?n=20`, { headers: AUTH });
120
+ const d = await r.json();
121
+ out.textContent = `length: ${d.length}\n${JSON.stringify(d.items, null, 2)}`;
122
+ } catch (e) { out.textContent = 'error: ' + e.message; }
123
+ }
124
+
125
+ async function loadDlq(type) {
126
+ const out = document.querySelector('#dlq-' + CSS.escape(type) + ' pre');
127
+ try {
128
+ const r = await fetch(`/api/v1/automations/${type}/dead?n=20`, { headers: AUTH });
129
+ const d = await r.json();
130
+ out.textContent = `length: ${d.length}\n${JSON.stringify(d.items, null, 2)}`;
131
+ } catch (e) { out.textContent = 'error: ' + e.message; }
132
+ }
133
+
134
+ async function loadPools(type) {
135
+ const root = document.getElementById('pools-' + type);
136
+ try {
137
+ const r = await fetch(`/api/v1/catalog/${type}`, { headers: AUTH });
138
+ if (!r.ok) { root.textContent = 'catalog http ' + r.status; return; }
139
+ const doc = await r.json();
140
+ const names = Object.keys(doc.pools || {});
141
+ if (!names.length) { root.innerHTML = '<p class="muted">this automation declares no pools.</p>'; return; }
142
+ root.innerHTML = names.map(p => `
143
+ <div class="pool-card" data-pool="${p}">
144
+ <strong>${p}</strong>
145
+ <button class="refresh">refresh</button>
146
+ <pre class="muted">loading…</pre>
147
+ </div>
148
+ `).join('');
149
+ for (const p of names) {
150
+ const card = root.querySelector(`.pool-card[data-pool="${p}"]`);
151
+ card.querySelector('.refresh').addEventListener('click', () => loadPool(p, card));
152
+ loadPool(p, card);
153
+ }
154
+ } catch (e) { root.textContent = 'error: ' + e.message; }
155
+ }
156
+
157
+ async function loadPool(name, card) {
158
+ const out = card.querySelector('pre');
159
+ try {
160
+ const r = await fetch(`/api/v1/pools/${encodeURIComponent(name)}?n=20`, { headers: AUTH });
161
+ const d = await r.json();
162
+ out.textContent = `size: ${d.size}\n${JSON.stringify(d.items, null, 2)}`;
163
+ } catch (e) { out.textContent = 'error: ' + e.message; }
164
+ }
165
+
166
+ async function loadGraph() {
167
+ const el = document.getElementById('graph');
168
+ try {
169
+ const r = await fetch('/api/v1/catalog', { headers: AUTH });
170
+ if (!r.ok) { el.textContent = 'catalog http ' + r.status; return; }
171
+ const doc = await r.json();
172
+ const src = buildMermaid(doc);
173
+ const waitMermaid = () => new Promise(res => {
174
+ if (window.__mermaid) return res(window.__mermaid);
175
+ const iv = setInterval(() => {
176
+ if (window.__mermaid) { clearInterval(iv); res(window.__mermaid); }
177
+ }, 50);
178
+ });
179
+ const mermaid = await waitMermaid();
180
+ const { svg } = await mermaid.render('graph-svg', src);
181
+ el.innerHTML = svg;
182
+ } catch (e) {
183
+ el.textContent = 'error: ' + e.message;
184
+ }
185
+ }
186
+
187
+ function safeId(s) { return s.replace(/[^a-zA-Z0-9_]/g, '_'); }
188
+
189
+ function buildMermaid(catalog) {
190
+ const lines = ['flowchart LR'];
191
+ const automations = Object.entries(catalog);
192
+ if (!automations.length) return 'flowchart LR\n empty[no automations installed]';
193
+ const pools = new Set();
194
+ for (const [type, doc] of automations) {
195
+ const a = `A_${safeId(type)}`;
196
+ lines.push(` ${a}[["${type}<br/>(${doc.kind})"]]`);
197
+ if (doc.queue) {
198
+ const q = `Q_${safeId(doc.queue.name)}`;
199
+ lines.push(` ${q}[/"queue: ${doc.queue.name}"/]`);
200
+ lines.push(` ${q} -->|consume| ${a}`);
201
+ }
202
+ if (doc.stream) {
203
+ const s = `S_${safeId(doc.stream.name)}`;
204
+ lines.push(` ${s}[("stream: ${doc.stream.name}")]`);
205
+ lines.push(` ${a} -->|publish| ${s}`);
206
+ }
207
+ for (const poolName of Object.keys(doc.pools || {})) {
208
+ const p = `P_${safeId(poolName)}`;
209
+ if (!pools.has(poolName)) {
210
+ lines.push(` ${p}{{"pool: ${poolName}"}}`);
211
+ pools.add(poolName);
212
+ }
213
+ lines.push(` ${a} <-->|claim/put| ${p}`);
214
+ }
215
+ }
216
+ lines.push(' classDef auto fill:#f7931a,stroke:#f7931a,color:#111;');
217
+ for (const [type] of automations) {
218
+ lines.push(` class A_${safeId(type)} auto;`);
219
+ }
220
+ return lines.join('\n');
221
+ }
222
+
223
+ async function loadWorkers(type) {
224
+ const el = document.getElementById('workers-' + type);
225
+ try {
226
+ const r = await fetch(`/api/v1/automations/${type}/workers`, { headers: AUTH });
227
+ if (!r.ok) { el.textContent = 'http ' + r.status; return; }
228
+ const ws = await r.json();
229
+ if (!ws.length) { el.textContent = 'no workers connected.'; return; }
230
+ el.innerHTML = '<table><thead><tr><th>worker</th><th>state</th><th>gen</th><th>slots</th><th>last seen</th></tr></thead><tbody>' +
231
+ ws.map(w => `<tr>
232
+ <td>${w.worker_id}</td>
233
+ <td>${w.state}</td>
234
+ <td>${w.config_gen}</td>
235
+ <td>${(w.current_stats||{}).slots ?? '-'}</td>
236
+ <td>${w.last_seen ? new Date(w.last_seen*1000).toLocaleTimeString() : '-'}</td>
237
+ </tr>`).join('') + '</tbody></table>';
238
+ } catch (e) {
239
+ el.textContent = 'error: ' + e.message;
240
+ }
241
+ }
242
+ setInterval(() => {
243
+ const active = document.querySelector('.pane:not([hidden])');
244
+ if (active) loadWorkers(active.dataset.type);
245
+ }, 5000);
246
+
247
+ async function loadSubmitForm(type) {
248
+ const root = document.getElementById('submit-' + type);
249
+ try {
250
+ const r = await fetch(`/api/v1/catalog/${type}`, { headers: AUTH });
251
+ if (!r.ok) { root.textContent = 'catalog http ' + r.status; return; }
252
+ const doc = await r.json();
253
+ if (!doc.queue || !doc.queue.payload) {
254
+ root.innerHTML = '<p class="muted">this automation does not accept tasks (no TaskPayload declared).</p>';
255
+ return;
256
+ }
257
+ renderForm(root, type, doc.queue.payload);
258
+ } catch (e) {
259
+ root.textContent = 'error: ' + e.message;
260
+ }
261
+ }
262
+
263
+ function renderForm(root, type, schema) {
264
+ const props = schema.properties || {};
265
+ const required = new Set(schema.required || []);
266
+ const refs = schema.$defs || schema.definitions || {};
267
+ const fields = [];
268
+ for (const [name, prop] of Object.entries(props)) {
269
+ fields.push(renderField(name, prop, required.has(name), refs));
270
+ }
271
+ root.innerHTML = `
272
+ <form class="task-form" data-type="${type}">
273
+ <div class="fields">${fields.join('')}</div>
274
+ <details class="advanced">
275
+ <summary>options</summary>
276
+ <div class="fields">
277
+ <label>priority <input type="number" name="__priority" value="0"/></label>
278
+ <label>max_attempts <input type="number" name="__max_attempts" placeholder="auto"/></label>
279
+ <label>ttl_seconds <input type="number" name="__ttl_seconds" placeholder="none"/></label>
280
+ <label>idempotency_key <input type="text" name="__idempotency_key" placeholder="optional"/></label>
281
+ </div>
282
+ </details>
283
+ <button type="submit">submit</button>
284
+ <pre class="submit-result muted"></pre>
285
+ </form>
286
+ `;
287
+ root.querySelector('form').addEventListener('submit', (ev) => {
288
+ ev.preventDefault();
289
+ onSubmit(root.querySelector('form'), props);
290
+ });
291
+ }
292
+
293
+ function renderField(name, prop, required, refs) {
294
+ const resolved = resolveRef(prop, refs);
295
+ const label = `${name}${required ? ' *' : ''}`;
296
+ const ph = resolved.description || resolved.title || '';
297
+ const inputType = inputTypeFor(resolved);
298
+ if (inputType === 'json') {
299
+ const def = resolved.default !== undefined ? JSON.stringify(resolved.default) : '';
300
+ return `<label>${label}<textarea name="${name}" placeholder='${ph}' data-shape="json" rows="3">${def}</textarea></label>`;
301
+ }
302
+ if (inputType === 'checkbox') {
303
+ const checked = resolved.default === true ? 'checked' : '';
304
+ return `<label class="bool"><input type="checkbox" name="${name}" data-shape="bool" ${checked}/> ${label}</label>`;
305
+ }
306
+ if (resolved.enum) {
307
+ const opts = resolved.enum.map(v => `<option value='${JSON.stringify(v)}'>${v}</option>`).join('');
308
+ return `<label>${label}<select name="${name}" data-shape="enum">${opts}</select></label>`;
309
+ }
310
+ const def = resolved.default !== undefined ? String(resolved.default) : '';
311
+ return `<label>${label}<input type="${inputType}" name="${name}" placeholder="${ph}" value="${def}" data-shape="${resolved.type || 'string'}"/></label>`;
312
+ }
313
+
314
+ function resolveRef(prop, refs) {
315
+ if (prop && prop['$ref']) {
316
+ const key = prop['$ref'].replace(/^#\/(?:\$defs|definitions)\//, '');
317
+ return refs[key] || prop;
318
+ }
319
+ return prop || {};
320
+ }
321
+
322
+ function inputTypeFor(p) {
323
+ const t = p.type;
324
+ if (t === 'integer' || t === 'number') return 'number';
325
+ if (t === 'boolean') return 'checkbox';
326
+ if (t === 'array' || t === 'object') return 'json';
327
+ return 'text';
328
+ }
329
+
330
+ async function onSubmit(form, props) {
331
+ const out = form.querySelector('.submit-result');
332
+ const payload = {};
333
+ const opts = {};
334
+ for (const el of form.querySelectorAll('[name]')) {
335
+ const name = el.name;
336
+ if (name.startsWith('__')) {
337
+ const raw = el.value.trim();
338
+ if (raw === '') continue;
339
+ opts[name.slice(2)] = ['priority','max_attempts','ttl_seconds'].includes(name.slice(2))
340
+ ? parseInt(raw, 10) : raw;
341
+ continue;
342
+ }
343
+ const shape = el.dataset.shape;
344
+ if (shape === 'bool') { payload[name] = el.checked; continue; }
345
+ const raw = el.value;
346
+ if (raw === '' && !(props[name] && props[name].default === '')) continue;
347
+ if (shape === 'json' || shape === 'enum') {
348
+ try { payload[name] = JSON.parse(raw); }
349
+ catch { out.textContent = `field ${name}: invalid JSON`; return; }
350
+ } else if (shape === 'integer' || shape === 'number') {
351
+ payload[name] = Number(raw);
352
+ } else {
353
+ payload[name] = raw;
354
+ }
355
+ }
356
+ const body = { payload, ...opts };
357
+ out.textContent = 'submitting…';
358
+ try {
359
+ const r = await fetch(`/api/v1/automations/${form.dataset.type}/tasks`, {
360
+ method: 'POST',
361
+ headers: { ...AUTH, 'content-type': 'application/json' },
362
+ body: JSON.stringify(body),
363
+ });
364
+ const data = await r.json().catch(() => ({}));
365
+ out.textContent = `[${r.status}] ${JSON.stringify(data, null, 2)}`;
366
+ } catch (e) {
367
+ out.textContent = 'error: ' + e.message;
368
+ }
369
+ }
370
+ </script>
371
+ </body>
372
+ </html>