Feat v0.7.4 documentation + deferred surface work
Docs restructuring: category grouping (Getting Started, Platform, Extension Development, Operations) with 14 ordered docs. Four new guides: Permissions & Groups, Workflows, Starlark Reference, Frontend JS Guide. Extension Guide updated with config_section docs. Content refresh across 10 existing docs: rebrand volume names, stale CSS vars, TLS_MODE env, self-hosted font notes, Architecture frontend section. Team Admin workflows.js (722 lines) split into 3 modules: workflows.js (router+CRUD), workflow-editor.js (editor+stages), workflow-monitor.js (assignments+monitor+signoff). Fix: docs outline scroll no longer pushes topbar off-screen. Fix: --bg-2 (undefined CSS var) replaced with --bg-secondary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
ROADMAP.md
16
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Armature — Roadmap
|
||||
|
||||
## Current: v0.7.3 — Extension Shell Migration
|
||||
## Current: v0.7.4 — Documentation + Deferred Surface Work
|
||||
|
||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
||||
@@ -170,11 +170,15 @@ same pattern as the kernel surface migrations.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Permissions & Groups guide | | RBAC model, permission slugs with descriptions, group scoping, settings cascade. |
|
||||
| Workflows user guide | | Entry modes, stages, team roles, signoff gates, SLA, public forms. |
|
||||
| Docs content refresh | | Review all 5 existing docs for accuracy at v0.7.x. |
|
||||
| Extension config section docs | | Document `config_section` manifest field for Settings/Admin extensibility. |
|
||||
| Team Admin Workflows evaluation | | 723-line inline designer — extract to surface or split into files. Decision doc if needed. |
|
||||
| Docs category grouping | done | Backend `Category` field on doc entries. Frontend groups sidebar by category with headings. Four categories: Getting Started, Platform, Extension Development, Operations. |
|
||||
| Permissions & Groups guide | done | RBAC model, 7 permission slugs, system/custom groups, settings cascade, extension permissions. |
|
||||
| Workflows user guide | done | Entry modes, stages, team roles, signoff gates, SLA, public forms, branch rules, Starlark hooks. |
|
||||
| Starlark Reference | done | Sandbox constraints, 10 modules with function signatures, permission gates, example hook script. |
|
||||
| Frontend JS Guide | done | Preact+htm runtime, 16 SDK modules with API reference, shell topbar patterns, CSS contract. |
|
||||
| Extension config_section docs | done | Manifest schema, backend discovery, frontend contract, example component. Added to Extension Guide. |
|
||||
| Docs content refresh | done | All 10 user-facing docs reviewed for v0.7.x accuracy: rebrand volume names, stale CSS vars, TLS_MODE env, self-hosted font notes, Architecture frontend section. |
|
||||
| Team Admin Workflows split | done | 722-line `workflows.js` split into 3 modules: `workflows.js` (router+CRUD), `workflow-editor.js` (editor+stages), `workflow-monitor.js` (assignments+monitor+signoff). |
|
||||
| Stale CSS variable fix | done | `--bg-2` references in `sw-shell.css` and `sw-primitives.css` replaced with `--bg-secondary`. |
|
||||
|
||||
### v0.7.5 — Headless E2E + CI Gate
|
||||
|
||||
|
||||
@@ -271,12 +271,13 @@ graph TD
|
||||
## Frontend
|
||||
|
||||
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
||||
(except CM6 via esbuild). IIFE/global-namespace pattern with
|
||||
`sb.register()`/`sb.ns()`.
|
||||
(except CM6 via esbuild). ES modules loaded via `<script type="module">`.
|
||||
The SDK is exposed at `window.sw` — see the [Frontend JS Guide](FRONTEND-JS-GUIDE).
|
||||
|
||||
The shell loads surfaces into a viewport. Extensions use `window.html`
|
||||
and `window.preact` directly. Hooks via `window.hooks`. Vendor libs
|
||||
(marked.js, DOMPurify, KaTeX, CodeMirror 6) baked into the image.
|
||||
The shell provides a two-slot topbar (left title + center slot) that every
|
||||
surface inherits. Extensions use `window.html` and `window.preact` directly.
|
||||
Hooks via `window.hooks`. Vendor libs (marked.js, DOMPurify, KaTeX,
|
||||
CodeMirror 6) baked into the image.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -41,13 +41,13 @@ services:
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
volumes:
|
||||
- sb_storage:/data/storage
|
||||
- armature_storage:/data/storage
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
sb_storage:
|
||||
armature_storage:
|
||||
```
|
||||
|
||||
## Kubernetes
|
||||
@@ -70,6 +70,7 @@ See the `k8s/` directory for example manifests. Key considerations:
|
||||
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
|
||||
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
|
||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
|
||||
| `TLS_MODE` | (empty) | `native` for node-to-node mTLS. Requires `MTLS_CERT_PATH` and `MTLS_KEY_PATH`. |
|
||||
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||
| `BASE_PATH` | | URL prefix (e.g., `/armature`) |
|
||||
|
||||
@@ -147,8 +147,8 @@ Example:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `--font` | Primary font family |
|
||||
| `--mono` | Monospace font family |
|
||||
| `--font` | Primary font family (self-hosted, no external requests) |
|
||||
| `--mono` | Monospace font family (self-hosted) |
|
||||
| `--radius-sm` | Small border-radius (4px) — badges, inline controls |
|
||||
| `--radius` | Default border-radius (8px) — buttons, inputs, cards |
|
||||
| `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards |
|
||||
|
||||
@@ -79,6 +79,7 @@ Every package has a `manifest.json` at its root. Example for a surface:
|
||||
| `settings` | no | User-configurable settings schema |
|
||||
| `exports` | libraries | Functions exported for other packages |
|
||||
| `hooks` | no | Event bus subscriptions |
|
||||
| `config_section` | no | Settings/Admin panel injection (see below) |
|
||||
| `schema_version` | no | Integer for additive schema migrations |
|
||||
|
||||
## db_tables Schema
|
||||
@@ -138,24 +139,73 @@ Only `path` and `method` are required. All other fields are optional. Malformed
|
||||
|
||||
## Starlark Sandbox API
|
||||
|
||||
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin):
|
||||
Starlark scripts run server-side with a 1M operation budget and no
|
||||
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
|
||||
the complete module catalog, function signatures, and permission gates.
|
||||
|
||||
| Module | Permission | API |
|
||||
|--------|-----------|-----|
|
||||
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
|
||||
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
|
||||
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
|
||||
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
|
||||
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
|
||||
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
|
||||
## config_section — Settings Panel Injection
|
||||
|
||||
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages.
|
||||
Packages can inject configuration panels into the Settings, Admin, or
|
||||
Team Admin surfaces. Declare `config_section` in `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"config_section": {
|
||||
"label": "My Config",
|
||||
"icon": "M12 2L2 7l10 5 10-5-10-5z",
|
||||
"component": "js/config.js",
|
||||
"surfaces": ["settings", "admin"],
|
||||
"category": "system"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `label` | yes | Navigation label shown in the sidebar or tab list |
|
||||
| `icon` | no | SVG path data for the nav icon |
|
||||
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
|
||||
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
|
||||
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. At page load, the backend scans all enabled packages for `config_section`
|
||||
entries targeting the current surface.
|
||||
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
|
||||
3. The frontend dynamically imports the component module and renders it
|
||||
as an additional tab/section.
|
||||
4. The component receives `{ packageId, teamId }` as props.
|
||||
|
||||
**Example component** (`js/config.js`):
|
||||
|
||||
```javascript
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
export default function MyConfig({ packageId }) {
|
||||
const [val, setVal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
|
||||
}, []);
|
||||
|
||||
return html`<div>
|
||||
<label>API Key</label>
|
||||
<input value=${val} onInput=${e => setVal(e.target.value)} />
|
||||
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions Model
|
||||
|
||||
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions.
|
||||
Extensions declare required permissions in `manifest.json`. The admin
|
||||
must grant each permission before the extension can use the corresponding
|
||||
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
|
||||
|
||||
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`.
|
||||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
|
||||
model, user permission slugs, and settings cascade.
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
256
docs/FRONTEND-JS-GUIDE.md
Normal file
256
docs/FRONTEND-JS-GUIDE.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# Frontend JS Guide
|
||||
|
||||
Armature extensions run in the browser using **Preact + htm** — a 3 KB
|
||||
runtime with no build step. The kernel provides a rich SDK at `window.sw`
|
||||
that extensions use for API calls, auth, events, theming, and UI.
|
||||
|
||||
## Getting started
|
||||
|
||||
Extension surfaces are ES modules loaded via `<script type="module">`.
|
||||
The SDK is available on `window.sw` after the `sw:ready` DOM event:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('sw:ready', () => {
|
||||
const { html } = window;
|
||||
const mount = document.getElementById('my-mount');
|
||||
preact.render(html`<${App} />`, mount);
|
||||
});
|
||||
```
|
||||
|
||||
Or use the global `hooks` object for Preact hooks:
|
||||
|
||||
```javascript
|
||||
const { useState, useEffect } = hooks;
|
||||
```
|
||||
|
||||
## SDK modules
|
||||
|
||||
All modules live on the `window.sw` object. They are frozen after boot
|
||||
and available to every extension.
|
||||
|
||||
### sw.api — REST client
|
||||
|
||||
Generic escape hatches for any endpoint:
|
||||
|
||||
```javascript
|
||||
sw.api.get('/api/v1/docs')
|
||||
sw.api.post('/api/v1/teams', { name: 'Eng' })
|
||||
sw.api.put(path, body)
|
||||
sw.api.patch(path, body)
|
||||
sw.api.del(path)
|
||||
sw.api.upload(path, file)
|
||||
sw.api.stream(path, body, signal)
|
||||
```
|
||||
|
||||
All methods auto-inject auth tokens and return unwrapped `data` from
|
||||
`{ data: ... }` response envelopes.
|
||||
|
||||
**Domain namespaces** provide typed CRUD methods:
|
||||
|
||||
| Namespace | Key methods |
|
||||
|-----------|-------------|
|
||||
| `sw.api.auth` | `login`, `register`, `refresh`, `logout` |
|
||||
| `sw.api.teams` | `list`, `get`, `create`, `members`, `workflows`, `assignments` |
|
||||
| `sw.api.workflows` | `list`, `get`, `stages`, `instances`, `advance`, `cancel` |
|
||||
| `sw.api.channels` | `list`, `get`, `create`, `update`, `del` |
|
||||
| `sw.api.notifications` | `list`, `unreadCount`, `markRead`, `markAllRead` |
|
||||
| `sw.api.admin` | Sub-objects for `users`, `teams`, `groups`, `packages`, `backup`, etc. |
|
||||
| `sw.api.users` | `search`, `resolve` |
|
||||
| `sw.api.connections` | `list`, `get`, `create`, `resolve` |
|
||||
| `sw.api.ext(pkgId)` | Scoped client for extension API routes: `get`, `post`, `put`, `del` |
|
||||
|
||||
### sw.auth — Authentication state
|
||||
|
||||
```javascript
|
||||
sw.auth.isAuthenticated // boolean
|
||||
sw.auth.user // { id, username, display_name, email, role, avatar }
|
||||
sw.auth.permissions // Set<string>
|
||||
sw.auth.teams // Array<{ id, name, role }>
|
||||
sw.auth.groups // Array<{ id, name, permissions }>
|
||||
```
|
||||
|
||||
Lifecycle: `sw.auth.login(login, pw)`, `sw.auth.logout()`, `sw.auth.refresh()`.
|
||||
|
||||
### sw.can — RBAC gates
|
||||
|
||||
```javascript
|
||||
sw.can('workflow.create') // true if user has permission
|
||||
sw.isAdmin // true if surface.admin.access granted
|
||||
sw.isTeamAdmin(teamId) // true if admin role in team
|
||||
```
|
||||
|
||||
Use these to conditionally render UI elements.
|
||||
|
||||
### sw.on / sw.off / sw.emit — Event bus
|
||||
|
||||
```javascript
|
||||
const unsub = sw.on('theme.changed', (payload) => { ... });
|
||||
sw.once('auth.login', (user) => { ... });
|
||||
sw.off('theme.changed'); // remove all listeners
|
||||
sw.off('theme.changed', fn); // remove specific listener
|
||||
sw.emit('my.event', { data });
|
||||
```
|
||||
|
||||
The event bus bridges to the WebSocket — server-emitted events
|
||||
(e.g. `notification.created`, `workflow.sla_breach`) arrive here.
|
||||
|
||||
### sw.theme — Theme control
|
||||
|
||||
```javascript
|
||||
sw.theme.current // 'dark' or 'light' (resolved)
|
||||
sw.theme.mode // 'dark', 'light', or 'system'
|
||||
sw.theme.set('dark')
|
||||
sw.theme.on('change', (theme) => { ... })
|
||||
sw.theme.tokens // live CSS variables as camelCase JS object
|
||||
```
|
||||
|
||||
### sw.storage — Namespaced localStorage
|
||||
|
||||
```javascript
|
||||
const store = sw.storage.local('my-extension');
|
||||
store.set('key', { complex: 'value' });
|
||||
store.get('key') // parsed object
|
||||
store.remove('key')
|
||||
store.keys() // ['key', ...]
|
||||
store.clear()
|
||||
```
|
||||
|
||||
### sw.realtime — WebSocket pub/sub
|
||||
|
||||
```javascript
|
||||
const unsub = sw.realtime.subscribe('my-channel', 'item.updated', (data) => { ... });
|
||||
// Or subscribe to all events on a channel:
|
||||
const unsub = sw.realtime.subscribe('my-channel', (event, data) => { ... });
|
||||
```
|
||||
|
||||
### sw.slots — UI slot registry
|
||||
|
||||
Register components into named shell slots (e.g. toolbar areas):
|
||||
|
||||
```javascript
|
||||
const unreg = sw.slots.register('topbar-actions', {
|
||||
id: 'my-button',
|
||||
component: MyButton,
|
||||
priority: 10,
|
||||
});
|
||||
```
|
||||
|
||||
### sw.actions — Named action registry
|
||||
|
||||
```javascript
|
||||
sw.actions.register('copy-link', {
|
||||
handler: async (url) => navigator.clipboard.writeText(url),
|
||||
label: 'Copy Link',
|
||||
icon: 'M12 2...',
|
||||
});
|
||||
await sw.actions.run('copy-link', someUrl);
|
||||
```
|
||||
|
||||
### sw.pipe — Filter pipeline
|
||||
|
||||
Three-stage pipeline for message processing:
|
||||
|
||||
```javascript
|
||||
sw.pipe.pre(10, async (ctx) => { /* pre-process */ return ctx; });
|
||||
sw.pipe.stream(10, async (ctx) => { /* streaming */ return ctx; });
|
||||
sw.pipe.render(10, async (ctx) => { /* post-render */ return ctx; });
|
||||
```
|
||||
|
||||
### sw.renderers — Block and post renderers
|
||||
|
||||
Register custom renderers for fenced code blocks or post-processing:
|
||||
|
||||
```javascript
|
||||
sw.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: /^mermaid$/,
|
||||
render: (code, container) => { /* render diagram */ },
|
||||
});
|
||||
|
||||
sw.renderers.register('linkify', {
|
||||
type: 'post',
|
||||
render: (container) => { /* post-process rendered HTML */ },
|
||||
});
|
||||
```
|
||||
|
||||
### sw.markdown — Unified rendering
|
||||
|
||||
```javascript
|
||||
const html = sw.markdown.renderSync(markdownString, { sanitize: false });
|
||||
await sw.markdown.render(markdownString); // async variant
|
||||
sw.markdown.ready // boolean — true after preload
|
||||
```
|
||||
|
||||
Uses marked + DOMPurify + registered `sw.renderers`.
|
||||
|
||||
### sw.users — Identity resolution
|
||||
|
||||
```javascript
|
||||
const user = await sw.users.resolve(userId);
|
||||
const map = await sw.users.resolveMany([id1, id2]);
|
||||
sw.users.displayName(userObj) // display_name || username || 'Unknown'
|
||||
```
|
||||
|
||||
Results are cached for 60 seconds. Batch fetches use the server's
|
||||
bulk resolve endpoint.
|
||||
|
||||
### sw.testing — Test framework
|
||||
|
||||
For writing package runner tests:
|
||||
|
||||
```javascript
|
||||
sw.testing.suite('CRUD', async (s) => {
|
||||
s.before(async () => { /* setup */ });
|
||||
s.after(async () => { /* cleanup */ });
|
||||
|
||||
s.test('creates item', async (t) => {
|
||||
const resp = await sw.api.post('/api/v1/items', { name: 'test' });
|
||||
t.assert.status(resp, 200);
|
||||
t.assert.ok(resp.id);
|
||||
s.track('item', resp.id); // auto-cleanup in afterAll
|
||||
});
|
||||
});
|
||||
await sw.testing.run();
|
||||
```
|
||||
|
||||
### sw.shell — Topbar API
|
||||
|
||||
The kernel injects a two-slot topbar into every extension surface.
|
||||
Extensions customize it via:
|
||||
|
||||
```javascript
|
||||
sw.shell.topbar.setTitle('My Surface'); // text in left slot
|
||||
sw.shell.topbar.setSlot(html`<${TabBar} />`); // center slot content
|
||||
sw.shell.topbar.hide(); // full-bleed mode
|
||||
sw.shell.topbar.show(); // restore topbar
|
||||
```
|
||||
|
||||
### sw.toast / sw.confirm / sw.prompt — UI primitives
|
||||
|
||||
```javascript
|
||||
sw.toast('Saved!', 'success'); // success | error | info
|
||||
sw.toast('Something broke', 'error', 5000); // custom duration
|
||||
|
||||
const ok = await sw.confirm('Delete this item?');
|
||||
const name = await sw.prompt('Enter name', 'default value');
|
||||
```
|
||||
|
||||
## Shell topbar patterns
|
||||
|
||||
Every surface uses one of three patterns:
|
||||
|
||||
| Pattern | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| **A — Default** | Shell title only. No center slot content. | Docs |
|
||||
| **B — Flat tabs** | `setTitle()` + tabs in center slot via `setSlot()`. Full-width content. | Settings, Team Admin |
|
||||
| **C — Category tabs + sidebar** | `setTitle()` + category tabs in center slot. Surface-owned sidebar below. | Admin |
|
||||
|
||||
The shell provides the home link, notification bell, and user menu
|
||||
on every surface for free.
|
||||
|
||||
## Extension CSS contract
|
||||
|
||||
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
|
||||
conflicts with kernel styles. See the [Extension CSS](EXTENSION-CSS)
|
||||
doc for the full isolation rules, available primitives from
|
||||
`sw-primitives.css`, and spacing tokens.
|
||||
@@ -12,7 +12,7 @@ docker compose up --build
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
|
||||
|
||||
Data persists in the `sb_data` named volume. To reset everything:
|
||||
Data persists in the `armature_data` named volume. To reset everything:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
|
||||
121
docs/PERMISSIONS-AND-GROUPS.md
Normal file
121
docs/PERMISSIONS-AND-GROUPS.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Permissions & Groups
|
||||
|
||||
Armature uses group-based RBAC. Permissions are granted to groups, and users
|
||||
inherit the union of permissions from all groups they belong to. There are no
|
||||
per-user permission grants — all access flows through group membership.
|
||||
|
||||
## Groups
|
||||
|
||||
### System groups
|
||||
|
||||
| Group | ID | Purpose |
|
||||
|-------|----|---------|
|
||||
| Everyone | `00000000-...0001` | Implicit membership for every authenticated user. Default permissions: `extension.use`, `workflow.submit`. |
|
||||
| Admins | `00000000-...0002` | Full platform access. Members receive all seven permission slugs. Replaces the legacy `role = admin` check. |
|
||||
|
||||
Every new user is automatically added to **Everyone** on registration.
|
||||
Admin status is granted by adding a user to the **Admins** group in
|
||||
**Admin > People > Groups**.
|
||||
|
||||
### Custom groups
|
||||
|
||||
Administrators can create additional groups under **Admin > People > Groups**.
|
||||
Each custom group has:
|
||||
|
||||
- **Name** — display label
|
||||
- **Description** — purpose (shown in admin UI)
|
||||
- **Scope** — always `global` (team-scoped groups reserved for future use)
|
||||
- **Permissions** — zero or more permission slugs from the table below
|
||||
|
||||
## Permission slugs
|
||||
|
||||
Seven platform permissions control access to kernel features:
|
||||
|
||||
| Slug | Description |
|
||||
|------|-------------|
|
||||
| `surface.admin.access` | Full admin panel access (tabs, settings, package management) |
|
||||
| `admin.view` | Read-only admin panel access (monitoring, health, audit log) |
|
||||
| `extension.use` | Use installed extension surfaces and libraries |
|
||||
| `extension.install` | Install, update, enable, and disable packages |
|
||||
| `workflow.create` | Create and edit workflow definitions |
|
||||
| `workflow.submit` | Submit instances to public-link workflows |
|
||||
| `token.unlimited` | Bypass per-user token budgets (API rate limiting) |
|
||||
|
||||
Permissions follow a `domain.action` naming convention.
|
||||
|
||||
## Permission resolution
|
||||
|
||||
When a request arrives, the kernel resolves the effective permission set:
|
||||
|
||||
1. Fetch all groups the user belongs to (including Everyone).
|
||||
2. Union all permission arrays across those groups.
|
||||
3. Cache the result for the duration of the request.
|
||||
|
||||
A user has a permission if **any** of their groups grants it.
|
||||
|
||||
Frontend code checks permissions via `sw.can('slug')` — see the
|
||||
[Frontend JS Guide](FRONTEND-JS-GUIDE) for details.
|
||||
|
||||
## Extension permissions
|
||||
|
||||
Separate from user permissions, each **package** can request sandbox
|
||||
capabilities. These are granted per-package in **Admin > Packages**:
|
||||
|
||||
| Permission | Grants |
|
||||
|------------|--------|
|
||||
| `db.read` | Query `ext_data` tables (read-only) |
|
||||
| `db.write` | Insert, update, and delete rows in `ext_data` tables |
|
||||
| `api.http` | Make outbound HTTP requests from Starlark |
|
||||
| `notifications.send` | Send in-app notifications to users |
|
||||
| `secrets.read` | Read admin-configured extension secrets |
|
||||
| `realtime.publish` | Publish WebSocket events to subscribed clients |
|
||||
| `connections.read` | Read external connection configs (decrypted) |
|
||||
| `workflow.access` | Read workflow definitions and instances |
|
||||
|
||||
See the [Starlark Reference](STARLARK-REFERENCE) for how these
|
||||
map to sandbox modules.
|
||||
|
||||
## Settings cascade
|
||||
|
||||
Package settings use a three-tier resolution model:
|
||||
|
||||
```
|
||||
user override → team override → global default
|
||||
```
|
||||
|
||||
At each tier:
|
||||
|
||||
- **Global** — set by admins in **Admin > Packages > Settings**
|
||||
- **Team** — set by team admins in **Team Admin > Settings**
|
||||
- **User** — set by users in **Settings > Extensions**
|
||||
|
||||
### The `user_overridable` flag
|
||||
|
||||
Each setting key in a package manifest can declare `user_overridable`:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": [
|
||||
{ "key": "theme", "user_overridable": true },
|
||||
{ "key": "api_endpoint", "user_overridable": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `true` (default) — team and user scopes can override the global value.
|
||||
- `false` — only the global (admin) value is used. Team and user values
|
||||
are silently ignored during resolution.
|
||||
|
||||
This gives administrators a lock mechanism: set `user_overridable: false`
|
||||
on security-sensitive keys to prevent lower scopes from changing them,
|
||||
while allowing cosmetic preferences to flow freely.
|
||||
|
||||
### Resolution algorithm
|
||||
|
||||
1. Start with the global value for each key.
|
||||
2. For each key where `user_overridable` is true (or undeclared):
|
||||
- If a team-scoped value exists, it overrides global.
|
||||
- If a user-scoped value exists, it overrides team.
|
||||
3. For keys where `user_overridable` is false:
|
||||
- Team and user values are discarded.
|
||||
4. Unknown keys (not in schema) default to overridable.
|
||||
232
docs/STARLARK-REFERENCE.md
Normal file
232
docs/STARLARK-REFERENCE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Starlark Reference
|
||||
|
||||
Armature extensions can include Starlark scripts for server-side logic.
|
||||
Starlark is a Python-like language designed for configuration and
|
||||
embedding — see the [official spec](https://github.com/google/starlark-go).
|
||||
|
||||
## Sandbox constraints
|
||||
|
||||
- **No `while` loops** — use `for` with bounded ranges.
|
||||
- **No `load()`** — use `lib.require()` for library dependencies.
|
||||
- **Max steps:** 1,000,000 bytecode operations per execution.
|
||||
- **No filesystem or OS access** — all I/O goes through gated modules.
|
||||
- **Deterministic** — same input produces same output (no `random`, no `time`).
|
||||
|
||||
## Always-available modules
|
||||
|
||||
These modules are injected into every script with no permission required.
|
||||
|
||||
### json
|
||||
|
||||
Standard Starlark JSON module.
|
||||
|
||||
```python
|
||||
data = json.decode('{"key": "value"}')
|
||||
text = json.encode({"key": "value"})
|
||||
```
|
||||
|
||||
### settings
|
||||
|
||||
Read resolved package settings (global → team → user cascade).
|
||||
|
||||
```python
|
||||
val = settings.get("theme", "light")
|
||||
# Returns the resolved value, or the default if unset.
|
||||
```
|
||||
|
||||
The cascade respects the `user_overridable` flag from the package manifest.
|
||||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for details.
|
||||
|
||||
### lib
|
||||
|
||||
Load exported functions from library packages.
|
||||
|
||||
```python
|
||||
helpers = lib.require("my-utils")
|
||||
result = helpers.format_date("2026-01-15")
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- The library must be declared in your package manifest's `dependencies`.
|
||||
- The library must be type `library`, status `active`, tier `starlark`.
|
||||
- Circular dependencies are detected and rejected.
|
||||
- Results are cached per execution (calling `require` twice returns the
|
||||
same object).
|
||||
|
||||
## Permission-gated modules
|
||||
|
||||
These modules are only available if the package has the corresponding
|
||||
permission granted in **Admin > Packages**.
|
||||
|
||||
### secrets
|
||||
|
||||
**Permission:** `secrets.read`
|
||||
|
||||
Read admin-configured secrets for this package.
|
||||
|
||||
```python
|
||||
api_key = secrets.get("OPENAI_KEY") # str or None
|
||||
all_keys = secrets.list() # list of key names
|
||||
```
|
||||
|
||||
Secrets are set in **Admin > Packages > Secrets** and scoped per package.
|
||||
|
||||
### notifications
|
||||
|
||||
**Permission:** `notifications.send`
|
||||
|
||||
Send in-app notifications to users.
|
||||
|
||||
```python
|
||||
notifications.send(
|
||||
user_id, # str — target user UUID
|
||||
title, # str — notification title
|
||||
body="", # str — optional body text
|
||||
type="extension.notify" # str — notification type
|
||||
)
|
||||
```
|
||||
|
||||
### db
|
||||
|
||||
**Permission:** `db.read` (queries) or `db.write` (mutations)
|
||||
|
||||
Read and write extension data tables. All tables are automatically
|
||||
namespaced as `ext_{package_id}_{table_name}`.
|
||||
|
||||
#### Read operations
|
||||
|
||||
```python
|
||||
# Query with filters, ordering, and pagination
|
||||
rows = db.query(
|
||||
"tasks", # table name (without prefix)
|
||||
filters={"status": "open"}, # equality WHERE clauses
|
||||
order="-created_at", # column name (prefix - for DESC)
|
||||
limit=50, # max 1000
|
||||
before={"created_at": ts}, # range: column < value
|
||||
after={"created_at": ts}, # range: column > value
|
||||
search_like={"title": "%bug%"} # LIKE/ILIKE search
|
||||
)
|
||||
|
||||
# Read from system views (read-only)
|
||||
users = db.view("users", filters={"display_name": "Alice"}, limit=10)
|
||||
channels = db.view("channels", limit=100)
|
||||
|
||||
# List all tables owned by this package
|
||||
tables = db.list_tables()
|
||||
```
|
||||
|
||||
Available views: `users`, `channels`.
|
||||
|
||||
#### Write operations
|
||||
|
||||
```python
|
||||
row = db.insert("tasks", {"title": "Fix bug", "status": "open"})
|
||||
# Returns the inserted row dict (with generated id, created_at)
|
||||
|
||||
db.update("tasks", row_id, {"status": "closed"})
|
||||
# Returns True on success
|
||||
|
||||
db.delete("tasks", row_id)
|
||||
# Returns True on success
|
||||
```
|
||||
|
||||
### http
|
||||
|
||||
**Permission:** `api.http`
|
||||
|
||||
Make outbound HTTP requests.
|
||||
|
||||
```python
|
||||
resp = http.get("https://api.example.com/data", headers={"Authorization": "Bearer ..."})
|
||||
resp = http.post(url, body='{"key": "val"}', headers={"Content-Type": "application/json"})
|
||||
resp = http.put(url, body="...", headers={})
|
||||
resp = http.delete(url, headers={})
|
||||
resp = http.request("PATCH", url, body="...", headers={})
|
||||
```
|
||||
|
||||
Response dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"status": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body": "..." # capped at 1 MB
|
||||
}
|
||||
```
|
||||
|
||||
**Security:**
|
||||
- Private/loopback IPs are blocked (SSRF protection).
|
||||
- Packages can declare `network_access.allow` (allowlist) or
|
||||
`network_access.block` (blocklist) in their manifest.
|
||||
- Max 10 redirects. 10-second timeout. 1 MB response body limit.
|
||||
|
||||
### realtime
|
||||
|
||||
**Permission:** `realtime.publish`
|
||||
|
||||
Publish WebSocket events to subscribed clients.
|
||||
|
||||
```python
|
||||
realtime.publish(
|
||||
"my-channel", # channel name
|
||||
"item.updated", # event label
|
||||
{"id": "abc"} # payload dict (max 7 KB)
|
||||
)
|
||||
```
|
||||
|
||||
The payload is automatically tagged with `_pkg: package_id`.
|
||||
|
||||
### connections
|
||||
|
||||
**Permission:** `connections.read`
|
||||
|
||||
Read external connection configurations (secrets are decrypted).
|
||||
|
||||
```python
|
||||
conn = connections.get("postgres", "main-db")
|
||||
# Returns dict with id, type, name, scope, plus flattened config fields
|
||||
# Returns None if not found
|
||||
|
||||
all_pg = connections.list("postgres")
|
||||
# Returns list of connection dicts
|
||||
```
|
||||
|
||||
Connections are resolved via scope chain: personal → team → global.
|
||||
|
||||
### workflow
|
||||
|
||||
**Permission:** `workflow.access`
|
||||
|
||||
Read workflow definitions and instances (read-only from Starlark;
|
||||
mutations go through the HTTP API).
|
||||
|
||||
```python
|
||||
defn = workflow.get_definition(workflow_id)
|
||||
# Returns dict: id, name, slug, entry_mode, is_active, version, stages[]
|
||||
|
||||
inst = workflow.get_instance(instance_id)
|
||||
# Returns dict: id, workflow_id, current_stage, status, stage_data, ...
|
||||
|
||||
instances = workflow.list_instances(workflow_id, status="active")
|
||||
# Returns list of instance dicts
|
||||
```
|
||||
|
||||
## Example: automated stage hook
|
||||
|
||||
A simple hook that reads a setting, queries data, and advances:
|
||||
|
||||
```python
|
||||
def on_run(ctx):
|
||||
threshold = settings.get("approval_threshold", 1000)
|
||||
amount = ctx["stage_data"].get("amount", 0)
|
||||
|
||||
if amount > threshold:
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"High-value submission",
|
||||
body="Amount %d exceeds threshold." % amount,
|
||||
)
|
||||
return {"advance": True, "data": {"needs_review": True}}
|
||||
|
||||
return {"advance": True, "data": {"needs_review": False}}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ and register with the SDK through `sw.renderers`:
|
||||
},
|
||||
render(lang, code, container) {
|
||||
container.innerHTML =
|
||||
'<div style="padding:12px;background:var(--bg-2);' +
|
||||
'<div style="padding:12px;background:var(--bg-secondary);' +
|
||||
'border:1px solid var(--border);border-radius:8px">' +
|
||||
'<strong>Demo:</strong> ' + code +
|
||||
'</div>';
|
||||
@@ -74,7 +74,7 @@ The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
|
||||
takes a name and an options object: `type: 'block'` targets fenced code blocks,
|
||||
`match` checks the language tag, and `render` receives the language, raw code,
|
||||
and a container element. The `sw:ready` event fires once the SDK initializes;
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-secondary)`
|
||||
and `var(--border)` to follow the active theme.
|
||||
|
||||
## Step 4: Package It
|
||||
|
||||
193
docs/WORKFLOWS.md
Normal file
193
docs/WORKFLOWS.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Workflows
|
||||
|
||||
Workflows are multi-stage processes with team assignment, validation gates,
|
||||
SLA enforcement, and optional Starlark automation. They are managed in
|
||||
**Team Admin > Workflows**.
|
||||
|
||||
## Core concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Definition** | A named template: stages, entry mode, staleness timeout. Created per-team or adopted from global definitions. |
|
||||
| **Stage** | One step in the workflow. Has a mode, audience, optional team assignment, and optional SLA. |
|
||||
| **Instance** | A running copy of a definition. Pins a published version snapshot and tracks accumulated stage data. |
|
||||
| **Assignment** | A queue entry linking an instance stage to a team member. Claim → work → complete. |
|
||||
| **Signoff** | An approval or rejection recorded against an instance stage (multi-party validation). |
|
||||
|
||||
## Entry modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `team_only` | Only authenticated team members can start instances. |
|
||||
| `public_link` | Anyone with the public URL can start an instance. The first stage must have `audience: public`. An `entry_token` is issued for the anonymous submitter to resume later. |
|
||||
|
||||
Public entry URL format:
|
||||
```
|
||||
{origin}/api/v1/public/workflows/{workflow_id}/start
|
||||
```
|
||||
|
||||
## Stage modes
|
||||
|
||||
Each stage has a **mode** that determines how it progresses:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `form` | User submits structured data. Stage data is accumulated into the instance. |
|
||||
| `review` | Multi-party sign-off gate. Requires configured approvals before advancing. |
|
||||
| `delegated` | Assigned to a team member queue. The assignee claims, works, and completes. |
|
||||
| `automated` | Starlark hook executes without user interaction. Can chain up to 10 consecutive automated stages. |
|
||||
|
||||
## Stage types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `simple` | Linear — always advances to the next ordinal. |
|
||||
| `dynamic` | Conditional — evaluates branch rules against stage data to pick the next stage. |
|
||||
| `automated` | Combined with mode `automated` for fully scripted stages. |
|
||||
|
||||
## Audiences
|
||||
|
||||
| Audience | Description |
|
||||
|----------|-------------|
|
||||
| `team` | Only authenticated team members can interact. |
|
||||
| `public` | Anonymous users can interact (used with `public_link` entry). |
|
||||
| `system` | System-generated stages, no direct user interaction. |
|
||||
|
||||
## Team assignment
|
||||
|
||||
When a stage has `assignment_team_id` set, the engine creates an
|
||||
**assignment** record:
|
||||
|
||||
1. Assignment enters the queue with status `unassigned`.
|
||||
2. A team member **claims** the assignment (status → `claimed`).
|
||||
3. The assignee works the stage and **completes** it (status → `completed`).
|
||||
4. The engine auto-advances to the next stage.
|
||||
|
||||
A **required role** can restrict who may claim:
|
||||
- Set `stage_config.required_role` to a team role name (e.g. `"reviewer"`).
|
||||
- Only members with that role can claim the assignment.
|
||||
|
||||
Team roles are configured in **Team Admin > Settings > Roles**.
|
||||
|
||||
## Signoff gates (multi-party validation)
|
||||
|
||||
Review-mode stages can require multiple approvals before advancing.
|
||||
Configure via `stage_config.validation`:
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 2,
|
||||
"required_role": "approver",
|
||||
"reject_action": "cancel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `required_approvals` | Minimum approve decisions needed to advance. |
|
||||
| `required_role` | Only members with this team role can sign off. Empty = any member. |
|
||||
| `reject_action` | What happens on rejection: `"cancel"` (default) cancels the instance, or a stage name to reroute. |
|
||||
|
||||
Each signoff records: user, decision (`approve` or `reject`), optional comment, timestamp.
|
||||
|
||||
## SLA enforcement
|
||||
|
||||
Two timeout mechanisms run in a background scanner (every 5 minutes):
|
||||
|
||||
### Per-stage SLA
|
||||
|
||||
Set `sla_seconds` on a stage. When an instance has been in that stage
|
||||
longer than the threshold:
|
||||
|
||||
- `sla_breached` flag is set in instance metadata.
|
||||
- A `workflow.sla_breach` WebSocket event is emitted.
|
||||
- The instance is **not** auto-cancelled — breaches are informational.
|
||||
|
||||
### Per-workflow staleness
|
||||
|
||||
Set `staleness_timeout_hours` on the workflow definition. When an instance
|
||||
has not been updated for longer than the threshold:
|
||||
|
||||
- Instance status is set to `stale`.
|
||||
- All open assignments are cancelled.
|
||||
- A `workflow.stale` WebSocket event is emitted.
|
||||
|
||||
## Branch rules
|
||||
|
||||
Dynamic stages evaluate conditions against accumulated `stage_data`
|
||||
to determine the next stage. Rules are a JSON array on the stage:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "field": "priority", "op": "eq", "value": "high", "target_stage": "escalation" },
|
||||
{ "field": "amount", "op": "gt", "value": 10000, "target_stage": "manager-review" }
|
||||
]
|
||||
```
|
||||
|
||||
First matching rule wins. If no rules match, the next ordinal stage is used.
|
||||
|
||||
### Operators
|
||||
|
||||
| Op | Description |
|
||||
|----|-------------|
|
||||
| `eq` | Equal (string-normalized) |
|
||||
| `neq` | Not equal |
|
||||
| `gt`, `lt`, `gte`, `lte` | Numeric comparisons |
|
||||
| `exists` | Field is present in stage data |
|
||||
| `not_exists` | Field is absent |
|
||||
| `in` | Value is in a list |
|
||||
| `contains` | String contains substring |
|
||||
|
||||
`target_stage` can be a stage name (case-insensitive) or a numeric ordinal.
|
||||
|
||||
## Publishing
|
||||
|
||||
Workflows have a draft/publish lifecycle:
|
||||
|
||||
1. Edit stages and configuration in the workflow editor (draft state).
|
||||
2. **Publish** creates a versioned snapshot of all stages.
|
||||
3. New instances pin the latest published version.
|
||||
4. Editing stages after publishing does not affect running instances.
|
||||
|
||||
Version numbers auto-increment. The snapshot preserves the complete
|
||||
stage definition array at publish time.
|
||||
|
||||
## Starlark hooks
|
||||
|
||||
Automated stages execute a Starlark script via the `starlark_hook` field:
|
||||
|
||||
```
|
||||
package_id:entry_point
|
||||
```
|
||||
|
||||
For example: `my-automation:on_review` calls the `on_review` function
|
||||
in the `my-automation` package. If no entry point is specified,
|
||||
`on_run` is used.
|
||||
|
||||
The hook receives a context dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"instance_id": "...",
|
||||
"current_stage": "...",
|
||||
"workflow_id": "...",
|
||||
"started_by": "...",
|
||||
"stage_data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The hook returns a dict controlling what happens next:
|
||||
|
||||
| Key | Effect |
|
||||
|-----|--------|
|
||||
| `advance: True` | Auto-advance to the next stage |
|
||||
| `data: { ... }` | Merge into stage data for the next stage |
|
||||
| `error: "msg"` | Set instance status to `error` and halt |
|
||||
|
||||
Up to 10 consecutive automated stages can chain before the engine
|
||||
stops with an error (cycle guard).
|
||||
|
||||
See the [Starlark Reference](STARLARK-REFERENCE) for available
|
||||
sandbox modules.
|
||||
@@ -25,17 +25,29 @@ type docEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
// docsOrder defines the display order and metadata for documentation files.
|
||||
var docsOrder = []docEntry{
|
||||
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide"},
|
||||
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components"},
|
||||
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions"},
|
||||
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format"},
|
||||
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview"},
|
||||
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide"},
|
||||
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages"},
|
||||
// Getting Started
|
||||
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide", Category: "Getting Started"},
|
||||
{Slug: "TUTORIAL-FIRST-EXTENSION", Title: "Tutorial: First Extension", Description: "Step-by-step extension walkthrough", Category: "Getting Started"},
|
||||
// Platform
|
||||
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components", Category: "Platform"},
|
||||
{Slug: "PERMISSIONS-AND-GROUPS", Title: "Permissions & Groups", Description: "RBAC model and settings cascade", Category: "Platform"},
|
||||
{Slug: "WORKFLOWS", Title: "Workflows", Description: "Multi-stage processes with team validation", Category: "Platform"},
|
||||
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide", Category: "Platform"},
|
||||
// Extension Development
|
||||
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions", Category: "Extension Development"},
|
||||
{Slug: "STARLARK-REFERENCE", Title: "Starlark Reference", Description: "Sandbox scripting API", Category: "Extension Development"},
|
||||
{Slug: "FRONTEND-JS-GUIDE", Title: "Frontend JS Guide", Description: "Preact SDK and shell contract", Category: "Extension Development"},
|
||||
{Slug: "EXTENSION-CSS", Title: "Extension CSS", Description: "CSS isolation and primitives", Category: "Extension Development"},
|
||||
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format", Category: "Extension Development"},
|
||||
// Operations
|
||||
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview", Category: "Operations"},
|
||||
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages", Category: "Operations"},
|
||||
{Slug: "PACKAGE-REGISTRY", Title: "Package Registry", Description: "Registry API and operations", Category: "Operations"},
|
||||
}
|
||||
|
||||
// ListDocs returns the list of available documentation files.
|
||||
@@ -70,7 +82,8 @@ func (h *DocsHandler) ListDocs(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
// Skip design docs and other non-user-facing files
|
||||
if strings.HasPrefix(slug, "DESIGN-") || strings.HasPrefix(slug, "DEMO-") {
|
||||
if strings.HasPrefix(slug, "DESIGN-") || strings.HasPrefix(slug, "DEMO-") ||
|
||||
strings.HasPrefix(slug, "AUDIT-") || strings.HasPrefix(slug, "USABILITY-") {
|
||||
continue
|
||||
}
|
||||
available = append(available, docEntry{
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
.sw-inline-error {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--bg-2); border: 1px solid var(--danger);
|
||||
background: var(--bg-secondary); border: 1px solid var(--danger);
|
||||
border-radius: var(--radius); font-size: 13px; color: var(--danger);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
|
||||
.sw-topbar__tab.active {
|
||||
color: var(--text);
|
||||
background: var(--bg-2);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -16,6 +16,17 @@ const { render } = preact;
|
||||
// Preload marked so renderSync works in useMemo
|
||||
if (sw?.markdown) sw.markdown.preload();
|
||||
|
||||
/** Group docs by category, preserving order of first appearance. */
|
||||
function groupByCategory(docs) {
|
||||
const map = new Map();
|
||||
for (const d of docs) {
|
||||
const cat = d.category || '';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat).push(d);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
function DocsSurface() {
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [active, setActive] = useState(window.__SECTION__ || 'GETTING-STARTED');
|
||||
@@ -81,7 +92,13 @@ function DocsSurface() {
|
||||
|
||||
function scrollToHeading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (!el) return;
|
||||
const container = document.querySelector('.docs-content');
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendered HTML via sw.markdown ──
|
||||
@@ -122,7 +139,10 @@ function DocsSurface() {
|
||||
<p>Failed to load docs list.</p>
|
||||
<button onclick=${retryList} class="docs-retry-btn">Retry</button>
|
||||
</div>
|
||||
` : docs.map(d => html`
|
||||
` : groupByCategory(docs).map(([cat, items]) => html`
|
||||
<div key=${cat} class="docs-category-group">
|
||||
${cat && html`<div class="docs-category-heading">${cat}</div>`}
|
||||
${items.map(d => html`
|
||||
<a key=${d.slug}
|
||||
class="docs-nav-item ${d.slug === active ? 'active' : ''}"
|
||||
onclick=${() => navigate(d.slug)}
|
||||
@@ -130,6 +150,8 @@ function DocsSurface() {
|
||||
${d.title}
|
||||
</a>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
</nav>
|
||||
<main class="docs-content">
|
||||
${loading ? html`
|
||||
@@ -184,6 +206,16 @@ style.textContent = `
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.docs-category-group { margin-bottom: 4px; }
|
||||
.docs-category-heading {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.docs-category-group:first-child .docs-category-heading { padding-top: 0; }
|
||||
.docs-nav-item {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
|
||||
337
src/js/sw/surfaces/team-admin/workflow-editor.js
Normal file
337
src/js/sw/surfaces/team-admin/workflow-editor.js
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Workflow Editor + Stage Form
|
||||
*
|
||||
* Extracted from workflows.js — inline editing for a single workflow
|
||||
* definition and its stages (modes, audiences, SLA, validation, branches).
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
||||
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
||||
const AUDIENCES = ['team', 'public', 'system'];
|
||||
|
||||
// ── Workflow Editor (with Stage Editor) ─────
|
||||
|
||||
export function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
const [stages, setStages] = useState([]);
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||
|
||||
const loadStages = useCallback(async () => {
|
||||
try {
|
||||
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
|
||||
setStages(s || []);
|
||||
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 {
|
||||
const patch = {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
};
|
||||
const sth = form.staleness_timeout_hours.value.trim();
|
||||
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onBack}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${publishWorkflow}>Publish</button>
|
||||
<button type="button" class="sw-btn sw-btn--danger sw-btn--sm" 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-row">
|
||||
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
|
||||
<input type="number" name="staleness_timeout_hours" min="0"
|
||||
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
${workflow.entry_mode === 'public_link' && html`
|
||||
<div style="margin:8px 0;">
|
||||
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
|
||||
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
|
||||
onClick=${e => e.target.select()} />
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
||||
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
e.target.textContent = 'Copied!';
|
||||
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">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="sw-btn sw-btn--secondary sw-btn--sm" 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>
|
||||
<span class="badge">${s.audience || 'team'}</span>
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" 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)}
|
||||
teams=${teams}
|
||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||
onCancel=${() => setEditingStage(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stage Form ──────────────────────────────
|
||||
|
||||
function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
const [branchRules, setBranchRules] = useState(
|
||||
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
||||
);
|
||||
|
||||
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
||||
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
|
||||
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
|
||||
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
|
||||
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
|
||||
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assignTeam) return;
|
||||
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
|
||||
}, [assignTeam]);
|
||||
|
||||
function submit() {
|
||||
const stageConfig = {};
|
||||
if (requiredRole) stageConfig.required_role = requiredRole;
|
||||
if (valApprovals) {
|
||||
stageConfig.validation = {
|
||||
required_approvals: parseInt(valApprovals, 10),
|
||||
...(valRole ? { required_role: valRole } : {}),
|
||||
reject_action: valReject || 'cancel',
|
||||
};
|
||||
}
|
||||
let parsedBranch = null;
|
||||
if (branchRules.trim()) {
|
||||
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
||||
}
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
audience,
|
||||
stage_type: stageType,
|
||||
starlark_hook: starlarkHook || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
||||
branch_rules: parsedBranch || [],
|
||||
});
|
||||
}
|
||||
|
||||
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}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Audience</label>
|
||||
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stage Type</label>
|
||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${stageType !== 'simple' && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Starlark Hook</label>
|
||||
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
||||
placeholder="package_id:entry_point" />
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-row">
|
||||
<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 class="form-group">
|
||||
<label>SLA (seconds)</label>
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
</div>
|
||||
</div>
|
||||
${assignTeam && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Required Role (claim)</label>
|
||||
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
||||
<strong style="font-size:12px;">Multi-party Validation</strong>
|
||||
<div class="form-row" style="margin-top:4px;">
|
||||
<div class="form-group">
|
||||
<label>Required Approvals</label>
|
||||
<input type="number" min="0" value=${valApprovals}
|
||||
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Signoff Role</label>
|
||||
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On Reject</label>
|
||||
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
||||
<option value="cancel">Cancel instance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
||||
<div class="form-group" style="margin-top:4px;">
|
||||
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
||||
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
||||
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
<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="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
|
||||
${stage ? 'Update' : 'Add Stage'}
|
||||
</button>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
239
src/js/sw/surfaces/team-admin/workflow-monitor.js
Normal file
239
src/js/sw/surfaces/team-admin/workflow-monitor.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Workflow Assignments, Monitor, and Signoff
|
||||
*
|
||||
* Extracted from workflows.js — runtime views for active workflow
|
||||
* instances: assignment queue, instance monitoring, and signoff panel.
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
function _formatDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '\u2014';
|
||||
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`;
|
||||
}
|
||||
|
||||
// ── Tab 2: Assignments ──────────────────────
|
||||
|
||||
export 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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" 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="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
|
||||
</div>
|
||||
`)}
|
||||
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Tab 3: Monitor ──────────────────────────
|
||||
|
||||
export function MonitorTab({ teamId }) {
|
||||
const [instances, setInstances] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedSignoff, setExpandedSignoff] = useState(null);
|
||||
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
</div>
|
||||
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
|
||||
</div>
|
||||
`)}
|
||||
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Signoff Panel ──────────────────
|
||||
|
||||
function SignoffPanel({ instanceId, teamId }) {
|
||||
const [signoffs, setSignoffs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
|
||||
setSignoffs(resp.data || []);
|
||||
} catch { setSignoffs([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function submit(decision) {
|
||||
try {
|
||||
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
|
||||
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
|
||||
setComment('');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
|
||||
|
||||
const approvals = signoffs.filter(s => s.decision === 'approve').length;
|
||||
const rejections = signoffs.filter(s => s.decision === 'reject').length;
|
||||
|
||||
return html`
|
||||
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
|
||||
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<input value=${comment} onInput=${e => setComment(e.target.value)}
|
||||
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => submit('approve')}>Approve</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => submit('reject')}>Reject</button>
|
||||
</div>
|
||||
${signoffs.length > 0 && html`
|
||||
<div style="margin-top:6px;">
|
||||
${signoffs.map(s => html`
|
||||
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
|
||||
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
|
||||
<span>${sw.users?.displayName?.(s.user_id) || s.user_id}</span>
|
||||
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2,38 +2,21 @@
|
||||
* Team Admin > Workflows
|
||||
*
|
||||
* Tab layout: Workflows | Assignments | Monitor
|
||||
* - Workflows: CRUD + inline stage editor (E2)
|
||||
* - Assignments: Team assignment queue (E1)
|
||||
* - Monitor: Active instance list with cancel (E3)
|
||||
* - Workflows: CRUD + inline stage editor → workflow-editor.js
|
||||
* - Assignments: Team assignment queue → workflow-monitor.js
|
||||
* - Monitor: Active instance list with cancel → workflow-monitor.js
|
||||
*/
|
||||
import { WorkflowEditor } from './workflow-editor.js';
|
||||
import { AssignmentsTab, MonitorTab } from './workflow-monitor.js';
|
||||
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
const { useState, useCallback, useEffect } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
||||
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
||||
const AUDIENCES = ['team', 'public', 'system'];
|
||||
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`;
|
||||
function _unwrapList(resp) {
|
||||
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
|
||||
}
|
||||
|
||||
// ── Main Section ────────────────────────────
|
||||
@@ -59,10 +42,6 @@ export default function WorkflowsSection({ teamId }) {
|
||||
|
||||
// ── Tab 1: Workflows ────────────────────────
|
||||
|
||||
function _unwrapList(resp) {
|
||||
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
|
||||
}
|
||||
|
||||
function WorkflowsTab({ teamId }) {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -186,537 +165,3 @@ function WorkflowsTab({ teamId }) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Workflow Editor (with Stage Editor) ─────
|
||||
|
||||
function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
const [stages, setStages] = useState([]);
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||
|
||||
const loadStages = useCallback(async () => {
|
||||
try {
|
||||
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
|
||||
setStages(s || []);
|
||||
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 {
|
||||
const patch = {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
};
|
||||
const sth = form.staleness_timeout_hours.value.trim();
|
||||
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onBack}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${publishWorkflow}>Publish</button>
|
||||
<button type="button" class="sw-btn sw-btn--danger sw-btn--sm" 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-row">
|
||||
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
|
||||
<input type="number" name="staleness_timeout_hours" min="0"
|
||||
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
${workflow.entry_mode === 'public_link' && html`
|
||||
<div style="margin:8px 0;">
|
||||
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
|
||||
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
|
||||
onClick=${e => e.target.select()} />
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
||||
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
e.target.textContent = 'Copied!';
|
||||
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">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="sw-btn sw-btn--secondary sw-btn--sm" 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>
|
||||
<span class="badge">${s.audience || 'team'}</span>
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" 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)}
|
||||
teams=${teams}
|
||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||
onCancel=${() => setEditingStage(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stage Form ──────────────────────────────
|
||||
|
||||
function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
const [branchRules, setBranchRules] = useState(
|
||||
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
||||
);
|
||||
|
||||
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
||||
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
|
||||
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
|
||||
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
|
||||
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
|
||||
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assignTeam) return;
|
||||
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
|
||||
}, [assignTeam]);
|
||||
|
||||
function submit() {
|
||||
const stageConfig = {};
|
||||
if (requiredRole) stageConfig.required_role = requiredRole;
|
||||
if (valApprovals) {
|
||||
stageConfig.validation = {
|
||||
required_approvals: parseInt(valApprovals, 10),
|
||||
...(valRole ? { required_role: valRole } : {}),
|
||||
reject_action: valReject || 'cancel',
|
||||
};
|
||||
}
|
||||
let parsedBranch = null;
|
||||
if (branchRules.trim()) {
|
||||
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
||||
}
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
audience,
|
||||
stage_type: stageType,
|
||||
starlark_hook: starlarkHook || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
||||
branch_rules: parsedBranch || [],
|
||||
});
|
||||
}
|
||||
|
||||
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}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Audience</label>
|
||||
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stage Type</label>
|
||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${stageType !== 'simple' && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Starlark Hook</label>
|
||||
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
||||
placeholder="package_id:entry_point" />
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-row">
|
||||
<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 class="form-group">
|
||||
<label>SLA (seconds)</label>
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
</div>
|
||||
</div>
|
||||
${assignTeam && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Required Role (claim)</label>
|
||||
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
||||
<strong style="font-size:12px;">Multi-party Validation</strong>
|
||||
<div class="form-row" style="margin-top:4px;">
|
||||
<div class="form-group">
|
||||
<label>Required Approvals</label>
|
||||
<input type="number" min="0" value=${valApprovals}
|
||||
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Signoff Role</label>
|
||||
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On Reject</label>
|
||||
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
||||
<option value="cancel">Cancel instance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
||||
<div class="form-group" style="margin-top:4px;">
|
||||
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
||||
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
||||
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
<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="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
|
||||
${stage ? 'Update' : 'Add Stage'}
|
||||
</button>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" 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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" 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="sw-btn sw-btn--primary sw-btn--sm" 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 [expandedSignoff, setExpandedSignoff] = useState(null);
|
||||
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
</div>
|
||||
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
|
||||
</div>
|
||||
`)}
|
||||
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Signoff Panel ──────────────────
|
||||
|
||||
function SignoffPanel({ instanceId, teamId }) {
|
||||
const [signoffs, setSignoffs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
|
||||
setSignoffs(resp.data || []);
|
||||
} catch { setSignoffs([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function submit(decision) {
|
||||
try {
|
||||
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
|
||||
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
|
||||
setComment('');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
|
||||
|
||||
const approvals = signoffs.filter(s => s.decision === 'approve').length;
|
||||
const rejections = signoffs.filter(s => s.decision === 'reject').length;
|
||||
|
||||
return html`
|
||||
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
|
||||
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<input value=${comment} onInput=${e => setComment(e.target.value)}
|
||||
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => submit('approve')}>Approve</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => submit('reject')}>Reject</button>
|
||||
</div>
|
||||
${signoffs.length > 0 && html`
|
||||
<div style="margin-top:6px;">
|
||||
${signoffs.map(s => html`
|
||||
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
|
||||
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
|
||||
<span>${sw.users?.displayName?.(s.user_id) || s.user_id}</span>
|
||||
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user