diff --git a/dashboard/app/(product)/app/findings/page.tsx b/dashboard/app/(product)/app/findings/page.tsx
index d260b5e3..7f8b6d92 100644
--- a/dashboard/app/(product)/app/findings/page.tsx
+++ b/dashboard/app/(product)/app/findings/page.tsx
@@ -42,7 +42,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise("/api/agents", { agents: [] }),
Promise.all(STATUSES.map((s) => safeApi(`/api/findings?status=${s.key}&limit=500${sp.agent ? `&agent=${sp.agent}` : ""}`, { findings: [] }))),
]);
- const rows = [...(data.findings || [])].sort(
+ const rows = group(data.findings || []).sort(
(a, b) => severityRank(a.severity) - severityRank(b.severity) || String(b.last_seen_at).localeCompare(String(a.last_seen_at)),
);
const keep = { agent: sp.agent, severity: sp.severity };
@@ -55,7 +55,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise ({
key: s.key,
label: s.label,
- count: counts[i].findings?.length,
+ count: group(counts[i].findings || []).length,
href: href("/app/findings", { ...keep, status: s.key === "open" ? undefined : s.key }),
}))}
/>
@@ -107,3 +107,24 @@ export default async function Issues({ searchParams }: { searchParams: Promise
);
}
+
+/**
+ * One row per problem. Issues raised before findings carried a fingerprint (Sept
+ * 2026) were never merged, so the same problem can appear as many rows; they are
+ * counted together here, linking to the most recent.
+ */
+function group(findings: any[]): any[] {
+ const byKey = new Map();
+ for (const f of findings) {
+ const key = [f.type, f.title, f.agent_slug || "", f.subject_type || "", f.subject_id || "", f.status].join("|");
+ const seen = byKey.get(key);
+ const at = String(f.last_seen_at || f.created_at || "");
+ if (!seen) {
+ byKey.set(key, { ...f, occurrences: f.occurrences || 1 });
+ continue;
+ }
+ seen.occurrences += f.occurrences || 1;
+ if (at > String(seen.last_seen_at || seen.created_at || "")) Object.assign(seen, { id: f.id, last_seen_at: at });
+ }
+ return Array.from(byKey.values());
+}
diff --git a/dashboard/app/(product)/app/observe/page.tsx b/dashboard/app/(product)/app/observe/page.tsx
index 5b3d8734..f1608cee 100644
--- a/dashboard/app/(product)/app/observe/page.tsx
+++ b/dashboard/app/(product)/app/observe/page.tsx
@@ -21,6 +21,7 @@ import {
} from "@/components/kit";
import { FilterBar } from "@/components/kit/FilterBar";
import { filtersFrom, metricsQs, observeTabs, runsHref, type Filters } from "@/lib/product/observe";
+import { ensureRange } from "@/lib/product/range";
import { CATEGORIES, categoryLabel, detectorName, dimLabel, ruleCategory, ruleTitle } from "@/lib/product/vocab";
export const metadata: Metadata = appPageMetadata("Observe");
@@ -30,6 +31,7 @@ type SP = Record;
export default async function Observe({ searchParams }: { searchParams: Promise }) {
const sp = await searchParams;
+ await ensureRange("/app/observe", sp);
const f = filtersFrom(sp);
const tab = ["breaks", "security", "review", "cost"].includes(sp.tab || "") ? sp.tab! : "overview";
diff --git a/dashboard/app/(product)/app/page.tsx b/dashboard/app/(product)/app/page.tsx
index 787cc3b4..008c9f37 100644
--- a/dashboard/app/(product)/app/page.tsx
+++ b/dashboard/app/(product)/app/page.tsx
@@ -2,11 +2,12 @@ import type { Metadata } from "next";
import Link from "next/link";
import { appPageMetadata } from "@/lib/site";
import { safeApi } from "@/lib/product/api";
-import { Card, Dot, Empty, Grid, Header, Kpi, Pill, SeverityPill, StackBar, TimeChart, ago, num, pctOf } from "@/components/kit";
+import { Card, Dot, Empty, Grid, Header, Kpi, Pill, SeverityPill, StackBar, TimeChart, ago, href, num, pctOf } from "@/components/kit";
import { Countdown } from "@/components/product/Countdown";
import { SetupProgress } from "@/components/product/home/SetupProgress";
import { runsHref } from "@/lib/product/observe";
-import { severityRank } from "@/lib/product/vocab";
+import { activeRange } from "@/lib/product/range";
+import { RANGE_WORDS, severityRank } from "@/lib/product/vocab";
// `appPageMetadata`, not a literal: it also clears the canonical URL, so this page
// does not declare itself a duplicate of the marketing home page.
@@ -21,11 +22,13 @@ type SP = { review_notice?: string; review_error?: string };
*/
export default async function Home({ searchParams }: { searchParams: Promise }) {
const sp = await searchParams;
- const f = { range: "7d" as const };
+ // The week, or the smallest window that holds the latest traffic in a quiet workspace.
+ const { range, last } = await activeRange();
+ const f = { range };
const [onboarding, summary, byAgent, agents, approvals, findings, policies] = await Promise.all([
safeApi("/api/onboarding", null),
- safeApi("/api/metrics/summary?range=7d", null),
- safeApi("/api/metrics/breakdown?dim=agent&range=7d", { rows: [] }),
+ safeApi(`/api/metrics/summary?range=${range}`, null),
+ safeApi(`/api/metrics/breakdown?dim=agent&range=${range}`, { rows: [] }),
safeApi("/api/agents", { agents: [] }),
safeApi("/api/approvals?status=pending", { approvals: [] }),
safeApi("/api/findings?status=open&limit=200", { findings: [] }),
@@ -41,11 +44,11 @@ export default async function Home({ searchParams }: { searchParams: Promise
.sort((a: any, c: any) => severityRank(a.severity) - severityRank(c.severity))
.slice(0, 4);
const agentName: Record = Object.fromEntries((agents.agents || []).map((a: any) => [a.id, a.name || a.slug]));
- const watching = (policies.policies || []).filter((x: any) => x.mode === "observe");
+ const watching = (policies.policies || []).filter((x: any) => x.mode === "observe" && x.rules > 0);
const usage: Record = Object.fromEntries((byAgent.rows || []).map((r: any) => [r.key, r]));
const live = (agents.agents || []).filter((a: any) => a.status !== "draft");
const max = Math.max(1, ...Object.values(usage).map((r: any) => r.requests));
- const noTraffic = !t.requests && !p.requests;
+ const noTraffic = !last;
return (
<>
@@ -114,7 +117,7 @@ export default async function Home({ searchParams }: { searchParams: Promise
) : (
<>
- x.allowed + x.masked + x.held + x.blocked)} />
+ x.allowed + x.masked + x.held + x.blocked)} />
x.blocked)} />
x.held)} />
diff --git a/dashboard/app/(product)/app/policies/page.tsx b/dashboard/app/(product)/app/policies/page.tsx
index 8b12e168..64330f59 100644
--- a/dashboard/app/(product)/app/policies/page.tsx
+++ b/dashboard/app/(product)/app/policies/page.tsx
@@ -27,6 +27,7 @@ import { WorkspaceCode } from "@/components/product/policies/WorkspaceCode";
import { LegacyJudgmentTab, LegacyPacksTab, LegacyTuningTab } from "@/components/product/policies/legacy";
import { metricsQs, runsHref } from "@/lib/product/observe";
import { loadRules, ruleMode, type RuleInfo } from "@/lib/product/rules";
+import { ensureRange } from "@/lib/product/range";
import { CATEGORIES, categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab";
export const metadata: Metadata = appPageMetadata("Policies", "What your agents may do, and how well each rule is working.");
@@ -59,6 +60,7 @@ export default async function Policies({ searchParams }: { searchParams: Promise
const legacy = LEGACY[sp.tab || ""];
const tab = legacy?.tab || (TABS.some((t) => t.key === sp.tab) ? sp.tab! : "rules");
const sec = legacy?.sec || sp.sec;
+ if (tab === "rules" || tab === "performance") await ensureRange("/app/policies", sp);
return (
<>
@@ -110,7 +112,8 @@ async function RulesTab({ sp }: { sp: SP }) {
- {packs.map((p) => (
+ {/* A pack with no rules (a framework profile with nothing in it yet) decides nothing. */}
+ {packs.filter((p) => p.rules.length > 0).map((p) => (
-
+ {t.checks === 0 ? (
+
+ Not checked
+
+ ) : (
+
+ )}
{t.would_verdict && outcomeOf(t.verdict) === "allowed" && (
{WOULD[t.would_verdict]}
diff --git a/dashboard/lib/generated/reference/api.json b/dashboard/lib/generated/reference/api.json
index 6c5388ee..589bdc71 100644
--- a/dashboard/lib/generated/reference/api.json
+++ b/dashboard/lib/generated/reference/api.json
@@ -1,6 +1,6 @@
{
"generated_by": "scripts/gen/docs_reference.py",
- "count": 239,
+ "count": 240,
"routes": [
{
"method": "GET",
@@ -5956,6 +5956,41 @@
],
"body": null
},
+ {
+ "method": "GET",
+ "path": "/api/metrics/activity",
+ "section": "metrics",
+ "audience": "operator",
+ "summary": "Activity",
+ "description": "",
+ "params": [
+ {
+ "name": "agent",
+ "in": "query",
+ "required": false,
+ "type": null
+ },
+ {
+ "name": "environment",
+ "in": "query",
+ "required": false,
+ "type": null
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "type": null
+ },
+ {
+ "name": "x-agentfox-user",
+ "in": "header",
+ "required": false,
+ "type": null
+ }
+ ],
+ "body": null
+ },
{
"method": "GET",
"path": "/api/metrics/breakdown",
diff --git a/dashboard/lib/product/observe.ts b/dashboard/lib/product/observe.ts
index 8d19b089..4d2d19d7 100644
--- a/dashboard/lib/product/observe.ts
+++ b/dashboard/lib/product/observe.ts
@@ -17,7 +17,9 @@ export function metricsQs(f: Filters, extra: Record = {}): strin
/** Filters carried from one view to the next, so switching tabs keeps them. */
export function keep(f: Filters): Record {
- return { range: f.range === "7d" ? undefined : f.range, agent: f.agent, env: f.env };
+ // Always explicit: a view with no range picks one from the data (`lib/product/range.ts`),
+ // so dropping "7d" here would send a reader who chose a week back to the data's window.
+ return { range: f.range, agent: f.agent, env: f.env };
}
export const OBSERVE_TABS = [
diff --git a/dashboard/lib/product/range.ts b/dashboard/lib/product/range.ts
new file mode 100644
index 00000000..76898a3c
--- /dev/null
+++ b/dashboard/lib/product/range.ts
@@ -0,0 +1,28 @@
+import { redirect } from "next/navigation";
+import { href } from "@/components/kit";
+import { safeApi } from "@/lib/product/api";
+import type { RangeKey } from "@/lib/product/vocab";
+
+type SP = Record;
+
+/**
+ * The range a view opens on when the URL names none: the smallest window that holds
+ * the latest traffic (a week at least), so a quiet workspace opens on its data
+ * rather than on an empty week. Asked of the gateway (`/api/metrics/activity`).
+ */
+export async function activeRange(agent?: string): Promise<{ range: RangeKey; last: string | null }> {
+ const q = agent ? `?agent=${encodeURIComponent(agent)}` : "";
+ const a = await safeApi(`/api/metrics/activity${q}`, { range: "7d", last_request_at: null });
+ return { range: (a.range || "7d") as RangeKey, last: a.last_request_at };
+}
+
+/**
+ * For a page whose numbers depend on the range: with no range in the URL, send the
+ * reader to the URL with the range that has data, so the filter bar, the links to
+ * other tabs and everything on the page agree on one window.
+ */
+export async function ensureRange(path: string, sp: SP, agent?: string): Promise {
+ if (sp.range) return;
+ const { range } = await activeRange(agent ?? sp.agent);
+ if (range !== "7d") redirect(href(path, { ...sp, range }));
+}
diff --git a/dashboard/lib/product/vocab.ts b/dashboard/lib/product/vocab.ts
index d22697d8..60f87521 100644
--- a/dashboard/lib/product/vocab.ts
+++ b/dashboard/lib/product/vocab.ts
@@ -93,6 +93,9 @@ export const RANGES = [
export type RangeKey = (typeof RANGES)[number]["key"];
+/** A range in words, for labels: "Requests · 30 days". */
+export const RANGE_WORDS: Record = { "24h": "24 hours", "7d": "7 days", "30d": "30 days", "90d": "90 days" };
+
export function rangeOf(value?: string | null): RangeKey {
return (RANGES.find((r) => r.key === value)?.key || "7d") as RangeKey;
}
diff --git a/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl b/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl
index 10582b0a..db881488 100644
Binary files a/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl and b/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl differ
diff --git a/docs/architecture/api-spec.md b/docs/architecture/api-spec.md
index 0a07c7ec..4b10ce0b 100644
--- a/docs/architecture/api-spec.md
+++ b/docs/architecture/api-spec.md
@@ -104,7 +104,7 @@ from the code. Regenerate after changing any route:
-239 operations, generated from the running app's OpenAPI document. Request and response schemas: `GET /openapi.json` or the interactive `/docs`.
+240 operations, generated from the running app's OpenAPI document. Request and response schemas: `GET /openapi.json` or the interactive `/docs`.
### Inline enforcement (`/v1`)
@@ -132,6 +132,7 @@ from the code. Regenerate after changing any route:
| `GET` | `/api/memory` | List Entries |
| `POST` | `/api/memory/{entry_id}/revoke` | Pull an entry immediately — the concrete fix for 'no way to find and |
| `POST` | `/api/memory/{entry_id}/verify` | A human vouches for an entry — it stops decaying on the unverified TTL. |
+| `GET` | `/api/metrics/activity` | Activity |
| `GET` | `/api/metrics/breakdown` | Breakdown |
| `GET` | `/api/metrics/errors` | Steps that failed — a tool that threw, a model call that errored — grouped. |
| `GET` | `/api/metrics/rules` | How each rule behaved: fired, stopped something, or only watched. |
diff --git a/src/agentfox/apps/gateway/routes/metrics.py b/src/agentfox/apps/gateway/routes/metrics.py
index 75081375..1a2accff 100644
--- a/src/agentfox/apps/gateway/routes/metrics.py
+++ b/src/agentfox/apps/gateway/routes/metrics.py
@@ -11,6 +11,8 @@
* ``/breakdown`` — the same counts split by agent, environment, model, tool or surface
* ``/rules`` — per rule: how often it fired, enforced vs only watching, trend
* ``/errors`` — failed steps (tool and model errors), grouped
+* ``/activity`` — when the last request was, and the smallest range that shows it,
+ so a view with no range chosen opens on data instead of an empty week
Outcomes use the dashboard's four words, not the six verdicts: ``allowed``,
``masked`` (redact/mask/tokenize), ``held`` (escalate) and ``blocked``.
@@ -23,7 +25,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.orm import Session
from agentfox.apps.gateway.deps import current_user, db
@@ -168,6 +170,34 @@ def _percentile(values: list[float], p: float) -> float:
return round(values[k], 2)
+@router.get("/activity")
+def activity(
+ agent: str | None = None,
+ environment: str | None = None,
+ session: Session = Depends(db),
+ _user: User = Depends(current_user),
+) -> dict[str, Any]:
+ q = select(func.max(Trace.started_at))
+ if agent:
+ q = q.where(Trace.agent_slug == agent)
+ if environment:
+ q = q.where(Trace.environment == environment)
+ else:
+ q = q.where(Trace.environment != PLAYGROUND)
+ last = session.scalar(q)
+ if last is None:
+ return {"last_request_at": None, "range": "7d"}
+ age = dt.datetime.now(dt.UTC) - _aware(last)
+ # The smallest range from the default week up that contains it; past the
+ # longest, the longest. Never narrower than a week: recent traffic still
+ # reads better against its week than against one day.
+ fits = [k for k, (length, _step) in RANGES.items() if k != "24h" and age <= length]
+ return {
+ "last_request_at": _aware(last).isoformat(),
+ "range": fits[0] if fits else list(RANGES)[-1],
+ }
+
+
@router.get("/summary")
def summary(
window: str = Query("7d", alias="range"),
diff --git a/src/agentfox/platform/ledger/trace.py b/src/agentfox/platform/ledger/trace.py
index 4fc46a64..ee5c01e4 100644
--- a/src/agentfox/platform/ledger/trace.py
+++ b/src/agentfox/platform/ledger/trace.py
@@ -371,6 +371,9 @@ def search_traces(
fired: dict[str, list[str]] = {}
tools: dict[str, list[str]] = {}
surfaces: dict[str, list[str]] = {}
+ # How many checks ran. A run that reached us only as telemetry has none, and
+ # must not read as "allowed": nothing decided anything about it.
+ checks: dict[str, int] = {}
# What a rule that is only watching would have done: the strongest effect among
# fired rules in observe mode. A run that was allowed but would have been
# blocked is the fact a team needs before switching a rule to enforce.
@@ -380,6 +383,7 @@ def search_traces(
for d in session.scalars(
select(Decision).where(Decision.trace_id.in_({t.id for t in traces}))
):
+ checks[d.trace_id] = checks.get(d.trace_id, 0) + 1
for r in d.rules_fired_json or []:
rid = r.get("rule_id") if isinstance(r, dict) else None
if rid and rid not in fired.setdefault(d.trace_id, []):
@@ -411,6 +415,7 @@ def search_traces(
"tools": tools.get(t.id, []),
"surfaces": surfaces.get(t.id, []),
"would_verdict": would.get(t.id),
+ "checks": checks.get(t.id, 0),
}
for t in traces
]
diff --git a/tests/apps/gateway/test_metrics.py b/tests/apps/gateway/test_metrics.py
index 1df178ab..467aa06a 100644
--- a/tests/apps/gateway/test_metrics.py
+++ b/tests/apps/gateway/test_metrics.py
@@ -135,3 +135,31 @@ def test_playground_runs_are_excluded_unless_asked_for(client):
"/api/traces?agent=metrics-bot&environment=playground", headers=ADMIN
).json()["traces"]
assert len(played) == 1
+
+
+def test_activity_names_the_smallest_range_with_traffic(client):
+ from agentfox.core.db import session_scope
+ from agentfox.core.models import Trace
+
+ old = dt.datetime.now(dt.UTC) - dt.timedelta(days=20)
+ with session_scope() as session:
+ session.add(Trace(agent_slug="old-bot", verdict="allow", started_at=old))
+ session.add(
+ Trace(agent_slug="old-bot", verdict="allow", started_at=old, environment="playground")
+ )
+ out = client.get("/api/metrics/activity?agent=old-bot", headers=ADMIN).json()
+ assert out["range"] == "30d" and out["last_request_at"].startswith(old.date().isoformat())
+ assert client.get("/api/metrics/activity?agent=nobody", headers=ADMIN).json() == {
+ "last_request_at": None,
+ "range": "7d",
+ }
+
+
+def test_a_run_with_no_checks_says_so(client):
+ from agentfox.core.db import session_scope
+ from agentfox.core.models import Trace
+
+ with session_scope() as session:
+ session.add(Trace(agent_slug="otel-bot", verdict="allow"))
+ rows = client.get("/api/traces?agent=otel-bot", headers=ADMIN).json()["traces"]
+ assert rows and rows[0]["checks"] == 0