Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
eff0762
build: add axum, tower-http, rust-embed for dashboard
Jul 18, 2026
e84869b
feat(serve): wire agentflare serve subcommand
Jul 18, 2026
6f500be
feat(dashboard): read-only db helper and claims json
Jul 18, 2026
89a9bc5
feat(dashboard): axum server, embedded assets, claims endpoint
Jul 18, 2026
98c2517
test(dashboard): smoke-test claims endpoint
Jul 18, 2026
c1de60e
feat(dashboard): add pm_db_readonly helper for PM backend.db
Jul 18, 2026
4737281
feat(dashboard): add /api/pm/workspaces and /api/pm/projects endpoints
Jul 18, 2026
0740c41
feat(dashboard): add /api/pm/items and /api/pm/states endpoints
Jul 18, 2026
6e9864b
feat(dashboard): add /api/pm/comments, /api/pm/labels, and /api/pm/ev…
Jul 18, 2026
2ad568a
chore: gitignore Claude Code local runtime artifacts
Jul 18, 2026
a8d350b
Revert "chore: gitignore Claude Code local runtime artifacts"
Jul 18, 2026
cc2e66d
rename dashboard /api/pm/events route to /api/webhooks
Jul 18, 2026
875ab1b
add dashboard shell layout, design tokens, and shared nav chrome
Jul 18, 2026
23c5814
add PM board view
Jul 18, 2026
0cc81d4
add PM list view
Jul 18, 2026
e4ce599
add PM item detail view
Jul 18, 2026
70cf591
add webhook delivery log view
Jul 18, 2026
67d87fc
pull refined design tokens and board styling from claude.ai/design
Jul 18, 2026
4e4a864
add /api/cost endpoint reusing rollup query
Jul 18, 2026
b51416b
add /events SSE stream pushing live claims + cost snapshots
Jul 18, 2026
acae027
add live Claims and Cost dashboard views
Jul 18, 2026
9fa0d3f
default the dashboard port to 35273 (FLARE on a phone keypad)
Jul 18, 2026
672d11f
redirect dashboard root to the styled board view
Jul 18, 2026
c8fbccd
fix stray NUL byte in claims.html x-for key
Jul 18, 2026
41040d9
share one SSE snapshot across clients via broadcast
Jul 18, 2026
b05f700
keep serve near-idle: throttle the expensive cost refresh in /events
Jul 18, 2026
a1cf344
Merge remote-tracking branch 'origin/master' into feat/dashboard-design
Jul 18, 2026
45f2047
fix Cargo.lock merge: keep master's patched dep versions (crossbeam-e…
Jul 18, 2026
a09c13c
Merge remote-tracking branch 'origin/master' into feat/dashboard-design
Jul 18, 2026
d0d0e41
satisfy fmt + clippy: rustfmt dashboard files, drop dead live_snapsho…
Jul 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 218 additions & 14 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ serde_json = "1"
dirs = "6"
chrono = "0.4"
rmcp = { version = "1.8.0", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["rt", "macros", "io-util", "io-std", "sync"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "time"] }
ureq = { version = "2", features = ["json"] }
sha2 = "0.10"
hex = "0.4"
Expand Down Expand Up @@ -72,6 +72,10 @@ agentflare-backend = { package = "agentflare-backend", path = "crates/agentflare
db_kit = { package = "agentflare-db-kit", path = "crates/agentflare-db-kit" }
agent-detector = "0.2.1"
flare-search-kit = { path = "crates/flare-search-kit" }
axum = "0.8"
tower-http = { version = "0.6", features = ["trace"] }
rust-embed = "8"
tokio-stream = { version = "0.1", features = ["sync"] }
agentflare-store = { path = "crates/agentflare-store" }

[target.'cfg(unix)'.dependencies]
Expand All @@ -93,6 +97,7 @@ built = { version = "0.8", features = ["chrono"] }
insta = { version = "1", features = ["json"] }
tempfile = "3"
pretty_assertions = "1"
reqwest = { version = "0.12", default-features = false }

[lints.rust]
unsafe_code = "warn"
Expand Down
60 changes: 60 additions & 0 deletions crates/agentflare-backend/src/webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ fn row_to_webhook(row: &rusqlite::Row) -> rusqlite::Result<Webhook> {
})
}

