diff --git a/docsgpt/api/user/schedules/routes.py b/docsgpt/api/user/schedules/routes.py index 6f2d259a..b63e39a3 100644 --- a/docsgpt/api/user/schedules/routes.py +++ b/docsgpt/api/user/schedules/routes.py @@ -317,6 +317,55 @@ class AgentSchedules(Resource): return _ok({"schedule": _format_schedule(created)}, status=201) +@schedules_ns.route("/agents//schedules/stats") +class AgentScheduleStats(Resource): + @api.doc( + description="Run stats for an agent's schedules over a recent window.", + params={"days": "Window in days (default 30, clamped to 1..365)"}, + ) + @_safe_route + def get(self, agent_id: str): + """Return run count, failures, tokens and latest failure for an agent. + + Args: + agent_id: Agent id from the URL. + + Returns: + A Flask response with ``days``, ``runs``, ``failed``, ``tokens`` + and ``latest_failure`` (or an error envelope). + """ + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + agent = _agent_owned(agent_id, user_id) + if agent is None: + return _err("agent not found", 404) + try: + days = max(1, min(int(request.args.get("days", 30)), 365)) + except (TypeError, ValueError): + days = 30 + try: + with db_readonly() as conn: + stats = ScheduleRunsRepository(conn).stats_for_agent( + str(agent["id"]), user_id, days=days, + ) + except Exception as exc: + current_app.logger.error( + "schedule stats failed: %s", exc, exc_info=True, + ) + return _err("internal error", 500) + latest = stats.get("latest_failure") + return _ok( + { + "days": days, + "runs": stats["runs"], + "failed": stats["failed"], + "tokens": stats["tokens"], + "latest_failure": _format_run(latest) if latest else None, + } + ) + + @schedules_ns.route("/schedules/") class ScheduleResource(Resource): @api.doc(description="Get schedule by id.") diff --git a/docsgpt/storage/db/repositories/schedule_runs.py b/docsgpt/storage/db/repositories/schedule_runs.py index 22fb1d9c..95311d45 100644 --- a/docsgpt/storage/db/repositories/schedule_runs.py +++ b/docsgpt/storage/db/repositories/schedule_runs.py @@ -167,6 +167,79 @@ class ScheduleRunsRepository: ).fetchall() return [row_to_dict(r) for r in rows] + def stats_for_agent( + self, agent_id: str, user_id: str, *, days: int = 30, + ) -> dict: + """Aggregate run stats for an agent's owned schedules over a window. + + Only runs of schedules owned by ``user_id`` on ``agent_id`` whose + ``scheduled_for`` falls within the last ``days`` days are counted. + + Args: + agent_id: Agent UUID the schedules belong to. + user_id: Owner of the schedules (and runs). + days: Window length in days, counted back from now. + + Returns: + Dict with ``runs``, ``failed`` (``failed``/``timeout``), + ``tokens`` (prompt + generated) and ``latest_failure`` — the + most recent failed run's ``scheduled_for``/``status``/ + ``error_type``, or ``None`` when there is none. + """ + row = self._conn.execute( + text( + """ + WITH window_runs AS ( + SELECT r.scheduled_for, r.status, r.error_type, + r.prompt_tokens, r.generated_tokens + FROM schedule_runs r + JOIN schedules s ON s.id = r.schedule_id + WHERE s.agent_id = CAST(:agent_id AS uuid) + AND s.user_id = :user_id + AND r.user_id = :user_id + AND r.scheduled_for >= now() - make_interval(days => :days) + ), + latest_failure AS ( + SELECT scheduled_for, status, error_type + FROM window_runs + WHERE status IN ('failed', 'timeout') + ORDER BY scheduled_for DESC + LIMIT 1 + ) + SELECT agg.runs, agg.failed, agg.tokens, + lf.scheduled_for AS latest_failure_scheduled_for, + lf.status AS latest_failure_status, + lf.error_type AS latest_failure_error_type + FROM ( + SELECT count(*) AS runs, + count(*) FILTER ( + WHERE status IN ('failed', 'timeout') + ) AS failed, + COALESCE( + SUM(prompt_tokens + generated_tokens), 0 + ) AS tokens + FROM window_runs + ) agg + LEFT JOIN latest_failure lf ON true + """ + ), + {"agent_id": str(agent_id), "user_id": user_id, "days": int(days)}, + ).fetchone() + data = row_to_dict(row) + latest_failure = None + if data.get("latest_failure_scheduled_for") is not None: + latest_failure = { + "scheduled_for": data["latest_failure_scheduled_for"], + "status": data["latest_failure_status"], + "error_type": data["latest_failure_error_type"], + } + return { + "runs": int(data.get("runs") or 0), + "failed": int(data.get("failed") or 0), + "tokens": int(data.get("tokens") or 0), + "latest_failure": latest_failure, + } + def update(self, run_id: str, fields: dict) -> Optional[dict]: """Apply a whitelisted partial update to a run row.""" filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES} diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 0d776303..289d9d97 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1,8 +1,9 @@ # DocsGPT frontend design system The UI is Tailwind v4 plus the shadcn-style components in -`src/components/ui/`. `@shadcn/lint` enforces the rules below through -`npm run lint`; its messages point here. Everything in this file is either a +`src/components/ui/`. `@shadcn/lint` and the `no-restricted-syntax` +selectors in `eslint/` enforce the rules below through `npm run lint`; their +messages point here. Everything in this file is either a token in `src/index.css` or a variant in a `ui/` component, so an agent or a contributor can always find the concrete thing to use. @@ -36,7 +37,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and | `popover`, `popover-foreground` | floating menus and popovers | same as card | | `muted`, `muted-foreground` | quiet fills and secondary text, icons, placeholders, timestamps | `bg-gray-100/200`, `text-gray-400/500/600`, `dark:text-gray-400` | | `accent`, `accent-foreground` | hover and selected fills | `hover:bg-gray-100`, `dark:hover:bg-gray-700` | -| `border`, `input`, `ring` | dividers and field borders (fields use `border-border`), focus rings | `border-gray-200/300`, `dark:border-gray-600/700` | +| `border`, `input`, `ring` | dividers, field borders (fields use `border-input`, `fieldFrame`), focus rings | `border-gray-200/300`, `dark:border-gray-600/700` | | `primary`, `primary-foreground` | brand purple: primary actions, links, selected states | `bg-purple-*`, `text-purple-*`, `#7D54D1`, `#A076F6` | | `secondary`, `secondary-foreground` | pressed and active toggles (a brand tint), secondary buttons, the question bubble | `bg-accent` on an active icon button | | `destructive`, `destructive-foreground` | errors, failed states, dangerous actions | `text-red-*`, `bg-red-50/100`, `#B42318`, `#E60000` | @@ -45,7 +46,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and | `info`, `info-foreground` | running, informational | `text-blue-*`, `bg-blue-50/100` | | `sidebar-*` | the navigation rail | | | `chart-1` to `chart-5` | data series only, never UI chrome (see below) | | -| `answer-bubble` | assistant message background | | +| `answer-bubble` | the source cards under an answer and the answer-side panels (AnswerFlow) | | Status colours are tuned to stay vivid rather than turning brown or olive, so their contrast is low. Against white in light mode, `destructive` and @@ -82,7 +83,8 @@ One tint scale, by purpose: `selected`, Dropzone drag-active and drag-reject). The border carries the state; the wash only warms the surface. Never on a chip or a text fill. - Brand soft fill: `bg-secondary text-secondary-foreground` (Badge `default`, - Avatar `primary`, the OptionCard icon square), never `bg-primary/10`. + Avatar `primary`, the OptionCard icon square), never `bg-primary/10` + (enforced). - Status soft fill: `bg-/10` in both themes (Badge, Alert, ToastHeader, a danger-zone panel); no `dark:` twin. - Status border: `border-/50`. @@ -102,12 +104,11 @@ Patterns: - Solid status fill: `bg-warning text-warning-foreground`. - Guardrail outcomes: block `destructive`, flag `warning`, redact `info`, not evaluated `neutral`. -- Status border: `border-destructive/50`. - De-emphasised text: `text-muted-foreground`; go lighter with an opacity modifier (`text-muted-foreground/70`) rather than a lighter grey. - Text on the brand colour is `text-primary-foreground`, not `text-white`. -- `secondary` is a brand tint, not a grey: primary at 10% (light) or 15% - (dark) over whatever sits behind it, with `primary` text in light and a +- `secondary` is a brand tint, not a grey: primary at 10% (light) or the + lighter #976af3 at 15% (dark) over whatever sits behind it, with `primary` text in light and a lighter purple (#b89cf8) in dark. So a pressed toggle (CopyButton's copied state, text-to-speech while speaking) shows on card, background and muted alike, and never reads as the neutral ghost hover. Use it as @@ -142,7 +143,7 @@ Patterns: | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `variant` | `default`, `secondary`, `outline`, `outline-primary`, `ghost`, `ghost-muted`, `ghost-destructive`, `ghost-on-accent`, `ghost-destructive-on-accent`, `link`, `destructive`, `destructive-outline`, `combobox`, `sidebar-item`, `tab`, `section-toggle` | | `size` | `xs`, `sm`, `default`, `lg`, `field`, `icon-xs`, `icon-sm`, `icon`, `icon-lg`, `inline` | -| `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding) | +| `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding at `default`, `lg` and `field`) | - Round brand buttons (`rounded-3xl px-5`, `rounded-full px-6`): `shape="pill"`, plus `size="lg"` if they were `px-6`. @@ -159,9 +160,10 @@ Patterns: ghost's accent square would not. - Icons are `lucide-react` at the default stroke (2). Don't pass `strokeWidth`, and don't use an `` for a glyph lucide has. The one - exception is a tiny check mark inside a status circle (the custom-model - test result, the artifact and research step ticks), drawn at 2.5 to 3 so - it stays legible at 10 to 12px; raw progress-ring ``s are not icons. + exception is a tiny glyph inside a status circle or chip (the custom-model + test result and capability chips, the artifact and research step ticks, + `ToastStatus`'s four status icons), drawn at 2.5 to 3 so it stays legible + at 10 to 12px; raw progress-ring ``s are not icons. - Icon size is one `size-N` class (never `h-N w-N`, never lucide's `size` prop); colour goes on the icon (`text-muted-foreground`), spacing on the parent (`gap-*`, not `mr-2` on the icon). Use current lucide names @@ -181,9 +183,9 @@ Patterns: hint passes `text-xs font-normal` (an approved exception), and a link that must keep the colour of what it sits in (a status Alert, a dark overlay, a source card's URL row) passes `text-current`. Never style a raw `` as a - link. -- Icon-only buttons are `IconButton` (below), never a `Button` with a - `title`. + link (enforced). +- Icon-only buttons: see IconButton below. A `title` on a `Button` is + rejected (enforced). - An action whose label doesn't fit beside a name on a phone (the shared agent card's Edit, in a long locale) is two elements: an `IconButton` with `sm:hidden` and the labelled `Button` with `hidden sm:inline-flex`. @@ -196,7 +198,7 @@ Patterns: editor. A Cancel inside a line of text (the composer's queued send) is `link inline`. - The composer controls under the chat field (Attach, Voice, Tools, - Sources) are `size="sm" shape="pill"`. Their icons are `size-3.5 sm:size-4` + Sources) are `outline sm pill`. Their icons are `size-3.5 sm:size-4` with no margin; the size's `gap-1.5` spaces them, and the label span keeps `text-xs sm:text-sm` so the row still fits on a phone. - Popover comboboxes (`role="combobox"` + `Command`) use `variant="combobox"`, @@ -211,7 +213,28 @@ Patterns: retrieval, Sync) are `size="field" shape="pill"` too, with no min-width or hand height. With `shape="pill"` its text starts 21px in, like the Input and Select pills beside it. The agent form's pickers are - `combobox field pill`, its Add button `outline-primary field pill`. + `combobox field pill`, each labelled by a `FormField` (the Prompt picker + through `Prompts titleAs="field"`), its Add button `outline-primary field +pill`, the only `outline-primary` on the agent pages themselves (the Access + details modal they open has its own). +- One variant and size per role on the agent pages (Overview, Logs, + Schedules, the workflow builder's toolbar): the page's primary action is + `default field pill` (Publish, Save, New schedule), a secondary action + `outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost +field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`) + at the end of the title row, through `SectionShell titleAction`: Access + details and Share with team on Overview, and Preview until the agent is + published (before that the preview only says "Publish to preview"). So the + row never holds more than three buttons and fits a phone, where the main + one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot + spans the stacked row below `sm`). The workflow builder's toolbar has no + title row and keeps its ⋯ at the row's end. The + agent's Delete is in Overview's danger zone; only the workflow builder's + toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is + `agents/components/AgentPageToolbar` (see Page chrome). A row's common + action is `outline sm pill` (a schedule's Run now or Resume) with its other + actions in the row's `ActionMenu`; a delete there is a `destructive` item + that opens the confirm modal. - Rows in the navigation sidebar (`hover:bg-sidebar-accent … pl-3 gap-2.5 rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal weight, `bg-sidebar-accent` on hover and while `aria-current="page"`. Use it @@ -226,24 +249,30 @@ rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal are `variant="link" size="sm"` with only `-ml-3 w-fit justify-start`; a chevron, when the toggle has one, is a lucide `ChevronRight` (so it takes the link colour) as the first child. -- Underline tabs (FilePicker's My Files / Shared with Me, the agent page - sub-nav) are `variant="tab"`: muted text on a transparent 2px bottom border, - square corners, no hover fill. Mark the current tab with `data-active`, which - gives it `foreground` text and a `primary` underline. Padding-free tabs (a - sub-nav with `gap-6`) add `size="inline"`, which keeps 4px above the line; - the row draws the 1px baseline, and `-mb-px` lays the underline over it. - Use them for a row of route links (the agent sub-nav: a `` with `data-active`. The + agent section pages switch with the section sidebar (the phone menu below + `lg`) and carry no pill row; the agent tile's ⋯ also opens Logs. Tabs that switch a panel in place are + `ui/tabs` with `variant="underline"` on `TabsList` and each `TabsTrigger`: + the same pixels, plus `role="tablist"`/`"tab"`, `aria-selected` and + arrow-key focus; wrap the panel in `TabsContent` (FilePicker's My Files / + Shared with Me, Schedules' Recurring / One-time in + `agents/schedules/SchedulesView.tsx`). The `default` variant is a pill tab, + unused in the app. - A section panel's disclosure header (NewAgent's Advanced and Guardrails panels) is `variant="section-toggle" size="sm"` with `-ml-3 w-fit justify-start` and `aria-expanded`: a lucide `ChevronRight` first (`rotate-90` while open) in primary, then the foreground title, with a - primary underline on hover. The button draws no focus ring; the panel does, + primary underline on hover. Both call sites wrap the Button in an `

` + (a button flattens heading children, so the heading goes outside) and put + the title in a `text-lg font-semibold` span. The button draws no focus ring; the panel does, so keyboard focus outlines the whole panel. The panel is a `Card` (a place, `subtle lg`), and every Card carries that ring (`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`, @@ -276,6 +305,7 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first | `tone` | `default`, `destructive` (the status soft fill and border, `border-destructive/50 bg-destructive/10`, over any variant) | | `padding` | `none`, `sm` (p-3, row boxes and code blocks), `default` (p-4), `lg` (p-6, tiles, panels and stat tiles) | | `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `