diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 4e343ef3c..c6c74f0ab 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -242,6 +242,8 @@ Channel DB modules: `db/slack_channels.py` (workspace connections, channel-agent **Agent Detail Overview tab (#1107):** `components/OverviewPanel.vue` is the default landing tab — owns "trend over the last few days" while the persistent `AgentHeader` owns "now + cost" (no duplicate live gauges). Sections: About lead, needs-attention count + Operations link (hidden at zero), trend charts, health panel (uptime/latency clamped ≤7d by `agent_health_checks` retention), recent-activity drill-in, footprint chips. Charts: `StackedBarChart.vue` (CSS/flexbox) for executions-by-type; `TrendLineChart.vue` (uPlot) for line series. `InfoPanel.vue` leads with About + "What You Can Ask", `template.yaml` metadata behind a `
`. +**Dashboard Grid view (trinity-enterprise#47):** third dashboard mode (Grid / Graph / Timeline; Timeline stays default, choice in `localStorage['trinity-dashboard-view']`). Magnetic tile canvas: `components/FleetGrid.vue` (pan/zoom viewport + drag/swap/tidy/keyboard on an unbounded lattice — no Vue Flow) renders `components/AgentTile.vue` five-zone tiles (composing AgentAvatar/RuntimeBadge/Running+Autonomy toggles); `stores/fleetGrid.js` owns the per-user self-healing layout (localStorage v1) and lazy per-tile analytics hydration (viewport-gated, concurrency-capped, stale-while-revalidate over the `executions` store `${name}:${window}` cache); lattice math in `utils/gridLayout.js`. Chip data from batch endpoints (sync-health #389, operator-queue pending) on a visibility-aware poll active only while mounted. `network.js` additions: 3-state `viewMode`, `circuitBreakers` map, WS-driven `workingState`. **No new backend endpoints.** See [dashboard-grid-view.md](feature-flows/dashboard-grid-view.md). + **Collaboration Dashboard** (`views/AgentCollaboration.vue`, `components/AgentNode.vue`, `stores/collaborations.js`): Vue Flow node graph of agent-to-agent communication — draggable status-colored nodes, edges animated 3s on collaboration, real-time activity feed, replay with time-range filtering, localStorage node positions. Detection: the backend chat endpoint accepts `X-Source-Agent` and broadcasts `agent_collaboration` WS events; `activity_service` broadcasts `agent_activity` (`activity_type`: chat_start/chat_end/tool_call/schedule_start/schedule_end/agent_collaboration; `activity_state`: started/completed/failed/cancelled — a user-cancelled terminal is recorded as `cancelled`, distinct from `failed`, #1332). ### MCP Server (`src/mcp-server/`) diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index 4c8e23272..fde2ad6aa 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -17,6 +17,7 @@ |------|-----|--------|------| | 2026-07-04 | #903 | fix(slack): thread-scoped session + per-speaker attribution + sender-filtered memory | [slack-channel-routing.md](feature-flows/slack-channel-routing.md) | | 2026-07-04 | #1445 | fix(webhooks): gate schedule/webhook **creation** on a live owning agent (`is_agent_live` → 404; access-check-first so non-owners see a uniform 403) so a webhook token always resolves to a schedule of a live agent — closes the orphan-schedule class that made valid tokens 404 after #1423's soft-delete-aware token-lookup INNER JOIN | [webhook-triggers.md](feature-flows/webhook-triggers.md) | +| 2026-07-06 | trinity-enterprise#47 | feat(ui): Dashboard **Grid view** — third mode (Grid/Graph/Timeline, not default): magnetic tile canvas on an unbounded pan/zoom lattice; five-zone 384×216 tiles (adaptive chips, Activity·14d + Context·7d charts, Run/Auto toggles); drag with socket preview + swap, tidy/reset, keyboard reorder; skeleton-first render, viewport-gated cached analytics hydration, visibility-aware batch polling. No new backend endpoints | [dashboard-grid-view.md](feature-flows/dashboard-grid-view.md) | | 2026-07-04 | #1018 | fix(nevermined): settlement-ordering — honest `success_unsettled` on settle-fail (was lying `"success"`); `Idempotency-Key` on the paid boundary keyed on `(payment-signature ∥ message)` with in-flight-409 / settled-replay / unsettled-re-drive-converge; `/retry-settlement` stub → honest 501. Tier 2 durable retry split to a follow-up | [nevermined-payments.md](feature-flows/nevermined-payments.md), [effect-idempotency.md](feature-flows/effect-idempotency.md) | | 2026-07-04 | #186 | fix(security): close user & agent enumeration oracles — uniform 404 across the agent-access dependency family + router sweep + Tier-4 GETs; identical email-request body/status/timing; MCP `checkAgentAccess` uniform reason + owner-username removal (pentest 3.3.3) | [email-authentication.md](feature-flows/email-authentication.md), [agent-permissions.md](feature-flows/agent-permissions.md) | | 2026-07-04 | #1444 | fix(chat): fail-loud + owner-gated `/task` chat-session persistence (no silent swallow, IDOR fix, SUCCESS-guarded) + fast unit regression guard | [authenticated-chat-tab.md](feature-flows/authenticated-chat-tab.md) | @@ -84,6 +85,7 @@ |------|----------|-------------| | Agent Network (Dashboard) | [agent-network.md](feature-flows/agent-network.md) | Real-time visual graph at `/` | | Dashboard Timeline View | [dashboard-timeline-view.md](feature-flows/dashboard-timeline-view.md) | Graph/Timeline mode toggle with execution boxes | +| Dashboard Grid View | [dashboard-grid-view.md](feature-flows/dashboard-grid-view.md) | Magnetic tile canvas — third dashboard mode (trinity-enterprise#47) | | Replay Timeline | [replay-timeline.md](feature-flows/replay-timeline.md) | Waterfall-style timeline visualization | | Activity Stream | [activity-stream.md](feature-flows/activity-stream.md) | Centralized persistent activity tracking | | Activity Monitoring | [activity-monitoring.md](feature-flows/activity-monitoring.md) | Real-time tool execution tracking | diff --git a/docs/memory/feature-flows/dashboard-grid-view.md b/docs/memory/feature-flows/dashboard-grid-view.md new file mode 100644 index 000000000..944b0d378 --- /dev/null +++ b/docs/memory/feature-flows/dashboard-grid-view.md @@ -0,0 +1,129 @@ +# Feature Flow: Dashboard Grid View (magnetic tile canvas) + +> **Last Updated**: 2026-07-06 (initial implementation) +> **Status**: Implemented — third dashboard mode, not default +> **Issue**: trinity-enterprise#47 (design of record embedded in the issue) +> **Requirements**: `docs/memory/requirements/core-agent.md` §9.8 + +## Overview + +The Dashboard's third view mode alongside **Graph** (Vue Flow topology) and +**Timeline** (waterfall activity). Grid is a **magnetic tile canvas**: richer +384×216 landscape agent tiles that snap to a sparse, **unbounded** integer +lattice (negative coordinates included) the operator arranges freely — +islands, gaps, parked loners — with iPhone-style drag and live snap preview, +on the same pan/zoom dotted-canvas language as the graph view. + +- Mode toggle: `Grid / Graph / Timeline` in the Dashboard header; selection + persists to `localStorage['trinity-dashboard-view']`. **Timeline stays the + default** for users with no saved preference. +- **No Vue Flow dependency** in this mode, and **no new backend endpoints**. + +## Components & Data Flow + +``` +views/Dashboard.vue mode toggle, grid pane (v-if), Tidy up / Reset pills, + │ "N working now" header stat, empty/error/skeleton states + ├─ components/FleetGrid.vue pan/zoom viewport, lattice, drag physics, sockets, + │ │ cell shading, keyboard reorder, zoom controls + legend, + │ │ viewport culling, shared 1s tick for tile timers + │ └─ components/AgentTile.vue five-zone tile (see below); composes + │ AgentAvatar / RuntimeBadge / RunningStateToggle / AutonomyToggle + ├─ stores/fleetGrid.js per-user layout (localStorage v1, self-healing), + │ lazy analytics hydration queue (concurrency 4, + │ stale-while-revalidate over executions-store cache), + │ batch chip data (sync-health + operator-queue pending) + │ on a 60s visibility-aware poll, active only while mounted + ├─ stores/network.js agents list, contextStats / executionStats / slotStats + │ (15s shared poll), NEW: viewMode ('grid'|'graph'|'timeline'), + │ circuitBreakers map, WS-driven workingState map + ├─ stores/executions.js fetchAgentAnalytics(name, '14d') — existing + │ `${name}:${window}` cache (#1107) + └─ utils/gridLayout.js pure lattice math: cell geometry, spiral + nearest-free-cell, normalize (self-healing), tidy, bbox +``` + +### Data sources (all existing) + +| Tile element | Source | +|---|---| +| Identity, status, runtime, repo, autonomy | `GET /api/agents` (network store) | +| Activity·14d chart + Context·7d chart | `GET /api/agents/{name}/analytics?window=14d` (#1107) — one fetch feeds both; last 7 timeline entries drive the context line | +| Live context %, Active/Idle/Offline | fleet `GET /api/agents/context-stats` (15s poll) | +| Success meter, tasks/cost/last-run, schedules chip | fleet `GET /api/agents/execution-stats` (15s poll) | +| ⚡ circuit open chip | `GET /api/agents/slots` → `circuit_breakers` map (#526) | +| ⟳ sync failing / git ✓ chips | `GET /api/agents/sync-health` (#389, batch) | +| ⚠ needs response / approval pending chip | `GET /api/operator-queue?status=pending` (batch, grouped per agent) | +| ▶ working + elapsed timer | WS `agent_activity` events → `workingState` map, reconciled by the context-stats poll; fallback `activityState === 'active'` | + +### Trigger-bucket collapse (tile scale) + +The backend's #1107 buckets collapse to three groups: **Scheduled** ← +Scheduled · **Manual·MCP** ← Chat/Tasks, MCP, Loops, Agent-to-agent, Other · +**External** ← Channels, Public, Voice. A board-level legend (bottom-right) +explains the colors once — never repeated per tile. + +## Layout model + +- Layout = per-user map `agent → {c, r}`; localStorage key + `trinity-grid-layout-v1`; server-side per-user storage is a follow-up. +- **Self-healing** (`normalizeLayout`): new agents take the first free cell + near the origin (spiral search); deleted agents leave their gap; an invalid + or colliding saved position resolves to the nearest free cell. +- **Filters never destroy layout**: persisting merges the active layout over + the full saved map, so agents hidden by an owner/tag filter keep their + saved cells (filtering is indistinguishable from deletion client-side, so + absence is never treated as deletion). +- **Tidy up** compacts row-by-row (3 columns) preserving reading order, + anchored at the layout's own top-left, clamped to the coordinate bound. + **Reset** restores the deterministic default (system agent first, + reading-order grid) and drops stale saved entries. + +## Interaction constants (design of record) + +Cell 384×216, gaps 34/18 (column gap exceeds the 26px avatar overhang); +zoom 0.25–1.6× around the cursor; drag follows 1:1 (screen deltas ÷ zoom) +with ±3° velocity tilt; displace/reflow 280ms `cubic-bezier(.3,.7,.25,1)`; +overshoot snap 420ms `cubic-bezier(.22,1.35,.32,1)`; lock-ring pulse 450ms. +Drop on an occupied cell **swaps** (live preview while hovering; a swap never +disturbs a third tile). Keyboard: focused tile moves with arrow keys (through +a neighbor = swap). `prefers-reduced-motion` disables tilt/spring/pulse — +drops become instant placement. Multi-touch is discriminated by pointer id +(one drag at a time; a second touch cannot start a pan mid-drag). + +## Performance contract (#47 acceptance criteria) + +1. **Non-blocking first paint** — tiles render immediately from the agents + list the Dashboard already holds; per-section skeletons while analytics + stream in. Nothing awaits the full set. +2. **Lazy, capped hydration** — a tile asks the fleetGrid store to hydrate + only when near the viewport (culled tiles render a light placeholder and + fetch nothing); fetches run through a 4-slot queue into the executions + store's `(agent, window)` cache; stale entries (>5 min) serve instantly + and refresh in the background. +3. **Batch endpoints over per-agent loops** for chip data; the 60s poll is + visibility-aware (skips when `document.hidden`) and tears down when the + Grid unmounts (mode switch is `v-if`). +4. **A slow or failed per-agent fetch degrades that one tile only.** + +## Failure modes & edge cases + +- Corrupt/unavailable localStorage → default layout, session-local. +- Agent deleted/renamed mid-session → self-healing pass; a mid-drag removal + cancels the drag cleanly. +- Stopped agent → Offline state, context chart flattens to a dash. +- Analytics fetch error with no cache → charts degrade to flat/na quietly. +- WS `workingState` entries leaked by missed end events are reconciled by + the 15s context-stats poll (entries younger than one poll period are + spared to avoid a stale response evicting a fresh start). + +## Testing + +`src/frontend/e2e/dashboard-grid-view.spec.js` — mode toggle + tile render +(@smoke), mode persistence across reload, drag-to-cell with socket preview + +layout persistence, tidy/reset, and Graph/Timeline coexistence (@smoke). + +## Out of scope (tracked follow-ups) + +Fleet KPI strip; "Needs your attention" + live-activity right rail; +server-side per-user layout storage. diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 00ee05506..48705e343 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -196,6 +196,14 @@ - **Status**: ❌ Removed (2025-12-23) - **Reason**: Individual agent planning deferred to orchestrator-level. Claude Code handles task management internally. +### 9.8 Dashboard Grid View (trinity-enterprise#47) +- **Status**: ✅ Implemented (2026-07-06) +- **Description**: Third dashboard mode (Grid / Graph / Timeline) — a magnetic tile canvas: rich 384×216 landscape agent tiles snapping to a sparse, unbounded lattice the operator arranges freely, on the same pan/zoom dotted-canvas language as the graph view. Not the default (Timeline remains default for new users); selection persists to localStorage. +- **Key Features**: iPhone-style drag with live socket preview + swap-with-preview; Tidy up / Reset; keyboard arrow reorder; per-user layout (`agent → {col,row}`, localStorage v1, self-healing); five-zone tile (identity with half-out avatar, adaptive chip strip with live working timer, Activity·14d stacked-by-trigger + Context·7d trend charts, success micro-meter + stats, Run/Auto toggles); system agent keeps its purple treatment; `prefers-reduced-motion` honored. +- **Performance (first-class)**: skeleton-first render from `/api/agents`; per-tile analytics hydrate lazily (viewport-gated, concurrency-capped) into the existing `(agent, window)` cache with stale-while-revalidate; batch endpoints for chip data (sync-health, operator-queue) on a visibility-aware poll that tears down when the mode is inactive; viewport culling for 50+ fleets. **No new backend endpoints** — reads `/api/agents/{name}/analytics` (#1107), fleet context/execution/slot stats, `/api/agents/sync-health` (#389), operator-queue pending. +- **Out of scope (follow-ups)**: fleet KPI strip; "Needs your attention" + live-activity right rail. +- **Flow**: `docs/memory/feature-flows/dashboard-grid-view.md` + --- --- diff --git a/src/frontend/e2e/dashboard-grid-view.spec.js b/src/frontend/e2e/dashboard-grid-view.spec.js new file mode 100644 index 000000000..e90d01487 --- /dev/null +++ b/src/frontend/e2e/dashboard-grid-view.spec.js @@ -0,0 +1,124 @@ +import { test, expect } from '@playwright/test' + +/** + * Dashboard Grid view e2e (trinity-enterprise#47). + * + * The Grid is a third dashboard mode alongside Graph and Timeline: a + * magnetic tile canvas on an unbounded pan/zoom lattice. These specs cover + * the mode toggle + persistence, tile rendering, drag-to-cell with swap, + * tidy/reset, and that the existing Graph/Timeline modes are untouched. + * + * Runs against a live stack — every install has at least `trinity-system`, + * so no fixtures are needed. Layout state is reset per test via + * localStorage. + */ + +const LAYOUT_KEY = 'trinity-grid-layout-v1' +const MODE_KEY = 'trinity-dashboard-view' + +async function gotoGrid(page) { + await page.goto('/') + await page.getByRole('button', { name: 'grid', exact: true }).click() + await expect(page.locator('.fleet-canvas')).toBeVisible() +} + +test.describe('dashboard grid view (trinity-enterprise#47)', () => { + test.beforeEach(async ({ page }) => { + // Fresh layout + default mode for deterministic assertions. + await page.addInitScript( + ([layoutKey]) => { + localStorage.removeItem(layoutKey) + }, + [LAYOUT_KEY] + ) + }) + + test('@smoke mode toggle shows Grid and renders agent tiles', async ({ page }) => { + await gotoGrid(page) + + // At least the system agent tile renders, with tile chrome + zones. + const sysTile = page.locator('.gv-tile[data-agent="trinity-system"]') + await expect(sysTile).toBeVisible({ timeout: 15000 }) + await expect(sysTile).toContainText('SYSTEM') + await expect(sysTile).toContainText('Activity · 14d') + await expect(sysTile).toContainText('Context · 7d') + await expect(sysTile).toContainText('System Dashboard') + + // Grid-mode-only controls + board furniture. + await expect(page.getByRole('button', { name: 'Tidy up' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Reset', exact: true })).toBeVisible() + await expect(page.locator('.gv-legend')).toContainText('Scheduled') + await expect(page.locator('.gv-zoomlvl')).toContainText('%') + }) + + test('mode choice persists across reload', async ({ page }) => { + await gotoGrid(page) + expect(await page.evaluate((k) => localStorage.getItem(k), MODE_KEY)).toBe('grid') + + await page.reload() + // Lands straight back in grid mode without touching the toggle. + await expect(page.locator('.fleet-canvas')).toBeVisible({ timeout: 15000 }) + }) + + test('drag moves a tile to a free cell and persists the layout', async ({ page }) => { + await gotoGrid(page) + const tile = page.locator('.gv-tile[data-agent="trinity-system"]') + await expect(tile).toBeVisible({ timeout: 15000 }) + + const before = await page.evaluate((k) => localStorage.getItem(k), LAYOUT_KEY) + + // Drag by two cell-heights straight down (mouse-level, exercises the + // pointer capture + socket path). + const box = await tile.boundingBox() + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + for (let i = 1; i <= 10; i++) { + await page.mouse.move( + box.x + box.width / 2, + box.y + box.height / 2 + (i / 10) * box.height * 2.2, + { steps: 2 } + ) + } + // Socket preview is visible mid-drag. + await expect(page.locator('.gv-socket.show')).toBeVisible() + await page.mouse.up() + + await expect + .poll(async () => page.evaluate((k) => localStorage.getItem(k), LAYOUT_KEY)) + .not.toBe(before) + }) + + test('tidy up compacts and reset restores the default layout', async ({ page }) => { + await gotoGrid(page) + await expect(page.locator('.gv-tile').first()).toBeVisible({ timeout: 15000 }) + + await page.getByRole('button', { name: 'Tidy up' }).click() + const tidied = await page.evaluate((k) => localStorage.getItem(k), LAYOUT_KEY) + expect(tidied).toBeTruthy() + + await page.getByRole('button', { name: 'Reset', exact: true }).click() + const reset = await page.evaluate((k) => localStorage.getItem(k), LAYOUT_KEY) + expect(reset).toBeTruthy() + // Reset yields the deterministic default: every position is a small + // non-negative reading-order cell. + const positions = Object.values(JSON.parse(reset)) + for (const p of positions) { + expect(p.c).toBeGreaterThanOrEqual(0) + expect(p.r).toBeGreaterThanOrEqual(0) + } + }) + + test('@smoke graph and timeline modes still work alongside grid', async ({ page }) => { + await gotoGrid(page) + + // Graph: Vue Flow mounts, grid tears down. + await page.getByRole('button', { name: 'graph', exact: true }).click() + await expect(page.locator('.vue-flow')).toBeVisible({ timeout: 15000 }) + await expect(page.locator('.fleet-canvas')).toHaveCount(0) + + // Timeline: replay timeline mounts, graph tears down. + await page.getByRole('button', { name: 'timeline', exact: true }).click() + await expect(page.locator('.vue-flow')).toHaveCount(0) + await expect(page.locator('.fleet-canvas')).toHaveCount(0) + }) +}) diff --git a/src/frontend/src/components/AgentTile.vue b/src/frontend/src/components/AgentTile.vue new file mode 100644 index 000000000..456b8bd88 --- /dev/null +++ b/src/frontend/src/components/AgentTile.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/src/frontend/src/components/FleetGrid.vue b/src/frontend/src/components/FleetGrid.vue new file mode 100644 index 000000000..82b48ca55 --- /dev/null +++ b/src/frontend/src/components/FleetGrid.vue @@ -0,0 +1,861 @@ + + + + + diff --git a/src/frontend/src/stores/fleetGrid.js b/src/frontend/src/stores/fleetGrid.js new file mode 100644 index 000000000..45d75abbd --- /dev/null +++ b/src/frontend/src/stores/fleetGrid.js @@ -0,0 +1,276 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import axios from 'axios' +import { useAuthStore } from './auth' +import { useExecutionsStore } from './executions' +import { + defaultLayout, + normalizeLayout, + nearestFreeCell, + tidyLayout, + occupantAt, +} from '@/utils/gridLayout' + +/** + * Fleet Grid store (trinity-enterprise#47) — owns the Dashboard Grid view's + * per-user tile layout and its grid-scoped data hydration. + * + * Performance contract (first-class requirement of #47): + * - The grid renders skeleton tiles from the cheap `/api/agents` list the + * network store already holds; nothing here blocks first paint. + * - Per-tile analytics hydrate lazily (only tiles near the viewport ask), + * through a small concurrency-capped queue, into the executions store's + * existing `(agent, window)` cache — stale data is served instantly and + * refreshed in the background (stale-while-revalidate). + * - Grid-wide chip data (sync health, operator-queue pending) comes from + * two batch endpoints on a slow, visibility-aware poll that only runs + * while the Grid is mounted. + */ + +const LAYOUT_KEY = 'trinity-grid-layout-v1' +const ANALYTICS_WINDOW = '14d' // one fetch feeds Activity·14d + Context·7d (last 7 entries) +const ANALYTICS_STALE_MS = 5 * 60 * 1000 +const HYDRATE_CONCURRENCY = 4 +const BATCH_POLL_MS = 60000 + +export const useFleetGridStore = defineStore('fleetGrid', () => { + const authStore = useAuthStore() + const executionsStore = useExecutionsStore() + + // --- layout: agent → {c, r} on the unbounded lattice --- + // `layout` holds ONLY the agents currently shown. `_savedRaw` is the full + // persisted map, including agents hidden by an owner/tag filter — persisting + // merges over it so toggling a filter never erases the positions of the + // tiles it hides (filtering is indistinguishable from deletion here, so we + // never treat absence as deletion; a truly deleted agent's entry just + // lingers harmlessly in localStorage until Reset). + const layout = ref({}) + let _savedRaw = null // lazily loaded full persisted map + + function _loadSavedRaw() { + if (_savedRaw) return _savedRaw + try { + const raw = localStorage.getItem(LAYOUT_KEY) + const parsed = raw ? JSON.parse(raw) : null + _savedRaw = parsed && typeof parsed === 'object' ? parsed : {} + } catch { + _savedRaw = {} + } + return _savedRaw + } + + function _persist() { + _savedRaw = { ..._loadSavedRaw(), ...layout.value } + try { + localStorage.setItem(LAYOUT_KEY, JSON.stringify(_savedRaw)) + } catch { + /* private mode — layout stays session-local */ + } + } + + /** + * Reconcile the layout with the live agent list (self-healing, #47 AC): + * new agents take the first free cell near the origin, deleted agents + * leave their gap, invalid/colliding saved positions resolve to the + * nearest free cell. Falls back to the deterministic default layout when + * nothing usable is saved. + */ + function syncLayout(agentNames, systemNames = new Set()) { + const saved = { ..._loadSavedRaw(), ...layout.value } + if (Object.keys(saved).length === 0) { + layout.value = defaultLayout(agentNames, systemNames) + _persist() + return + } + const { layout: healed, changed } = normalizeLayout(saved, agentNames) + layout.value = healed + if (changed) _persist() + } + + /** Drop a tile on `(c, r)`; an occupied target swaps with the dragged tile. */ + function moveTile(name, c, r) { + const from = layout.value[name] + if (!from) return + const occ = occupantAt(layout.value, c, r, name) + const next = { ...layout.value, [name]: { c, r } } + if (occ) next[occ] = { c: from.c, r: from.r } + layout.value = next + _persist() + } + + function tidy() { + layout.value = tidyLayout(layout.value) + _persist() + } + + function resetLayout(agentNames, systemNames = new Set()) { + try { + localStorage.removeItem(LAYOUT_KEY) + } catch { + /* ignore */ + } + _savedRaw = {} // reset drops hidden/stale entries too + layout.value = defaultLayout(agentNames, systemNames) + _persist() + } + + /** Place an agent missing from the layout (e.g. created mid-session). */ + function ensurePlaced(name) { + if (layout.value[name]) return + layout.value = { ...layout.value, [name]: nearestFreeCell(layout.value, 0, 0) } + _persist() + } + + // --- per-tile analytics hydration (lazy, capped, stale-while-revalidate) --- + const analyticsState = ref({}) // agent → 'loading' | 'done' | 'error' + const _fetchedAt = {} // agent → epoch ms of last successful fetch (non-reactive) + const _queue = [] + const _inFlightNames = new Set() // dedup guard incl. the stale-revalidate path + let _inFlight = 0 + + const analyticsFor = computed(() => (name) => + executionsStore.analyticsCache[`${name}:${ANALYTICS_WINDOW}`] || null + ) + + function _pump() { + while (_inFlight < HYDRATE_CONCURRENCY && _queue.length > 0) { + const { name, force } = _queue.shift() + _inFlight++ + _inFlightNames.add(name) + executionsStore + .fetchAgentAnalytics(name, ANALYTICS_WINDOW, { force }) + .then(() => { + _fetchedAt[name] = Date.now() + analyticsState.value = { ...analyticsState.value, [name]: 'done' } + }) + .catch(() => { + // A failed per-agent fetch degrades that one tile only (#47 AC). + // Keep any cached payload usable; mark error only when we have none. + const has = executionsStore.analyticsCache[`${name}:${ANALYTICS_WINDOW}`] + analyticsState.value = { + ...analyticsState.value, + [name]: has ? 'done' : 'error', + } + }) + .finally(() => { + _inFlight-- + _inFlightNames.delete(name) + _pump() + }) + } + } + + /** + * Ask for an agent's analytics. Called by tiles when they come near the + * viewport. Cached data renders immediately; a background refresh is + * queued when the cache is stale. + */ + function hydrate(name, { force = false } = {}) { + const cached = executionsStore.analyticsCache[`${name}:${ANALYTICS_WINDOW}`] + const stale = !_fetchedAt[name] || Date.now() - _fetchedAt[name] > ANALYTICS_STALE_MS + if (cached && !stale && !force) { + if (analyticsState.value[name] !== 'done') { + analyticsState.value = { ...analyticsState.value, [name]: 'done' } + } + return + } + if (_inFlightNames.has(name) || _queue.some((q) => q.name === name)) return + if (!cached) { + analyticsState.value = { ...analyticsState.value, [name]: 'loading' } + } + _queue.push({ name, force: force || (!!cached && stale) }) + _pump() + } + + // --- grid-wide chip data: sync health + operator-queue pending --- + const syncHealth = ref({}) // agent → sync-health entry (#389 batch endpoint) + const opQueuePending = ref({}) // agent → { count, oldestCreatedAt, hasApproval } + + async function fetchSyncHealth() { + try { + const res = await axios.get('/api/agents/sync-health', { + headers: authStore.authHeader, + }) + const map = {} + for (const entry of res.data.agents || []) { + map[entry.agent_name] = entry + } + syncHealth.value = map + } catch { + // chip data is non-critical — keep last known state + } + } + + async function fetchOpQueuePending() { + try { + const res = await axios.get('/api/operator-queue', { + params: { status: 'pending', limit: 200 }, + headers: authStore.authHeader, + }) + const map = {} + for (const item of res.data.items || []) { + const cur = map[item.agent_name] || { + count: 0, + oldestCreatedAt: null, + hasApproval: false, + } + cur.count++ + if (!cur.oldestCreatedAt || item.created_at < cur.oldestCreatedAt) { + cur.oldestCreatedAt = item.created_at + } + if (item.type === 'approval') cur.hasApproval = true + map[item.agent_name] = cur + } + opQueuePending.value = map + } catch { + // non-critical + } + } + + let _pollTimer = null + + function refreshBatchData() { + return Promise.allSettled([fetchSyncHealth(), fetchOpQueuePending()]) + } + + /** Visibility-aware slow poll; active only while the Grid view is mounted. */ + function startPolling() { + stopPolling() + refreshBatchData() + _pollTimer = setInterval(() => { + if (document.hidden) return + refreshBatchData() + }, BATCH_POLL_MS) + } + + function stopPolling() { + if (_pollTimer) { + clearInterval(_pollTimer) + _pollTimer = null + } + } + + /** Force refresh (header refresh button): batch data + visible tiles re-ask. */ + function forceRefresh(visibleNames = []) { + refreshBatchData() + for (const name of visibleNames) hydrate(name, { force: true }) + } + + return { + layout, + syncLayout, + moveTile, + tidy, + resetLayout, + ensurePlaced, + analyticsState, + analyticsFor, + hydrate, + syncHealth, + opQueuePending, + refreshBatchData, + startPolling, + stopPolling, + forceRefresh, + } +}) diff --git a/src/frontend/src/stores/network.js b/src/frontend/src/stores/network.js index 00b121c6f..f26cc21c2 100644 --- a/src/frontend/src/stores/network.js +++ b/src/frontend/src/stores/network.js @@ -27,6 +27,11 @@ export const useNetworkStore = defineStore('network', () => { const contextStats = ref({}) // Map of agent name -> context stats const executionStats = ref({}) // Map of agent name -> execution stats const slotStats = ref({}) // Map of agent name -> { max, active } for capacity meters + const circuitBreakers = ref({}) // Map of agent name -> dispatch-breaker state (#526; only OPEN breakers present) + // #47: agents with a WS-observed in-flight execution — agent name -> { since }. + // Drives the Grid tiles' "▶ working" chip + elapsed timer between the 15s + // context-stats polls; the poll reconciles any entry a missed event leaks. + const workingState = ref({}) const contextPollingInterval = ref(null) // Interval ID for context polling const agentRefreshInterval = ref(null) // Interval ID for agent list refresh const activityRefreshInterval = ref(null) // Interval ID for activity refresh (fallback) @@ -44,9 +49,13 @@ export const useNetworkStore = defineStore('network', () => { // a network blip replays missed events instead of dropping them. const lastEventId = ref(null) - // View mode state (graph vs timeline) - default to timeline, persist to localStorage + // View mode state (grid | graph | timeline) — default timeline, persisted. + // trinity-enterprise#47 adds 'grid' as a third mode; legacy saved values + // ('graph'/'timeline') keep working unchanged. + const VIEW_MODES = ['grid', 'graph', 'timeline'] const savedViewMode = localStorage.getItem('trinity-dashboard-view') - const isTimelineMode = ref(savedViewMode ? savedViewMode === 'timeline' : true) + const viewMode = ref(VIEW_MODES.includes(savedViewMode) ? savedViewMode : 'timeline') + const isTimelineMode = computed(() => viewMode.value === 'timeline') const isPlaying = ref(false) const replaySpeed = ref(10) // 10x default const currentEventIndex = ref(0) @@ -616,6 +625,13 @@ export const useNetworkStore = defineStore('network', () => { handleCollaborationEvent(data) } else if (data.type === 'agent_status') { handleAgentStatusChange(data) + } else if (data.type === 'agent_started' || data.type === 'agent_stopped') { + // #47: the backend broadcasts these on start/stop — sync both + // render surfaces so another session's toggle propagates live. + handleAgentStatusChange({ + agent_name: data.name, + status: data.type === 'agent_started' ? 'running' : 'stopped' + }) } else if (data.type === 'agent_deleted') { handleAgentDeleted(data) } else if (data.type === 'agent_activity') { @@ -721,8 +737,37 @@ export const useNetworkStore = defineStore('network', () => { agents.value = agents.value.filter(a => a.name !== event.agent_name) } + // #47: keep the WS-driven working map current. `chat_start`/`schedule_start` + // beginning marks the agent working; the same activity reaching a terminal + // state — or the matching `_end` activity — clears it. Parallel executions + // per agent are approximated (last event wins); the 15s context-stats poll + // reconciles. + function _updateWorkingState(event) { + const name = event.agent_name + if (!name) return + const type = event.activity_type || '' + const state = event.activity_state + if ((type === 'chat_start' || type === 'schedule_start') && state === 'started') { + workingState.value = { + ...workingState.value, + [name]: { since: event.timestamp || new Date().toISOString() } + } + } else if ( + type === 'chat_end' || + type === 'schedule_end' || + ((type === 'chat_start' || type === 'schedule_start') && state !== 'started') + ) { + if (workingState.value[name]) { + const next = { ...workingState.value } + delete next[name] + workingState.value = next + } + } + } + // Handle activity status changes (completion, failure) from WebSocket function handleActivityStatusChange(event) { + _updateWorkingState(event) // Update activity in historicalCollaborations const activity = historicalCollaborations.value.find( a => a.activity_id === event.activity_id @@ -1023,6 +1068,23 @@ export const useNetworkStore = defineStore('network', () => { }) contextStats.value = newStats + // #47: reconcile the WS working map — drop entries for agents the + // authoritative poll no longer reports active (missed end events). + // Entries younger than one poll period are spared: a response computed + // before the execution started must not evict a fresh WS entry. + const staleCutoff = Date.now() - 20000 + const leaked = Object.keys(workingState.value).filter( + name => + newStats[name] && + newStats[name].activityState !== 'active' && + new Date(workingState.value[name].since || 0).getTime() < staleCutoff + ) + if (leaked.length > 0) { + const next = { ...workingState.value } + leaked.forEach(name => delete next[name]) + workingState.value = next + } + // Update node data with new stats nodes.value.forEach(node => { const stats = newStats[node.id] @@ -1104,6 +1166,9 @@ export const useNetworkStore = defineStore('network', () => { } }) slotStats.value = newStats + // #47: expose the breaker map directly for non-VueFlow consumers + // (the Grid tiles read it by agent name). + circuitBreakers.value = circuitMap // Thread slot stats + circuit-breaker state onto node data. Setting // circuitBreaker to null when absent clears a badge once the breaker @@ -1239,26 +1304,21 @@ export const useNetworkStore = defineStore('network', () => { } } - // View Mode Functions (graph vs timeline) + // View Mode Functions (grid | graph | timeline) function setViewMode(mode) { - const isTimeline = mode === 'timeline' - isTimelineMode.value = isTimeline + if (!VIEW_MODES.includes(mode)) mode = 'graph' + viewMode.value = mode localStorage.setItem('trinity-dashboard-view', mode) - // Keep WebSocket and polling active in BOTH views for live updates - // Timeline view now shows live events, so we need real-time data - if (isTimeline) { - // Entering timeline mode - stop any active replay playback - stopReplay() - // Start activity refresh polling as fallback + // Keep WebSocket and shared stats polling active in ALL views for live + // updates; only the timeline's activity-refresh fallback is mode-scoped. + stopReplay() + if (mode === 'timeline') { startActivityRefresh() console.log('[Collaboration] Switched to Timeline view (live mode)') } else { - // Entering graph mode - stopReplay() - // Stop activity refresh polling (not needed in graph mode) stopActivityRefresh() - console.log('[Collaboration] Switched to Graph view') + console.log(`[Collaboration] Switched to ${mode === 'grid' ? 'Grid' : 'Graph'} view`) } } @@ -1574,14 +1634,17 @@ export const useNetworkStore = defineStore('network', () => { // Toggle autonomy mode for an agent async function toggleAutonomy(agentName) { - // Find the node to get current state + // #47: both render surfaces must reflect the change — the graph reads + // node.data, the Grid tiles read the agents[] list. Resolve current + // state from either (an owner-filtered graph may lack the node). const node = nodes.value.find(n => n.id === agentName) - if (!node) { + const agent = agents.value.find(a => a.name === agentName) + if (!node && !agent) { console.error('[Network] Agent not found:', agentName) return { success: false, error: 'Agent not found' } } - const currentState = node.data.autonomy_enabled + const currentState = node ? node.data.autonomy_enabled : agent.autonomy_enabled const newState = !currentState try { @@ -1592,8 +1655,9 @@ export const useNetworkStore = defineStore('network', () => { { headers: { Authorization: `Bearer ${token}` } } ) - // Update the node data - node.data.autonomy_enabled = newState + // Update both render surfaces + if (node) node.data.autonomy_enabled = newState + if (agent) agent.autonomy_enabled = newState console.log(`[Network] Autonomy ${newState ? 'enabled' : 'disabled'} for ${agentName}`) @@ -1627,38 +1691,34 @@ export const useNetworkStore = defineStore('network', () => { // Toggle agent running state (start/stop) async function toggleAgentRunning(agentName) { + // #47: update both render surfaces (graph nodes AND the agents[] list + // the Grid tiles read). const node = nodes.value.find(n => n.id === agentName) - if (!node) { + const agent = agents.value.find(a => a.name === agentName) + if (!node && !agent) { console.error('[Network] Agent not found:', agentName) return { success: false, error: 'Agent not found' } } - const isRunning = node.data.status === 'running' + const isRunning = (node ? node.data.status : agent.status) === 'running' runningToggleLoading.value[agentName] = true try { const token = localStorage.getItem('token') - if (isRunning) { - await axios.post( - `/api/agents/${agentName}/stop`, - {}, - { headers: { Authorization: `Bearer ${token}` } } - ) - node.data.status = 'stopped' - node.data.activityState = 'offline' - console.log(`[Network] Agent ${agentName} stopped`) - } else { - await axios.post( - `/api/agents/${agentName}/start`, - {}, - { headers: { Authorization: `Bearer ${token}` } } - ) - node.data.status = 'running' - node.data.activityState = 'idle' - console.log(`[Network] Agent ${agentName} started`) + const newStatus = isRunning ? 'stopped' : 'running' + await axios.post( + `/api/agents/${agentName}/${isRunning ? 'stop' : 'start'}`, + {}, + { headers: { Authorization: `Bearer ${token}` } } + ) + if (node) { + node.data.status = newStatus + node.data.activityState = isRunning ? 'offline' : 'idle' } + if (agent) agent.status = newStatus + console.log(`[Network] Agent ${agentName} ${isRunning ? 'stopped' : 'started'}`) - return { success: true, status: node.data.status } + return { success: true, status: newStatus } } catch (error) { console.error('[Network] Failed to toggle agent running:', error) return { @@ -1693,10 +1753,13 @@ export const useNetworkStore = defineStore('network', () => { contextStats, executionStats, slotStats, + circuitBreakers, + workingState, schedules, filterTags, filterOwner, // View mode / Replay state + viewMode, isTimelineMode, isPlaying, replaySpeed, diff --git a/src/frontend/src/utils/gridLayout.js b/src/frontend/src/utils/gridLayout.js new file mode 100644 index 000000000..da4a1b4ae --- /dev/null +++ b/src/frontend/src/utils/gridLayout.js @@ -0,0 +1,197 @@ +/** + * Pure lattice-layout helpers for the Dashboard Grid view (trinity-enterprise#47). + * + * The Grid view places agent tiles on a sparse, unbounded integer lattice + * (negative coordinates included). Layout = map `agentName → {c, r}`. + * These functions own all coordinate math so the store and components stay + * free of layout edge cases. No DOM, no Vue — plain data in, data out. + */ + +// Interaction constants from the approved design of record (issue #47 mockup). +// GAP_X exceeds the 26px avatar overhang, so a protruding avatar always +// floats in clear space between columns. +export const CELL_W = 384 +export const CELL_H = 216 +export const GAP_X = 34 +export const GAP_Y = 18 +export const COORD_LIMIT = 60 // sanity bound, ~unbounded in practice +export const Z_MIN = 0.25 +export const Z_MAX = 1.6 + +export function cellXY(c, r) { + return [c * (CELL_W + GAP_X), r * (CELL_H + GAP_Y)] +} + +export function cellFromCenter(cx, cy) { + const c = Math.round(cx / (CELL_W + GAP_X)) + const r = Math.round(cy / (CELL_H + GAP_Y)) + return { + c: Math.max(-COORD_LIMIT, Math.min(COORD_LIMIT, c)), + r: Math.max(-COORD_LIMIT, Math.min(COORD_LIMIT, r)), + } +} + +function key(c, r) { + return `${c},${r}` +} + +function occupiedSet(layout, exceptName = null) { + const occ = new Set() + for (const [name, p] of Object.entries(layout)) { + if (name === exceptName) continue + occ.add(key(p.c, p.r)) + } + return occ +} + +function isValidPos(p) { + return ( + p && + Number.isInteger(p.c) && + Number.isInteger(p.r) && + Math.abs(p.c) <= COORD_LIMIT && + Math.abs(p.r) <= COORD_LIMIT + ) +} + +/** + * Walk lattice cells in growing rings around `(oc, or_)` and return the first + * free one. Ring order scans top-to-bottom, left-to-right within each ring so + * placement reads naturally. Always terminates: the lattice is bounded by + * COORD_LIMIT and callers never hold more tiles than cells. + */ +export function nearestFreeCell(layout, oc = 0, or_ = 0, exceptName = null) { + const occ = occupiedSet(layout, exceptName) + if (!occ.has(key(oc, or_))) return { c: oc, r: or_ } + for (let radius = 1; radius <= COORD_LIMIT * 2; radius++) { + for (let dr = -radius; dr <= radius; dr++) { + for (let dc = -radius; dc <= radius; dc++) { + if (Math.max(Math.abs(dc), Math.abs(dr)) !== radius) continue + const c = oc + dc + const r = or_ + dr + if (Math.abs(c) > COORD_LIMIT || Math.abs(r) > COORD_LIMIT) continue + if (!occ.has(key(c, r))) return { c, r } + } + } + } + return { c: oc, r: or_ } // unreachable in practice +} + +/** + * Default layout for a fleet: reading-order grid near the origin, system + * agent first. Deterministic for a given agent list. + */ +export function defaultLayout(agentNames, systemNames = new Set()) { + const sorted = [...agentNames].sort((a, b) => { + const sa = systemNames.has(a) ? 0 : 1 + const sb = systemNames.has(b) ? 0 : 1 + return sa - sb || a.localeCompare(b) + }) + const cols = Math.max(3, Math.ceil(Math.sqrt(sorted.length))) + const layout = {} + sorted.forEach((name, i) => { + layout[name] = { c: i % cols, r: Math.floor(i / cols) } + }) + return layout +} + +/** + * Self-healing pass (#47 AC): reconcile a saved layout against the live + * agent list. + * - new agents take the first free cell near the origin + * - deleted agents leave their gap (their entries are dropped) + * - an invalid or colliding saved position resolves to the nearest free cell + * Returns `{ layout, changed }` — `changed` signals the caller to re-persist. + */ +export function normalizeLayout(saved, agentNames) { + const layout = {} + let changed = false + const names = [...agentNames] + const source = saved && typeof saved === 'object' ? saved : {} + + // Drop entries for agents that no longer exist (gap remains free). + if (Object.keys(source).some((n) => !agentNames.includes(n))) changed = true + + // First pass: keep valid, non-colliding saved positions. + const pending = [] + for (const name of names) { + const p = source[name] + if (isValidPos(p) && !occupiedSet(layout).has(key(p.c, p.r))) { + layout[name] = { c: p.c, r: p.r } + } else { + pending.push(name) + if (p !== undefined) changed = true + } + } + + // Second pass: place newcomers / evicted entries near the origin. + for (const name of pending) { + layout[name] = nearestFreeCell(layout, 0, 0) + changed = true + } + return { layout, changed } +} + +/** + * Tidy up (#47): compact row-by-row preserving reading order, anchored at + * the layout's own top-left. Column count fixed at 3 per the design of + * record. Returns a new layout map. + */ +export function tidyLayout(layout) { + const names = Object.keys(layout) + if (names.length === 0) return {} + let minC = Infinity + let minR = Infinity + for (const p of Object.values(layout)) { + if (p.c < minC) minC = p.c + if (p.r < minR) minR = p.r + } + // Clamp the anchor so the compacted block stays inside COORD_LIMIT — + // otherwise the next normalize pass would evict the out-of-bounds tiles. + const rows = Math.ceil(names.length / 3) + minC = Math.max(-COORD_LIMIT, Math.min(minC, COORD_LIMIT - 2)) + minR = Math.max(-COORD_LIMIT, Math.min(minR, COORD_LIMIT - rows + 1)) + const sorted = names.sort( + (x, y) => layout[x].r - layout[y].r || layout[x].c - layout[y].c + ) + const out = {} + sorted.forEach((name, i) => { + out[name] = { c: minC + (i % 3), r: minR + Math.floor(i / 3) } + }) + return out +} + +/** + * World-space bounding box of the current constellation (used by fit-view). + */ +export function layoutBBox(layout) { + const positions = Object.values(layout) + if (positions.length === 0) { + return { x: 0, y: 0, w: CELL_W, h: CELL_H } + } + let minC = Infinity + let maxC = -Infinity + let minR = Infinity + let maxR = -Infinity + for (const p of positions) { + if (p.c < minC) minC = p.c + if (p.c > maxC) maxC = p.c + if (p.r < minR) minR = p.r + if (p.r > maxR) maxR = p.r + } + return { + x: minC * (CELL_W + GAP_X), + y: minR * (CELL_H + GAP_Y), + w: (maxC - minC) * (CELL_W + GAP_X) + CELL_W, + h: (maxR - minR) * (CELL_H + GAP_Y) + CELL_H, + } +} + +/** Name of the agent occupying `(c, r)`, or null. */ +export function occupantAt(layout, c, r, exceptName = null) { + for (const [name, p] of Object.entries(layout)) { + if (name === exceptName) continue + if (p.c === c && p.r === r) return name + } + return null +} diff --git a/src/frontend/src/views/Dashboard.vue b/src/frontend/src/views/Dashboard.vue index 5b3e94257..5d68912ac 100644 --- a/src/frontend/src/views/Dashboard.vue +++ b/src/frontend/src/views/Dashboard.vue @@ -21,6 +21,12 @@ {{ runningCount }}/{{ agents.length }} agents + + · + + {{ workingNowCount }} + working now + · {{ totalCollaborationCount }} @@ -112,27 +118,38 @@ | - +
+
+ + +