Merge remote-tracking branch 'origin/feature/frontend' into feature/next-release

This commit is contained in:
Dmitry Ng
2026-04-22 16:00:32 +03:00
57 changed files with 12690 additions and 18841 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ Include any new environment variables, configuration changes, or migration steps
- [ ] I have added tests to cover my changes
- [ ] All new and existing tests pass
- [ ] I have run `go fmt` and `go vet` (for Go code)
- [ ] I have run `npm run lint` (for TypeScript/JavaScript code)
- [ ] I have run `pnpm run lint` (for TypeScript/JavaScript code)
#### Security
- [ ] I have considered security implications
+12 -23
View File
@@ -36,50 +36,39 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
# pnpm setup
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: frontend/package.json
# Node.js setup and cache
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '23'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
# Cache npm dependencies
- name: Get npm cache directory
id: npm-cache-dir
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache npm packages
uses: actions/cache@v5
id: npm-cache
with:
path: |
${{ steps.npm-cache-dir.outputs.dir }}
frontend/node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
cache: 'pnpm'
cache-dependency-path: 'frontend/pnpm-lock.yaml'
# Frontend lint and test
- name: Frontend - Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
working-directory: frontend
run: npm ci
run: pnpm install --frozen-lockfile
continue-on-error: true
- name: Frontend - Prettier
working-directory: frontend
run: npm run prettier
run: pnpm run prettier
continue-on-error: true
- name: Frontend - Lint
working-directory: frontend
run: npm run lint
run: pnpm run lint
continue-on-error: true
- name: Frontend - Test
working-directory: frontend
run: npm run test
run: pnpm run test
continue-on-error: true
# Backend lint and test
+1 -1
View File
@@ -30,7 +30,7 @@
"type": "node",
"request": "launch",
"name": "Launch Frontend",
"runtimeExecutable": "npm",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"env": {
"VITE_APP_LOG_LEVEL": "DEBUG",
+11 -11
View File
@@ -39,16 +39,16 @@ swag init -g ../../pkg/server/router.go -o pkg/server/docs/ --parseDependency --
### Frontend (run from `frontend/`)
```bash
npm ci # Install dependencies
npm run dev # Dev server on http://localhost:8000
npm run build # Production build
npm run lint # ESLint check
npm run lint:fix # ESLint auto-fix
npm run prettier # Prettier check
npm run prettier:fix # Prettier auto-format
npm run test # Vitest
npm run test:coverage # Coverage report
npm run graphql:generate # Regenerate GraphQL types from schema
pnpm install # Install dependencies
pnpm run dev # Dev server on http://localhost:8000
pnpm run build # Production build
pnpm run lint # ESLint check
pnpm run lint:fix # ESLint auto-fix
pnpm run prettier # Prettier check
pnpm run prettier:fix # Prettier auto-format
pnpm run test # Vitest
pnpm run test:coverage # Coverage report
pnpm run graphql:generate # Regenerate GraphQL types from schema
```
### Docker (run from repo root)
@@ -139,7 +139,7 @@ State is managed primarily through Apollo Client (GraphQL) with real-time update
### Code Generation
When modifying `backend/pkg/graph/schema.graphqls`, re-run the gqlgen command to regenerate resolver stubs. When modifying REST handler annotations, re-run swag to update Swagger docs. When modifying `frontend/src/graphql/*.graphql` query files, re-run `npm run graphql:generate` to update TypeScript types.
When modifying `backend/pkg/graph/schema.graphqls`, re-run the gqlgen command to regenerate resolver stubs. When modifying REST handler annotations, re-run swag to update Swagger docs. When modifying `frontend/src/graphql/*.graphql` query files, re-run `pnpm run graphql:generate` to update TypeScript types.
### Utility Binaries
+1 -1
View File
@@ -28,7 +28,7 @@ When adding new dependencies, ensure they use compatible licenses:
1. Update dependencies:
```bash
cd backend && go mod tidy
cd ../frontend && npm install
cd ../frontend && pnpm install
```
2. Generate license reports:
+10 -7
View File
@@ -9,17 +9,20 @@ FROM node:23-slim AS frontend-compiler
ENV NODE_ENV=production
ENV VITE_BUILD_MEMORY_LIMIT=4096
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV PNPM_HOME="/usr/local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
WORKDIR /app/ui
# Install build essentials
# Install build essentials and enable pnpm via corepack
RUN apt-get update && apt-get install -y \
ca-certificates \
tzdata \
gcc \
g++ \
make \
git
git \
&& corepack enable && corepack prepare pnpm@latest --activate
# GraphQL schema for code generation
COPY ./backend/pkg/graph/schema.graphqls ../backend/pkg/graph/
@@ -27,18 +30,18 @@ COPY ./backend/pkg/graph/schema.graphqls ../backend/pkg/graph/
# Application source code
COPY frontend/ .
# Install dependencies with package manager detection for SBOM
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
# Install dependencies
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Generate license report for frontend dependencies
RUN npm install -g license-checker && \
RUN pnpm add -g license-checker && \
mkdir -p /licenses/frontend && \
license-checker --production --json > /licenses/frontend/licenses.json && \
license-checker --production --csv > /licenses/frontend/licenses.csv
# Build frontend with optimizations and parallel processing
RUN npm run build -- \
RUN pnpm run build -- \
--mode production \
--minify esbuild \
--outDir dist \
+12 -12
View File
@@ -2472,7 +2472,7 @@ fern generate --local
and to install fern-cli
```bash
npm install -g fern-api
pnpm add -g fern-api
```
#### Testing
@@ -2481,23 +2481,23 @@ For running tests `cd backend && go test -v ./...`
#### Frontend Setup
Run once `cd frontend && npm install` to install needed packages.
Run once `cd frontend && pnpm install` to install needed packages.
For generating graphql files have to run `npm run graphql:generate` which using `graphql-codegen.ts` file.
For generating graphql files have to run `pnpm run graphql:generate` which using `graphql-codegen.ts` file.
Be sure that you have `graphql-codegen` installed globally:
```bash
npm install -g graphql-codegen
pnpm add -g graphql-codegen
```
After that you can run:
* `npm run prettier` to check if your code is formatted correctly
* `npm run prettier:fix` to fix it
* `npm run lint` to check if your code is linted correctly
* `npm run lint:fix` to fix it
* `pnpm run prettier` to check if your code is formatted correctly
* `pnpm run prettier:fix` to fix it
* `pnpm run lint` to check if your code is linted correctly
* `pnpm run lint:fix` to fix it
For generating SSL certificates you need to run `npm run ssl:generate` which using `generate-ssl.ts` file or it will be generated automatically when you run `npm run dev`.
For generating SSL certificates you need to run `pnpm run ssl:generate` which using `generate-ssl.ts` file or it will be generated automatically when you run `pnpm run dev`.
#### Backend Configuration
@@ -2531,9 +2531,9 @@ Run the command(s) in `backend` folder:
#### Frontend
Run the command(s) in `frontend` folder:
- Run `npm install` to install the dependencies
- Run `npm run dev` to run the web app
- Run `npm run build` to build the web app
- Run `pnpm install` to install the dependencies
- Run `pnpm run dev` to run the web app
- Run `pnpm run build` to build the web app
Open your browser and visit the web app URL.
+1 -1
View File
@@ -34,7 +34,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/invopop/jsonschema v0.12.0
github.com/jackc/pgx/v5 v5.7.2
github.com/jackc/pgx/v5 v5.8.0
github.com/jinzhu/gorm v1.9.16
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
+2 -2
View File
@@ -358,8 +358,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
+4 -4
View File
@@ -95,19 +95,19 @@ src/
### Prerequisites
- Node.js 18+
- npm 8+
- pnpm 10+
### Installation
1. Clone the repository
2. Install dependencies:
npm install
pnpm install
3. Start the development server:
npm run dev
pnpm run dev
### Building for Production
npm run build
pnpm run build
### Environment Variables
+29
View File
@@ -32,6 +32,35 @@
rel="manifest"
href="/favicon/site.webmanifest"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-regular.woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-500.woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-600.woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-700.woff2"
crossorigin="anonymous"
/>
</head>
<body class="bg-background font-sans antialiased">
-18162
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -3,15 +3,15 @@
"type": "module",
"version": "0.2.0",
"scripts": {
"build": "npx tsc && vite build",
"build": "tsc && vite build",
"commit": "commit",
"commitlint": "commitlint --edit",
"dev": "vite",
"graphql:generate": "graphql-codegen --config graphql-codegen.ts",
"lint": "eslint \"src/**/*.{ts,tsx,js,jsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix",
"prettier": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,scss}\"",
"prettier:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss}\"",
"prettier": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,scss}\"",
"prettier:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss}\"",
"ssl:generate": "tsx --eval 'import { generateCertificates } from \"./scripts/generate-ssl.ts\"; generateCertificates();'",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
@@ -54,10 +54,14 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"github-slugger": "^2.0.0",
"graphql": "^16.11.0",
"graphql-ws": "^6.0.5",
"highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0",
"js-cookie": "^3.0.5",
"lodash": "^4.18.1",
"lowlight": "^3.3.0",
"lru-cache": "^11.1.0",
"lucide-react": "^0.553.0",
"marked": "^17.0.3",
@@ -86,6 +90,7 @@
"@commitlint/cli": "^20.0.0",
"@commitlint/config-conventional": "^20.0.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.4",
"@graphql-codegen/cli": "^5.0.3",
"@graphql-codegen/client-preset": "^4.5.1",
"@graphql-codegen/near-operation-file-preset": "^5.0.0",
@@ -123,6 +128,14 @@
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^4.0.0"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"simple-git-hooks"
]
},
"eslintConfig": {
"extends": [
"plugin:storybook/recommended"
+11837
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
import type { ReactNode } from 'react';
import { BarChart2, Loader2 } from 'lucide-react';
import { ResponsiveContainer } from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export const ChartCard = ({
children,
className,
description,
empty,
height = 300,
loading,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
empty?: boolean;
height?: number;
loading?: boolean;
title: ReactNode;
}) => (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
{loading ? (
<div
className="flex items-center justify-center"
style={{ height }}
>
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : empty ? (
<div
className="flex flex-col items-center justify-center gap-2"
style={{ height }}
>
<BarChart2 className="text-muted-foreground/30 size-10" />
<p className="text-muted-foreground text-sm">No data for this period</p>
</div>
) : (
<ResponsiveContainer
height={height}
width="100%"
>
{children}
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
@@ -0,0 +1,48 @@
import { formatNumber } from '@/lib/utils/format';
export type ChartTooltipPayloadEntry = {
color: string;
name: string;
value: number;
};
export const ChartTooltip = ({
active,
formatter,
label,
labelFormatter,
payload,
}: {
active?: boolean;
formatter?: (value: number, name: string) => string;
label?: string;
labelFormatter?: (label: string) => string;
payload?: Array<ChartTooltipPayloadEntry>;
}) => {
if (!active || !payload?.length) {
return null;
}
const renderedLabel = label ? (labelFormatter ? labelFormatter(label) : label) : '';
return (
<div className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 shadow-md">
{renderedLabel && <p className="text-muted-foreground mb-1 text-xs">{renderedLabel}</p>}
{payload.map((entry) => (
<div
className="flex items-center gap-2 text-sm"
key={entry.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)}
</span>
</div>
))}
</div>
);
};
@@ -0,0 +1,3 @@
export { ChartCard } from './chart-card';
export { ChartTooltip, type ChartTooltipPayloadEntry } from './chart-tooltip';
export { MetricCard } from './metric-card';
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export const MetricCard = ({
className,
description,
icon,
loading,
title,
value,
}: {
className?: string;
description?: ReactNode;
icon?: ReactNode;
loading?: boolean;
title: ReactNode;
value: ReactNode;
}) => (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{loading ? <Skeleton className="h-5 w-24" /> : title}</CardTitle>
{loading ? <Skeleton className="size-4 shrink-0 rounded" /> : icon}
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
{description &&
(loading ? (
<Skeleton className="mt-1 h-3 w-32" />
) : (
<p className="text-muted-foreground text-xs">{description}</p>
))}
</CardContent>
</Card>
);
@@ -0,0 +1,24 @@
import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
import { Badge } from '@/components/ui/badge';
import { StatusType } from '@/graphql/types';
const STATUS_LABELS: Record<StatusType, string> = {
[StatusType.Created]: 'Created',
[StatusType.Failed]: 'Failed',
[StatusType.Finished]: 'Finished',
[StatusType.Running]: 'Running',
[StatusType.Waiting]: 'Waiting',
};
export const FlowStatusBadge = ({ className, status }: { className?: string; status: StatusType }) => (
<Badge
className={className}
variant="outline"
>
<FlowStatusIcon
className="size-3"
status={status}
/>
{STATUS_LABELS[status]}
</Badge>
);
+15 -12
View File
@@ -147,8 +147,8 @@ const Markdown = ({ children, className, searchValue }: MarkdownProps) => {
);
// Optimized helper function to process text nodes recursively
const processTextNode = useCallback(
(nodeChildren: any): any => {
const processTextNode = useMemo(() => {
const fn = (nodeChildren: any): any => {
if (!processedSearch) {
return nodeChildren;
}
@@ -163,15 +163,13 @@ const Markdown = ({ children, className, searchValue }: MarkdownProps) => {
return createHighlightedText(child);
}
// Avoid deep cloning React elements to prevent memory leaks
// Only process if it's a simple object with props
if (child && typeof child === 'object' && child.props && child.props.children !== undefined) {
return {
...child,
key: child.key || `processed-${index}`,
props: {
...child.props,
children: processTextNode(child.props.children),
children: fn(child.props.children),
},
};
}
@@ -180,7 +178,6 @@ const Markdown = ({ children, className, searchValue }: MarkdownProps) => {
});
}
// Handle React elements safely
if (
nodeChildren &&
typeof nodeChildren === 'object' &&
@@ -191,25 +188,31 @@ const Markdown = ({ children, className, searchValue }: MarkdownProps) => {
...nodeChildren,
props: {
...nodeChildren.props,
children: processTextNode(nodeChildren.props.children),
children: fn(nodeChildren.props.children),
},
};
}
return nodeChildren;
},
[processedSearch, createHighlightedText],
);
};
return fn;
}, [processedSearch, createHighlightedText]);
// Create a simple component renderer factory to avoid recreating functions
const createComponentRenderer = useCallback(
(ComponentName: string) => {
return ({ children: nodeChildren, ...props }: any) => {
const Component = ComponentName as React.ElementType;
const Renderer = ({ children: nodeChildren, ...props }: Record<string, unknown>) => {
const processedChildren = processTextNode(nodeChildren);
const Component = ComponentName as any;
return <Component {...props}>{processedChildren}</Component>;
};
Renderer.displayName = `Highlighted(${ComponentName})`;
return Renderer;
},
[processTextNode],
);
@@ -67,7 +67,11 @@ const injectedColorClasses = new Set<string>();
const rgbStringToHex = (rgb: string): string =>
rgb
.split(',')
.map((part) => Math.min(255, Math.max(0, parseInt(part.trim(), 10))).toString(16).padStart(2, '0'))
.map((part) =>
Math.min(255, Math.max(0, parseInt(part.trim(), 10)))
.toString(16)
.padStart(2, '0'),
)
.join('');
/**
@@ -102,10 +102,7 @@ export function useXterm({ theme }: { theme: 'dark' | 'light' | 'system' }): Use
const openLink = (event: MouseEvent, uri: string) => {
const uriLower = uri.toLowerCase();
if (
(mac ? event.metaKey : event.ctrlKey) &&
SAFE_PROTOCOLS.some((p) => uriLower.startsWith(p))
) {
if ((mac ? event.metaKey : event.ctrlKey) && SAFE_PROTOCOLS.some((p) => uriLower.startsWith(p))) {
window.open(uri, '_blank', 'noopener,noreferrer');
}
};
+9
View File
@@ -11,10 +11,17 @@ const badgeVariants = cva(
},
variants: {
variant: {
blue: 'border-blue-500/20 bg-blue-500/10 text-blue-600',
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
green: 'border-green-500/20 bg-green-500/10 text-green-600',
orange: 'border-orange-500/20 bg-orange-500/10 text-orange-600',
outline: 'text-foreground',
pink: 'border-pink-500/20 bg-pink-500/10 text-pink-600',
purple: 'border-purple-500/20 bg-purple-500/10 text-purple-600',
red: 'border-red-500/20 bg-red-500/10 text-red-600',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
yellow: 'border-yellow-500/20 bg-yellow-500/10 text-yellow-600',
},
},
},
@@ -22,6 +29,8 @@ const badgeVariants = cva(
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
+1 -2
View File
@@ -35,8 +35,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+1 -1
View File
@@ -121,7 +121,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
function InputGroupTextarea({ className, ...props }: React.ComponentProps<typeof Textarea>) {
return (
<Textarea
className={cn(
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
+23 -23
View File
@@ -1,28 +1,28 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
className={cn(
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
className,
)}
data-slot="kbd"
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
className={cn('inline-flex items-center gap-1', className)}
data-slot="kbd-group"
{...props}
/>
);
}
export { Kbd, KbdGroup }
export { Kbd, KbdGroup };
+1 -2
View File
@@ -48,8 +48,7 @@ const sheetVariants = cva(
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {
container?: HTMLElement | null;
overlay?: boolean;
}
+4 -4
View File
@@ -16,7 +16,7 @@ const useTextarea = ({
textareaRef,
triggerAutoSize,
}: UseTextareaProps) => {
const [init, setInit] = React.useState(true);
const initRef = React.useRef(true);
React.useEffect(() => {
const offsetBorder = 0;
@@ -26,20 +26,20 @@ const useTextarea = ({
return;
}
if (init) {
if (initRef.current) {
textareaElement.style.minHeight = `${minHeight + offsetBorder}px`;
if (maxHeight > minHeight) {
textareaElement.style.maxHeight = `${maxHeight}px`;
}
setInit(false);
initRef.current = false;
}
textareaElement.style.height = `${minHeight + offsetBorder}px`;
const scrollHeight = textareaElement.scrollHeight;
textareaElement.style.height = scrollHeight > maxHeight ? `${maxHeight}px` : `${scrollHeight + offsetBorder}px`;
}, [textareaRef.current, triggerAutoSize]);
}, [triggerAutoSize, maxHeight, minHeight, textareaRef]);
};
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
@@ -1,5 +1,5 @@
import { Copy } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import type { AgentLogFragmentFragment } from '@/graphql/types';
@@ -45,20 +45,23 @@ const FlowAgent = ({ log, searchValue = '' }: FlowAgentProps) => {
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
// Auto-expand details if they contain search matches
useEffect(() => {
const [prevSearchValue, setPrevSearchValue] = useState(searchValue);
const [prevHasResultMatch, setPrevHasResultMatch] = useState(searchChecks.hasResultMatch);
if (searchValue !== prevSearchValue || searchChecks.hasResultMatch !== prevHasResultMatch) {
setPrevSearchValue(searchValue);
setPrevHasResultMatch(searchChecks.hasResultMatch);
const trimmedSearch = searchValue.trim();
if (trimmedSearch) {
// Expand result block only if it contains the search term
if (searchChecks.hasResultMatch) {
setIsDetailsVisible(true);
}
} else {
// Reset to default state when search is cleared
setIsDetailsVisible(false);
}
}, [searchValue, searchChecks.hasResultMatch]);
}
// Determine if we should show full task or preview
// Show full task if: search found in task OR details are manually visible OR task is short
@@ -3,8 +3,9 @@ import { useMemo } from 'react';
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { MetricCard } from '@/components/dashboard';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
useFlowStatsByFlowQuery,
@@ -13,32 +14,7 @@ import {
useUsageStatsByAgentTypeForFlowQuery,
useUsageStatsByFlowQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const StatCard = ({
description,
icon,
loading,
title,
value,
}: {
description: string;
icon: React.ReactNode;
loading: boolean;
title: string;
value: string;
}) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
<p className="text-muted-foreground text-xs">{description}</p>
</CardContent>
</Card>
);
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => (
<TableRow>
@@ -126,33 +102,33 @@ export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
description="LLM spending for this flow"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
<MetricCard
description={`Subtasks: ${flowStats?.totalSubtasksCount ?? 0} · Assistants: ${flowStats?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Cost"
value={formatCost(totalCost)}
title="Tasks"
value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'}
/>
<StatCard
description="Input + Output tokens"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tokens"
value={formatTokenCount(totalTokens)}
/>
<StatCard
<MetricCard
description={`Duration: ${toolcalls ? formatDuration(toolcalls.totalDurationSeconds) : '—'}`}
icon={<Activity className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tool Calls"
value={toolcalls ? formatNumber(toolcalls.totalCount) : '0'}
/>
<StatCard
description={`Subtasks: ${flowStats?.totalSubtasksCount ?? 0} · Assistants: ${flowStats?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
<MetricCard
description="Input + Output tokens"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tasks"
value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'}
title="Tokens"
value={formatTokenCount(totalTokens)}
/>
<MetricCard
description="LLM spending for this flow"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Cost"
value={formatCost(totalCost)}
/>
</div>
@@ -219,15 +195,9 @@ export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => {
<TableRow key={item.functionName}>
<TableCell className="font-medium">{item.functionName}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
item.isAgent
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
<Badge variant={item.isAgent ? 'secondary' : 'outline'}>
{item.isAgent ? 'Agent' : 'Tool'}
</span>
</Badge>
</TableCell>
<TableCell className="text-right">
{formatNumber(item.totalCount)}
@@ -1,5 +1,5 @@
import { Copy } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import type { AssistantLogFragmentFragment, MessageLogFragmentFragment } from '@/graphql/types';
@@ -48,26 +48,34 @@ const FlowMessage = ({ log, searchValue = '' }: FlowMessageProps) => {
const [isDetailsVisible, setIsDetailsVisible] = useState(isReportMessage);
const [isThinkingVisible, setIsThinkingVisible] = useState(false);
// Auto-expand blocks if they contain search matches
useEffect(() => {
const [prevSearchValue, setPrevSearchValue] = useState(searchValue);
const [prevHasThinkingMatch, setPrevHasThinkingMatch] = useState(searchChecks.hasThinkingMatch);
const [prevHasResultMatch, setPrevHasResultMatch] = useState(searchChecks.hasResultMatch);
if (
searchValue !== prevSearchValue ||
searchChecks.hasThinkingMatch !== prevHasThinkingMatch ||
searchChecks.hasResultMatch !== prevHasResultMatch
) {
setPrevSearchValue(searchValue);
setPrevHasThinkingMatch(searchChecks.hasThinkingMatch);
setPrevHasResultMatch(searchChecks.hasResultMatch);
const trimmedSearch = searchValue.trim();
if (trimmedSearch) {
// Expand thinking block only if it contains the search term
if (searchChecks.hasThinkingMatch) {
setIsThinkingVisible(true);
}
// Expand result block only if it contains the search term
if (searchChecks.hasResultMatch) {
setIsDetailsVisible(true);
}
} else {
// Reset to default state when search is cleared
setIsDetailsVisible(isReportMessage);
setIsThinkingVisible(false);
}
}, [searchValue, searchChecks.hasThinkingMatch, searchChecks.hasResultMatch, isReportMessage]);
}
// Use useCallback to memoize the toggle functions
const toggleDetails = useCallback(() => {
@@ -1,5 +1,5 @@
import { ListCheck, ListTodo } from 'lucide-react';
import { memo, useEffect, useMemo, useState } from 'react';
import { memo, useMemo, useState } from 'react';
import type { SubtaskFragmentFragment } from '@/graphql/types';
@@ -41,20 +41,25 @@ const FlowSubtask = ({ searchValue = '', subtask }: FlowSubtaskProps) => {
};
}, [searchValue, description, result]);
// Auto-expand details if they contain search matches
useEffect(() => {
const [prevSearchValue, setPrevSearchValue] = useState(searchValue);
const [prevHasMatch, setPrevHasMatch] = useState(searchChecks.hasDescriptionMatch || searchChecks.hasResultMatch);
const hasMatch = searchChecks.hasDescriptionMatch || searchChecks.hasResultMatch;
if (searchValue !== prevSearchValue || hasMatch !== prevHasMatch) {
setPrevSearchValue(searchValue);
setPrevHasMatch(hasMatch);
const trimmedSearch = searchValue.trim();
if (trimmedSearch) {
// Expand details if description or result contains the search term
if (searchChecks.hasDescriptionMatch || searchChecks.hasResultMatch) {
if (hasMatch) {
setIsDetailsVisible(true);
}
} else {
// Reset to default state when search is cleared
setIsDetailsVisible(false);
}
}, [searchValue, searchChecks.hasDescriptionMatch, searchChecks.hasResultMatch]);
}
return (
<div className="group relative flex gap-2.5 pb-4 pl-0.5">
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { memo, useMemo, useState } from 'react';
import type { TaskFragmentFragment } from '@/graphql/types';
@@ -41,20 +41,23 @@ const FlowTask = ({ searchValue = '', task }: FlowTaskProps) => {
};
}, [searchValue, result]);
// Auto-expand details if they contain search matches
useEffect(() => {
const [prevSearchValue, setPrevSearchValue] = useState(searchValue);
const [prevHasResultMatch, setPrevHasResultMatch] = useState(searchChecks.hasResultMatch);
if (searchValue !== prevSearchValue || searchChecks.hasResultMatch !== prevHasResultMatch) {
setPrevSearchValue(searchValue);
setPrevHasResultMatch(searchChecks.hasResultMatch);
const trimmedSearch = searchValue.trim();
if (trimmedSearch) {
// Expand result block only if it contains the search term
if (searchChecks.hasResultMatch) {
setIsDetailsVisible(true);
}
} else {
// Reset to default state when search is cleared
setIsDetailsVisible(false);
}
}, [searchValue, searchChecks.hasResultMatch]);
}
const sortedSubtasks = [...(subtasks || [])].sort((a, b) => +a.id - +b.id);
const hasSubtasks = subtasks && subtasks.length > 0;
@@ -1,5 +1,5 @@
import { Copy, Hammer } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import type { SearchLogFragmentFragment } from '@/graphql/types';
@@ -41,21 +41,23 @@ const FlowTool = ({ log, searchValue = '' }: FlowToolProps) => {
}, [searchValue, query, result]);
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
const [prevSearchValue, setPrevSearchValue] = useState(searchValue);
const [prevHasResultMatch, setPrevHasResultMatch] = useState(searchChecks.hasResultMatch);
if (searchValue !== prevSearchValue || searchChecks.hasResultMatch !== prevHasResultMatch) {
setPrevSearchValue(searchValue);
setPrevHasResultMatch(searchChecks.hasResultMatch);
// Auto-expand details if they contain search matches
useEffect(() => {
const trimmedSearch = searchValue.trim();
if (trimmedSearch) {
// Expand result block only if it contains the search term
if (searchChecks.hasResultMatch) {
setIsDetailsVisible(true);
}
} else {
// Reset to default state when search is cleared
setIsDetailsVisible(false);
}
}, [searchValue, searchChecks.hasResultMatch]);
}
const handleCopy = useCallback(async () => {
await copyMessageToClipboard({
@@ -1,5 +1,5 @@
import { Copy } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import type { VectorStoreLogFragmentFragment } from '@/graphql/types';
@@ -85,21 +85,23 @@ const FlowVectorStore = ({ log, searchValue = '' }: FlowVectorStoreProps) => {
}, [searchValue, query, result]);
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
const [prevSearchValue, setPrevSearchValue] = useState(searchValue);
const [prevHasResultMatch, setPrevHasResultMatch] = useState(searchChecks.hasResultMatch);
if (searchValue !== prevSearchValue || searchChecks.hasResultMatch !== prevHasResultMatch) {
setPrevSearchValue(searchValue);
setPrevHasResultMatch(searchChecks.hasResultMatch);
// Auto-expand details if they contain search matches
useEffect(() => {
const trimmedSearch = searchValue.trim();
if (trimmedSearch) {
// Expand result block only if it contains the search term
if (searchChecks.hasResultMatch) {
setIsDetailsVisible(true);
}
} else {
// Reset to default state when search is cleared
setIsDetailsVisible(false);
}
}, [searchValue, searchChecks.hasResultMatch]);
}
const description = getDescription(log);
@@ -82,9 +82,7 @@ export const useAdaptiveColumnVisibility = ({
const userPreference = userPreferences[column.id];
const isVisible =
userPreference !== undefined
? !shouldHideByWidth && userPreference
: !shouldHideByWidth;
userPreference !== undefined ? !shouldHideByWidth && userPreference : !shouldHideByWidth;
return [column.id, isVisible];
}),
+1 -3
View File
@@ -287,9 +287,7 @@ const parseMarkdownTokens = (markdown: string): ParsedContent[] => {
}
case 'list': {
const tokenItems = (
Array.isArray(token.items) ? token.items : []
) as Array<Record<string, unknown>>;
const tokenItems = (Array.isArray(token.items) ? token.items : []) as Array<Record<string, unknown>>;
const items = tokenItems.map((item) => ({
inlineTokens: parseInlineTokens(String(item.text || '')),
raw: String(item.text || ''),
+5 -1
View File
@@ -1,6 +1,6 @@
const STORAGE_KEY_SEPARATOR = '_4_';
export type LocalStorageKeyType = 'column' | 'page' | 'sorting';
export type LocalStorageKeyType = 'column' | 'page' | 'period' | 'sorting';
export function getColumnStorageKey(urlPath?: string): string {
return getStorageKey('column', urlPath);
@@ -10,6 +10,10 @@ export function getPageStorageKey(urlPath?: string): string {
return getStorageKey('page', urlPath);
}
export function getPeriodStorageKey(urlPath?: string): string {
return getStorageKey('period', urlPath);
}
export function getSortingStorageKey(urlPath?: string): string {
return getStorageKey('sorting', urlPath);
}
+8 -12
View File
@@ -1,4 +1,5 @@
import type { SortingState, VisibilityState } from '@tanstack/react-table';
import { z } from 'zod';
const sortingSchema = z.array(z.object({ desc: z.boolean(), id: z.string() }));
@@ -9,7 +10,7 @@ const pageStateSchema = z.object({ page: z.number(), pageSize: z.number() });
export type StoredPageState = z.infer<typeof pageStateSchema>;
function loadFromStorage<T>(key: string, schema: z.ZodType<T>): T | null {
function loadFromStorage<T>(key: string, schema: z.ZodType<T>): null | T {
try {
const raw = localStorage.getItem(key);
@@ -33,19 +34,14 @@ function saveToStorage(key: string, value: unknown): void {
}
}
export const loadSorting = (key: string): SortingState | null => loadFromStorage(key, sortingSchema);
export const loadSorting = (key: string): null | SortingState => loadFromStorage(key, sortingSchema);
export const loadColumnVisibility = (key: string): VisibilityState | null =>
loadFromStorage(key, visibilitySchema);
export const loadColumnVisibility = (key: string): null | VisibilityState => loadFromStorage(key, visibilitySchema);
export const loadPageState = (key: string): StoredPageState | null =>
loadFromStorage(key, pageStateSchema);
export const loadPageState = (key: string): null | StoredPageState => loadFromStorage(key, pageStateSchema);
export const saveSorting = (key: string, sorting: SortingState): void =>
saveToStorage(key, sorting);
export const saveSorting = (key: string, sorting: SortingState): void => saveToStorage(key, sorting);
export const saveColumnVisibility = (key: string, visibility: VisibilityState): void =>
saveToStorage(key, visibility);
export const saveColumnVisibility = (key: string, visibility: VisibilityState): void => saveToStorage(key, visibility);
export const savePageState = (key: string, state: StoredPageState): void =>
saveToStorage(key, state);
export const savePageState = (key: string, state: StoredPageState): void => saveToStorage(key, state);
+56
View File
@@ -18,3 +18,59 @@ export const formatDate = (date: Date) => {
return format(date, 'HH:mm, d MMM yyyy', { locale: enUS });
};
export const formatNumber = (value: number): string => new Intl.NumberFormat('en-US').format(value);
export const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
export const formatCost = (cost: number): string => {
if (!cost) {
return '$0';
}
if (cost >= 1) {
return `$${cost.toFixed(2)}`;
}
if (cost >= 0.01) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(4)}`;
};
export const formatDuration = (seconds: number): string => {
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
if (seconds >= 1) {
return `${seconds.toFixed(1)}s`;
}
return `${(seconds * 1000).toFixed(0)}ms`;
};
+1 -9
View File
@@ -18,7 +18,7 @@ export interface CopyableMessage {
* This removes ANSI escape codes and returns formatted text as it appears in UI
*/
export const getCleanTerminalText = (terminalContent: string): Promise<string> => {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
let hiddenTerminal: null | XTerminal = null;
let hiddenDiv: HTMLDivElement | null = null;
let timeoutId: NodeJS.Timeout | null = null;
@@ -68,14 +68,6 @@ export const getCleanTerminalText = (terminalContent: string): Promise<string> =
}
};
const safeReject = (error: any) => {
if (!isResolved) {
isResolved = true;
cleanup();
reject(error);
}
};
try {
// Create a hidden terminal instance
hiddenTerminal = new XTerminal({
@@ -1,19 +1,23 @@
import { format } from 'date-fns';
import { ChevronRight, Clock, Loader2, Wrench } from 'lucide-react';
import { useState } from 'react';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { useMemo, useState } from 'react';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { UsageStatsPeriod } from '@/graphql/types';
import type { FlowFragmentFragment, UsageStatsPeriod } from '@/graphql/types';
import { ChartCard, ChartTooltip } from '@/components/dashboard';
import { FlowStatusBadge } from '@/components/icons/flow-status-badge';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
useFlowsExecutionStatsByPeriodQuery,
useFlowsQuery,
useFlowsStatsByPeriodQuery,
useToolcallsStatsByPeriodQuery,
useUsageStatsByPeriodQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
const CHART_COLORS = {
area1: 'var(--color-chart-1)',
@@ -31,48 +35,7 @@ const formatDateLabel = (dateString: string): string => {
}
};
const ChartLoading = () => (
<div className="flex h-[300px] items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
const CustomTooltip = ({
active,
formatter,
label,
payload,
}: {
active?: boolean;
formatter?: (value: number, name: string) => string;
label?: string;
payload?: Array<{ color: string; name: string; value: number }>;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 shadow-md">
<p className="text-muted-foreground mb-1 text-xs">{label ? formatDateLabel(label) : ''}</p>
{payload.map((entry) => (
<div
className="flex items-center gap-2 text-sm"
key={entry.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)}
</span>
</div>
))}
</div>
);
};
const axisTickStyle = { fill: 'var(--color-muted-foreground)', fontSize: 12 };
export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => {
const { data: usageByPeriodData, loading: usageByPeriodLoading } = useUsageStatsByPeriodQuery({
@@ -87,12 +50,22 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
const { data: executionStatsData, loading: executionStatsLoading } = useFlowsExecutionStatsByPeriodQuery({
variables: { period },
});
const { data: flowsData } = useFlowsQuery();
const flowsById = useMemo(() => {
const map = new Map<string, FlowFragmentFragment>();
(flowsData?.flows ?? []).forEach((flow) => {
map.set(flow.id, flow);
});
return map;
}, [flowsData?.flows]);
const usageChartData = [...(usageByPeriodData?.usageStatsByPeriod ?? [])].reverse().map((item) => ({
cacheIn: item.stats.totalUsageCacheIn,
costIn: item.stats.totalUsageCostIn,
costOut: item.stats.totalUsageCostOut,
date: formatDateLabel(item.date),
date: item.date,
tokensIn: item.stats.totalUsageIn,
tokensOut: item.stats.totalUsageOut,
totalCost: item.stats.totalUsageCostIn + item.stats.totalUsageCostOut,
@@ -100,13 +73,13 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
const toolcallsChartData = [...(toolcallsByPeriodData?.toolcallsStatsByPeriod ?? [])].reverse().map((item) => ({
count: item.stats.totalCount,
date: formatDateLabel(item.date),
date: item.date,
duration: item.stats.totalDurationSeconds,
}));
const flowsChartData = [...(flowsByPeriodData?.flowsStatsByPeriod ?? [])].reverse().map((item) => ({
assistants: item.stats.totalAssistantsCount,
date: formatDateLabel(item.date),
date: item.date,
flows: item.stats.totalFlowsCount,
subtasks: item.stats.totalSubtasksCount,
tasks: item.stats.totalTasksCount,
@@ -116,210 +89,188 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
return (
<div className="flex flex-col gap-6">
<ChartCard
description="Flows, tasks, and subtasks created per day"
empty={!flowsByPeriodLoading && flowsChartData.length === 0}
height={320}
loading={flowsByPeriodLoading}
title="Flows Activity Over Time"
>
<BarChart data={flowsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickMargin={8}
/>
<Tooltip
content={<ChartTooltip labelFormatter={formatDateLabel} />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="flows"
fill={CHART_COLORS.area1}
name="Flows"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="tasks"
fill={CHART_COLORS.area2}
name="Tasks"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="subtasks"
fill={CHART_COLORS.area3}
name="Subtasks"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartCard>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Token Usage Over Time</CardTitle>
<CardDescription>Input and output tokens processed daily</CardDescription>
</CardHeader>
<CardContent>
{usageByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickFormatter={formatTokenCount}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip formatter={(value) => formatTokenCount(value)} />}
/>
<Area
dataKey="tokensIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Tokens In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="tokensOut"
fill={CHART_COLORS.area2}
fillOpacity={0.3}
name="Tokens Out"
stroke={CHART_COLORS.area2}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<ChartCard
description="Number of tool executions per day"
empty={!toolcallsByPeriodLoading && toolcallsChartData.length === 0}
loading={toolcallsByPeriodLoading}
title="Tool Calls Over Time"
>
<BarChart data={toolcallsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickMargin={8}
/>
<Tooltip
content={<ChartTooltip labelFormatter={formatDateLabel} />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="count"
fill={CHART_COLORS.bar1}
name="Tool Calls"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartCard>
<Card>
<CardHeader>
<CardTitle>Cost Over Time</CardTitle>
<CardDescription>LLM spending per day</CardDescription>
</CardHeader>
<CardContent>
{usageByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickFormatter={(value) => formatCost(value)}
tickMargin={8}
/>
<Tooltip content={<CustomTooltip formatter={(value) => formatCost(value)} />} />
<Area
dataKey="costIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Cost In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="costOut"
fill={CHART_COLORS.area3}
fillOpacity={0.3}
name="Cost Out"
stroke={CHART_COLORS.area3}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tool Calls Over Time</CardTitle>
<CardDescription>Number of tool executions per day</CardDescription>
</CardHeader>
<CardContent>
{toolcallsByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<BarChart data={toolcallsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="count"
fill={CHART_COLORS.bar1}
name="Tool Calls"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Flows Activity Over Time</CardTitle>
<CardDescription>Flows, tasks, and subtasks created per day</CardDescription>
</CardHeader>
<CardContent>
{flowsByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<BarChart data={flowsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="flows"
fill={CHART_COLORS.area1}
name="Flows"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="tasks"
fill={CHART_COLORS.area2}
name="Tasks"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="subtasks"
fill={CHART_COLORS.area3}
name="Subtasks"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<ChartCard
description="Input and output tokens processed daily"
empty={!usageByPeriodLoading && usageChartData.length === 0}
loading={usageByPeriodLoading}
title="Token Usage Over Time"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickFormatter={formatTokenCount}
tickMargin={8}
/>
<Tooltip
content={
<ChartTooltip
formatter={(value) => formatTokenCount(value)}
labelFormatter={formatDateLabel}
/>
}
/>
<Area
dataKey="tokensIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Tokens In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="tokensOut"
fill={CHART_COLORS.area2}
fillOpacity={0.3}
name="Tokens Out"
stroke={CHART_COLORS.area2}
type="monotone"
/>
</AreaChart>
</ChartCard>
</div>
<ChartCard
description="LLM spending per day. May stay near zero when using local engines — this is expected."
empty={!usageByPeriodLoading && usageChartData.length === 0}
height={240}
loading={usageByPeriodLoading}
title="Cost Over Time"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={axisTickStyle}
tickFormatter={formatDateLabel}
tickMargin={8}
/>
<YAxis
tick={axisTickStyle}
tickFormatter={(value) => formatCost(value)}
tickMargin={8}
/>
<Tooltip
content={
<ChartTooltip
formatter={(value) => formatCost(value)}
labelFormatter={formatDateLabel}
/>
}
/>
<Area
dataKey="costIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Cost In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="costOut"
fill={CHART_COLORS.area3}
fillOpacity={0.3}
name="Cost Out"
stroke={CHART_COLORS.area3}
type="monotone"
/>
</AreaChart>
</ChartCard>
<Card>
<CardHeader>
<CardTitle>Flow Execution Details</CardTitle>
@@ -339,6 +290,7 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) =>
{executionStats.map((flow) => (
<FlowExecutionItem
flow={flow}
flowMeta={flowsById.get(flow.flowId)}
key={flow.flowId}
/>
))}
@@ -370,25 +322,39 @@ type FlowExecution = {
totalToolcallsCount: number;
};
const FlowExecutionItem = ({ flow }: { flow: FlowExecution }) => {
const FlowExecutionItem = ({ flow, flowMeta }: { flow: FlowExecution; flowMeta?: FlowFragmentFragment }) => {
const [isOpen, setIsOpen] = useState(false);
const taskCount = flow.tasks.length;
const subtaskCount = flow.tasks.reduce((sum, task) => sum + task.subtasks.length, 0);
return (
<Collapsible
onOpenChange={setIsOpen}
open={isOpen}
>
<CollapsibleTrigger className="hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors">
<ChevronRight className={`size-4 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
<div className="flex-1 truncate font-medium">{flow.flowTitle || `Flow #${flow.flowId}`}</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<CollapsibleTrigger className="hover:bg-muted/50 group flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left transition-colors">
<ChevronRight className={`mt-1 size-4 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{flow.flowTitle || `Flow #${flow.flowId}`}</span>
{flowMeta?.status && <FlowStatusBadge status={flowMeta.status} />}
{flowMeta?.provider?.name && <Badge variant="secondary">{flowMeta.provider.name}</Badge>}
</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{taskCount} {taskCount === 1 ? 'task' : 'tasks'}
{subtaskCount > 0 && ` · ${subtaskCount} ${subtaskCount === 1 ? 'subtask' : 'subtasks'}`}
{flow.totalAssistantsCount > 0 &&
` · ${flow.totalAssistantsCount} ${flow.totalAssistantsCount === 1 ? 'assistant' : 'assistants'}`}
</div>
</div>
<div className="text-muted-foreground flex shrink-0 items-center gap-4 pt-1 text-sm">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(flow.totalDurationSeconds)}
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{flow.totalToolcallsCount}
{formatNumber(flow.totalToolcallsCount)}
</span>
</div>
</CollapsibleTrigger>
@@ -432,7 +398,7 @@ const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) =
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{task.totalToolcallsCount}
{formatNumber(task.totalToolcallsCount)}
</span>
</div>
</CollapsibleTrigger>
@@ -454,7 +420,7 @@ const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) =
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{subtask.totalToolcallsCount}
{formatNumber(subtask.totalToolcallsCount)}
</span>
</div>
</div>
@@ -2,8 +2,9 @@ import { Activity, CircleDollarSign, Cpu, GitFork, Loader2 } from 'lucide-react'
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { MetricCard } from '@/components/dashboard';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
useFlowsStatsTotalQuery,
@@ -14,32 +15,7 @@ import {
useUsageStatsByProviderQuery,
useUsageStatsTotalQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const StatCard = ({
description,
icon,
loading,
title,
value,
}: {
description: string;
icon: React.ReactNode;
loading: boolean;
title: string;
value: string;
}) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
<p className="text-muted-foreground text-xs">{description}</p>
</CardContent>
</Card>
);
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => (
<TableRow>
@@ -124,33 +100,33 @@ export const DashboardOverview = () => {
return (
<div className="flex flex-col gap-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
description="Total LLM spending across all providers"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Cost"
value={formatCost(totalCost)}
<MetricCard
description={`Tasks: ${flowsTotal?.totalTasksCount ?? 0} · Subtasks: ${flowsTotal?.totalSubtasksCount ?? 0} · Assistants: ${flowsTotal?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={flowsTotalLoading}
title="Total Flows"
value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'}
/>
<StatCard
description="Input + Output tokens processed"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Tokens"
value={formatTokenCount(totalTokens)}
/>
<StatCard
<MetricCard
description={`Total duration: ${toolcallsTotal ? formatDuration(toolcallsTotal.totalDurationSeconds) : '—'}`}
icon={<Activity className="text-muted-foreground size-4" />}
loading={toolcallsTotalLoading}
title="Tool Calls"
value={toolcallsTotal ? formatNumber(toolcallsTotal.totalCount) : '0'}
/>
<StatCard
description={`Tasks: ${flowsTotal?.totalTasksCount ?? 0} · Subtasks: ${flowsTotal?.totalSubtasksCount ?? 0} · Assistants: ${flowsTotal?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={flowsTotalLoading}
title="Total Flows"
value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'}
<MetricCard
description="Input + Output tokens processed"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Tokens"
value={formatTokenCount(totalTokens)}
/>
<MetricCard
description="Total LLM spending across all providers"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Cost"
value={formatCost(totalCost)}
/>
</div>
@@ -208,15 +184,9 @@ export const DashboardOverview = () => {
<TableRow key={item.functionName}>
<TableCell className="font-medium">{item.functionName}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
item.isAgent
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
<Badge variant={item.isAgent ? 'secondary' : 'outline'}>
{item.isAgent ? 'Agent' : 'Tool'}
</span>
</Badge>
</TableCell>
<TableCell className="text-right">{formatNumber(item.totalCount)}</TableCell>
<TableCell className="text-right">
+35 -3
View File
@@ -1,11 +1,12 @@
import { LayoutDashboard } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { UsageStatsPeriod } from '@/graphql/types';
import { getPeriodStorageKey } from '@/lib/storage-keys';
import { DashboardAnalytics } from '@/pages/dashboard/dashboard-analytics';
import { DashboardOverview } from '@/pages/dashboard/dashboard-overview';
@@ -15,9 +16,40 @@ const periodOptions: { label: string; value: UsageStatsPeriod }[] = [
{ label: 'Quarter', value: UsageStatsPeriod.Quarter },
];
const VALID_PERIODS = new Set<string>(Object.values(UsageStatsPeriod));
const loadPeriod = (storageKey: string): UsageStatsPeriod => {
try {
const stored = localStorage.getItem(storageKey);
if (stored && VALID_PERIODS.has(stored)) {
return stored as UsageStatsPeriod;
}
} catch {
/* localStorage may be unavailable */
}
return UsageStatsPeriod.Week;
};
const savePeriod = (storageKey: string, value: UsageStatsPeriod): void => {
try {
localStorage.setItem(storageKey, value);
} catch {
/* localStorage may be unavailable */
}
};
const Dashboard = () => {
const periodStorageKey = useMemo(() => getPeriodStorageKey(), []);
const [activeTab, setActiveTab] = useState('analytics');
const [period, setPeriod] = useState<UsageStatsPeriod>(UsageStatsPeriod.Week);
const [period, setPeriod] = useState<UsageStatsPeriod>(() => loadPeriod(periodStorageKey));
const handlePeriodChange = (value: string) => {
const next = value as UsageStatsPeriod;
setPeriod(next);
savePeriod(periodStorageKey, next);
};
return (
<>
@@ -53,7 +85,7 @@ const Dashboard = () => {
{activeTab === 'analytics' && (
<Tabs
onValueChange={(value) => setPeriod(value as UsageStatsPeriod)}
onValueChange={handlePeriodChange}
value={period}
>
<TabsList>
@@ -1,57 +0,0 @@
export const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
export const formatCost = (cost: number): string => {
if (!cost) {
return '$0';
}
if (cost >= 1) {
return `$${cost.toFixed(2)}`;
}
if (cost >= 0.01) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(4)}`;
};
export const formatDuration = (seconds: number): string => {
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
if (seconds >= 1) {
return `${seconds.toFixed(1)}s`;
}
return `${(seconds * 1000).toFixed(0)}ms`;
};
export const formatNumber = (value: number): string => {
return new Intl.NumberFormat('en-US').format(value);
};
+54 -44
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import Logo from '@/components/icons/logo';
@@ -7,6 +7,7 @@ import { useFlowReportQuery } from '@/graphql/types';
import { Log } from '@/lib/log';
import { generateFileName, generatePDFFromMarkdown, generateReport } from '@/lib/report';
type PdfPhase = 'done' | 'error' | 'idle';
type ReportState = 'content' | 'error' | 'generating' | 'loading';
const FlowReport = () => {
@@ -15,9 +16,17 @@ const FlowReport = () => {
const download = searchParams.has('download');
const silent = searchParams.has('silent');
const [state, setState] = useState<ReportState>('loading');
const [error, setError] = useState<null | string>(null);
const [reportContent, setReportContent] = useState<string>('');
const [pdfPhase, setPdfPhase] = useState<PdfPhase>('idle');
const [pdfError, setPdfError] = useState<null | string>(null);
const pdfTriggered = useRef(false);
const [prevFlowId, setPrevFlowId] = useState(flowId);
if (flowId !== prevFlowId) {
setPrevFlowId(flowId);
setPdfPhase('idle');
setPdfError(null);
}
const {
data,
@@ -29,55 +38,58 @@ const FlowReport = () => {
variables: { id: flowId! },
});
// Reset state when component mounts or flowId changes
const dataReady = !loading && !queryError && !!data?.flow;
const reportContent = useMemo(
() => (dataReady ? generateReport(data.tasks || [], data.flow!) : ''),
[dataReady, data],
);
useEffect(() => {
setState('loading');
setError(null);
setReportContent('');
pdfTriggered.current = false;
}, [flowId]);
useEffect(() => {
if (loading) {
if (!dataReady || !download || pdfTriggered.current || !data?.flow) {
return;
}
if (queryError || !data?.flow) {
setError('Failed to load flow data');
setState('error');
pdfTriggered.current = true;
return;
}
const fileName = `${generateFileName(data.flow)}.pdf`;
// Generate report content using flow and tasks from GraphQL response
const content = generateReport(data.tasks || [], data.flow);
setReportContent(content);
generatePDFFromMarkdown(reportContent, fileName)
.then(() => {
if (silent) {
setTimeout(() => window.close(), 1000);
} else {
setPdfPhase('done');
}
})
.catch((err) => {
Log.error('PDF generation failed:', err);
setPdfError('Failed to generate PDF');
setPdfPhase('error');
});
}, [dataReady, download, silent, reportContent, data]);
if (download) {
// Download mode - generate PDF and download it
setState('generating');
const fileName = `${generateFileName(data.flow)}.pdf`;
let state: ReportState;
let errorMessage: null | string = null;
generatePDFFromMarkdown(content, fileName)
.then(() => {
if (silent) {
// Silent download - close window after successful download
setTimeout(() => window.close(), 1000);
} else {
// Normal download - show content after download
setState('content');
}
})
.catch((err) => {
Log.error('PDF generation failed:', err);
setError('Failed to generate PDF');
setState('error');
});
} else {
setState('content');
}
}, [data, loading, queryError, download, silent]);
if (loading) {
state = 'loading';
} else if (queryError || !data?.flow) {
state = 'error';
errorMessage = 'Failed to load flow data';
} else if (pdfPhase === 'error') {
state = 'error';
errorMessage = pdfError;
} else if (download && pdfPhase !== 'done') {
state = 'generating';
} else {
state = 'content';
}
// Loading state (for all modes during initial loading and PDF generation)
if (state === 'loading' || state === 'generating') {
return (
<div className="min-h-screen bg-linear-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
@@ -99,7 +111,6 @@ const FlowReport = () => {
);
}
// Error state
if (state === 'error') {
return (
<div className="min-h-screen bg-linear-to-br from-red-50 via-white to-orange-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
@@ -108,7 +119,7 @@ const FlowReport = () => {
<div className="flex flex-col gap-4 text-center">
<h1 className="text-2xl font-semibold text-red-600 dark:text-red-400">Error Loading Report</h1>
<p className="max-w-md text-gray-600 dark:text-gray-400">
{error || 'An unexpected error occurred while loading the report.'}
{errorMessage || 'An unexpected error occurred while loading the report.'}
</p>
<button
className="mt-4 rounded-md bg-red-600 px-4 py-2 text-white transition-colors hover:bg-red-700"
@@ -122,7 +133,6 @@ const FlowReport = () => {
);
}
// Content viewing state (normal mode without download)
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="h-screen w-full overflow-auto p-8">
+1 -4
View File
@@ -13,10 +13,7 @@ const Login = () => {
const authProviders = authInfo?.providers || [];
// Extract the return URL from either location state or query parameters
const returnUrl = getSafeReturnUrl(
(location.state?.from as string) || searchParams.get('returnUrl'),
'/flows/new',
);
const returnUrl = getSafeReturnUrl((location.state?.from as string) || searchParams.get('returnUrl'), '/flows/new');
return (
<div className="flex h-dvh w-full items-center justify-center">
@@ -105,7 +105,6 @@ const formatFullDateTime = (dateString: string) => {
const SettingsMcpServers = () => {
const navigate = useNavigate();
// Mocked data stored locally. This can be replaced by a real query later.
const initialData: McpServerItem[] = useMemo(
() => [
@@ -533,7 +533,6 @@ const SettingsPrompt = () => {
// For creation, check if the template is identical to the default
if (!isUpdate && formData.template === promptInfo.defaultSystemTemplate) {
return;
}
@@ -585,7 +584,6 @@ const SettingsPrompt = () => {
// For creation, check if the template is identical to the default
if (!isUpdate && formData.template === promptInfo.defaultHumanTemplate) {
return;
}
@@ -78,7 +78,6 @@ const SettingsPrompts = () => {
type: 'all' | 'human' | 'system' | 'tool';
}>(null);
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = (column: {
clearSorting: () => void;
@@ -328,7 +328,9 @@ const FormModelComboboxItem: React.FC<FormModelComboboxItemProps> = ({
const displayValue = field.value ?? '';
// Format price for display
const formatPrice = (price?: null | { cacheRead: number; cacheWrite: number; input: number; output: number }): string => {
const formatPrice = (
price?: null | { cacheRead: number; cacheWrite: number; input: number; output: number },
): string => {
if (!price || ((!price.input || price.input === 0) && (!price.output || price.output === 0))) {
return 'free';
}
@@ -338,7 +340,7 @@ const FormModelComboboxItem: React.FC<FormModelComboboxItemProps> = ({
};
const basePrice = `$${formatValue(price.input)}/$${formatValue(price.output)}`;
// Add cache prices if available
const hasCachePrices = (price.cacheRead && price.cacheRead > 0) || (price.cacheWrite && price.cacheWrite > 0);
@@ -142,7 +142,6 @@ const SettingsProviders = () => {
const [deletingProvider, setDeletingProvider] = useState<null | Provider>(null);
const navigate = useNavigate();
// Get current page from URL
const currentPage = useMemo(() => {
const page = searchParams.get('page');
@@ -66,6 +66,7 @@ export const ThemeProvider = ({
// Store only light or dark themes
localStorage.setItem(storageKey, theme);
}
setTheme(theme);
},
theme,
+20 -11
View File
@@ -4,6 +4,15 @@
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22.43%;
line-gap-override: 0%;
size-adjust: 107.64%;
}
@font-face {
font-family: 'Inter';
font-style: normal;
@@ -286,8 +295,8 @@
--ring: oklch(0.25 0.14 245);
--chart-1: oklch(0.25 0.14 245);
--chart-2: oklch(0.38 0.18 245);
--chart-3: oklch(0.50 0.22 245);
--chart-4: oklch(0.42 0.10 245);
--chart-3: oklch(0.5 0.22 245);
--chart-4: oklch(0.42 0.1 245);
--chart-5: oklch(0.55 0.14 245);
--sidebar: oklch(0.98 0 240);
--sidebar-foreground: oklch(0.32 0 0);
@@ -297,9 +306,9 @@
--sidebar-accent-foreground: oklch(0.32 0 0);
--sidebar-border: oklch(0.93 0.01 240);
--sidebar-ring: oklch(0.25 0.14 245);
--font-sans: Inter, sans-serif;
--font-serif: Inter, serif;
--font-mono: Roboto Mono, monospace;
--font-sans: Inter, 'Inter Fallback', sans-serif;
--font-serif: Inter, 'Inter Fallback', serif;
--font-mono: 'Roboto Mono', monospace;
--radius: 0.375rem;
--shadow-x: 0;
--shadow-y: 1px;
@@ -339,10 +348,10 @@
--border: oklch(0.3 0.04 245);
--input: oklch(0.3 0.04 245);
--ring: oklch(0.5 0.16 245);
--chart-1: oklch(0.50 0.16 245);
--chart-2: oklch(0.60 0.20 245);
--chart-3: oklch(0.70 0.22 245);
--chart-4: oklch(0.58 0.10 245);
--chart-1: oklch(0.5 0.16 245);
--chart-2: oklch(0.6 0.2 245);
--chart-3: oklch(0.7 0.22 245);
--chart-4: oklch(0.58 0.1 245);
--chart-5: oklch(0.74 0.14 245);
--sidebar: oklch(0.15 0.02 245);
--sidebar-foreground: oklch(0.92 0 0);
@@ -352,8 +361,8 @@
--sidebar-accent-foreground: oklch(0.92 0.02 245);
--sidebar-border: oklch(0.3 0.04 245);
--sidebar-ring: oklch(0.5 0.16 245);
--font-sans: Inter, sans-serif;
--font-serif: Inter, serif;
--font-sans: Inter, 'Inter Fallback', sans-serif;
--font-serif: Inter, 'Inter Fallback', serif;
--font-mono: 'Roboto Mono', monospace;
--radius: 0.5rem;
--shadow-x: 0;
+3 -3
View File
@@ -16,14 +16,14 @@ Run the generator script to create/update license reports (run from project root
- `backend-dependencies.txt` - Complete list of Go modules
- `backend-licenses.csv` - Detailed license information (CSV format)
### Frontend (npm)
- `frontend-dependencies.json` - Complete npm dependency tree (JSON)
### Frontend (pnpm)
- `frontend-dependencies.json` - Complete dependency tree (JSON)
- `frontend-licenses.json` - Detailed license data (JSON)
- `frontend-licenses.csv` - License data (CSV)
**Note:**
- Backend reports require `go-licenses` tool: `go install github.com/google/go-licenses@latest`
- Frontend reports require `npm ci` in the frontend directory first.
- Frontend reports require `pnpm install` in the frontend directory first.
## License
+3 -3
View File
@@ -31,18 +31,18 @@ fi
cd ..
# Frontend (npm)
# Frontend (pnpm)
echo "→ Frontend..."
cd frontend
if [ -d "node_modules" ]; then
npm ls --production --json > "../$LICENSES_DIR/frontend-dependencies.json" 2>/dev/null || true
pnpm ls --prod --json > "../$LICENSES_DIR/frontend-dependencies.json" 2>/dev/null || true
if command -v license-checker &> /dev/null; then
license-checker --production --json > "../$LICENSES_DIR/frontend-licenses.json" 2>/dev/null || true
license-checker --production --csv > "../$LICENSES_DIR/frontend-licenses.csv" 2>/dev/null || true
fi
else
echo " Run 'npm ci' in frontend/ for detailed reports"
echo " Run 'pnpm install' in frontend/ for detailed reports"
fi
cd ..