diff --git a/forge_workflows.py b/forge_workflows.py new file mode 100644 index 0000000..b8e3814 --- /dev/null +++ b/forge_workflows.py @@ -0,0 +1,306 @@ +"""Workflows tab (v0.1) data layer. + +Talks to the local `openclaw cron` CLI to enumerate scheduled jobs +and normalize them into the shape the front-end (`web/app.js` → +`workflowsView`) expects. + +Design owner: designer / issue #115. v0.1 is a UI shell — we do NOT +mutate ~/.openclaw/cron/jobs.json here, only *read* via `cron list +--json` and *trigger* immediate runs via `cron run --id `. +""" +from __future__ import annotations + +import json +import subprocess +import time +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + + +CRON_BIN = "openclaw" +CLI_TIMEOUT = 8.0 # seconds; the local CLI is fast, we bail early on hang + + +# ─── low-level CLI adapters ────────────────────────────────────────── + +def _run_cli(args: list[str]) -> dict: + """Run `openclaw ` and parse stdout as JSON. + + Raises RuntimeError with the collected stderr snippet on failure so + the HTTP layer can surface a useful message instead of a 500. + """ + try: + proc = subprocess.run( + [CRON_BIN, *args], + check=False, + capture_output=True, + text=True, + timeout=CLI_TIMEOUT, + ) + except FileNotFoundError as e: + raise RuntimeError(f"openclaw CLI not found: {e}") from e + except subprocess.TimeoutExpired as e: + raise RuntimeError(f"openclaw {' '.join(args)} timed out") from e + if proc.returncode != 0: + raise RuntimeError( + f"openclaw {' '.join(args)} exited {proc.returncode}: " + f"{(proc.stderr or proc.stdout or '').strip()[:400]}" + ) + out = (proc.stdout or "").strip() + if not out or out[0] not in "{[": + raise RuntimeError( + f"openclaw {' '.join(args)} produced non-JSON output: " + f"{out[:200]!r}" + ) + return json.loads(out) + + +# ─── schedule → 人话 ──────────────────────────────────────────────── + +def _cron_to_human(expr: str) -> str: + """Map the 5 patterns we care about; fall back to raw.""" + parts = expr.strip().split() + if len(parts) != 5: + return expr + m, h, dom, mon, dow = parts + # 只覆盖 5 条规则;其他 fallback raw + if dom == "*" and mon == "*" and dow == "*": + try: + mi = int(m) + hi = int(h) + except ValueError: + return expr + return f"每天 {hi:02d}:{mi:02d}" + if dom == "*" and mon == "*" and dow in ("1-5", "1,2,3,4,5"): + try: + mi = int(m); hi = int(h) + except ValueError: + return expr + return f"工作日 {hi:02d}:{mi:02d}" + if dom == "*" and mon == "*" and dow in ("6,0", "0,6", "6-7", "0,6,7"): + try: + mi = int(m); hi = int(h) + except ValueError: + return expr + return f"周末 {hi:02d}:{mi:02d}" + return expr + + +def _next_fire_for_cron(expr: str, tz_name: str, from_ms: int | None = None) -> int | None: + """Compute the next-fire time for our supported patterns. + + Returns ms since epoch, or None if unsupported (fallback: use + whatever `state.nextRunAtMs` already exists on the job). + """ + parts = expr.strip().split() + if len(parts) != 5: + return None + m, h, dom, mon, dow = parts + try: + mi = int(m); hi = int(h) + except ValueError: + return None + if dom != "*" or mon != "*": + return None + try: + tz = ZoneInfo(tz_name or "Asia/Shanghai") + except Exception: + tz = ZoneInfo("Asia/Shanghai") + now = datetime.fromtimestamp((from_ms or int(time.time() * 1000)) / 1000, tz=tz) + candidate = now.replace(hour=hi, minute=mi, second=0, microsecond=0) + for _ in range(14): + weekday = candidate.weekday() # 0=Mon..6=Sun + ok = False + if dow == "*": + ok = True + elif dow in ("1-5", "1,2,3,4,5"): + ok = weekday <= 4 + elif dow in ("6,0", "0,6", "6-7", "0,6,7"): + ok = weekday in (5, 6) + else: + return None + if ok and candidate > now: + return int(candidate.timestamp() * 1000) + candidate = candidate + timedelta(days=1) + return None + + +def _human_next(ms: int | None, tz_name: str) -> str: + if not ms: + return "—" + try: + tz = ZoneInfo(tz_name or "Asia/Shanghai") + except Exception: + tz = ZoneInfo("Asia/Shanghai") + now = datetime.now(tz=tz) + dt = datetime.fromtimestamp(ms / 1000, tz=tz) + same_day = dt.date() == now.date() + delta = ms - int(now.timestamp() * 1000) + if delta < 0: + return "已过期" + total_min = delta // 60000 + hours = total_min // 60 + mins = total_min % 60 + if same_day: + prefix = f"今天 {dt.strftime('%H:%M')}" + elif dt.date() == (now.date() + timedelta(days=1)): + prefix = f"明天 {dt.strftime('%H:%M')}" + else: + prefix = dt.strftime("%m-%d %H:%M") + if hours >= 24: + days = hours // 24 + rem_h = hours % 24 + rel = f"in {days}d {rem_h}h" if rem_h else f"in {days}d" + elif hours >= 1: + rel = f"in {hours}h {mins}m" if mins else f"in {hours}h" + else: + rel = f"in {mins}m" + return f"{prefix} · {rel}" + + +# ─── target / delivery labels ──────────────────────────────────────── + +def _target_labels(session_target: str | None) -> tuple[str, str, str]: + """(kind, label, labelShort) for a job's sessionTarget.""" + t = session_target or "isolated" + if t == "isolated": + return ("isolated", "isolated", "isolated") + if t == "current": + return ("current", "current", "current") + if t.startswith("session:"): + rest = t.split(":", 1)[1] + segs = rest.split(":") + tail = segs[-1] if segs else rest + if len(tail) > 20: + tail = tail[:8] + "…" + tail[-8:] + short = f"session:…{tail}" + return ("session", t, short) + return ("session", t, t[:32]) + + +# ─── normalize one job ─────────────────────────────────────────────── + +def _normalize(job: dict) -> dict: + sched = job.get("schedule") or {} + kind = sched.get("kind") or "cron" + tz = sched.get("tz") or "Asia/Shanghai" + raw = sched.get("expr") or sched.get("at") or "" + state = job.get("state") or {} + + if kind == "cron": + human = _cron_to_human(raw) + next_ms = _next_fire_for_cron(raw, tz) + if next_ms is None: + next_ms = state.get("nextRunAtMs") + elif kind == "at": + at_val = sched.get("at") or raw + # `at` may be ISO string or ms + next_ms = None + try: + if isinstance(at_val, (int, float)): + next_ms = int(at_val) + elif isinstance(at_val, str): + try: + next_ms = int(at_val) + except ValueError: + dt = datetime.fromisoformat(at_val.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + next_ms = int(dt.timestamp() * 1000) + except Exception: + next_ms = state.get("nextRunAtMs") + now = int(time.time() * 1000) + if next_ms and next_ms >= now: + human = f"一次性 · {datetime.fromtimestamp(next_ms/1000, tz=ZoneInfo(tz)).strftime('%Y-%m-%d %H:%M')}" + else: + human = "一次性 · 已过期" + next_ms = None + else: + human = raw or kind + next_ms = state.get("nextRunAtMs") + + kind_t, label, label_short = _target_labels(job.get("sessionTarget")) + delivery = job.get("delivery") or {} + + last_run = None + lr_status = state.get("lastRunStatus") or state.get("lastStatus") + if state.get("lastRunAtMs") or lr_status: + raw_status = (lr_status or "").lower() + if raw_status == "error": + mapped = "failed" + elif raw_status in ("ok", "success"): + mapped = "ok" + elif raw_status == "running": + mapped = "running" + elif raw_status == "skipped": + mapped = "ok" # skipped shown as neutral; disabled state carries the off signal + else: + mapped = raw_status or "ok" + last_run = { + "status": mapped, + "startedAt": state.get("lastRunAtMs"), + "durationMs": state.get("lastDurationMs"), + "resultDetail": state.get("lastDiagnosticSummary"), + } + if state.get("lastError") and mapped == "failed": + # Trim: lastError tends to be long; UI has a small cell. + err = str(state["lastError"]) + if len(err) > 80: + err = err[:80] + "…" + last_run["resultDetail"] = err + + return { + "id": job.get("id"), + "name": job.get("name") or job.get("id"), + "agent": job.get("agentId") or "?", + "enabled": bool(job.get("enabled", True)), + "schedule": { + "kind": kind, + "human": human, + "tz": tz, + "raw": raw, + "nextRunAt": next_ms, + "nextRunHuman": _human_next(next_ms, tz), + }, + "target": {"kind": kind_t, "label": label, "labelShort": label_short}, + "delivery": {"mode": delivery.get("mode") or "none", "to": delivery.get("to")}, + "lastRun": last_run, + } + + +# ─── public API ────────────────────────────────────────────────────── + +def list_workflows() -> dict: + """Return {jobs: [NormalizedJob, ...]} for the front-end.""" + data = _run_cli(["cron", "list", "--json"]) + raw_jobs = data.get("jobs") if isinstance(data, dict) else None + if not isinstance(raw_jobs, list): + return {"jobs": []} + out = [] + for j in raw_jobs: + try: + out.append(_normalize(j)) + except Exception as e: # keep the response alive if one job is malformed + out.append({ + "id": j.get("id"), + "name": j.get("name") or "(malformed)", + "agent": j.get("agentId") or "?", + "enabled": bool(j.get("enabled", False)), + "schedule": {"kind": "cron", "human": "(parse error)", "tz": "Asia/Shanghai", + "raw": str(e)[:80], "nextRunAt": None, "nextRunHuman": "—"}, + "target": {"kind": "isolated", "label": "?", "labelShort": "?"}, + "delivery": {"mode": "none"}, + "lastRun": None, + }) + return {"jobs": out} + + +def run_now(job_id: str) -> dict: + """Trigger an immediate run for a single job. + + Returns {"ok": True} on success; raises RuntimeError otherwise. + """ + if not job_id or not isinstance(job_id, str): + raise RuntimeError("job id required") + _run_cli(["cron", "run", "--id", job_id]) + return {"ok": True} diff --git a/server.py b/server.py index a9f91db..43b5256 100644 --- a/server.py +++ b/server.py @@ -730,6 +730,14 @@ def do_GET(self): self._json(forge_employees.list_employees()) return + if path == "/api/workflows": + try: + import forge_workflows + self._json(forge_workflows.list_workflows()) + except Exception as e: # noqa: BLE001 + self._json({"error": str(e), "jobs": []}, 500) + return + if path == "/api/config": # V1.0.0 §4.3: front-end pulls this at boot to learn the # webchat base URL (employee-avatar deep-links). Cheap GET, @@ -1019,6 +1027,17 @@ def do_POST(self): self.send_error(401, "auth required for non-local host") return + # Workflows (issue #115): trigger an immediate cron run. + m = re.match(r"^/api/workflows/([A-Za-z0-9._-]+)/run$", url.path) + if m: + try: + import forge_workflows + res = forge_workflows.run_now(m.group(1)) + self._json(res) + except Exception as e: # noqa: BLE001 + self._json({"error": str(e)}, 500) + return + # PRD v1.2 follow-up (judy review #2 of PR #53): observable v0.7 # chip usage. Bumped by web/app.js renderBody when a v0.7-shaped # [[root/name.md]] chip is rendered. This is the *only* path that diff --git a/web/app.js b/web/app.js index b8df43f..cb2602e 100644 --- a/web/app.js +++ b/web/app.js @@ -3760,6 +3760,7 @@ Promise.all([loadWebchatBase(), loadEmployeeSet()]).finally(() => { (function () { const homeView = document.getElementById('home-view'); const filesView = document.getElementById('files-view'); + const workflowsView = document.getElementById('workflows-view'); const items = Array.from(document.querySelectorAll('.icon-rail-item')); const toastEl = document.getElementById('toast'); @@ -3780,6 +3781,7 @@ Promise.all([loadWebchatBase(), loadEmployeeSet()]).finally(() => { filesView.hidden = true; if (agentsView) agentsView.hidden = true; if (activityView) activityView.hidden = true; + if (workflowsView) workflowsView.hidden = true; }; if (view === 'files') { hideAll(); @@ -3792,10 +3794,15 @@ Promise.all([loadWebchatBase(), loadEmployeeSet()]).finally(() => { hideAll(); if (activityView) activityView.hidden = false; if (window.__forgeActivityActivate) window.__forgeActivityActivate(); + } else if (view === 'workflows') { + hideAll(); + if (workflowsView) workflowsView.hidden = false; + if (window.__forgeWorkflowsActivate) window.__forgeWorkflowsActivate(); } else { hideAll(); homeView.hidden = false; if (window.__forgeActivityDeactivate) window.__forgeActivityDeactivate(); + if (window.__forgeWorkflowsDeactivate) window.__forgeWorkflowsDeactivate(); } } @@ -3835,6 +3842,10 @@ Promise.all([loadWebchatBase(), loadEmployeeSet()]).finally(() => { if (document.body.classList.contains('is-popout')) { document.body.classList.remove('is-popout'); } + if (h.startsWith('#/workflows')) { + setActive('workflows'); + return; + } if (h.startsWith('#/activity')) { setActive('activity'); const m = h.match(/^#\/activity\/(th_[A-Za-z0-9_]+)$/); @@ -3896,6 +3907,7 @@ Promise.all([loadWebchatBase(), loadEmployeeSet()]).finally(() => { if (v === 'files') location.hash = '#/files'; else if (v === 'agents') location.hash = '#/agents'; else if (v === 'activity') location.hash = '#/activity'; + else if (v === 'workflows') location.hash = '#/workflows'; else location.hash = '#/squads'; } else { toast('「' + (it.querySelector('.label')?.textContent || v) + '」敬请期待'); @@ -5937,3 +5949,357 @@ Promise.all([loadWebchatBase(), loadEmployeeSet()]).finally(() => { if (!collapsedBtn || !realBtn) return; collapsedBtn.addEventListener('click', () => realBtn.click()); })(); + +/* ─── Workflows tab (v0.1 UI shell) ─── */ +/* See issue #115. Talks to /api/workflows (list) and + * /api/workflows//run (POST). 30s poll while view is active. */ +(function workflowsView() { + const view = document.getElementById('workflows-view'); + if (!view) return; + const tbody = view.querySelector('#wf-tbody'); + const sidebar = view.querySelector('#wf-sidebar-body'); + const sidebarEmpty = view.querySelector('#wf-sidebar-empty'); + const sidebarErr = view.querySelector('#wf-sidebar-error'); + const sidebarRetry = view.querySelector('#wf-sidebar-retry'); + const sidebarCount = view.querySelector('#wf-header-count'); + const chipsWrap = view.querySelector('#wf-chips'); + const searchInput = view.querySelector('#wf-search'); + const tableEmpty = view.querySelector('#wf-table-empty'); + const tableLoading = view.querySelector('#wf-table-loading'); + const btnRefresh = view.querySelector('#wf-btn-refresh'); + const health = { + ok: view.querySelector('.wf-tile-ok'), + err: view.querySelector('.wf-tile-err'), + warn: view.querySelector('.wf-tile-warn'), + upcoming: view.querySelector('.wf-tile-neutral'), + }; + + let jobs = []; + let filter = 'all'; // all | on | off | failed | agent: + let query = ''; + let selectedId = null; + let pollTimer = null; + let inflight = null; + let active = false; + + function fmtDuration(ms) { + if (ms == null || ms < 0) return ''; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s - m * 60; + if (m < 60) return rs ? `${m}m ${rs}s` : `${m}m`; + const h = Math.floor(m / 60); + const rm = m - h * 60; + return rm ? `${h}h ${rm}m` : `${h}h`; + } + function fmtRelPast(ms) { + if (!ms) return ''; + const delta = Date.now() - ms; + if (delta < 0) return 'in the future'; + const s = Math.floor(delta / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) { + const rm = m - h * 60; + return rm ? `${h}h ${rm}m ago` : `${h}h ago`; + } + const d = Math.floor(h / 24); + return `${d}d ago`; + } + function fmtRelFuture(ms) { + if (!ms) return ''; + const delta = ms - Date.now(); + if (delta < 0) return ''; + const s = Math.floor(delta / 1000); + if (s < 60) return `in ${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `in ${m}m`; + const h = Math.floor(m / 60); + const rm = m - h * 60; + if (h < 24) return rm ? `in ${h}h ${rm}m` : `in ${h}h`; + const d = Math.floor(h / 24); + return `in ${d}d`; + } + + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + function jobStatus(j) { + // 'failing' | 'running' | 'ok' | 'off' + if (!j.enabled) return 'off'; + if (j.lastRun && j.lastRun.status === 'running') return 'running'; + if (j.lastRun && j.lastRun.status === 'failed') return 'failing'; + return 'ok'; + } + + function computeHealth() { + const now = Date.now(); + const sixH = now + 6 * 3600 * 1000; + let ok = 0, err = 0, running = 0; + const errList = []; + const runList = []; + const upcoming = []; + jobs.forEach(j => { + if (j.lastRun && j.lastRun.status === 'ok' && j.lastRun.startedAt && (now - j.lastRun.startedAt) < 24 * 3600 * 1000) ok++; + if (j.lastRun && j.lastRun.status === 'failed' && j.lastRun.startedAt && (now - j.lastRun.startedAt) < 24 * 3600 * 1000) { + err++; + errList.push(j); + } + if (j.lastRun && j.lastRun.status === 'running') { + running++; + runList.push(j); + } + if (j.enabled && j.schedule.nextRunAt && j.schedule.nextRunAt > now && j.schedule.nextRunAt <= sixH) { + upcoming.push(j); + } + }); + upcoming.sort((a, b) => a.schedule.nextRunAt - b.schedule.nextRunAt); + setTile(health.ok, `${ok} runs`, ok ? `succeeded today` : ''); + const errSub = errList.slice(0, 2).map(j => `${j.name} (${fmtRelPast(j.lastRun.startedAt)})`).join(' · ') || '—'; + setTile(health.err, `${err} runs`, errSub); + const runSub = runList.slice(0, 1).map(j => `${j.name} · elapsed ${fmtDuration(now - j.lastRun.startedAt)}`).join('') || '—'; + setTile(health.warn, `${running} run${running === 1 ? '' : 's'}`, runSub); + const upSub = upcoming[0] ? `next ${new Date(upcoming[0].schedule.nextRunAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })} · ${upcoming[0].name}` : '—'; + setTile(health.upcoming, `${upcoming.length} runs`, upSub); + } + function setTile(el, v, sub) { + if (!el) return; + const vEl = el.querySelector('.wf-tile-v'); + const subEl = el.querySelector('.wf-tile-sub'); + if (vEl) vEl.textContent = v; + if (subEl) subEl.textContent = sub || ''; + } + + function renderChips() { + if (!chipsWrap) return; + const total = jobs.length; + const on = jobs.filter(j => j.enabled).length; + const off = total - on; + const failed = jobs.filter(j => jobStatus(j) === 'failing').length; + const agents = Array.from(new Set(jobs.map(j => j.agent))).sort(); + const chips = [ + ['all', `All · ${total}`], + ['on', `On · ${on}`], + ['off', `Off · ${off}`], + ['failed', `Failing · ${failed}`], + ]; + agents.forEach(a => chips.push([`agent:${a}`, `agent: ${a}`])); + chipsWrap.innerHTML = chips.map(([k, label]) => + `` + ).join(''); + chipsWrap.querySelectorAll('.wf-chip').forEach(btn => { + btn.addEventListener('click', () => { + filter = btn.dataset.chip; + renderChips(); + renderTable(); + }); + }); + } + + function jobPassesFilter(j) { + if (query) { + const q = query.toLowerCase(); + if (!(j.name.toLowerCase().includes(q) || (j.agent || '').toLowerCase().includes(q))) return false; + } + if (filter === 'all') return true; + if (filter === 'on') return j.enabled; + if (filter === 'off') return !j.enabled; + if (filter === 'failed') return jobStatus(j) === 'failing'; + if (filter.startsWith('agent:')) return j.agent === filter.slice(6); + return true; + } + + function statusPill(j) { + const st = jobStatus(j); + const lr = j.lastRun; + if (st === 'off') return `◯ disabled`; + if (st === 'running') { + const started = lr && lr.startedAt ? new Date(lr.startedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '—'; + return `● running · started ${escapeHtml(started)}`; + } + if (st === 'failing') { + return `✕ failed · ${escapeHtml(fmtRelPast(lr && lr.startedAt))}`; + } + if (lr && lr.startedAt) { + return `✓ ok · ${escapeHtml(fmtRelPast(lr.startedAt))}`; + } + return `— no runs yet`; + } + + function lastRunSub(j) { + const lr = j.lastRun; + if (!lr) return '—'; + if (lr.status === 'running') return `elapsed ${fmtDuration(Date.now() - lr.startedAt)}`; + if (lr.status === 'failed') { + const bits = []; + if (lr.exitCode != null) bits.push(`exit ${lr.exitCode}`); + if (lr.resultDetail) bits.push(lr.resultDetail); + if (lr.durationMs != null) bits.push(`duration ${fmtDuration(lr.durationMs)}`); + return bits.join(' · ') || '—'; + } + const bits = []; + if (lr.resultDetail) bits.push(lr.resultDetail); + if (lr.durationMs != null) bits.push(`duration ${fmtDuration(lr.durationMs)}`); + return bits.join(' · ') || '—'; + } + + function renderRow(j) { + const st = jobStatus(j); + const cls = []; + if (st === 'failing') cls.push('is-failing'); + if (!j.enabled) cls.push('is-disabled'); + const actions = st === 'running' + ? `` + : ``; + const del = (j.schedule.kind === 'at' && j.schedule.nextRunAt == null) + ? `` + : ''; + return ` + + ${escapeHtml(j.name)}
${escapeHtml(j.agent)}${escapeHtml(j.target.label)} · ${escapeHtml(j.delivery.mode)}${j.delivery.to ? ' → ' + escapeHtml(j.delivery.to) : ''}
+ ${escapeHtml(j.schedule.human)}${escapeHtml(j.schedule.tz)}${escapeHtml(j.schedule.raw)} + ${statusPill(j)}
${escapeHtml(lastRunSub(j))}
+ ${escapeHtml(j.schedule.nextRunHuman || '—')} +
+ ${actions} + + + ${del} +
+ `; + } + + function renderTable() { + if (!tbody) return; + const filtered = jobs.filter(jobPassesFilter); + tbody.innerHTML = filtered.map(renderRow).join(''); + if (tableEmpty) tableEmpty.hidden = filtered.length > 0; + tbody.querySelectorAll('button[data-act="run"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const tr = btn.closest('tr'); + const id = tr && tr.dataset.id; + if (!id) return; + btn.disabled = true; + try { + const resp = await fetch(`/api/workflows/${encodeURIComponent(id)}/run`, { method: 'POST' }); + const j = await resp.json().catch(() => ({})); + if (!resp.ok || j.error) { + if (typeof toast === 'function') toast('Run failed: ' + (j.error || resp.status)); + } else { + if (typeof toast === 'function') toast('Triggered ▶'); + } + } catch (err) { + if (typeof toast === 'function') toast('Run failed: ' + err.message); + } finally { + btn.disabled = false; + } + // 3s / 6s / 12s poll for status updates + setTimeout(() => refresh(true), 3000); + setTimeout(() => refresh(true), 6000); + setTimeout(() => refresh(true), 12000); + }); + }); + } + + function renderSidebar() { + if (!sidebar) return; + if (sidebarCount) { + const on = jobs.filter(j => j.enabled).length; + sidebarCount.textContent = `${jobs.length} · ${on} on`; + } + const failing = jobs.filter(j => jobStatus(j) === 'failing'); + const running = jobs.filter(j => jobStatus(j) === 'running'); + const others = jobs.filter(j => !failing.includes(j) && !running.includes(j)); + const chunk = (title, list, extraCls) => + list.length + ? `
${escapeHtml(title)} · ${list.length}
` + + list.map(j => { + const dot = jobStatus(j); + const dotCls = dot === 'failing' ? 'wf-dot-err' : dot === 'running' ? 'wf-dot-warn' : dot === 'off' ? 'wf-dot-off' : 'wf-dot-ok'; + return `
${escapeHtml(j.name)}${escapeHtml(j.agent)}
`; + }).join('') + : ''; + const html = chunk('Failing today', failing) + chunk('Running now', running) + chunk('All workflows', others); + sidebar.innerHTML = html; + if (sidebarEmpty) sidebarEmpty.hidden = jobs.length > 0; + sidebar.querySelectorAll('.wf-cl-row').forEach(row => { + row.addEventListener('click', () => { + selectedId = row.dataset.id; + sidebar.querySelectorAll('.wf-cl-row').forEach(r => r.classList.toggle('is-active', r === row)); + query = ''; + if (searchInput) searchInput.value = ''; + const job = jobs.find(j => j.id === selectedId); + if (job) { + filter = `agent:${job.agent}`; + renderChips(); + renderTable(); + // Scroll matching row into view. + const tr = tbody && tbody.querySelector(`tr[data-id="${CSS.escape(selectedId)}"]`); + if (tr) tr.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }); + }); + } + + async function refresh(quiet) { + if (inflight) return; + if (!quiet && tableLoading && !jobs.length) tableLoading.hidden = false; + if (sidebarErr) sidebarErr.hidden = true; + inflight = fetch('/api/workflows').then(r => r.json()).then(data => { + jobs = Array.isArray(data.jobs) ? data.jobs : []; + if (tableLoading) tableLoading.hidden = true; + renderChips(); + renderSidebar(); + renderTable(); + computeHealth(); + }).catch(err => { + if (tableLoading) tableLoading.hidden = true; + if (sidebarErr) sidebarErr.hidden = false; + console.warn('[workflows] load failed', err); + }).finally(() => { + inflight = null; + }); + return inflight; + } + + if (searchInput) { + searchInput.addEventListener('input', () => { + query = searchInput.value.trim(); + renderTable(); + }); + } + if (btnRefresh) btnRefresh.addEventListener('click', () => refresh()); + if (sidebarRetry) sidebarRetry.addEventListener('click', () => refresh()); + view.querySelectorAll('.wf-tile').forEach(tile => { + tile.addEventListener('click', () => { + const f = tile.dataset.filter; + if (f === 'failed') filter = 'failed'; + else if (f === 'ok') filter = 'all'; + else if (f === 'running') filter = 'all'; + else if (f === 'upcoming') filter = 'on'; + view.querySelectorAll('.wf-tile').forEach(t => t.classList.toggle('is-filtered', t === tile)); + renderChips(); + renderTable(); + }); + }); + + function activate() { + if (active) return; + active = true; + refresh(); + pollTimer = setInterval(() => { if (active && !document.hidden) refresh(true); }, 30000); + } + function deactivate() { + active = false; + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + } + window.__forgeWorkflowsActivate = activate; + window.__forgeWorkflowsDeactivate = deactivate; +})(); diff --git a/web/index.html b/web/index.html index 22d8cb8..76943f5 100644 --- a/web/index.html +++ b/web/index.html @@ -62,8 +62,8 @@ -

+ +
+
+
+

Workflows

+
定时任务 · 数据源 ~/.openclaw/cron/jobs.json(v0.1 UI 壳,不改 runtime)
+
+
+ + +
+
+
+
Run activity · today
+
+
Succeeded
— runs
+
Failed
— runs
+
Running
— runs
+
Upcoming · next 6h
— runs
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
NameScheduleLast runNextActions
+ + +
+
+ ok + running + failed + disabled + hover schedule 单元格看原 cron 表达式 +
+
+ +