|
| 1 | +<!DOCTYPE html> |
| 2 | +<!-- |
| 3 | + Inalpha Scheduler · 极简 admin 界面(D-9) |
| 4 | +
|
| 5 | + 用法: |
| 6 | +
|
| 7 | + 1. SCHEDULER_ENABLED=true bash scripts/dev.sh # 起 mastra:4111 |
| 8 | + 2. 浏览器打开本文件(直接 open,或 file://) |
| 9 | + 3. 默认连 http://localhost:4111;右上角输入框可改 |
| 10 | +
|
| 11 | + 为什么不用 Next.js:D-9 阶段仓库还没 apps/web;这是临时管理面。 |
| 12 | +
|
| 13 | + 特性:列 jobs / 切 enabled / 手动 trigger / 查 runs。 |
| 14 | + 没有:编辑 cron 表达式 / 创建 job(用 curl POST + 复杂 JSON 更稳)。 |
| 15 | +--> |
| 16 | +<html lang="zh-CN"> |
| 17 | +<head> |
| 18 | +<meta charset="UTF-8"> |
| 19 | +<title>Inalpha Scheduler Admin</title> |
| 20 | +<style> |
| 21 | + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| 22 | + max-width: 1100px; margin: 24px auto; padding: 0 16px; color: #222; } |
| 23 | + h1 { font-size: 20px; margin-bottom: 4px; } |
| 24 | + .meta { color: #888; font-size: 13px; margin-bottom: 16px; } |
| 25 | + .controls { margin-bottom: 16px; } |
| 26 | + .controls input { padding: 4px 8px; width: 280px; font-family: monospace; } |
| 27 | + .controls button { padding: 4px 12px; cursor: pointer; } |
| 28 | + table { width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 13px; } |
| 29 | + th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; vertical-align: top; } |
| 30 | + th { background: #f5f5f5; } |
| 31 | + tr.disabled { opacity: 0.5; } |
| 32 | + .pill { display: inline-block; padding: 1px 8px; border-radius: 10px; |
| 33 | + font-size: 11px; font-weight: 600; } |
| 34 | + .pill.tool { background: #eaf4ff; color: #1769aa; } |
| 35 | + .pill.agent { background: #fff3cd; color: #856404; } |
| 36 | + .pill.success { background: #d4edda; color: #155724; } |
| 37 | + .pill.failed, .pill.timeout { background: #f8d7da; color: #721c24; } |
| 38 | + .pill.running { background: #cce5ff; color: #004085; } |
| 39 | + button.action { padding: 2px 8px; margin-right: 4px; font-size: 12px; cursor: pointer; } |
| 40 | + pre { background: #f9f9f9; padding: 8px; border: 1px solid #eee; max-height: 200px; |
| 41 | + overflow: auto; font-size: 11px; margin: 0; } |
| 42 | + #status { margin: 8px 0; padding: 6px 10px; border-radius: 4px; font-size: 12px; } |
| 43 | + #status.ok { background: #d4edda; color: #155724; } |
| 44 | + #status.err { background: #f8d7da; color: #721c24; } |
| 45 | +</style> |
| 46 | +</head> |
| 47 | +<body> |
| 48 | +<h1>Inalpha Scheduler · admin</h1> |
| 49 | +<div class="meta">D-9 临时管理面(HTML+fetch,无 build)。每 10s 自动刷新。</div> |
| 50 | + |
| 51 | +<div class="controls"> |
| 52 | + Base URL: |
| 53 | + <input id="baseUrl" value="http://localhost:4111"> |
| 54 | + <button onclick="loadAll()">刷新</button> |
| 55 | + <span id="schedRunning" style="margin-left:16px;color:#888;"></span> |
| 56 | +</div> |
| 57 | +<div id="status"></div> |
| 58 | + |
| 59 | +<h2 style="font-size:16px;">Jobs</h2> |
| 60 | +<table id="jobsTable"> |
| 61 | + <thead><tr> |
| 62 | + <th>job_id</th><th>mode</th><th>cron</th><th>tz</th><th>enabled</th> |
| 63 | + <th>next fire</th><th>actions</th> |
| 64 | + </tr></thead> |
| 65 | + <tbody></tbody> |
| 66 | +</table> |
| 67 | + |
| 68 | +<h2 style="font-size:16px;">Recent Runs (50)</h2> |
| 69 | +<table id="runsTable"> |
| 70 | + <thead><tr> |
| 71 | + <th>job_id</th><th>status</th><th>trigger</th> |
| 72 | + <th>scheduled_at</th><th>finished_at</th><th>result / error</th> |
| 73 | + </tr></thead> |
| 74 | + <tbody></tbody> |
| 75 | +</table> |
| 76 | + |
| 77 | +<script> |
| 78 | +const $ = (q) => document.querySelector(q); |
| 79 | +const base = () => $("#baseUrl").value.replace(/\/$/, ""); |
| 80 | + |
| 81 | +function setStatus(msg, ok = true) { |
| 82 | + const el = $("#status"); |
| 83 | + el.className = ok ? "ok" : "err"; |
| 84 | + el.textContent = msg; |
| 85 | + if (ok) setTimeout(() => { if (el.textContent === msg) el.textContent = ""; }, 3000); |
| 86 | +} |
| 87 | + |
| 88 | +async function api(path, init = {}) { |
| 89 | + const url = base() + path; |
| 90 | + const res = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init.headers || {}) } }); |
| 91 | + const body = await res.json().catch(() => null); |
| 92 | + if (!res.ok) throw new Error((body && body.message) || `${res.status} ${res.statusText}`); |
| 93 | + return body; |
| 94 | +} |
| 95 | + |
| 96 | +function fmtDate(s) { |
| 97 | + if (!s) return "-"; |
| 98 | + return new Date(s).toLocaleString(); |
| 99 | +} |
| 100 | + |
| 101 | +function fmtJson(o) { |
| 102 | + if (o === null || o === undefined) return ""; |
| 103 | + return JSON.stringify(o, null, 2); |
| 104 | +} |
| 105 | + |
| 106 | +async function loadJobs() { |
| 107 | + const data = await api("/scheduler/jobs"); |
| 108 | + $("#schedRunning").textContent = "scheduler running: " + (data.schedulerRunning ? "yes" : "no"); |
| 109 | + const tbody = $("#jobsTable tbody"); |
| 110 | + tbody.innerHTML = ""; |
| 111 | + for (const j of data.jobs) { |
| 112 | + const tr = document.createElement("tr"); |
| 113 | + if (!j.enabled) tr.className = "disabled"; |
| 114 | + tr.innerHTML = ` |
| 115 | + <td><b>${j.jobId}</b><div style="color:#888;font-size:11px;">${j.description || ""}</div></td> |
| 116 | + <td><span class="pill ${j.mode}">${j.mode}</span></td> |
| 117 | + <td><code>${j.cronExpr}</code></td> |
| 118 | + <td>${j.timezone}</td> |
| 119 | + <td>${j.enabled ? "✓" : ""}</td> |
| 120 | + <td style="font-size:11px;">${fmtDate(j.nextFireAt)}</td> |
| 121 | + <td> |
| 122 | + <button class="action" onclick="toggleEnabled('${j.jobId}', ${!j.enabled})">${j.enabled ? "disable" : "enable"}</button> |
| 123 | + <button class="action" onclick="triggerJob('${j.jobId}')">trigger</button> |
| 124 | + <button class="action" onclick="deleteJob('${j.jobId}')">delete</button> |
| 125 | + </td>`; |
| 126 | + tbody.appendChild(tr); |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +async function loadRuns() { |
| 131 | + const data = await api("/scheduler/runs?limit=50"); |
| 132 | + const tbody = $("#runsTable tbody"); |
| 133 | + tbody.innerHTML = ""; |
| 134 | + for (const r of data.runs) { |
| 135 | + const tr = document.createElement("tr"); |
| 136 | + const blob = r.status === "success" ? r.result : r.error; |
| 137 | + tr.innerHTML = ` |
| 138 | + <td>${r.jobId}</td> |
| 139 | + <td><span class="pill ${r.status}">${r.status}</span></td> |
| 140 | + <td>${r.trigger}</td> |
| 141 | + <td style="font-size:11px;">${fmtDate(r.scheduledAt)}</td> |
| 142 | + <td style="font-size:11px;">${fmtDate(r.finishedAt)}</td> |
| 143 | + <td><pre>${fmtJson(blob)}</pre></td>`; |
| 144 | + tbody.appendChild(tr); |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +async function loadAll() { |
| 149 | + try { await loadJobs(); await loadRuns(); } |
| 150 | + catch (e) { setStatus("load failed: " + e.message, false); } |
| 151 | +} |
| 152 | + |
| 153 | +async function toggleEnabled(id, enabled) { |
| 154 | + try { |
| 155 | + await api(`/scheduler/jobs/${encodeURIComponent(id)}`, { |
| 156 | + method: "PATCH", body: JSON.stringify({ enabled }), |
| 157 | + }); |
| 158 | + setStatus(`${id} ${enabled ? "enabled" : "disabled"}`); |
| 159 | + await loadJobs(); |
| 160 | + } catch (e) { setStatus("toggle failed: " + e.message, false); } |
| 161 | +} |
| 162 | + |
| 163 | +async function triggerJob(id) { |
| 164 | + setStatus(`triggering ${id}...`); |
| 165 | + try { |
| 166 | + const r = await api(`/scheduler/jobs/${encodeURIComponent(id)}/trigger`, { method: "POST" }); |
| 167 | + setStatus(`${id} → ${r.status}`, r.status === "success"); |
| 168 | + await loadRuns(); |
| 169 | + } catch (e) { setStatus("trigger failed: " + e.message, false); } |
| 170 | +} |
| 171 | + |
| 172 | +async function deleteJob(id) { |
| 173 | + if (!confirm(`delete ${id}? 关联 runs 会级联清除。`)) return; |
| 174 | + try { |
| 175 | + await api(`/scheduler/jobs/${encodeURIComponent(id)}`, { method: "DELETE" }); |
| 176 | + setStatus(`${id} deleted`); |
| 177 | + await loadAll(); |
| 178 | + } catch (e) { setStatus("delete failed: " + e.message, false); } |
| 179 | +} |
| 180 | + |
| 181 | +loadAll(); |
| 182 | +setInterval(loadAll, 10000); |
| 183 | +</script> |
| 184 | +</body> |
| 185 | +</html> |
0 commit comments