mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-09-25 19:55:44 +00:00
Merge remote-tracking branch 'origin/feature/frontend' into feature/next-release
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -30,7 +30,7 @@
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"env": {
|
||||
"VITE_APP_LOG_LEVEL": "DEBUG",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
-18162
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -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"
|
||||
|
||||
Generated
+11837
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>
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}),
|
||||
|
||||
@@ -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 || ''),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 ..
|
||||
|
||||
|
||||
Reference in New Issue
Block a user