Changeset 0.37.15 (#227)
This commit is contained in:
48
CHANGELOG.md
48
CHANGELOG.md
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [0.37.15.0] — 2026-03-23
|
||||
|
||||
### Summary
|
||||
|
||||
Workflow Ownership & Lifecycle (CS1–CS5). Adds instance cancel with assignment
|
||||
cascade, assignment unclaim/reassign/cancel operations, full FE API domain
|
||||
wiring for stage CRUD and assignments, team-admin workflows rewrite with
|
||||
tabbed layout (Workflows + Stage Editor | Assignments Queue | Instance Monitor),
|
||||
and settings assignments view replacing the read-only workflow list.
|
||||
|
||||
### Added
|
||||
|
||||
- **Instance cancel:** `POST /channels/:id/workflow/cancel` sets
|
||||
`workflow_status='cancelled'` and cascades to all open assignments. Auth:
|
||||
instance owner, team admin, or global admin. Team-scoped variant at
|
||||
`POST /teams/:teamId/workflows/monitor/instances/:channelId/cancel`.
|
||||
(`server/handlers/workflow_instances.go`, `server/store/*/channel.go`)
|
||||
- **Assignment unclaim:** `POST /workflow-assignments/:id/unclaim` returns
|
||||
claimed assignment to unassigned. Auth: claimer or team admin.
|
||||
(`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
|
||||
- **Assignment reassign:** `POST /workflow-assignments/:id/reassign` changes
|
||||
`assigned_to` on claimed assignment. Auth: team admin.
|
||||
(`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
|
||||
- **Assignment cancel:** `POST /workflow-assignments/:id/cancel` cancels a
|
||||
single assignment. Auth: team admin.
|
||||
(`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
|
||||
- **FE API domains:** `workflowAssignments` domain (mine, claim, unclaim,
|
||||
complete, reassign, cancel, comment). Team domain additions: assignments,
|
||||
workflowInstances, cancelWorkflowInstance, stage CRUD (workflowStages,
|
||||
createWorkflowStage, updateWorkflowStage, deleteWorkflowStage,
|
||||
reorderWorkflowStages). (`src/js/sw/sdk/api-domains.js`)
|
||||
- **Team-admin workflows tabs:** Tabbed layout (Workflows | Assignments |
|
||||
Monitor). Workflows tab includes inline stage editor with add/edit/delete.
|
||||
Assignments tab shows claimed + available queue with claim/release/complete
|
||||
actions and WS live updates. Monitor tab shows active instances with cancel.
|
||||
(`src/js/sw/surfaces/team-admin/workflows.js`)
|
||||
- **Settings assignments:** Replaces read-only workflow list with personal
|
||||
assignment queue across all teams. Shows claimed and available assignments
|
||||
with claim/release/open/complete actions.
|
||||
(`src/js/sw/surfaces/settings/workflows.js`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings sidebar label "Workflows" → "Assignments".
|
||||
(`src/js/sw/surfaces/settings/index.js`)
|
||||
|
||||
---
|
||||
|
||||
## [0.37.14.23] — 2026-03-23
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -32,11 +32,13 @@ build_package() {
|
||||
local out="$DIST_DIR/${name}.pkg"
|
||||
rm -f "$out"
|
||||
|
||||
# Zip whichever standard dirs exist alongside manifest.json
|
||||
# Zip whichever standard dirs/files exist alongside manifest.json
|
||||
local dirs=""
|
||||
[ -d "$dir/js" ] && dirs="$dirs js/"
|
||||
[ -d "$dir/css" ] && dirs="$dirs css/"
|
||||
[ -d "$dir/assets" ] && dirs="$dirs assets/"
|
||||
[ -f "$dir/script.star" ] && dirs="$dirs script.star"
|
||||
[ -d "$dir/migrations" ] && dirs="$dirs migrations/"
|
||||
|
||||
(cd "$dir" && zip -qr "$out" manifest.json $dirs)
|
||||
|
||||
|
||||
224
packages/team-activity-log/css/main.css
Normal file
224
packages/team-activity-log/css/main.css
Normal file
@@ -0,0 +1,224 @@
|
||||
/* Team Activity Log — Surface Styles
|
||||
* Uses platform CSS variables exclusively for theming.
|
||||
*/
|
||||
|
||||
.tal-shell {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────── */
|
||||
|
||||
.tal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tal-header__left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tal-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Entry Form ────────────────────────────── */
|
||||
|
||||
.tal-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.tal-form__select {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tal-form__input {
|
||||
flex: 1;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.tal-form__input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tal-form__input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.tal-form__btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.tal-form__btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.tal-form__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Filters ───────────────────────────────── */
|
||||
|
||||
.tal-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tal-filter {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-2);
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.tal-filter:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tal-filter.active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tal-count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Entry List ────────────────────────────── */
|
||||
|
||||
.tal-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tal-entry {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.tal-entry:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.tal-entry__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tal-entry__category {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tal-entry__user {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tal-entry__time {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tal-entry__delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.tal-entry:hover .tal-entry__delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tal-entry__delete:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tal-entry__message {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Empty / Loading ───────────────────────── */
|
||||
|
||||
.tal-empty,
|
||||
.tal-loading {
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
221
packages/team-activity-log/js/main.js
Normal file
221
packages/team-activity-log/js/main.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Team Activity Log — Surface Entry Point
|
||||
*
|
||||
* Demonstrates:
|
||||
* - SDK boot + sw.* API access
|
||||
* - Preact rendering into #extension-mount
|
||||
* - Calling Starlark-backed ext API routes (/s/:slug/api/*)
|
||||
* - sw.toast, sw.auth, sw.on (WS event subscription)
|
||||
* - sw.userMenu for shell integration
|
||||
* - Platform CSS variables for theming
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
var mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
// ── Boot SDK (extension surfaces don't auto-load it) ──
|
||||
var base = window.__BASE__ || '';
|
||||
var ver = window.__VERSION__ || '0';
|
||||
var slug = (window.__MANIFEST__ || {}).id || 'team-activity-log';
|
||||
|
||||
try {
|
||||
if (!window.preact) {
|
||||
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||||
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||||
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||||
window.preact = { h, render };
|
||||
window.hooks = hooksModule;
|
||||
window.html = htmModule.default.bind(h);
|
||||
}
|
||||
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||||
await sdk.boot();
|
||||
} catch (e) {
|
||||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var { html } = window;
|
||||
var { useState, useEffect, useCallback, useRef } = hooks;
|
||||
var { render } = preact;
|
||||
|
||||
// ── API helper: call Starlark backend ──
|
||||
var API_BASE = base + '/s/' + slug + '/api';
|
||||
|
||||
async function api(method, path, body) {
|
||||
var token = sw.auth._getToken();
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
var resp = await fetch(API_BASE + path, opts);
|
||||
var text = await resp.text();
|
||||
try { return JSON.parse(text); } catch (_) { return { _raw: text, _status: resp.status }; }
|
||||
}
|
||||
|
||||
// ── Components ─────────────────────────────
|
||||
|
||||
var CATEGORIES = ['note', 'update', 'decision', 'action-item'];
|
||||
|
||||
function App() {
|
||||
var [entries, setEntries] = useState([]);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var [filter, setFilter] = useState('');
|
||||
var menuRef = useRef(null);
|
||||
|
||||
var load = useCallback(async function () {
|
||||
var path = '/entries' + (filter ? '?category=' + encodeURIComponent(filter) : '');
|
||||
var d = await api('GET', path);
|
||||
setEntries(d.data || []);
|
||||
setLoading(false);
|
||||
}, [filter]);
|
||||
|
||||
useEffect(function () { load(); }, [load]);
|
||||
|
||||
// Mount user menu
|
||||
useEffect(function () {
|
||||
if (menuRef.current && sw.userMenu) {
|
||||
sw.userMenu(menuRef.current, { placement: 'down-right' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function addEntry(category, message) {
|
||||
var user = sw.auth.user;
|
||||
var result = await api('POST', '/entries', {
|
||||
category: category,
|
||||
message: message,
|
||||
username: user ? (user.display_name || user.username) : '',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
if (result.error) {
|
||||
sw.toast(result.error, 'error');
|
||||
return;
|
||||
}
|
||||
sw.toast('Entry added', 'success');
|
||||
load();
|
||||
}
|
||||
|
||||
async function deleteEntry(id) {
|
||||
await api('DELETE', '/entries/' + id);
|
||||
sw.toast('Entry deleted', 'info');
|
||||
load();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="tal-shell">
|
||||
<header class="tal-header">
|
||||
<div class="tal-header__left">
|
||||
<h1 class="tal-title">Activity Log</h1>
|
||||
<span class="tal-subtitle">Team activity tracker</span>
|
||||
</div>
|
||||
<div class="tal-header__right">
|
||||
<div ref=${menuRef}></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<${EntryForm} onSubmit=${addEntry} />
|
||||
|
||||
<div class="tal-filters">
|
||||
<button class="tal-filter ${filter === '' ? 'active' : ''}"
|
||||
onClick=${() => setFilter('')}>All</button>
|
||||
${CATEGORIES.map(c => html`
|
||||
<button key=${c}
|
||||
class="tal-filter ${filter === c ? 'active' : ''}"
|
||||
onClick=${() => setFilter(c)}>
|
||||
${c}
|
||||
</button>
|
||||
`)}
|
||||
<span class="tal-count">${entries.length} entries</span>
|
||||
</div>
|
||||
|
||||
${loading
|
||||
? html`<div class="tal-loading">Loading…</div>`
|
||||
: html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function EntryForm({ onSubmit }) {
|
||||
var [message, setMessage] = useState('');
|
||||
var [category, setCategory] = useState('note');
|
||||
var [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
if (!message.trim()) return;
|
||||
setSubmitting(true);
|
||||
await onSubmit(category, message.trim());
|
||||
setMessage('');
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
return html`
|
||||
<form class="tal-form" onSubmit=${submit}>
|
||||
<select class="tal-form__select" value=${category}
|
||||
onChange=${e => setCategory(e.target.value)}>
|
||||
${CATEGORIES.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
|
||||
</select>
|
||||
<input class="tal-form__input" type="text"
|
||||
placeholder="What happened?"
|
||||
value=${message}
|
||||
onInput=${e => setMessage(e.target.value)}
|
||||
disabled=${submitting} />
|
||||
<button class="tal-form__btn" type="submit" disabled=${submitting || !message.trim()}>
|
||||
${submitting ? '…' : 'Log'}
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function EntryList({ entries, onDelete }) {
|
||||
if (!entries.length) {
|
||||
return html`<div class="tal-empty">No entries yet. Log your first activity above.</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="tal-entries">
|
||||
${entries.map(e => html`
|
||||
<div key=${e.id} class="tal-entry">
|
||||
<div class="tal-entry__header">
|
||||
<span class="tal-entry__category badge">${e.category}</span>
|
||||
<span class="tal-entry__user">${esc(e.username || 'unknown')}</span>
|
||||
<span class="tal-entry__time">${timeAgo(e.created_at)}</span>
|
||||
<button class="tal-entry__delete" onClick=${() => onDelete(e.id)}
|
||||
title="Delete">×</button>
|
||||
</div>
|
||||
<div class="tal-entry__message">${esc(e.message)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Utilities ──────────────────────────────
|
||||
|
||||
function esc(s) {
|
||||
var el = document.createElement('span');
|
||||
el.textContent = String(s || '');
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
var ms = Date.now() - new Date(iso).getTime();
|
||||
var sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
var min = Math.floor(sec / 60);
|
||||
if (min < 60) return min + 'm ago';
|
||||
var hr = Math.floor(min / 60);
|
||||
if (hr < 24) return hr + 'h ago';
|
||||
var d = Math.floor(hr / 24);
|
||||
return d + 'd ago';
|
||||
}
|
||||
|
||||
// ── Mount ──────────────────────────────────
|
||||
|
||||
render(html`<${App} />`, mount);
|
||||
console.log('[team-activity-log] Surface mounted');
|
||||
})();
|
||||
48
packages/team-activity-log/manifest.json
Normal file
48
packages/team-activity-log/manifest.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "team-activity-log",
|
||||
"title": "Team Activity Log",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"route": "/s/team-activity-log",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.1.0",
|
||||
"description": "Example surface demonstrating Starlark API routes, extension-owned DB tables, admin settings, and SDK integration.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["api.http", "db.read", "db.write"],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/entries"},
|
||||
{"method": "POST", "path": "/entries"},
|
||||
{"method": "DELETE", "path": "/entries/*"}
|
||||
],
|
||||
|
||||
"db_tables": [
|
||||
{
|
||||
"name": "entries",
|
||||
"columns": {
|
||||
"user_id": "text",
|
||||
"username": "text",
|
||||
"category": "text",
|
||||
"message": "text",
|
||||
"created_at": "text"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"settings": {
|
||||
"max_entries": {
|
||||
"type": "number",
|
||||
"label": "Max Entries",
|
||||
"description": "Maximum number of entries to retain (oldest are pruned).",
|
||||
"default": 500
|
||||
},
|
||||
"categories": {
|
||||
"type": "string",
|
||||
"label": "Categories (comma-separated)",
|
||||
"description": "Allowed activity categories. Leave blank for freeform.",
|
||||
"default": "note,update,decision,action-item"
|
||||
}
|
||||
}
|
||||
}
|
||||
186
packages/team-activity-log/script.star
Normal file
186
packages/team-activity-log/script.star
Normal file
@@ -0,0 +1,186 @@
|
||||
# Team Activity Log — Starlark Backend
|
||||
#
|
||||
# Handles:
|
||||
# GET /entries → list (optional ?category= filter)
|
||||
# POST /entries → create
|
||||
# DELETE /entries/:id → delete
|
||||
#
|
||||
# NOTE: The sandbox has no json module. Request body arrives as a raw
|
||||
# string; response body must be a raw string. Helper functions build
|
||||
# JSON manually. A json.encode/decode module would clean this up —
|
||||
# tracked as a sandbox improvement.
|
||||
|
||||
def on_request(req):
|
||||
method = req["method"]
|
||||
path = req["path"]
|
||||
|
||||
if method == "GET" and path == "/entries":
|
||||
return _list_entries(req)
|
||||
elif method == "POST" and path == "/entries":
|
||||
return _create_entry(req)
|
||||
elif method == "DELETE" and path.startswith("/entries/"):
|
||||
entry_id = path[len("/entries/"):]
|
||||
return _delete_entry(req, entry_id)
|
||||
|
||||
return {"status": 404, "body": '{"error":"not found"}'}
|
||||
|
||||
|
||||
def _list_entries(req):
|
||||
category = ""
|
||||
q = req.get("query", None)
|
||||
if q != None:
|
||||
cv = q.get("category", None)
|
||||
if cv != None:
|
||||
category = str(cv)
|
||||
|
||||
if category:
|
||||
rows = db.query("entries", filters={"category": category}, order="-created_at", limit=200)
|
||||
else:
|
||||
rows = db.query("entries", order="-created_at", limit=200)
|
||||
|
||||
parts = []
|
||||
for row in rows:
|
||||
parts.append(_entry_json(row))
|
||||
return {"status": 200, "body": '{"data":[' + ",".join(parts) + "]}"}
|
||||
|
||||
|
||||
def _create_entry(req):
|
||||
body = _parse_body(str(req.get("body", "")))
|
||||
if not body:
|
||||
return {"status": 400, "body": '{"error":"JSON body required"}'}
|
||||
|
||||
message = body.get("message", "")
|
||||
if not message:
|
||||
return {"status": 400, "body": '{"error":"message is required"}'}
|
||||
|
||||
category = body.get("category", "note")
|
||||
username = body.get("username", "")
|
||||
created_at = body.get("created_at", "")
|
||||
user_id = str(req.get("user_id", ""))
|
||||
|
||||
# Validate category against admin settings
|
||||
allowed = _allowed_categories()
|
||||
if allowed and category not in allowed:
|
||||
return {"status": 400, "body": '{"error":"invalid category: ' + _esc(category) + '"}'}
|
||||
|
||||
row_id = db.insert("entries", {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"category": category,
|
||||
"message": message,
|
||||
"created_at": created_at,
|
||||
})
|
||||
|
||||
_prune()
|
||||
|
||||
return {"status": 201, "body": _entry_json({
|
||||
"id": str(row_id), "user_id": user_id, "username": username,
|
||||
"category": category, "message": message, "created_at": created_at,
|
||||
})}
|
||||
|
||||
|
||||
def _delete_entry(req, entry_id):
|
||||
if not entry_id:
|
||||
return {"status": 400, "body": '{"error":"entry id required"}'}
|
||||
db.delete("entries", entry_id)
|
||||
return {"status": 200, "body": '{"deleted":true,"id":"' + _esc(entry_id) + '"}'}
|
||||
|
||||
|
||||
# ── Settings helpers ─────────────────────────
|
||||
|
||||
def _allowed_categories():
|
||||
raw = settings.get("categories")
|
||||
if not raw:
|
||||
return []
|
||||
return [p.strip() for p in raw.split(",") if p.strip()]
|
||||
|
||||
|
||||
def _prune():
|
||||
max_raw = settings.get("max_entries")
|
||||
if not max_raw:
|
||||
return
|
||||
limit = int(max_raw)
|
||||
if limit <= 0:
|
||||
return
|
||||
rows = db.query("entries", order="-created_at", limit=limit + 50)
|
||||
if len(rows) > limit:
|
||||
for row in rows[limit:]:
|
||||
db.delete("entries", row["id"])
|
||||
|
||||
|
||||
# ── JSON helpers (no json module in sandbox) ─
|
||||
|
||||
def _esc(s):
|
||||
return str(s).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
def _entry_json(e):
|
||||
return (
|
||||
'{"id":"' + _esc(e.get("id", ""))
|
||||
+ '","user_id":"' + _esc(e.get("user_id", ""))
|
||||
+ '","username":"' + _esc(e.get("username", ""))
|
||||
+ '","category":"' + _esc(e.get("category", ""))
|
||||
+ '","message":"' + _esc(e.get("message", ""))
|
||||
+ '","created_at":"' + _esc(e.get("created_at", ""))
|
||||
+ '"}'
|
||||
)
|
||||
|
||||
def _parse_body(raw):
|
||||
"""Minimal flat-object JSON parser. Handles {"key":"value"} payloads."""
|
||||
s = raw.strip()
|
||||
if not s or s[0] != "{":
|
||||
return None
|
||||
s = s[1:]
|
||||
if s and s[-1] == "}":
|
||||
s = s[:-1]
|
||||
|
||||
result = {}
|
||||
in_str = False
|
||||
escape = False
|
||||
current = ""
|
||||
pairs = []
|
||||
|
||||
for ch in s.elems():
|
||||
if escape:
|
||||
current += ch
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
current += ch
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
current += ch
|
||||
in_str = not in_str
|
||||
elif ch == "," and not in_str:
|
||||
pairs.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current.strip():
|
||||
pairs.append(current.strip())
|
||||
|
||||
for pair in pairs:
|
||||
colon = -1
|
||||
in_s = False
|
||||
esc = False
|
||||
for i in range(len(pair)):
|
||||
c = pair[i]
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_s = not in_s
|
||||
elif c == ":" and not in_s:
|
||||
colon = i
|
||||
break
|
||||
if colon < 0:
|
||||
continue
|
||||
key = _unquote(pair[:colon].strip())
|
||||
val = _unquote(pair[colon+1:].strip())
|
||||
if key:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
def _unquote(s):
|
||||
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
||||
return s[1:-1].replace('\\"', '"').replace("\\\\", "\\").replace("\\n", "\n")
|
||||
return s
|
||||
@@ -241,6 +241,33 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.37.15: Read script.star from archive if present.
|
||||
// Keeps the manifest clean — authors write a file, installer injects
|
||||
// it as _starlark_script for the sandbox runner.
|
||||
if _, hasInline := manifest["_starlark_script"]; !hasInline {
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
base := filepath.Base(name)
|
||||
if base == "script.star" && !f.FileInfo().IsDir() {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
script := string(data)
|
||||
if strings.TrimSpace(script) != "" {
|
||||
manifest["_starlark_script"] = script
|
||||
log.Printf("[packages] Injected script.star (%d bytes) into manifest", len(data))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
pkgID, _ := manifest["id"].(string)
|
||||
title, _ := manifest["title"].(string)
|
||||
|
||||
@@ -123,6 +123,169 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
// Unclaim returns a claimed assignment to unassigned.
|
||||
// POST /api/v1/workflow-assignments/:id/unclaim
|
||||
func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
// Auth: claimer or team admin or global admin
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil || a == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" && (a.AssignedTo == nil || *a.AssignedTo != userID) {
|
||||
isTA := false
|
||||
if h.stores.Teams != nil {
|
||||
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
||||
}
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only the claimer or a team admin can unclaim"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unclaim assignment"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Emit WS event
|
||||
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
||||
if h.hub != nil && channelID != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"assignment_id": assignmentID,
|
||||
"unclaimed_by": userID,
|
||||
"channel_id": channelID,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "workflow.unclaimed",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
||||
for _, uid := range pids {
|
||||
h.hub.PublishToUser(uid, evt)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"unclaimed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// Reassign changes the assigned_to on a claimed assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/reassign
|
||||
func (h *WorkflowAssignmentHandler) Reassign(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Auth: team admin or global admin
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil || a == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" {
|
||||
isTA := false
|
||||
if h.stores.Teams != nil {
|
||||
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
||||
}
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err := h.stores.Workflows.ReassignAssignment(c.Request.Context(), assignmentID, body.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reassign assignment"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Emit WS event
|
||||
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
||||
if h.hub != nil && channelID != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"assignment_id": assignmentID,
|
||||
"reassigned_by": userID,
|
||||
"new_assignee": body.UserID,
|
||||
"channel_id": channelID,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "workflow.reassigned",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
||||
for _, uid := range pids {
|
||||
h.hub.PublishToUser(uid, evt)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"reassigned": true, "assignment_id": assignmentID, "new_assignee": body.UserID})
|
||||
}
|
||||
|
||||
// CancelAssignment cancels a single assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/cancel
|
||||
func (h *WorkflowAssignmentHandler) CancelAssignment(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
// Auth: team admin or global admin
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil || a == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" {
|
||||
isTA := false
|
||||
if h.stores.Teams != nil {
|
||||
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
||||
}
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
n, err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel assignment"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in cancellable state"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// CommentOnAssignment adds a review comment to an assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/comment
|
||||
func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) {
|
||||
|
||||
@@ -302,6 +302,128 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Cancel (v0.37.15) ────────────────────────
|
||||
|
||||
// CancelInstance cancels a workflow instance and all its open assignments.
|
||||
// POST /api/v1/channels/:id/workflow/cancel
|
||||
func (h *WorkflowInstanceHandler) CancelInstance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
channelID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
workflowID, _, status, err := h.readWorkflowState(ctx, channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
if status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
|
||||
return
|
||||
}
|
||||
|
||||
// Auth: instance owner, team admin, or global admin
|
||||
if role != "admin" {
|
||||
ch, err := h.stores.Channels.GetByID(ctx, channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
isOwner := ch.UserID == userID
|
||||
isTeamAdmin := false
|
||||
if ch.TeamID != nil && h.stores.Teams != nil {
|
||||
isTeamAdmin, _ = h.stores.Teams.IsTeamAdmin(ctx, *ch.TeamID, userID)
|
||||
}
|
||||
if !isOwner && !isTeamAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only instance owner, team admin, or global admin can cancel"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the workflow instance
|
||||
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel all open assignments
|
||||
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
|
||||
|
||||
// Emit WS event
|
||||
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
|
||||
"channel_id": channelID,
|
||||
"workflow_id": workflowID,
|
||||
"cancelled_by": userID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cancelled": true,
|
||||
"channel_id": channelID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
}
|
||||
|
||||
// CancelTeamInstance cancels a workflow instance via the team-admin monitor.
|
||||
// POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
|
||||
func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
channelID := c.Param("channelId")
|
||||
userID := c.GetString("user_id")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Auth: team admin or global admin
|
||||
if role != "admin" {
|
||||
if h.stores.Teams == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
isTA, _ := h.stores.Teams.IsTeamAdmin(ctx, teamID, userID)
|
||||
if !isTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the channel belongs to this team
|
||||
ch, err := h.stores.Channels.GetByID(ctx, channelID)
|
||||
if err != nil || ch.TeamID == nil || *ch.TeamID != teamID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow instance not found for this team"})
|
||||
return
|
||||
}
|
||||
|
||||
_, _, status, err := h.readWorkflowState(ctx, channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
if status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
|
||||
|
||||
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
|
||||
"channel_id": channelID,
|
||||
"cancelled_by": userID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cancelled": true,
|
||||
"channel_id": channelID,
|
||||
"assignments_cancelled": cancelled,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {
|
||||
|
||||
@@ -712,6 +712,7 @@ func main() {
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||
protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15
|
||||
|
||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
|
||||
@@ -720,6 +721,9 @@ func main() {
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
|
||||
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
|
||||
protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim) // v0.37.15
|
||||
protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign) // v0.37.15
|
||||
protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15
|
||||
|
||||
// Tasks (v0.27.1, permissions v0.27.2)
|
||||
taskH := handlers.NewTaskHandler(stores)
|
||||
@@ -1086,6 +1090,10 @@ func main() {
|
||||
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
|
||||
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
|
||||
|
||||
// Team workflow instance cancel (v0.37.15)
|
||||
teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
|
||||
teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
teamTaskH := handlers.NewTaskHandler(stores)
|
||||
teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/}}
|
||||
|
||||
{{define "surface-team-admin"}}
|
||||
<div id="team-admin-mount" class="surface-team-admin" style="flex-direction:column;">
|
||||
<div id="team-admin-mount" class="surface-team-admin" style="height:100%;overflow:hidden;">
|
||||
<div class="settings-placeholder" style="padding:40px;text-align:center;">Loading team admin…</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -510,6 +510,9 @@ type ChannelStore interface {
|
||||
// CompleteWorkflow sets workflow_status='completed' and ai_mode='off'.
|
||||
CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error
|
||||
|
||||
// CancelWorkflow sets workflow_status='cancelled' and ai_mode='off'. (v0.37.15)
|
||||
CancelWorkflow(ctx context.Context, channelID string) error
|
||||
|
||||
// RejectWorkflowToStage resets current_stage (no stage_data change).
|
||||
RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error
|
||||
|
||||
|
||||
@@ -644,6 +644,15 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = $1
|
||||
WHERE id = $2 AND type = 'workflow'
|
||||
`, time.Now().UTC(), channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
|
||||
@@ -449,6 +449,56 @@ func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE channel_id = $1 AND status IN ('unassigned', 'claimed')
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
|
||||
WHERE id = $1 AND status = 'claimed'
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, claimed_at = $2
|
||||
WHERE id = $3 AND status = 'claimed'
|
||||
`, newUserID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE id = $1 AND status IN ('unassigned', 'claimed')
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
// Find least-recently-assigned team member
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
|
||||
@@ -645,6 +645,15 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = ?
|
||||
WHERE id = ? AND type = 'workflow'
|
||||
`, time.Now().UTC().Format(timeFmt), channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
|
||||
@@ -452,6 +452,56 @@ func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE channel_id = ? AND status IN ('unassigned', 'claimed')
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, claimed_at = ?
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, newUserID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE id = ? AND status IN ('unassigned', 'claimed')
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim
|
||||
|
||||
@@ -55,6 +55,23 @@ type WorkflowStore interface {
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
// CancelAssignmentsForChannel sets status='cancelled' on all
|
||||
// unassigned/claimed assignments for a channel.
|
||||
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
|
||||
|
||||
// UnclaimAssignment returns a claimed assignment to unassigned.
|
||||
// Returns rows affected (0 = not claimed or not found).
|
||||
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// ReassignAssignment changes assigned_to on a claimed assignment.
|
||||
// Returns rows affected.
|
||||
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
|
||||
|
||||
// CancelAssignment sets a single assignment to cancelled.
|
||||
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// ── Review Comments (v0.35.0) ──
|
||||
|
||||
// GetAssignmentByID returns a single assignment by ID.
|
||||
|
||||
@@ -248,6 +248,16 @@ export function createDomains(restClient) {
|
||||
updateWorkflow: (id, wfId, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}`, data),
|
||||
deleteWorkflow: (id, wfId) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}`),
|
||||
publishWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/publish`, {}),
|
||||
// Team workflow stages (v0.37.15 — FE wiring for existing BE routes)
|
||||
workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`),
|
||||
createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data),
|
||||
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
|
||||
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
|
||||
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
|
||||
// Team assignments + monitor (v0.37.15)
|
||||
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
|
||||
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
|
||||
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
|
||||
// Team tasks
|
||||
tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
|
||||
createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
|
||||
@@ -265,6 +275,19 @@ export function createDomains(restClient) {
|
||||
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
|
||||
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
|
||||
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
|
||||
cancel: (channelId) => rc.post(`/api/v1/channels/${channelId}/workflow/cancel`, {}),
|
||||
},
|
||||
|
||||
// ── 15b. Workflow Assignments (v0.37.15) ─
|
||||
workflowAssignments: {
|
||||
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
|
||||
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
|
||||
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
|
||||
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
|
||||
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
|
||||
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
|
||||
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
|
||||
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
|
||||
},
|
||||
|
||||
// ── 16. Tasks ──────────────────────────
|
||||
|
||||
@@ -45,7 +45,7 @@ const NAV_ITEMS = [
|
||||
{ key: 'personas', label: 'Personas', gate: 'personas' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'workflows', label: 'Assignments' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'gitkeys', label: 'Git Keys' },
|
||||
{ key: 'knowledge', label: 'Knowledge Bases' },
|
||||
@@ -64,7 +64,7 @@ const BYOK_ITEMS = [
|
||||
const SECTION_TITLES = {
|
||||
general: 'General', appearance: 'Appearance', models: 'Models',
|
||||
personas: 'Personas', profile: 'Profile', teams: 'Teams',
|
||||
workflows: 'Workflows', tasks: 'Tasks', gitkeys: 'Git Keys',
|
||||
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
||||
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
||||
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
||||
notifications: 'Notifications',
|
||||
|
||||
@@ -1,88 +1,138 @@
|
||||
/**
|
||||
* Settings > Workflows — read-only view of user's visible workflows
|
||||
* Settings > Workflows — v0.37.15 rewrite
|
||||
*
|
||||
* Replaces read-only workflow list with MyAssignments (E4).
|
||||
* Shows the user's personal assignment queue across all teams.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function _timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function WorkflowsSection() {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [assignments, setAssignments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [stages, setStages] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.workflows.list();
|
||||
setWorkflows(data || []);
|
||||
const data = await sw.api.workflowAssignments.mine();
|
||||
setAssignments(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(wf) {
|
||||
setDetail(wf);
|
||||
const userId = sw.auth?.user?.id;
|
||||
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === userId);
|
||||
const available = assignments.filter(a => a.status === 'unassigned');
|
||||
|
||||
async function claim(id) {
|
||||
try {
|
||||
const d = await sw.api.workflows.get(wf.id);
|
||||
setStages(d.stages || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
||||
await sw.api.workflowAssignments.claim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => { setDetail(null); setStages([]); }}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
<span class="badge ${detail.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${detail.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${detail.description && html`
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">${detail.description}</p>
|
||||
`}
|
||||
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;font-size:12px;color:var(--text-3);">
|
||||
<span>Slug: <strong>/${detail.slug}</strong></span>
|
||||
<span>Entry: <strong>${detail.entry_mode}</strong></span>
|
||||
<span>Version: <strong>${detail.version || 0}</strong></span>
|
||||
</div>
|
||||
|
||||
<h5 style="margin:0 0 8px;">Stages (${stages.length})</h5>
|
||||
<div class="admin-list">
|
||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||
${stages.map((s, i) => html`
|
||||
<div class="admin-surface-row" key=${s.id}>
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode}</span>
|
||||
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${mine.length > 0 && html`
|
||||
<h4 style="margin:0 0 8px;">Claimed (${mine.length})</h4>
|
||||
<div class="admin-list">
|
||||
${workflows.length === 0 && html`<div class="empty-hint">No workflows available.</div>`}
|
||||
${workflows.map(w => html`
|
||||
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openDetail(w)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${w.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
|
||||
</div>
|
||||
<span class="badge">${w.entry_mode}</span>
|
||||
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${w.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
|
||||
</div>
|
||||
${mine.map(a => html`
|
||||
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${available.length > 0 && html`
|
||||
<h4 style="margin:${mine.length > 0 ? '16px' : '0'} 0 8px;">Available (${available.length})</h4>
|
||||
<div class="admin-list">
|
||||
${available.map(a => html`
|
||||
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${mine.length === 0 && available.length === 0 && html`
|
||||
<div class="empty-hint">No assignments</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function AssignmentRow({ assignment: a, onAction, showTeam }) {
|
||||
const userId = sw.auth?.user?.id;
|
||||
const isMine = a.assigned_to === userId;
|
||||
|
||||
async function claim() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.claim(a.id);
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(a.id);
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(a.id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="admin-surface-row" style="${isMine ? 'border-left:3px solid var(--accent-1);' : ''}">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${a.workflow_name || 'Workflow'}</strong>
|
||||
${showTeam && a.team_name && html`<span class="badge">${a.team_name}</span>`}
|
||||
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
|
||||
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
|
||||
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
${a.status === 'unassigned' && html`
|
||||
<button class="btn-small btn-primary" onClick=${claim}>Claim</button>
|
||||
`}
|
||||
${isMine && html`
|
||||
<button class="btn-small" onClick=${unclaim}>Release</button>
|
||||
<button class="btn-small btn-primary"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="btn-small btn-success" onClick=${complete}>Complete</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ function TeamAdminSurface() {
|
||||
// Team picker if multiple teams
|
||||
if (!selectedTeam && adminTeams.length > 1) {
|
||||
return html`
|
||||
<div class="surface-settings">
|
||||
<div class="surface-settings" style="flex-direction:column;">
|
||||
<div class="settings-topbar">
|
||||
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@@ -144,7 +144,7 @@ function TeamAdminSurface() {
|
||||
// No teams
|
||||
if (!selectedTeam && adminTeams.length === 0) {
|
||||
return html`
|
||||
<div class="surface-settings">
|
||||
<div class="surface-settings" style="flex-direction:column;">
|
||||
<div class="settings-topbar">
|
||||
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@@ -167,7 +167,7 @@ function TeamAdminSurface() {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="surface-settings">
|
||||
<div class="surface-settings" style="flex-direction:column;">
|
||||
${/* Top Bar */``}
|
||||
<div class="settings-topbar">
|
||||
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
|
||||
@@ -188,7 +188,7 @@ function TeamAdminSurface() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<div style="display:flex;flex:1;min-height:0;">
|
||||
${/* Left Nav */``}
|
||||
<div class="settings-nav">
|
||||
${SECTIONS.map(s => html`
|
||||
@@ -202,10 +202,7 @@ function TeamAdminSurface() {
|
||||
|
||||
${/* Content */``}
|
||||
<div class="settings-content">
|
||||
<div class="settings-content-header">
|
||||
<h2>${sectionLabel}</h2>
|
||||
</div>
|
||||
<div class="settings-content-body">
|
||||
${SectionComponent && teamId
|
||||
? html`<${SectionComponent} teamId=${teamId} />`
|
||||
: html`<div class="settings-placeholder">Loading\u2026</div>`
|
||||
@@ -213,7 +210,6 @@ function TeamAdminSurface() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
|
||||
@@ -1,16 +1,67 @@
|
||||
/**
|
||||
* Team Admin > Workflows
|
||||
* Team Admin > Workflows — v0.37.15 rewrite
|
||||
*
|
||||
* Tab layout: Workflows | Assignments | Monitor
|
||||
* - Workflows: CRUD + inline stage editor (E2)
|
||||
* - Assignments: Team assignment queue (E1)
|
||||
* - Monitor: Active instance list with cancel (E3)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
|
||||
const HISTORY_MODES = ['full', 'summary', 'fresh'];
|
||||
const TABS = ['Workflows', 'Assignments', 'Monitor'];
|
||||
|
||||
function _timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function _formatDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '—';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
|
||||
return `${Math.floor(hrs / 24)}d ${hrs % 24}h`;
|
||||
}
|
||||
|
||||
// ── Main Section ────────────────────────────
|
||||
|
||||
export default function WorkflowsSection({ teamId }) {
|
||||
const [tab, setTab] = useState('Workflows');
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-tabs" style="display:flex;gap:4px;margin-bottom:16px;">
|
||||
${TABS.map(t => html`
|
||||
<button key=${t}
|
||||
class="btn-small ${tab === t ? 'btn-primary' : ''}"
|
||||
onClick=${() => setTab(t)}>${t}</button>
|
||||
`)}
|
||||
</div>
|
||||
${tab === 'Workflows' && html`<${WorkflowsTab} teamId=${teamId} />`}
|
||||
${tab === 'Assignments' && html`<${AssignmentsTab} teamId=${teamId} />`}
|
||||
${tab === 'Monitor' && html`<${MonitorTab} teamId=${teamId} />`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Tab 1: Workflows ────────────────────────
|
||||
|
||||
function WorkflowsTab({ teamId }) {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | workflow
|
||||
const [stages, setStages] = useState([]);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -23,15 +74,6 @@ export default function WorkflowsSection({ teamId }) {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function openEdit(wf) {
|
||||
setEditing(wf);
|
||||
try {
|
||||
const detail = await sw.api.teams.workflows(teamId);
|
||||
const match = (detail || []).find(w => w.id === wf.id);
|
||||
setStages(match?.stages || wf.stages || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
||||
}
|
||||
|
||||
async function createWorkflow(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
@@ -48,83 +90,10 @@ export default function WorkflowsSection({ teamId }) {
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateWorkflow(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.updateWorkflow(teamId, editing.id, {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
});
|
||||
sw.toast('Workflow updated', 'success');
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function publishWorkflow(wfId) {
|
||||
try {
|
||||
await sw.api.teams.publishWorkflow(teamId, wfId);
|
||||
sw.toast('Workflow published', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteWorkflow(id) {
|
||||
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteWorkflow(teamId, id);
|
||||
sw.toast('Workflow deleted', 'success');
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) return html`
|
||||
<form onSubmit=${updateWorkflow}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${editing.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="btn-small" onClick=${() => publishWorkflow(editing.id)}>Publish</button>
|
||||
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="name" value=${editing.name || ''} /></div>
|
||||
<div class="form-group"><label>Entry Mode</label>
|
||||
<select name="entry_mode">
|
||||
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${editing.entry_mode === m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
|
||||
<span class="toggle-track"></span><span>Active</span>
|
||||
</label>
|
||||
|
||||
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
|
||||
<div class="admin-list">
|
||||
${stages.map((s, i) => html`
|
||||
<div class="admin-surface-row" key=${s.id || i}>
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
||||
</div>
|
||||
`)}
|
||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing}
|
||||
onBack=${() => { setEditing(null); load(); }} />`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
@@ -155,7 +124,7 @@ export default function WorkflowsSection({ teamId }) {
|
||||
<div class="admin-list">
|
||||
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
|
||||
${workflows.map(w => html`
|
||||
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
|
||||
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => setEditing(w)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${w.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
|
||||
@@ -169,3 +138,372 @@ export default function WorkflowsSection({ teamId }) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Workflow Editor (with Stage Editor) ─────
|
||||
|
||||
function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
const [stages, setStages] = useState([]);
|
||||
const [personas, setPersonas] = useState([]);
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||
|
||||
const loadStages = useCallback(async () => {
|
||||
try {
|
||||
const [s, p] = await Promise.all([
|
||||
sw.api.teams.workflowStages(teamId, workflow.id),
|
||||
sw.api.teams.personas(teamId),
|
||||
]);
|
||||
setStages(s || []);
|
||||
setPersonas(p || []);
|
||||
setTeams(sw.auth?.teams || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}, [teamId, workflow.id]);
|
||||
|
||||
useEffect(() => { loadStages(); }, [loadStages]);
|
||||
|
||||
async function updateWorkflow(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
});
|
||||
sw.toast('Workflow updated', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function publishWorkflow() {
|
||||
try {
|
||||
await sw.api.teams.publishWorkflow(teamId, workflow.id);
|
||||
sw.toast('Workflow published', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteWorkflow() {
|
||||
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteWorkflow(teamId, workflow.id);
|
||||
sw.toast('Workflow deleted', 'success');
|
||||
onBack();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function addStage(data) {
|
||||
try {
|
||||
await sw.api.teams.createWorkflowStage(teamId, workflow.id, data);
|
||||
setEditingStage(null);
|
||||
loadStages();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateStage(stageId, data) {
|
||||
try {
|
||||
await sw.api.teams.updateWorkflowStage(teamId, workflow.id, stageId, data);
|
||||
setEditingStage(null);
|
||||
loadStages();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteStage(stageId) {
|
||||
const ok = await sw.confirm('Delete this stage?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteWorkflowStage(teamId, workflow.id, stageId);
|
||||
loadStages();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
return html`
|
||||
<form onSubmit=${updateWorkflow}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${onBack}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="btn-small" onClick=${publishWorkflow}>Publish</button>
|
||||
<button type="button" class="btn-small btn-danger" onClick=${deleteWorkflow}>Delete</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="name" value=${workflow.name || ''} /></div>
|
||||
<div class="form-group"><label>Entry Mode</label>
|
||||
<select name="entry_mode">
|
||||
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${workflow.entry_mode === m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_active" checked=${workflow.is_active !== false} />
|
||||
<span class="toggle-track"></span><span>Active</span>
|
||||
</label>
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Stage Editor -->
|
||||
<div style="margin-top:24px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<h5 style="margin:0;">Stages (${stages.length})</h5>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" onClick=${() => setEditingStage('new')}>+ Add Stage</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${stages.map((s, i) => html`
|
||||
<div class="admin-surface-row" key=${s.id || i}>
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
||||
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="btn-small" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
|
||||
</div>
|
||||
`)}
|
||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||
</div>
|
||||
|
||||
${editingStage && html`
|
||||
<${StageForm}
|
||||
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
|
||||
personas=${personas}
|
||||
teams=${teams}
|
||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||
onCancel=${() => setEditingStage(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stage Form ──────────────────────────────
|
||||
|
||||
function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
|
||||
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
|
||||
function submit() {
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
persona_id: personaId || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
history_mode: historyMode,
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-top:12px;padding:12px;border:1px solid var(--border-1);border-radius:6px;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input value=${name} onInput=${e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Mode</label>
|
||||
<select value=${mode} onChange=${e => setMode(e.target.value)}>
|
||||
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m.replace('_', ' ')}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Persona (LLM)</label>
|
||||
<select value=${personaId} onChange=${e => setPersonaId(e.target.value)}>
|
||||
<option value="">\u2014 none \u2014</option>
|
||||
${personas.map(p => html`<option key=${p.id} value=${p.id}>${p.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Queue to Team</label>
|
||||
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
|
||||
<option value="">\u2014 none (visitor stage) \u2014</option>
|
||||
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>History Mode</label>
|
||||
<select value=${historyMode} onChange=${e => setHistoryMode(e.target.value)}>
|
||||
${HISTORY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SLA (seconds)</label>
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
|
||||
<span class="toggle-track"></span><span>Auto-advance when complete</span>
|
||||
</label>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="button" class="btn-small btn-primary" onClick=${submit}>
|
||||
${stage ? 'Update' : 'Add Stage'}
|
||||
</button>
|
||||
<button type="button" class="btn-small" onClick=${onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Tab 2: Assignments ──────────────────────
|
||||
|
||||
function AssignmentsTab({ teamId }) {
|
||||
const [claimed, setClaimed] = useState([]);
|
||||
const [unassigned, setUnassigned] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, u] = await Promise.all([
|
||||
sw.api.teams.assignments(teamId, { status: 'claimed' }),
|
||||
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
|
||||
]);
|
||||
setClaimed(c || []);
|
||||
setUnassigned(u || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// WS live updates
|
||||
useEffect(() => {
|
||||
const off1 = sw.on('workflow.assigned', load);
|
||||
const off2 = sw.on('workflow.claimed', load);
|
||||
const off3 = sw.on('workflow.unclaimed', load);
|
||||
return () => { off1(); off2(); off3(); };
|
||||
}, [teamId]);
|
||||
|
||||
async function claim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.claim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
const userId = sw.auth?.user?.id;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<h4 style="margin:0 0 8px;">My Active (${claimed.length})</h4>
|
||||
<div class="admin-list">
|
||||
${claimed.map(a => html`
|
||||
<div class="admin-surface-row" key=${a.id} style="border-left:3px solid var(--accent-1);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${a.workflow_name || 'Workflow'}</strong>
|
||||
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
|
||||
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn-small" onClick=${() => unclaim(a.id)}>Release</button>
|
||||
<button class="btn-small btn-primary"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="btn-small btn-success" onClick=${() => complete(a.id)}>Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
${claimed.length === 0 && html`<div class="empty-hint">No claimed assignments</div>`}
|
||||
</div>
|
||||
|
||||
<h4 style="margin:16px 0 8px;">Available (${unassigned.length})</h4>
|
||||
<div class="admin-list">
|
||||
${unassigned.map(a => html`
|
||||
<div class="admin-surface-row" key=${a.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${a.workflow_name || 'Workflow'}</strong>
|
||||
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
|
||||
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
|
||||
</div>
|
||||
<button class="btn-small btn-primary" onClick=${() => claim(a.id)}>Claim</button>
|
||||
</div>
|
||||
`)}
|
||||
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Tab 3: Monitor ──────────────────────────
|
||||
|
||||
function MonitorTab({ teamId }) {
|
||||
const [instances, setInstances] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.workflowInstances(teamId);
|
||||
setInstances(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function cancelInstance(channelId) {
|
||||
const ok = await sw.confirm('Cancel this workflow instance? All open assignments will be cancelled.', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
|
||||
sw.toast('Instance cancelled', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<h4 style="margin:0 0 8px;">Active Instances (${instances.length})</h4>
|
||||
<div class="admin-list">
|
||||
${instances.map(inst => html`
|
||||
<div class="admin-surface-row" key=${inst.channel_id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${inst.workflow_name}</strong>
|
||||
<span class="badge">${inst.stage_name || `Stage ${inst.current_stage}`}</span>
|
||||
${inst.sla_breached && html`<span class="badge badge-danger">SLA breached</span>`}
|
||||
<span class="text-muted" style="font-size:11px;">Age: ${_formatDuration(inst.age_seconds)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn-small"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user