Skip to content

Commit 7306b0d

Browse files
anbei.yuanCopilot
andcommitted
feat(tool-trend): drill-down on bar click
- New endpoint GET /api/tools/bucket?since=&until=&tool= Returns sessions active in a time bucket with their tool call counts (sorted desc, capped at 50). Optional tool filter. - ToolTrend bars now clickable: opens an inline drill-down list showing the bucket window, session count, agent badge, cwd, and tool call count per session. - Click a session row → onOpenSession (jumps to detail view). - ✕ button to dismiss the drill-down. Smoke test: 2 sessions / 178 calls in a sample 1h window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 20717ce commit 7306b0d

5 files changed

Lines changed: 151 additions & 3 deletions

File tree

crates/pawscope-server/src/api.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,77 @@ pub async fn tools_trend(
600600
}))
601601
.into_response()
602602
}
603+
604+
#[derive(Debug, Deserialize)]
605+
pub struct ToolBucketQuery {
606+
pub since: String,
607+
pub until: String,
608+
#[serde(default)]
609+
pub tool: Option<String>,
610+
#[serde(default)]
611+
pub limit: Option<usize>,
612+
}
613+
614+
#[derive(Debug, Serialize)]
615+
struct BucketHit {
616+
session_id: String,
617+
agent: String,
618+
cwd: Option<String>,
619+
count: u64,
620+
last_event_at: String,
621+
}
622+
623+
pub async fn tools_bucket(
624+
Query(p): Query<ToolBucketQuery>,
625+
State(s): State<AppState>,
626+
) -> impl IntoResponse {
627+
let since = match chrono::DateTime::parse_from_rfc3339(&p.since) {
628+
Ok(t) => t.with_timezone(&chrono::Utc),
629+
Err(e) => return (StatusCode::BAD_REQUEST, format!("since: {e}")).into_response(),
630+
};
631+
let until = match chrono::DateTime::parse_from_rfc3339(&p.until) {
632+
Ok(t) => t.with_timezone(&chrono::Utc),
633+
Err(e) => return (StatusCode::BAD_REQUEST, format!("until: {e}")).into_response(),
634+
};
635+
let limit = p.limit.unwrap_or(50).clamp(1, 200);
636+
let tool_filter = p.tool.as_deref();
637+
638+
let sessions = match s.adapter.list_sessions().await {
639+
Ok(v) => v,
640+
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
641+
};
642+
643+
let mut hits: Vec<BucketHit> = Vec::new();
644+
for sess in &sessions {
645+
let detail = match s.adapter.get_detail(&sess.id).await {
646+
Ok(d) => d,
647+
Err(_) => continue,
648+
};
649+
let mut count: u64 = 0;
650+
for tc in &detail.tool_calls {
651+
if tc.timestamp < since || tc.timestamp >= until {
652+
continue;
653+
}
654+
if let Some(t) = tool_filter {
655+
if tc.name != t {
656+
continue;
657+
}
658+
}
659+
count += 1;
660+
}
661+
if count == 0 {
662+
continue;
663+
}
664+
hits.push(BucketHit {
665+
session_id: sess.id.clone(),
666+
agent: format!("{:?}", sess.agent).to_lowercase(),
667+
cwd: Some(sess.cwd.display().to_string()),
668+
count,
669+
last_event_at: sess.last_event_at.to_rfc3339(),
670+
});
671+
}
672+
hits.sort_by(|a, b| b.count.cmp(&a.count));
673+
hits.truncate(limit);
674+
675+
Json(hits).into_response()
676+
}

crates/pawscope-server/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub fn build_app(adapter: Arc<dyn AgentAdapter>) -> (Router, AppState) {
3636
.route("/api/realms", get(api::realm_detail))
3737
.route("/api/prompts/search", get(api::prompts_search))
3838
.route("/api/tools/trend", get(api::tools_trend))
39+
.route("/api/tools/bucket", get(api::tools_bucket))
3940
.route("/api/skills", get(skills::list_skills))
4041
.route("/api/skills/content", get(skills::skill_content))
4142
.route("/api/skills/usage", get(skills::skill_usage))

web/src/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ export async function fetchToolsTrend(hours = 168, top = 6): Promise<ToolTrendRe
133133
return r.json();
134134
}
135135

136+
export interface BucketHit {
137+
session_id: string;
138+
agent: string;
139+
cwd: string | null;
140+
count: number;
141+
last_event_at: string;
142+
}
143+
export async function fetchToolsBucket(
144+
since: string,
145+
until: string,
146+
tool?: string,
147+
): Promise<BucketHit[]> {
148+
const params = new URLSearchParams({ since, until, limit: '50' });
149+
if (tool) params.set('tool', tool);
150+
const r = await fetch(`/api/tools/bucket?${params}`);
151+
if (!r.ok) throw new Error(`tools bucket ${r.status}`);
152+
return r.json();
153+
}
154+
136155
export interface PromptHit {
137156
session_id: string;
138157
agent: string;

web/src/components/OverviewPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,7 @@ export function OverviewPanel({
737737
</div>
738738
</section>
739739

740-
<ToolTrend />
740+
<ToolTrend onOpenSession={onOpenSession} />
741741

742742
<section className="text-[11px] text-slate-600 px-1">
743743
Messages: ↑ {data.total_user_messages.toLocaleString()} user · ↓ {data.total_assistant_messages.toLocaleString()} assistant

web/src/components/ToolTrend.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useMemo, useState } from 'react';
2-
import { fetchToolsTrend, type ToolTrendResponse } from '../api';
2+
import { fetchToolsBucket, fetchToolsTrend, type BucketHit, type ToolTrendResponse } from '../api';
33
import { useT } from '../i18n';
44

55
const COLORS = [
@@ -14,12 +14,15 @@ const RANGES: { key: '24h' | '7d' | '30d'; hours: number }[] = [
1414
{ key: '30d', hours: 720 },
1515
];
1616

17-
export function ToolTrend() {
17+
export function ToolTrend({ onOpenSession }: { onOpenSession?: (id: string) => void }) {
1818
const { t } = useT();
1919
const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d');
2020
const [data, setData] = useState<ToolTrendResponse | null>(null);
2121
const [loading, setLoading] = useState(false);
2222
const [hover, setHover] = useState<{ idx: number; x: number; y: number } | null>(null);
23+
const [drill, setDrill] = useState<{ idx: number; since: string; until: string; hits: BucketHit[] | null }>(
24+
{ idx: -1, since: '', until: '', hits: null },
25+
);
2326

2427
useEffect(() => {
2528
setLoading(true);
@@ -118,6 +121,18 @@ export function ToolTrend() {
118121
return (
119122
<g
120123
key={i}
124+
style={{ cursor: 'pointer' }}
125+
onClick={() => {
126+
const bucketHourSpan = data.hours / data.totals.length;
127+
const untilTs = endTs - (data.totals.length - 1 - i) * 3600_000;
128+
const sinceTs = untilTs - bucketHourSpan * 3600_000;
129+
const since = new Date(sinceTs).toISOString();
130+
const until = new Date(untilTs).toISOString();
131+
setDrill({ idx: i, since, until, hits: null });
132+
fetchToolsBucket(since, until)
133+
.then((hits) => setDrill({ idx: i, since, until, hits }))
134+
.catch(() => setDrill({ idx: i, since, until, hits: [] }));
135+
}}
121136
onMouseEnter={(e) => {
122137
const rect = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
123138
setHover({
@@ -201,6 +216,45 @@ export function ToolTrend() {
201216
</div>
202217
))}
203218
</div>
219+
220+
{drill.idx >= 0 && (
221+
<div className="mt-4 border-t border-slate-800 pt-3">
222+
<div className="flex items-center gap-2 text-[11px] text-slate-400 mb-2">
223+
<span className="font-mono text-slate-200">
224+
{new Date(drill.since).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
225+
{' → '}
226+
{new Date(drill.until).toLocaleString([], { hour: '2-digit', minute: '2-digit' })}
227+
</span>
228+
<span className="text-slate-500">·</span>
229+
<span>{drill.hits ? `${drill.hits.length} sessions` : '…'}</span>
230+
<button
231+
onClick={() => setDrill({ idx: -1, since: '', until: '', hits: null })}
232+
className="ml-auto text-slate-500 hover:text-slate-200 text-xs"
233+
></button>
234+
</div>
235+
{drill.hits && drill.hits.length === 0 ? (
236+
<div className="text-[11px] text-slate-600 py-2">no sessions in this bucket</div>
237+
) : (
238+
<ul className="divide-y divide-slate-800/60">
239+
{(drill.hits ?? []).map((h) => (
240+
<li
241+
key={h.session_id}
242+
onClick={() => onOpenSession?.(h.session_id)}
243+
className="py-1.5 px-1 flex items-center gap-2 cursor-pointer hover:bg-slate-800/40 rounded"
244+
>
245+
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-800/70 text-slate-300 font-mono">
246+
{h.agent}
247+
</span>
248+
<span className="text-[11px] text-slate-300 font-mono truncate flex-1">
249+
{h.cwd ?? h.session_id}
250+
</span>
251+
<span className="text-[11px] tabular-nums text-cyan-300">{h.count}</span>
252+
</li>
253+
))}
254+
</ul>
255+
)}
256+
</div>
257+
)}
204258
</>
205259
)}
206260
</div>

0 commit comments

Comments
 (0)