fn row_to_webhook_log(row: &rusqlite::Row) -> rusqlite::Result<WebhookLog> {
Ok(WebhookLog {
id: row.get(0)?,
workspace_id: row.get(1)?,
webhook_id: row.get(2)?,
event_type: row.get(3)?,
request_method: row.get(4)?,
request_headers: row.get(5)?,
request_body: row.get(6)?,
response_status: row.get(7)?,
response_headers: row.get(8)?,
response_body: row.get(9)?,
retry_count: row.get(10)?,
created_at: row.get(11)?,
})
}

/// Blocks loopback, unspecified, multicast, and private/link-local ranges
/// (RFC1918, 169.254.0.0/16, IPv6 link-local fe80::/10, IPv6 ULA fc00::/7) —
/// the ranges an SSRF'd webhook could use to reach internal services.
Expand Down Expand Up @@ -244,6 +261,17 @@ pub fn list_active_matching(
Ok(rows.collect::<std::result::Result<_, _>>()?)
}

/// Delivery log entries for a workspace, most recent first — the audit
/// trail the dashboard's `/api/webhooks` view reads.
pub fn list_logs_by_workspace(conn: &Connection, workspace_id: &str) -> Result<Vec<WebhookLog>> {
let mut stmt = conn.prepare(
"SELECT id, workspace_id, webhook_id, event_type, request_method, request_headers, request_body, response_status, response_headers, response_body, retry_count, created_at
FROM webhook_logs WHERE workspace_id = ?1 ORDER BY created_at DESC",
)?;
let rows = stmt.query_map(params![workspace_id], row_to_webhook_log)?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
Comment on lines +264 to +273

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Paginate webhook delivery history.

This query loads every log and its potentially large request/response bodies. The API then serializes the full history and the browser sorts it in memory, so latency and memory grow without bound. Add a limit/cursor and fetch full bodies only when a delivery is expanded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-backend/src/webhook.rs` around lines 264 - 273, Update
list_logs_by_workspace to support bounded pagination with a limit and cursor,
ordering consistently by created_at and a stable unique key. Avoid selecting
potentially large request_body and response_body fields in the history query;
fetch those bodies through a separate delivery-detail path used only when an
entry is expanded, and update callers to use the paginated results.


pub fn update(conn: &Connection, id: &str, input: UpdateWebhook) -> Result<Webhook> {
if let Some(ref url) = input.url {
validate_webhook_url(url)?;
Expand Down Expand Up @@ -606,6 +634,38 @@ mod tests {
assert_eq!(log_count, 1, "a log row must be created even on failure");
}

#[test]
fn list_logs_by_workspace_returns_delivery_history() {
let conn = db::open_in_memory().unwrap();
let wid = seed_workspace(&conn);
let wh = create(
&conn,
CreateWebhook {
workspace_id: wid.clone(),
url: "https://example.invalid/hook".into(),
secret_key: "s3cret".into(),
on_item: Some(true),
on_state: None,
on_project: None,
},
)
.unwrap();
deliver(
&conn,
&wh,
"item",
"create",
serde_json::json!({"id": "123"}),
)
.unwrap();
let logs = list_logs_by_workspace(&conn, &wid).unwrap();
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].webhook_id, wh.id);
assert_eq!(logs[0].event_type.as_deref(), Some("item"));
let empty = list_logs_by_workspace(&conn, "nonexistent-workspace").unwrap();
assert!(empty.is_empty());
}

#[test]
fn delete_soft() {
let conn = db::open_in_memory().unwrap();
Expand Down
145 changes: 145 additions & 0 deletions dashboard/web/board.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!-- @dsCard group="PM" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>agentflare dashboard — board</title>
<link rel="stylesheet" href="/tokens.css" />
<script defer src="/shell.js"></script>
<script defer src="/vendor/alpine.js"></script>
</head>
<body>
<div class="af-app">
<aside class="af-sidebar" x-data="shellNav()" x-init="init()">
<div class="af-sidebar-header"><span class="af-logo-mark"></span>agentflare</div>

<div class="af-nav-section">
<label class="af-nav-section-label" for="af-workspace-select">Workspace</label>
<select
id="af-workspace-select"
class="af-select"
x-model="workspaceId"
@change="onWorkspaceChange()"
>
<template x-if="!loading && workspaces.length === 0">
<option value="">No workspaces</option>
</template>
<template x-for="w in workspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</div>

<div class="af-nav-section">
<label class="af-nav-section-label" for="af-project-select">Project</label>
<select
id="af-project-select"
class="af-select"
x-model="projectId"
@change="onProjectChange()"
>
<template x-if="!loading && projects.length === 0">
<option value="">No projects</option>
</template>
<template x-for="p in projects" :key="p.id">
<option :value="p.id" x-text="p.identifier + ' — ' + p.name"></option>
</template>
</select>
</div>

<nav class="af-nav-links">
<a class="af-nav-link" :class="{ 'is-active': isActive('/board.html') }" :href="navHref('/board.html')">Board</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/list.html') }" :href="navHref('/list.html')">List</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/webhooks.html') }" :href="navHref('/webhooks.html')">Webhooks</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/claims.html') }" :href="navHref('/claims.html')">Claims</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/cost.html') }" :href="navHref('/cost.html')">Cost</a>
</nav>
</aside>

<div class="af-main-col">
<header class="af-topbar">
<h1>Board</h1>
</header>
<main
class="af-content"
x-data="boardView()"
x-init="init()"
>
<template x-if="!loading && !projectId">
<p class="af-empty">No project selected — create a workspace and project first.</p>
</template>
<template x-if="!loading && projectId && states.length === 0">
<p class="af-empty">No states configured for this project.</p>
</template>
<div class="af-board" x-show="!loading && states.length > 0">
<template x-for="state in states" :key="state.id">
<div class="af-board-col">
<div class="af-board-col-header">
<span class="af-state-dot" :style="{ background: state.color, color: state.color }"></span>
<span x-text="state.name"></span>
<span class="af-board-col-count" x-text="itemsForState(state.id).length"></span>
</div>
<div class="af-board-col-body">
<template x-if="itemsForState(state.id).length === 0">
<p class="af-empty-col">No items</p>
</template>
<template x-for="item in itemsForState(state.id)" :key="item.id">
<a
class="af-card"
:href="itemHref(item)"
>
<div class="af-card-title" x-text="item.name"></div>
<div class="af-card-meta">
<span class="af-badge af-prio" :class="'af-prio-' + (item.priority || 'none').toLowerCase()" x-text="item.priority"></span>
<template x-if="item.assignee_agent">
<span class="af-badge af-badge-assignee" x-text="item.assignee_agent"></span>
</template>
</div>
</a>
</template>
</div>
</div>
</template>
</div>
</main>
</div>
</div>

<script>
function boardView() {
return {
projectId: '',
states: [],
items: [],
loading: true,

async init() {
const params = afParams();
const scope = await afResolveScope(params.get('workspace_id'), params.get('project_id'));
this.projectId = scope.projectId;
if (!this.projectId) {
this.loading = false;
return;
}
const [states, items] = await Promise.all([
afGetJson(`/api/pm/states?project_id=${encodeURIComponent(this.projectId)}`, []),
afGetJson(`/api/pm/items?project_id=${encodeURIComponent(this.projectId)}`, []),
]);
this.states = states.slice().sort((a, b) => a.sequence - b.sequence);
this.items = items;
this.loading = false;
},

itemsForState(stateId) {
return this.items.filter((i) => i.state_id === stateId);
},

itemHref(item) {
return afLink('/item.html', { project_id: this.projectId, id: item.id });
},
};
}
</script>
</body>
</html>
137 changes: 137 additions & 0 deletions dashboard/web/claims.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<!-- @dsCard group="Runtime" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>agentflare dashboard — claims</title>
<link rel="stylesheet" href="/tokens.css" />
<script defer src="/shell.js"></script>
<script defer src="/vendor/alpine.js"></script>
</head>
<body>
<div class="af-app">
<aside class="af-sidebar" x-data="shellNav()" x-init="init()">
<div class="af-sidebar-header"><span class="af-logo-mark"></span>agentflare</div>

<div class="af-nav-section">
<label class="af-nav-section-label" for="af-workspace-select">Workspace</label>
<select
id="af-workspace-select"
class="af-select"
x-model="workspaceId"
@change="onWorkspaceChange()"
>
<template x-if="!loading && workspaces.length === 0">
<option value="">No workspaces</option>
</template>
<template x-for="w in workspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</div>

<div class="af-nav-section">
<label class="af-nav-section-label" for="af-project-select">Project</label>
<select
id="af-project-select"
class="af-select"
x-model="projectId"
@change="onProjectChange()"
>
<template x-if="!loading && projects.length === 0">
<option value="">No projects</option>
</template>
<template x-for="p in projects" :key="p.id">
<option :value="p.id" x-text="p.identifier + ' — ' + p.name"></option>
</template>
</select>
</div>

<nav class="af-nav-links">
<a class="af-nav-link" :class="{ 'is-active': isActive('/board.html') }" :href="navHref('/board.html')">Board</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/list.html') }" :href="navHref('/list.html')">List</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/webhooks.html') }" :href="navHref('/webhooks.html')">Webhooks</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/claims.html') }" :href="navHref('/claims.html')">Claims</a>
<a class="af-nav-link" :class="{ 'is-active': isActive('/cost.html') }" :href="navHref('/cost.html')">Cost</a>
</nav>
</aside>

<div class="af-main-col" x-data="claimsView()" x-init="init()">
<header class="af-topbar">
<h1>Claims</h1>
<span class="af-topbar-meta">
<span class="af-live" :class="{ 'is-on': connected }"></span>
<span x-text="connected ? 'live' : 'connecting…'"></span>
</span>
</header>
<main class="af-content">
<template x-if="claims.length === 0">
<p class="af-empty">No active claims. Agents holding a repo/target lock will appear here in real time.</p>
</template>
<div class="af-table-wrap" x-show="claims.length > 0">
<table class="af-table">
<thead>
<tr>
<th>Repo</th>
<th>Target</th>
<th>Owner</th>
<th>Status</th>
<th>Claimed</th>
<th>Last heartbeat</th>
</tr>
</thead>
<tbody>
<template x-for="c in claims" :key="c.repo + '|' + c.target">
<tr>
<td class="af-mono af-truncate" x-text="c.repo"></td>
<td class="af-mono" x-text="c.target"></td>
<td x-text="c.owner"></td>
<td>
<span
class="af-badge"
:class="c.stale ? 'af-prio af-prio-urgent' : ''"
x-text="c.stale ? 'stale' : (c.status || 'active')"
></span>
</td>
<td x-text="afFormatTime(c.created_at)"></td>
<td x-text="afFormatTime(c.heartbeat_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</main>
</div>
</div>

<script>
function claimsView() {
return {
claims: [],
connected: false,
source: null,

init() {
// Live feed: the /events SSE stream pushes a full { claims, ... }
// snapshot every ~2s, so no polling and no manual refresh.
this.source = new EventSource('/events');
this.source.onmessage = (e) => {
try {
const snap = JSON.parse(e.data);
this.claims = Array.isArray(snap.claims) ? snap.claims : [];
this.connected = true;
} catch (_e) {
/* ignore a malformed frame; the next tick replaces it */
}
};
this.source.onerror = () => {
// EventSource auto-reconnects; just reflect the gap in the UI.
this.connected = false;
};
},
};
}
</script>
</body>
</html>
Loading
Loading