🚀 RELEASE: Analytics Layer
This commit is contained in:
parent
19259b1e15
commit
49d05a0b1e
@ -1,4 +1,6 @@
|
||||
import { readWorkspaceFile } from "@/lib/workspace";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { readWorkspaceFile, safeResolvePath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -24,3 +26,45 @@ export async function GET(req: Request) {
|
||||
|
||||
return Response.json(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/file
|
||||
* Body: { path: string, content: string }
|
||||
*
|
||||
* Writes a file to the dench workspace. Creates parent directories as needed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, content } = body;
|
||||
if (!relPath || typeof relPath !== "string" || typeof content !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'content' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Write failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
54
apps/web/app/api/workspace/reports/execute/route.ts
Normal file
54
apps/web/app/api/workspace/reports/execute/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { duckdbQuery } from "@/lib/workspace";
|
||||
import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters";
|
||||
import type { FilterEntry } from "@/lib/report-filters";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/reports/execute
|
||||
*
|
||||
* Body: { sql: string, filters?: FilterEntry[] }
|
||||
*
|
||||
* Executes a report panel's SQL query with optional filter injection.
|
||||
* Only SELECT-compatible queries are allowed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: {
|
||||
sql?: string;
|
||||
filters?: FilterEntry[];
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { sql, filters } = body;
|
||||
if (!sql || typeof sql !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sql' field in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic SQL safety: reject mutation statements
|
||||
const safetyError = checkSqlSafety(sql);
|
||||
if (safetyError) {
|
||||
return Response.json({ error: safetyError }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build filter clauses and inject into SQL
|
||||
const filterClauses = buildFilterClauses(filters);
|
||||
const finalSql = injectFilters(sql, filterClauses);
|
||||
|
||||
try {
|
||||
const rows = duckdbQuery(finalSql);
|
||||
return Response.json({ rows, sql: finalSql });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Query execution failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ export const runtime = "nodejs";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to dench/
|
||||
type: "object" | "document" | "folder" | "file" | "database";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -117,13 +117,14 @@ function buildTree(
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = isDatabaseFile(entry.name);
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -133,6 +134,7 @@ function buildTree(
|
||||
|
||||
/** Classify a top-level file's type. */
|
||||
function classifyFileType(name: string): TreeNode["type"] {
|
||||
if (name.endsWith(".report.json")) {return "report";}
|
||||
if (isDatabaseFile(name)) {return "database";}
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
if (ext === "md" || ext === "mdx") {return "document";}
|
||||
@ -149,6 +151,7 @@ export async function GET() {
|
||||
const dbObjects = loadDbObjects();
|
||||
|
||||
const knowledgeDir = join(root, "knowledge");
|
||||
const reportsDir = join(root, "reports");
|
||||
const tree: TreeNode[] = [];
|
||||
|
||||
// Build knowledge tree
|
||||
@ -156,6 +159,19 @@ export async function GET() {
|
||||
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
|
||||
}
|
||||
|
||||
// Build reports tree
|
||||
if (existsSync(reportsDir)) {
|
||||
const reportNodes = buildTree(reportsDir, "reports", dbObjects);
|
||||
if (reportNodes.length > 0) {
|
||||
tree.push({
|
||||
name: "reports",
|
||||
path: "reports",
|
||||
type: "folder",
|
||||
children: reportNodes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.)
|
||||
try {
|
||||
const topLevel = readdirSync(root, { withFileTypes: true });
|
||||
|
||||
414
apps/web/app/components/charts/chart-panel.tsx
Normal file
414
apps/web/app/components/charts/chart-panel.tsx
Normal file
@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
RadarChart,
|
||||
Radar,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
FunnelChart,
|
||||
Funnel,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import type { PanelConfig } from "./types";
|
||||
|
||||
// --- Color palette derived from CSS variables + accessible defaults ---
|
||||
|
||||
const CHART_PALETTE = [
|
||||
"#e85d3a", // accent
|
||||
"#60a5fa", // blue
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#c084fc", // purple
|
||||
"#fb923c", // orange
|
||||
"#14b8a6", // teal
|
||||
"#f43f5e", // rose
|
||||
"#a78bfa", // violet
|
||||
"#38bdf8", // sky
|
||||
];
|
||||
|
||||
type ChartPanelProps = {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
/** Compact mode for inline chat cards */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
// --- Shared tooltip/axis styles ---
|
||||
|
||||
const axisStyle = {
|
||||
fontSize: 11,
|
||||
fill: "#888",
|
||||
};
|
||||
|
||||
const gridStyle = {
|
||||
stroke: "#262626",
|
||||
strokeDasharray: "3 3",
|
||||
};
|
||||
|
||||
function tooltipStyle() {
|
||||
return {
|
||||
contentStyle: {
|
||||
background: "#141414",
|
||||
border: "1px solid #262626",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#ededed",
|
||||
},
|
||||
itemStyle: { color: "#ededed" },
|
||||
labelStyle: { color: "#888", marginBottom: 4 },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Formatters ---
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
if (typeof val === "number") {
|
||||
if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
|
||||
if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(2);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function formatLabel(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
const str = String(val);
|
||||
// Truncate long date strings
|
||||
if (str.length > 16 && !isNaN(Date.parse(str))) {
|
||||
return str.slice(0, 10);
|
||||
}
|
||||
// Truncate long labels
|
||||
if (str.length > 20) {return str.slice(0, 18) + "...";}
|
||||
return str;
|
||||
}
|
||||
|
||||
// --- Chart renderers ---
|
||||
|
||||
function CartesianChart({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
ChartComponent,
|
||||
SeriesComponent,
|
||||
areaProps,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
ChartComponent: typeof BarChart | typeof LineChart | typeof AreaChart;
|
||||
SeriesComponent: typeof Bar | typeof Line | typeof Area;
|
||||
areaProps?: Record<string, unknown>;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
|
||||
const yKeys = mapping.yAxis ?? Object.keys(data[0] ?? {}).filter((k) => k !== xKey);
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ChartComponent data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid {...gridStyle} />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={axisStyle}
|
||||
tickFormatter={formatLabel}
|
||||
axisLine={{ stroke: "#262626" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={axisStyle}
|
||||
tickFormatter={formatValue}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<Tooltip {...ttStyle} formatter={formatValue} labelFormatter={formatLabel} />
|
||||
{yKeys.length > 1 && !compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
{yKeys.map((key, i) => {
|
||||
const color = colors[i % colors.length];
|
||||
const props: Record<string, unknown> = {
|
||||
key,
|
||||
dataKey: key,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
name: key,
|
||||
...areaProps,
|
||||
};
|
||||
if (SeriesComponent === Bar) {
|
||||
props.radius = [4, 4, 0, 0];
|
||||
props.maxBarSize = 48;
|
||||
}
|
||||
if (SeriesComponent === Line) {
|
||||
props.strokeWidth = 2;
|
||||
props.dot = { r: 3, fill: color };
|
||||
props.activeDot = { r: 5 };
|
||||
}
|
||||
if (SeriesComponent === Area) {
|
||||
props.fillOpacity = 0.15;
|
||||
props.strokeWidth = 2;
|
||||
}
|
||||
// @ts-expect-error - dynamic component props
|
||||
return <SeriesComponent {...props} />;
|
||||
})}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PieDonutChart({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping, type } = config;
|
||||
const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
const innerRadius = type === "donut" ? "50%" : 0;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={compact ? 70 : 110}
|
||||
paddingAngle={2}
|
||||
label={compact ? undefined : ((props: unknown) => {
|
||||
const p = props as Record<string, unknown>;
|
||||
const name = p.name;
|
||||
const percent = typeof p.percent === "number" ? p.percent : 0;
|
||||
return `${formatLabel(name)} ${(percent * 100).toFixed(0)}%`;
|
||||
}) as never}
|
||||
labelLine={!compact}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={colors[i % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...ttStyle} formatter={formatValue} />
|
||||
{!compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RadarChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const nameKey = mapping.xAxis ?? mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "value"];
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RadarChart data={data} cx="50%" cy="50%" outerRadius={compact ? 60 : 100}>
|
||||
<PolarGrid stroke="#262626" />
|
||||
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "#888" }} />
|
||||
<PolarRadiusAxis tick={{ fontSize: 10, fill: "#888" }} />
|
||||
{valueKeys.map((key, i) => (
|
||||
<Radar
|
||||
key={key}
|
||||
name={key}
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
fill={colors[i % colors.length]}
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
))}
|
||||
<Tooltip {...ttStyle} />
|
||||
{!compact && valueKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ScatterChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
|
||||
const yKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "y"];
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid {...gridStyle} />
|
||||
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "#262626" }} tickLine={false} />
|
||||
<YAxis tick={axisStyle} tickFormatter={formatValue} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip {...ttStyle} />
|
||||
{yKeys.map((key, i) => (
|
||||
<Scatter
|
||||
key={key}
|
||||
name={key}
|
||||
data={data}
|
||||
fill={colors[i % colors.length]}
|
||||
/>
|
||||
))}
|
||||
{!compact && yKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
// Funnel expects data with fill colors
|
||||
const funnelData = data.map((row, i) => ({
|
||||
...row,
|
||||
fill: colors[i % colors.length],
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<FunnelChart>
|
||||
<Tooltip {...ttStyle} />
|
||||
<Funnel
|
||||
data={funnelData}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
isAnimationActive
|
||||
>
|
||||
<LabelList
|
||||
position="right"
|
||||
fill="#888"
|
||||
stroke="none"
|
||||
fontSize={11}
|
||||
dataKey={nameKey}
|
||||
/>
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main ChartPanel component ---
|
||||
|
||||
export function ChartPanel({ config, data, compact }: ChartPanelProps) {
|
||||
// Coerce numeric values for Recharts
|
||||
const processedData = useMemo(() => {
|
||||
if (!data || data.length === 0) {return [];}
|
||||
const { mapping } = config;
|
||||
const numericKeys = new Set([
|
||||
...(mapping.yAxis ?? []),
|
||||
...(mapping.valueKey ? [mapping.valueKey] : []),
|
||||
]);
|
||||
|
||||
return data.map((row) => {
|
||||
const out: Record<string, unknown> = { ...row };
|
||||
for (const key of numericKeys) {
|
||||
if (key in out) {
|
||||
const v = out[key];
|
||||
if (typeof v === "string" && v !== "" && !isNaN(Number(v))) {
|
||||
out[key] = Number(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}, [data, config]);
|
||||
|
||||
if (processedData.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl"
|
||||
style={{
|
||||
height: compact ? 200 : 320,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case "bar":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
|
||||
case "line":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={LineChart} SeriesComponent={Line} />;
|
||||
case "area":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={AreaChart} SeriesComponent={Area} />;
|
||||
case "pie":
|
||||
return <PieDonutChart config={config} data={processedData} compact={compact} />;
|
||||
case "donut":
|
||||
return <PieDonutChart config={config} data={processedData} compact={compact} />;
|
||||
case "radar":
|
||||
case "radialBar":
|
||||
return <RadarChartPanel config={config} data={processedData} compact={compact} />;
|
||||
case "scatter":
|
||||
return <ScatterChartPanel config={config} data={processedData} compact={compact} />;
|
||||
case "funnel":
|
||||
return <FunnelChartPanel config={config} data={processedData} compact={compact} />;
|
||||
default:
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
|
||||
}
|
||||
}
|
||||
345
apps/web/app/components/charts/filter-bar.tsx
Normal file
345
apps/web/app/components/charts/filter-bar.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { FilterConfig, FilterState, FilterValue } from "./types";
|
||||
|
||||
type FilterBarProps = {
|
||||
filters: FilterConfig[];
|
||||
value: FilterState;
|
||||
onChange: (state: FilterState) => void;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function FilterIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Individual filter components ---
|
||||
|
||||
function DateRangeFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}) {
|
||||
const current = value?.type === "dateRange" ? value : { type: "dateRange" as const };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={current.from ?? ""}
|
||||
onChange={(e) => onChange({ ...current, from: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
colorScheme: "dark",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={current.to ?? ""}
|
||||
onChange={(e) => onChange({ ...current, to: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
colorScheme: "dark",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
const current = value?.type === "select" ? value.value : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<select
|
||||
value={current ?? ""}
|
||||
onChange={(e) => onChange({ type: "select", value: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
const current = value?.type === "multiSelect" ? (value.values ?? []) : [];
|
||||
|
||||
const toggleOption = (opt: string) => {
|
||||
const next = current.includes(opt)
|
||||
? current.filter((v) => v !== opt)
|
||||
: [...current, opt];
|
||||
onChange({ type: "multiSelect", values: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.map((opt) => {
|
||||
const selected = current.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggleOption(opt)}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: selected ? "rgba(232, 93, 58, 0.15)" : "var(--color-surface)",
|
||||
border: `1px solid ${selected ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
color: selected ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}) {
|
||||
const current = value?.type === "number" ? value : { type: "number" as const };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={current.min ?? ""}
|
||||
onChange={(e) => onChange({ ...current, min: e.target.value ? Number(e.target.value) : undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={current.max ?? ""}
|
||||
onChange={(e) => onChange({ ...current, max: e.target.value ? Number(e.target.value) : undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main FilterBar ---
|
||||
|
||||
export function FilterBar({ filters, value, onChange }: FilterBarProps) {
|
||||
// Fetch options for select/multiSelect filters
|
||||
const [optionsMap, setOptionsMap] = useState<Record<string, string[]>>({});
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
const toFetch = filters.filter(
|
||||
(f) => (f.type === "select" || f.type === "multiSelect") && f.sql,
|
||||
);
|
||||
if (toFetch.length === 0) {return;}
|
||||
|
||||
const results: Record<string, string[]> = {};
|
||||
await Promise.all(
|
||||
toFetch.map(async (f) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: f.sql }),
|
||||
});
|
||||
if (!res.ok) {return;}
|
||||
const data = await res.json();
|
||||
const rows: Record<string, unknown>[] = data.rows ?? [];
|
||||
// Extract the first column's values as options
|
||||
const opts = rows
|
||||
.map((r) => {
|
||||
const vals = Object.values(r);
|
||||
return vals[0] != null ? String(vals[0]) : null;
|
||||
})
|
||||
.filter((v): v is string => v !== null);
|
||||
results[f.id] = opts;
|
||||
} catch {
|
||||
// skip failed option fetches
|
||||
}
|
||||
}),
|
||||
);
|
||||
setOptionsMap(results);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(filterId: string, v: FilterValue) => {
|
||||
onChange({ ...value, [filterId]: v });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const hasActiveFilters = Object.values(value).some((v) => {
|
||||
if (!v) {return false;}
|
||||
if (v.type === "dateRange") {return v.from || v.to;}
|
||||
if (v.type === "select") {return v.value;}
|
||||
if (v.type === "multiSelect") {return v.values && v.values.length > 0;}
|
||||
if (v.type === "number") {return v.min !== undefined || v.max !== undefined;}
|
||||
return false;
|
||||
});
|
||||
|
||||
const clearFilters = () => onChange({});
|
||||
|
||||
if (filters.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-2.5 border-b flex-wrap"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
</span>
|
||||
|
||||
{filters.map((filter) => {
|
||||
const fv = value[filter.id];
|
||||
switch (filter.type) {
|
||||
case "dateRange":
|
||||
return (
|
||||
<DateRangeFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
options={optionsMap[filter.id] ?? []}
|
||||
/>
|
||||
);
|
||||
case "multiSelect":
|
||||
return (
|
||||
<MultiSelectFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
options={optionsMap[filter.id] ?? []}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "rgba(232, 93, 58, 0.1)",
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
apps/web/app/components/charts/report-card.tsx
Normal file
289
apps/web/app/components/charts/report-card.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { ChartPanel } from "./chart-panel";
|
||||
import type { ReportConfig, PanelConfig } from "./types";
|
||||
|
||||
type ReportCardProps = {
|
||||
config: ReportConfig;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function ChartBarIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="17" y2="22" />
|
||||
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Panel data state ---
|
||||
|
||||
type PanelData = {
|
||||
rows: Record<string, unknown>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// --- Main ReportCard ---
|
||||
|
||||
export function ReportCard({ config }: ReportCardProps) {
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
const [pinning, setPinning] = useState(false);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
|
||||
// Show at most 2 panels inline
|
||||
const visiblePanels = config.panels.slice(0, 2);
|
||||
|
||||
// Execute panel SQL queries
|
||||
const executePanels = useCallback(async () => {
|
||||
const initial: Record<string, PanelData> = {};
|
||||
for (const panel of visiblePanels) {
|
||||
initial[panel.id] = { rows: [], loading: true };
|
||||
}
|
||||
setPanelData(initial);
|
||||
|
||||
await Promise.all(
|
||||
visiblePanels.map(async (panel) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: panel.sql }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: [], loading: false, error: data.error || `HTTP ${res.status}` },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: data.rows ?? [], loading: false },
|
||||
}));
|
||||
} catch (err) {
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: [], loading: false, error: err instanceof Error ? err.message : "Failed" },
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [visiblePanels]);
|
||||
|
||||
useEffect(() => {
|
||||
executePanels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Pin report to workspace filesystem
|
||||
const handlePin = async () => {
|
||||
setPinning(true);
|
||||
try {
|
||||
const slug = config.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
const filename = `${slug}.report.json`;
|
||||
|
||||
await fetch("/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: `reports/${filename}`,
|
||||
content: JSON.stringify(config, null, 2),
|
||||
}),
|
||||
});
|
||||
setPinned(true);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl overflow-hidden my-2"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span style={{ color: "#22c55e" }}>
|
||||
<ChartBarIcon />
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
color: "#22c55e",
|
||||
}}
|
||||
>
|
||||
{config.panels.length} chart{config.panels.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{!pinned && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePin}
|
||||
disabled={pinning}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
title="Save to workspace"
|
||||
>
|
||||
<PinIcon />
|
||||
{pinning ? "Saving..." : "Pin"}
|
||||
</button>
|
||||
)}
|
||||
{pinned && (
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-md"
|
||||
style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }}
|
||||
>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="/workspace"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "rgba(232, 93, 58, 0.1)",
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{config.description && (
|
||||
<div className="px-3 py-1.5">
|
||||
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Panels (compact mode) */}
|
||||
<div className={`grid gap-2 p-2 ${visiblePanels.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
|
||||
{visiblePanels.map((panel) => (
|
||||
<CompactPanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* More panels indicator */}
|
||||
{config.panels.length > 2 && (
|
||||
<div
|
||||
className="px-3 py-1.5 text-center border-t"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
+{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Compact panel card for inline rendering ---
|
||||
|
||||
function CompactPanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="px-2.5 py-1.5">
|
||||
<h4
|
||||
className="text-[11px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
{data?.loading ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 200 }}>
|
||||
<div
|
||||
className="w-4 h-4 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 200 }}>
|
||||
<p className="text-[10px]" style={{ color: "#f87171" }}>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
apps/web/app/components/charts/report-viewer.tsx
Normal file
407
apps/web/app/components/charts/report-viewer.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { ChartPanel } from "./chart-panel";
|
||||
import { FilterBar } from "./filter-bar";
|
||||
import type { ReportConfig, FilterState, PanelConfig, FilterConfig } from "./types";
|
||||
|
||||
type ReportViewerProps = {
|
||||
/** Report config object (inline or loaded) */
|
||||
config?: ReportConfig;
|
||||
/** Path to load report config from filesystem */
|
||||
reportPath?: string;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function ChartBarIcon({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type PanelData = {
|
||||
panelId: string;
|
||||
rows: Record<string, unknown>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Build filter entries for the API from active filter state + filter configs. */
|
||||
function buildFilterEntries(
|
||||
filterState: FilterState,
|
||||
filterConfigs: FilterConfig[],
|
||||
): Array<{ id: string; column: string; value: FilterState[string] }> {
|
||||
const entries: Array<{ id: string; column: string; value: FilterState[string] }> = [];
|
||||
for (const fc of filterConfigs) {
|
||||
const v = filterState[fc.id];
|
||||
if (!v) {continue;}
|
||||
// Only include if the filter has an active value
|
||||
const hasValue =
|
||||
(v.type === "dateRange" && (v.from || v.to)) ||
|
||||
(v.type === "select" && v.value) ||
|
||||
(v.type === "multiSelect" && v.values && v.values.length > 0) ||
|
||||
(v.type === "number" && (v.min !== undefined || v.max !== undefined));
|
||||
if (hasValue) {
|
||||
entries.push({ id: fc.id, column: fc.column, value: v });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// --- Grid size helpers ---
|
||||
|
||||
function panelColSpan(size?: string): string {
|
||||
switch (size) {
|
||||
case "full":
|
||||
return "col-span-6";
|
||||
case "third":
|
||||
return "col-span-2";
|
||||
case "half":
|
||||
default:
|
||||
return "col-span-3";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ReportViewer ---
|
||||
|
||||
export function ReportViewer({ config: propConfig, reportPath }: ReportViewerProps) {
|
||||
const [config, setConfig] = useState<ReportConfig | null>(propConfig ?? null);
|
||||
const [configLoading, setConfigLoading] = useState(!propConfig && !!reportPath);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
const [filterState, setFilterState] = useState<FilterState>({});
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Load report config from filesystem if path provided
|
||||
useEffect(() => {
|
||||
if (propConfig) {
|
||||
setConfig(propConfig);
|
||||
return;
|
||||
}
|
||||
if (!reportPath) {return;}
|
||||
|
||||
let cancelled = false;
|
||||
setConfigLoading(true);
|
||||
setConfigError(null);
|
||||
|
||||
fetch(`/api/workspace/file?path=${encodeURIComponent(reportPath)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {throw new Error(`Failed to load report: HTTP ${res.status}`);}
|
||||
const data = await res.json();
|
||||
if (cancelled) {return;}
|
||||
try {
|
||||
const parsed = JSON.parse(data.content) as ReportConfig;
|
||||
setConfig(parsed);
|
||||
} catch {
|
||||
throw new Error("Invalid report JSON");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setConfigError(err instanceof Error ? err.message : "Failed to load report");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {setConfigLoading(false);}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [propConfig, reportPath]);
|
||||
|
||||
// Execute all panel SQL queries when config or filters change
|
||||
const executeAllPanels = useCallback(async () => {
|
||||
if (!config) {return;}
|
||||
|
||||
const filterEntries = buildFilterEntries(filterState, config.filters ?? []);
|
||||
|
||||
// Mark all panels as loading
|
||||
const initialState: Record<string, PanelData> = {};
|
||||
for (const panel of config.panels) {
|
||||
initialState[panel.id] = { panelId: panel.id, rows: [], loading: true };
|
||||
}
|
||||
setPanelData(initialState);
|
||||
|
||||
// Execute all panels in parallel
|
||||
await Promise.all(
|
||||
config.panels.map(async (panel) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sql: panel.sql,
|
||||
filters: filterEntries.length > 0 ? filterEntries : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: data.rows ?? [],
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Query failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [config, filterState]);
|
||||
|
||||
// Re-execute when config, filters, or refresh key changes
|
||||
useEffect(() => {
|
||||
executeAllPanels();
|
||||
}, [executeAllPanels, refreshKey]);
|
||||
|
||||
const totalRows = useMemo(() => {
|
||||
return Object.values(panelData).reduce((sum, pd) => sum + pd.rows.length, 0);
|
||||
}, [panelData]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-3">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading report...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (configError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
||||
<ChartBarIcon size={48} />
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Failed to load report
|
||||
</p>
|
||||
<p
|
||||
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
|
||||
style={{ background: "var(--color-surface)", color: "#f87171" }}
|
||||
>
|
||||
{configError}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No report configuration found
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Report header */}
|
||||
<div
|
||||
className="px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<ChartBarIcon />
|
||||
</span>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{config.title}
|
||||
</h1>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p
|
||||
className="text-sm ml-7"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{config.panels.length} panel{config.panels.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{totalRows} rows
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="p-1.5 rounded-md transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{config.filters && config.filters.length > 0 && (
|
||||
<FilterBar
|
||||
filters={config.filters}
|
||||
value={filterState}
|
||||
onChange={setFilterState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-6 gap-5">
|
||||
{config.panels.map((panel) => (
|
||||
<PanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Individual panel card ---
|
||||
|
||||
function PanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
const colSpan = panelColSpan(panel.size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${colSpan} rounded-xl overflow-hidden`}
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h3>
|
||||
{data && !data.loading && !data.error && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.rows.length} rows
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="px-2 pb-3">
|
||||
{data?.loading ? (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<p className="text-xs" style={{ color: "#f87171" }}>
|
||||
Query error
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] px-2 py-1 rounded max-w-xs text-center"
|
||||
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171" }}
|
||||
>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/app/components/charts/types.ts
Normal file
64
apps/web/app/components/charts/types.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/** Shared types for the report/analytics system. */
|
||||
|
||||
export type ChartType =
|
||||
| "bar"
|
||||
| "line"
|
||||
| "area"
|
||||
| "pie"
|
||||
| "donut"
|
||||
| "radar"
|
||||
| "radialBar"
|
||||
| "scatter"
|
||||
| "funnel";
|
||||
|
||||
export type PanelSize = "full" | "half" | "third";
|
||||
|
||||
export type PanelMapping = {
|
||||
/** Key for x-axis or category axis */
|
||||
xAxis?: string;
|
||||
/** One or more keys for y-axis values (supports stacked/multi-series) */
|
||||
yAxis?: string[];
|
||||
/** Key used as label for pie/donut/funnel */
|
||||
nameKey?: string;
|
||||
/** Key used as value for pie/donut/funnel */
|
||||
valueKey?: string;
|
||||
/** Custom colors for series (hex). Falls back to palette. */
|
||||
colors?: string[];
|
||||
};
|
||||
|
||||
export type PanelConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ChartType;
|
||||
sql: string;
|
||||
mapping: PanelMapping;
|
||||
size?: PanelSize;
|
||||
};
|
||||
|
||||
export type FilterType = "dateRange" | "select" | "multiSelect" | "number";
|
||||
|
||||
export type FilterConfig = {
|
||||
id: string;
|
||||
type: FilterType;
|
||||
label: string;
|
||||
column: string;
|
||||
/** SQL to fetch available options (for select/multiSelect) */
|
||||
sql?: string;
|
||||
};
|
||||
|
||||
export type ReportConfig = {
|
||||
version: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
panels: PanelConfig[];
|
||||
filters?: FilterConfig[];
|
||||
};
|
||||
|
||||
/** Active filter values keyed by filter ID */
|
||||
export type FilterState = Record<string, FilterValue>;
|
||||
|
||||
export type FilterValue =
|
||||
| { type: "dateRange"; from?: string; to?: string }
|
||||
| { type: "select"; value?: string }
|
||||
| { type: "multiSelect"; values?: string[] }
|
||||
| { type: "number"; min?: number; max?: number };
|
||||
@ -1,18 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChainOfThought, type ChainPart } from "./chain-of-thought";
|
||||
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
|
||||
import type { ReportConfig } from "./charts/types";
|
||||
|
||||
// Lazy-load ReportCard (uses Recharts which is heavy)
|
||||
const ReportCard = dynamic(
|
||||
() => import("./charts/report-card").then((m) => ({ default: m.ReportCard })),
|
||||
{ ssr: false, loading: () => <div className="h-48 rounded-xl animate-pulse" style={{ background: "var(--color-surface)" }} /> },
|
||||
);
|
||||
|
||||
/* ─── Part grouping ─── */
|
||||
|
||||
type MessageSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "chain"; parts: ChainPart[] };
|
||||
| { type: "chain"; parts: ChainPart[] }
|
||||
| { type: "report-artifact"; config: ReportConfig };
|
||||
|
||||
/** Map AI SDK tool state string to a simplified status */
|
||||
function toolStatus(state: string): "running" | "done" | "error" {
|
||||
if (state === "output-available") return "done";
|
||||
if (state === "error") return "error";
|
||||
if (state === "output-available") {return "done";}
|
||||
if (state === "error") {return "error";}
|
||||
return "running";
|
||||
}
|
||||
|
||||
@ -34,10 +44,13 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
flush();
|
||||
segments.push({
|
||||
type: "text",
|
||||
text: (part as { type: "text"; text: string }).text,
|
||||
});
|
||||
const text = (part as { type: "text"; text: string }).text;
|
||||
// Check for report-json fenced blocks in text
|
||||
if (hasReportBlocks(text)) {
|
||||
segments.push(...splitReportBlocks(text) as MessageSegment[]);
|
||||
} else {
|
||||
segments.push({ type: "text", text });
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
const rp = part as {
|
||||
type: "reasoning";
|
||||
@ -99,10 +112,12 @@ function asRecord(
|
||||
val: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (val && typeof val === "object" && !Array.isArray(val))
|
||||
return val as Record<string, unknown>;
|
||||
{return val as Record<string, unknown>;}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// splitReportBlocks and hasReportBlocks imported from @/lib/report-blocks
|
||||
|
||||
/* ─── Chat message ─── */
|
||||
|
||||
export function ChatMessage({ message }: { message: UIMessage }) {
|
||||
@ -126,21 +141,29 @@ export function ChatMessage({ message }: { message: UIMessage }) {
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text)]"
|
||||
}`}
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="whitespace-pre-wrap text-[15px] leading-relaxed"
|
||||
>
|
||||
{segment.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
return (
|
||||
<ChainOfThought key={index} parts={segment.parts} />
|
||||
<div
|
||||
key={index}
|
||||
className="whitespace-pre-wrap text-[15px] leading-relaxed"
|
||||
>
|
||||
{segment.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
if (segment.type === "report-artifact") {
|
||||
return (
|
||||
<ReportCard
|
||||
key={index}
|
||||
config={segment.config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChainOfThought key={index} parts={segment.parts} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
|
||||
@ -27,13 +27,13 @@ type MemoryFile = {
|
||||
type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type SidebarSection = "chats" | "skills" | "memories" | "workspace";
|
||||
type SidebarSection = "chats" | "skills" | "memories" | "workspace" | "reports";
|
||||
|
||||
type SidebarProps = {
|
||||
onSessionSelect?: (sessionId: string) => void;
|
||||
@ -233,7 +233,9 @@ function WorkspaceTreeNode({
|
||||
? "#60a5fa"
|
||||
: node.type === "database"
|
||||
? "#c084fc"
|
||||
: "var(--color-text-muted)";
|
||||
: node.type === "report"
|
||||
? "#22c55e"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -243,7 +245,7 @@ function WorkspaceTreeNode({
|
||||
onClick={() => {
|
||||
if (isExpandable) {onToggle(node.path);}
|
||||
// Navigate to workspace page for actionable items
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database") {
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
|
||||
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
|
||||
}
|
||||
}}
|
||||
@ -287,6 +289,12 @@ function WorkspaceTreeNode({
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
) : node.type === "report" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
@ -381,6 +389,52 @@ function WorkspaceSection({ tree }: { tree: TreeNode[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reports Section ---
|
||||
|
||||
function ReportsSection({ tree }: { tree: TreeNode[] }) {
|
||||
// Collect all report nodes from the tree (recursive)
|
||||
const reports: TreeNode[] = [];
|
||||
function collect(nodes: TreeNode[]) {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "report") {reports.push(n);}
|
||||
if (n.children) {collect(n.children);}
|
||||
}
|
||||
}
|
||||
collect(tree);
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
|
||||
No reports yet. Ask the agent to create one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{reports.map((report) => (
|
||||
<a
|
||||
key={report.path}
|
||||
href={`/workspace?path=${encodeURIComponent(report.path)}`}
|
||||
className="flex items-center gap-2 mx-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ color: "#22c55e" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="truncate flex-1">
|
||||
{report.name.replace(/\.report\.json$/, "")}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Collapsible Header ---
|
||||
|
||||
function SectionHeader({
|
||||
@ -474,8 +528,7 @@ export function Sidebar({
|
||||
{/* Header with New Chat button */}
|
||||
<div className="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||
<h1 className="text-base font-bold flex items-center gap-2">
|
||||
<span className="text-xl">🦞</span>
|
||||
<span>OpenClaw</span>
|
||||
<span>OpenClaw Dench</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
@ -506,8 +559,23 @@ export function Sidebar({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Chats (web sessions) */}
|
||||
<div>
|
||||
{/* Workspace */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
count={workspaceTree.length}
|
||||
isOpen={openSections.has("workspace")}
|
||||
onToggle={() => toggleSection("workspace")}
|
||||
/>
|
||||
{openSections.has("workspace") && (
|
||||
<WorkspaceSection tree={workspaceTree} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chats (web sessions) */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Chats"
|
||||
count={webSessions.length}
|
||||
@ -523,17 +591,16 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
{/* Reports */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
count={workspaceTree.length}
|
||||
isOpen={openSections.has("workspace")}
|
||||
onToggle={() => toggleSection("workspace")}
|
||||
title="Reports"
|
||||
isOpen={openSections.has("reports")}
|
||||
onToggle={() => toggleSection("reports")}
|
||||
/>
|
||||
{openSections.has("workspace") && (
|
||||
<WorkspaceSection tree={workspaceTree} />
|
||||
{openSections.has("reports") && (
|
||||
<ReportsSection tree={workspaceTree} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
|
||||
|
||||
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
|
||||
const MarkdownContent = dynamic(
|
||||
@ -18,6 +19,21 @@ const MarkdownContent = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
// Lazy-load ReportCard (uses Recharts which is heavy)
|
||||
const ReportCard = dynamic(
|
||||
() =>
|
||||
import("../charts/report-card").then((m) => ({ default: m.ReportCard })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="h-48 rounded-xl animate-pulse my-4"
|
||||
style={{ background: "var(--color-surface)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type DocumentViewProps = {
|
||||
content: string;
|
||||
title?: string;
|
||||
@ -33,6 +49,9 @@ export function DocumentView({ content, title }: DocumentViewProps) {
|
||||
const markdownBody =
|
||||
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
|
||||
|
||||
// Check if the markdown contains embedded report-json blocks
|
||||
const hasReports = hasReportBlocks(markdownBody);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
{displayTitle && (
|
||||
@ -44,9 +63,41 @@ export function DocumentView({ content, title }: DocumentViewProps) {
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="workspace-prose">
|
||||
<MarkdownContent content={markdownBody} />
|
||||
</div>
|
||||
{hasReports ? (
|
||||
<EmbeddedReportContent content={markdownBody} />
|
||||
) : (
|
||||
<div className="workspace-prose">
|
||||
<MarkdownContent content={markdownBody} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders markdown content that contains embedded report-json blocks.
|
||||
* Splits the content into alternating markdown and interactive chart sections.
|
||||
*/
|
||||
function EmbeddedReportContent({ content }: { content: string }) {
|
||||
const segments = splitReportBlocks(content);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "report-artifact") {
|
||||
return (
|
||||
<div key={index} className="my-6">
|
||||
<ReportCard config={segment.config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Text segment -- render as markdown
|
||||
return (
|
||||
<div key={index} className="workspace-prose">
|
||||
<MarkdownContent content={segment.text} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -67,6 +67,16 @@ function DatabaseIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ReportIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
@ -100,6 +110,8 @@ function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
||||
return <FolderIcon open={open} />;
|
||||
case "database":
|
||||
return <DatabaseIcon />;
|
||||
case "report":
|
||||
return <ReportIcon />;
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
@ -141,7 +153,9 @@ function TreeNodeItem({
|
||||
? "#60a5fa"
|
||||
: node.type === "database"
|
||||
? "#c084fc"
|
||||
: "var(--color-text-muted)";
|
||||
: node.type === "report"
|
||||
? "#22c55e"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -11,6 +11,7 @@ import { FileViewer } from "../components/workspace/file-viewer";
|
||||
import { DatabaseViewer } from "../components/workspace/database-viewer";
|
||||
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
import { ReportViewer } from "../components/charts/report-viewer";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@ -58,6 +59,7 @@ type ContentState =
|
||||
| { kind: "document"; data: FileData; title: string }
|
||||
| { kind: "file"; data: FileData; filename: string }
|
||||
| { kind: "database"; dbPath: string; filename: string }
|
||||
| { kind: "report"; reportPath: string; filename: string }
|
||||
| { kind: "directory"; node: TreeNode };
|
||||
|
||||
// --- Helpers ---
|
||||
@ -165,6 +167,9 @@ export default function WorkspacePage() {
|
||||
} else if (node.type === "database") {
|
||||
// Database files are handled entirely by the DatabaseViewer component
|
||||
setContent({ kind: "database", dbPath: node.path, filename: node.name });
|
||||
} else if (node.type === "report") {
|
||||
// Report files are handled entirely by the ReportViewer component
|
||||
setContent({ kind: "report", reportPath: node.path, filename: node.name });
|
||||
} else if (node.type === "file") {
|
||||
const res = await fetch(
|
||||
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
|
||||
@ -379,6 +384,13 @@ function ContentRenderer({
|
||||
/>
|
||||
);
|
||||
|
||||
case "report":
|
||||
return (
|
||||
<ReportViewer
|
||||
reportPath={content.reportPath}
|
||||
/>
|
||||
);
|
||||
|
||||
case "directory":
|
||||
return (
|
||||
<DirectoryListing
|
||||
@ -456,7 +468,9 @@ function DirectoryListing({
|
||||
? "rgba(96, 165, 250, 0.1)"
|
||||
: child.type === "database"
|
||||
? "rgba(192, 132, 252, 0.1)"
|
||||
: "var(--color-surface-hover)",
|
||||
: child.type === "report"
|
||||
? "rgba(34, 197, 94, 0.1)"
|
||||
: "var(--color-surface-hover)",
|
||||
color:
|
||||
child.type === "object"
|
||||
? "var(--color-accent)"
|
||||
@ -464,7 +478,9 @@ function DirectoryListing({
|
||||
? "#60a5fa"
|
||||
: child.type === "database"
|
||||
? "#c084fc"
|
||||
: "var(--color-text-muted)",
|
||||
: child.type === "report"
|
||||
? "#22c55e"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<NodeTypeIcon type={child.type} />
|
||||
@ -665,6 +681,14 @@ function NodeTypeIcon({ type }: { type: string }) {
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
);
|
||||
case "report":
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
190
apps/web/lib/report-blocks.test.ts
Normal file
190
apps/web/lib/report-blocks.test.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { splitReportBlocks, hasReportBlocks } from "./report-blocks";
|
||||
|
||||
// ─── hasReportBlocks ───
|
||||
|
||||
describe("hasReportBlocks", () => {
|
||||
it("returns false for plain text", () => {
|
||||
expect(hasReportBlocks("Hello world")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for regular code blocks", () => {
|
||||
expect(hasReportBlocks("```json\n{}\n```")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when report-json block is present", () => {
|
||||
expect(hasReportBlocks('```report-json\n{"version":1,"title":"Test","panels":[]}\n```')).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for partial/streaming content with marker", () => {
|
||||
expect(hasReportBlocks("Some text ```report-json")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitReportBlocks ───
|
||||
|
||||
describe("splitReportBlocks", () => {
|
||||
const validReport = JSON.stringify({
|
||||
version: 1,
|
||||
title: "Test Report",
|
||||
panels: [{ id: "p1", title: "Panel 1", type: "bar", sql: "SELECT 1", mapping: { xAxis: "a" } }],
|
||||
});
|
||||
|
||||
it("returns text segment for plain text with no blocks", () => {
|
||||
const result = splitReportBlocks("Hello world");
|
||||
expect(result).toEqual([{ type: "text", text: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("returns empty array for whitespace-only text", () => {
|
||||
const result = splitReportBlocks(" ");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a single report block", () => {
|
||||
const text = `\`\`\`report-json\n${validReport}\n\`\`\``;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("report-artifact");
|
||||
if (result[0].type === "report-artifact") {
|
||||
expect(result[0].config.title).toBe("Test Report");
|
||||
expect(result[0].config.panels).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("splits text before and after a report block", () => {
|
||||
const text = `Before text\n\n\`\`\`report-json\n${validReport}\n\`\`\`\n\nAfter text`;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ type: "text", text: "Before text\n\n" });
|
||||
expect(result[1].type).toBe("report-artifact");
|
||||
expect(result[2]).toEqual({ type: "text", text: "\n\nAfter text" });
|
||||
});
|
||||
|
||||
it("handles multiple report blocks", () => {
|
||||
const report2 = JSON.stringify({
|
||||
version: 1,
|
||||
title: "Second Report",
|
||||
panels: [{ id: "p2", title: "Panel 2", type: "pie", sql: "SELECT 2", mapping: { nameKey: "a" } }],
|
||||
});
|
||||
const text = `First:\n\`\`\`report-json\n${validReport}\n\`\`\`\nSecond:\n\`\`\`report-json\n${report2}\n\`\`\`\nDone.`;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect(result[1].type).toBe("report-artifact");
|
||||
expect(result[2].type).toBe("text");
|
||||
expect(result[3].type).toBe("report-artifact");
|
||||
expect(result[4].type).toBe("text");
|
||||
if (result[1].type === "report-artifact") {
|
||||
expect(result[1].config.title).toBe("Test Report");
|
||||
}
|
||||
if (result[3].type === "report-artifact") {
|
||||
expect(result[3].config.title).toBe("Second Report");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to text for invalid JSON", () => {
|
||||
const text = "```report-json\n{not valid json}\n```";
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect(result[0]).toEqual({ type: "text", text: "```report-json\n{not valid json}\n```" });
|
||||
});
|
||||
|
||||
it("falls back to text for valid JSON without panels array", () => {
|
||||
const text = '```report-json\n{"version":1,"title":"Bad"}\n```';
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("falls back to text for JSON with panels as non-array", () => {
|
||||
const text = '```report-json\n{"version":1,"title":"Bad","panels":"not-array"}\n```';
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("handles report block at the very beginning with no preceding text", () => {
|
||||
const text = `\`\`\`report-json\n${validReport}\n\`\`\`\nSome analysis.`;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe("report-artifact");
|
||||
expect(result[1].type).toBe("text");
|
||||
});
|
||||
|
||||
it("handles report block at the very end with no following text", () => {
|
||||
const text = `Here is the data:\n\`\`\`report-json\n${validReport}\n\`\`\``;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect(result[1].type).toBe("report-artifact");
|
||||
});
|
||||
|
||||
it("handles report-json with extra whitespace after language tag", () => {
|
||||
const text = `\`\`\`report-json \n${validReport}\n\`\`\``;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("report-artifact");
|
||||
});
|
||||
|
||||
it("handles empty panels array (valid config, zero charts)", () => {
|
||||
const emptyPanels = JSON.stringify({ version: 1, title: "Empty", panels: [] });
|
||||
const text = `\`\`\`report-json\n${emptyPanels}\n\`\`\``;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("report-artifact");
|
||||
if (result[0].type === "report-artifact") {
|
||||
expect(result[0].config.panels).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves report config fields (description, filters)", () => {
|
||||
const fullReport = JSON.stringify({
|
||||
version: 1,
|
||||
title: "Full Report",
|
||||
description: "A detailed report",
|
||||
panels: [{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} }],
|
||||
filters: [{ id: "f1", type: "dateRange", label: "Date", column: "created_at" }],
|
||||
});
|
||||
const text = `\`\`\`report-json\n${fullReport}\n\`\`\``;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
if (result[0].type === "report-artifact") {
|
||||
expect(result[0].config.description).toBe("A detailed report");
|
||||
expect(result[0].config.filters).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match regular json code blocks", () => {
|
||||
const text = '```json\n{"panels":[]}\n```';
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect((result[0] as { type: "text"; text: string }).text).toContain("```json");
|
||||
});
|
||||
|
||||
it("does not match inline backticks", () => {
|
||||
const text = 'Use `report-json` format for charts.';
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("handles SQL with special characters in report config", () => {
|
||||
const reportWithSpecialSql = JSON.stringify({
|
||||
version: 1,
|
||||
title: "Special SQL",
|
||||
panels: [{
|
||||
id: "p1",
|
||||
title: "P1",
|
||||
type: "bar",
|
||||
sql: `SELECT "Stage", COUNT(*) as count FROM v_deal WHERE "Name" LIKE '%O''Brien%' GROUP BY "Stage"`,
|
||||
mapping: { xAxis: "Stage", yAxis: ["count"] },
|
||||
}],
|
||||
});
|
||||
const text = `\`\`\`report-json\n${reportWithSpecialSql}\n\`\`\``;
|
||||
const result = splitReportBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("report-artifact");
|
||||
});
|
||||
});
|
||||
75
apps/web/lib/report-blocks.ts
Normal file
75
apps/web/lib/report-blocks.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Pure utility functions for parsing report-json blocks from chat text.
|
||||
* Extracted from chat-message.tsx for testability.
|
||||
*/
|
||||
|
||||
export type ReportConfig = {
|
||||
version: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
panels: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
sql: string;
|
||||
mapping: Record<string, unknown>;
|
||||
size?: string;
|
||||
}>;
|
||||
filters?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
column: string;
|
||||
sql?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ParsedSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "report-artifact"; config: ReportConfig };
|
||||
|
||||
/**
|
||||
* Split text containing ```report-json ... ``` fenced blocks into
|
||||
* alternating text and report-artifact segments.
|
||||
*/
|
||||
export function splitReportBlocks(text: string): ParsedSegment[] {
|
||||
const reportFenceRegex = /```report-json\s*\n([\s\S]*?)```/g;
|
||||
const segments: ParsedSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(reportFenceRegex)) {
|
||||
const before = text.slice(lastIndex, match.index);
|
||||
if (before.trim()) {
|
||||
segments.push({ type: "text", text: before });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(match[1]) as ReportConfig;
|
||||
if (config.panels && Array.isArray(config.panels)) {
|
||||
segments.push({ type: "report-artifact", config });
|
||||
} else {
|
||||
// Invalid report config -- render as plain text
|
||||
segments.push({ type: "text", text: match[0] });
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON -- render as plain text
|
||||
segments.push({ type: "text", text: match[0] });
|
||||
}
|
||||
|
||||
lastIndex = (match.index ?? 0) + match[0].length;
|
||||
}
|
||||
|
||||
const remaining = text.slice(lastIndex);
|
||||
if (remaining.trim()) {
|
||||
segments.push({ type: "text", text: remaining });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any report-json fenced blocks.
|
||||
*/
|
||||
export function hasReportBlocks(text: string): boolean {
|
||||
return text.includes("```report-json");
|
||||
}
|
||||
312
apps/web/lib/report-filters.test.ts
Normal file
312
apps/web/lib/report-filters.test.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
escapeSqlString,
|
||||
buildFilterClauses,
|
||||
injectFilters,
|
||||
checkSqlSafety,
|
||||
type FilterEntry,
|
||||
} from "./report-filters";
|
||||
|
||||
// ─── escapeSqlString ───
|
||||
|
||||
describe("escapeSqlString", () => {
|
||||
it("returns unchanged string when no quotes", () => {
|
||||
expect(escapeSqlString("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("escapes single quotes", () => {
|
||||
expect(escapeSqlString("O'Brien")).toBe("O''Brien");
|
||||
});
|
||||
|
||||
it("escapes multiple single quotes", () => {
|
||||
expect(escapeSqlString("it's a 'test'")).toBe("it''s a ''test''");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(escapeSqlString("")).toBe("");
|
||||
});
|
||||
|
||||
it("does not double-escape already escaped quotes", () => {
|
||||
expect(escapeSqlString("don''t")).toBe("don''''t");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildFilterClauses ───
|
||||
|
||||
describe("buildFilterClauses", () => {
|
||||
it("returns empty array for undefined filters", () => {
|
||||
expect(buildFilterClauses(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty filters array", () => {
|
||||
expect(buildFilterClauses([])).toEqual([]);
|
||||
});
|
||||
|
||||
// --- dateRange ---
|
||||
it("builds dateRange clause with from only", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "d", column: "created_at", value: { type: "dateRange", from: "2025-01-01" } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses).toEqual([`"created_at" >= '2025-01-01'`]);
|
||||
});
|
||||
|
||||
it("builds dateRange clause with to only", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "d", column: "created_at", value: { type: "dateRange", to: "2025-12-31" } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses).toEqual([`"created_at" <= '2025-12-31'`]);
|
||||
});
|
||||
|
||||
it("builds dateRange clause with both from and to", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "d", column: "created_at", value: { type: "dateRange", from: "2025-01-01", to: "2025-12-31" } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses).toHaveLength(2);
|
||||
expect(clauses[0]).toBe(`"created_at" >= '2025-01-01'`);
|
||||
expect(clauses[1]).toBe(`"created_at" <= '2025-12-31'`);
|
||||
});
|
||||
|
||||
it("skips dateRange with no from or to", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "d", column: "created_at", value: { type: "dateRange" } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([]);
|
||||
});
|
||||
|
||||
// --- select ---
|
||||
it("builds select clause", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "s", column: "Status", value: { type: "select", value: "Active" } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`"Status" = 'Active'`]);
|
||||
});
|
||||
|
||||
it("skips select with no value", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "s", column: "Status", value: { type: "select" } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([]);
|
||||
});
|
||||
|
||||
it("escapes SQL injection in select value", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "s", column: "Status", value: { type: "select", value: "'; DROP TABLE users; --" } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
// The single quote in the injected value is doubled, preventing breakout
|
||||
// from the SQL string literal. "DROP TABLE" remains as inert text inside quotes.
|
||||
expect(clauses[0]).toBe(`"Status" = '''; DROP TABLE users; --'`);
|
||||
// Verify the quote is doubled (key defense)
|
||||
expect(clauses[0]).toContain("''");
|
||||
});
|
||||
|
||||
it("escapes multiple injection attempts in multiSelect", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "m", column: "Stage", value: { type: "multiSelect", values: ["a'b", "c'd"] } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses[0]).toBe(`"Stage" IN ('a''b', 'c''d')`);
|
||||
});
|
||||
|
||||
// --- multiSelect ---
|
||||
it("builds multiSelect clause with one value", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "m", column: "Stage", value: { type: "multiSelect", values: ["New"] } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`"Stage" IN ('New')`]);
|
||||
});
|
||||
|
||||
it("builds multiSelect clause with multiple values", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "m", column: "Stage", value: { type: "multiSelect", values: ["New", "Active", "Closed"] } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`"Stage" IN ('New', 'Active', 'Closed')`]);
|
||||
});
|
||||
|
||||
it("skips multiSelect with empty values", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "m", column: "Stage", value: { type: "multiSelect", values: [] } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips multiSelect with undefined values", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "m", column: "Stage", value: { type: "multiSelect" } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([]);
|
||||
});
|
||||
|
||||
// --- number ---
|
||||
it("builds number clause with min only", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Amount", value: { type: "number", min: 100 } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`CAST("Amount" AS NUMERIC) >= 100`]);
|
||||
});
|
||||
|
||||
it("builds number clause with max only", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Amount", value: { type: "number", max: 1000 } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`CAST("Amount" AS NUMERIC) <= 1000`]);
|
||||
});
|
||||
|
||||
it("builds number clause with both min and max", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Amount", value: { type: "number", min: 100, max: 1000 } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses).toHaveLength(2);
|
||||
expect(clauses[0]).toBe(`CAST("Amount" AS NUMERIC) >= 100`);
|
||||
expect(clauses[1]).toBe(`CAST("Amount" AS NUMERIC) <= 1000`);
|
||||
});
|
||||
|
||||
it("handles min of 0 correctly", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Score", value: { type: "number", min: 0 } },
|
||||
];
|
||||
// min is 0, which is defined, so should produce a clause
|
||||
expect(buildFilterClauses(filters)).toEqual([`CAST("Score" AS NUMERIC) >= 0`]);
|
||||
});
|
||||
|
||||
it("skips number with no min or max", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Amount", value: { type: "number" } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([]);
|
||||
});
|
||||
|
||||
// --- column escaping ---
|
||||
it("escapes double quotes in column names", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "s", column: 'Bad"Column', value: { type: "select", value: "x" } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`"Bad""Column" = 'x'`]);
|
||||
});
|
||||
|
||||
// --- multiple filters combined ---
|
||||
it("builds clauses for multiple different filter types", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "d", column: "created_at", value: { type: "dateRange", from: "2025-01-01" } },
|
||||
{ id: "s", column: "Status", value: { type: "select", value: "Active" } },
|
||||
{ id: "n", column: "Amount", value: { type: "number", min: 50 } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses).toHaveLength(3);
|
||||
expect(clauses[0]).toContain("created_at");
|
||||
expect(clauses[1]).toContain("Status");
|
||||
expect(clauses[2]).toContain("Amount");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── injectFilters ───
|
||||
|
||||
describe("injectFilters", () => {
|
||||
it("returns original SQL when no clauses", () => {
|
||||
const sql = "SELECT * FROM v_deals";
|
||||
expect(injectFilters(sql, [])).toBe(sql);
|
||||
});
|
||||
|
||||
it("wraps SQL as CTE with single filter", () => {
|
||||
const sql = "SELECT * FROM v_deals";
|
||||
const clauses = [`"Status" = 'Active'`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
expect(result).toBe(
|
||||
`WITH __report_data AS (SELECT * FROM v_deals) SELECT * FROM __report_data WHERE "Status" = 'Active'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps SQL with multiple filters joined by AND", () => {
|
||||
const sql = "SELECT * FROM v_deals";
|
||||
const clauses = [`"Status" = 'Active'`, `"Amount" >= 100`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
expect(result).toContain("AND");
|
||||
expect(result).toContain(`"Status" = 'Active'`);
|
||||
expect(result).toContain(`"Amount" >= 100`);
|
||||
});
|
||||
|
||||
it("strips trailing semicolon from original SQL", () => {
|
||||
const sql = "SELECT * FROM v_deals;";
|
||||
const clauses = [`"Status" = 'Active'`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
expect(result).toContain("AS (SELECT * FROM v_deals)");
|
||||
expect(result).not.toContain("v_deals;)");
|
||||
});
|
||||
|
||||
it("handles complex SQL with GROUP BY", () => {
|
||||
const sql = `SELECT "Stage", COUNT(*) as cnt FROM v_deals GROUP BY "Stage"`;
|
||||
const clauses = [`"Status" = 'Active'`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
expect(result).toContain("__report_data");
|
||||
expect(result).toContain("GROUP BY");
|
||||
// The CTE wraps the entire query, so the GROUP BY is inside the CTE
|
||||
expect(result).toMatch(/^WITH __report_data AS/);
|
||||
});
|
||||
|
||||
it("preserves SQL with existing WITH clause", () => {
|
||||
const sql = `WITH base AS (SELECT * FROM v_deals) SELECT * FROM base`;
|
||||
const clauses = [`"x" = '1'`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
// The original CTE gets nested inside __report_data
|
||||
expect(result).toContain("__report_data");
|
||||
expect(result).toContain("WITH base AS");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── checkSqlSafety ───
|
||||
|
||||
describe("checkSqlSafety", () => {
|
||||
it("allows SELECT queries", () => {
|
||||
expect(checkSqlSafety("SELECT * FROM v_deals")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows SELECT with leading whitespace", () => {
|
||||
expect(checkSqlSafety(" SELECT * FROM v_deals")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows WITH (CTE) queries", () => {
|
||||
expect(checkSqlSafety("WITH base AS (SELECT 1) SELECT * FROM base")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects DROP statements", () => {
|
||||
expect(checkSqlSafety("DROP TABLE users")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects DELETE statements", () => {
|
||||
expect(checkSqlSafety("DELETE FROM users")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects INSERT statements", () => {
|
||||
expect(checkSqlSafety("INSERT INTO users VALUES (1)")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects UPDATE statements", () => {
|
||||
expect(checkSqlSafety("UPDATE users SET name = 'x'")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects ALTER statements", () => {
|
||||
expect(checkSqlSafety("ALTER TABLE users ADD COLUMN x INT")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects CREATE statements", () => {
|
||||
expect(checkSqlSafety("CREATE TABLE x (id INT)")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects TRUNCATE statements", () => {
|
||||
expect(checkSqlSafety("TRUNCATE TABLE users")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(checkSqlSafety("drop table users")).not.toBeNull();
|
||||
expect(checkSqlSafety("Drop Table Users")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("allows SELECT that contains mutation keywords in column names", () => {
|
||||
// The SQL starts with SELECT, so it should be allowed
|
||||
expect(checkSqlSafety('SELECT "delete_count", "update_time" FROM v_stats')).toBeNull();
|
||||
});
|
||||
});
|
||||
86
apps/web/lib/report-filters.ts
Normal file
86
apps/web/lib/report-filters.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Pure utility functions for report filter SQL injection.
|
||||
* Extracted from the execute API route for testability.
|
||||
*/
|
||||
|
||||
export type FilterValue =
|
||||
| { type: "dateRange"; from?: string; to?: string }
|
||||
| { type: "select"; value?: string }
|
||||
| { type: "multiSelect"; values?: string[] }
|
||||
| { type: "number"; min?: number; max?: number };
|
||||
|
||||
export type FilterEntry = {
|
||||
id: string;
|
||||
column: string;
|
||||
value: FilterValue;
|
||||
};
|
||||
|
||||
/** Escape single quotes in SQL string values. */
|
||||
export function escapeSqlString(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WHERE clause fragments from active filters.
|
||||
* Returns an array of SQL condition strings (safe -- values are escaped).
|
||||
*/
|
||||
export function buildFilterClauses(filters?: FilterEntry[]): string[] {
|
||||
if (!filters || filters.length === 0) {return [];}
|
||||
|
||||
const clauses: string[] = [];
|
||||
|
||||
for (const f of filters) {
|
||||
const col = `"${f.column.replace(/"/g, '""')}"`;
|
||||
const v = f.value;
|
||||
|
||||
if (v.type === "dateRange") {
|
||||
if (v.from) {
|
||||
clauses.push(`${col} >= '${escapeSqlString(v.from)}'`);
|
||||
}
|
||||
if (v.to) {
|
||||
clauses.push(`${col} <= '${escapeSqlString(v.to)}'`);
|
||||
}
|
||||
} else if (v.type === "select" && v.value) {
|
||||
clauses.push(`${col} = '${escapeSqlString(v.value)}'`);
|
||||
} else if (v.type === "multiSelect" && v.values && v.values.length > 0) {
|
||||
const vals = v.values.map((x) => `'${escapeSqlString(x)}'`).join(", ");
|
||||
clauses.push(`${col} IN (${vals})`);
|
||||
} else if (v.type === "number") {
|
||||
if (v.min !== undefined) {
|
||||
clauses.push(`CAST(${col} AS NUMERIC) >= ${Number(v.min)}`);
|
||||
}
|
||||
if (v.max !== undefined) {
|
||||
clauses.push(`CAST(${col} AS NUMERIC) <= ${Number(v.max)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clauses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject filter WHERE clauses into a SQL query.
|
||||
* Strategy: wrap the original query as a CTE and filter on top.
|
||||
*/
|
||||
export function injectFilters(sql: string, filterClauses: string[]): string {
|
||||
if (filterClauses.length === 0) {return sql;}
|
||||
|
||||
const whereClause = filterClauses.join(" AND ");
|
||||
// Wrap original SQL as CTE to avoid parsing complexities
|
||||
return `WITH __report_data AS (${sql.replace(/;$/, "")}) SELECT * FROM __report_data WHERE ${whereClause}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SQL is read-only (no mutation statements).
|
||||
* Returns an error message if unsafe, or null if safe.
|
||||
*/
|
||||
export function checkSqlSafety(sql: string): string | null {
|
||||
const upper = sql.toUpperCase().trim();
|
||||
const forbidden = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "TRUNCATE"];
|
||||
for (const keyword of forbidden) {
|
||||
if (upper.startsWith(keyword)) {
|
||||
return "Only SELECT queries are allowed in reports";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
341
apps/web/lib/report-utils.test.ts
Normal file
341
apps/web/lib/report-utils.test.ts
Normal file
@ -0,0 +1,341 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isReportFile,
|
||||
classifyFileType,
|
||||
reportTitleToSlug,
|
||||
panelColSpan,
|
||||
formatChartValue,
|
||||
formatChartLabel,
|
||||
validateReportConfig,
|
||||
} from "./report-utils";
|
||||
|
||||
// ─── isReportFile ───
|
||||
|
||||
describe("isReportFile", () => {
|
||||
it("returns true for .report.json files", () => {
|
||||
expect(isReportFile("deals-pipeline.report.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for deeply nested report files", () => {
|
||||
expect(isReportFile("analytics.report.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular JSON", () => {
|
||||
expect(isReportFile("config.json")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for similarly named non-report files", () => {
|
||||
expect(isReportFile("report.json")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for markdown", () => {
|
||||
expect(isReportFile("report.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isReportFile("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── classifyFileType ───
|
||||
|
||||
describe("classifyFileType", () => {
|
||||
const mockIsDb = (n: string) => /\.(duckdb|sqlite|sqlite3|db)$/.test(n);
|
||||
|
||||
it("classifies .report.json as report", () => {
|
||||
expect(classifyFileType("test.report.json", mockIsDb)).toBe("report");
|
||||
});
|
||||
|
||||
it("classifies .duckdb as database", () => {
|
||||
expect(classifyFileType("workspace.duckdb", mockIsDb)).toBe("database");
|
||||
});
|
||||
|
||||
it("classifies .sqlite as database", () => {
|
||||
expect(classifyFileType("data.sqlite", mockIsDb)).toBe("database");
|
||||
});
|
||||
|
||||
it("classifies .md as document", () => {
|
||||
expect(classifyFileType("readme.md", mockIsDb)).toBe("document");
|
||||
});
|
||||
|
||||
it("classifies .mdx as document", () => {
|
||||
expect(classifyFileType("page.mdx", mockIsDb)).toBe("document");
|
||||
});
|
||||
|
||||
it("classifies .yaml as file", () => {
|
||||
expect(classifyFileType("config.yaml", mockIsDb)).toBe("file");
|
||||
});
|
||||
|
||||
it("classifies .txt as file", () => {
|
||||
expect(classifyFileType("notes.txt", mockIsDb)).toBe("file");
|
||||
});
|
||||
|
||||
it("report takes priority over other extensions", () => {
|
||||
// .report.json should be "report", not "file"
|
||||
expect(classifyFileType("x.report.json", mockIsDb)).toBe("report");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reportTitleToSlug ───
|
||||
|
||||
describe("reportTitleToSlug", () => {
|
||||
it("converts simple title to slug", () => {
|
||||
expect(reportTitleToSlug("Deals Pipeline")).toBe("deals-pipeline");
|
||||
});
|
||||
|
||||
it("removes special characters", () => {
|
||||
expect(reportTitleToSlug("Q1 2025 Revenue (Draft)")).toBe("q1-2025-revenue-draft");
|
||||
});
|
||||
|
||||
it("trims leading/trailing hyphens", () => {
|
||||
expect(reportTitleToSlug(" Hello World! ")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("truncates to 40 characters", () => {
|
||||
const long = "A".repeat(100);
|
||||
expect(reportTitleToSlug(long).length).toBeLessThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(reportTitleToSlug("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles unicode/emoji gracefully", () => {
|
||||
const result = reportTitleToSlug("Sales Overview 📊");
|
||||
expect(result).toBe("sales-overview");
|
||||
expect(result).not.toContain("📊");
|
||||
});
|
||||
|
||||
it("collapses multiple dashes", () => {
|
||||
expect(reportTitleToSlug("a --- b")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── panelColSpan ───
|
||||
|
||||
describe("panelColSpan", () => {
|
||||
it("returns col-span-6 for full", () => {
|
||||
expect(panelColSpan("full")).toBe("col-span-6");
|
||||
});
|
||||
|
||||
it("returns col-span-3 for half", () => {
|
||||
expect(panelColSpan("half")).toBe("col-span-3");
|
||||
});
|
||||
|
||||
it("returns col-span-2 for third", () => {
|
||||
expect(panelColSpan("third")).toBe("col-span-2");
|
||||
});
|
||||
|
||||
it("returns col-span-3 for undefined (default)", () => {
|
||||
expect(panelColSpan(undefined)).toBe("col-span-3");
|
||||
});
|
||||
|
||||
it("returns col-span-3 for unknown size", () => {
|
||||
expect(panelColSpan("quarter")).toBe("col-span-3");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatChartValue ───
|
||||
|
||||
describe("formatChartValue", () => {
|
||||
it("returns empty string for null", () => {
|
||||
expect(formatChartValue(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatChartValue(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("formats millions", () => {
|
||||
expect(formatChartValue(1_500_000)).toBe("1.5M");
|
||||
});
|
||||
|
||||
it("formats thousands", () => {
|
||||
expect(formatChartValue(1_500)).toBe("1.5K");
|
||||
});
|
||||
|
||||
it("formats negative millions", () => {
|
||||
expect(formatChartValue(-2_500_000)).toBe("-2.5M");
|
||||
});
|
||||
|
||||
it("formats negative thousands", () => {
|
||||
expect(formatChartValue(-2_500)).toBe("-2.5K");
|
||||
});
|
||||
|
||||
it("formats integers below 1000 as-is", () => {
|
||||
expect(formatChartValue(42)).toBe("42");
|
||||
});
|
||||
|
||||
it("formats floats to 2 decimal places", () => {
|
||||
expect(formatChartValue(3.14159)).toBe("3.14");
|
||||
});
|
||||
|
||||
it("formats zero as integer", () => {
|
||||
expect(formatChartValue(0)).toBe("0");
|
||||
});
|
||||
|
||||
it("formats strings as-is", () => {
|
||||
expect(formatChartValue("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
it("formats boolean as string", () => {
|
||||
expect(formatChartValue(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("formats exactly 1000", () => {
|
||||
expect(formatChartValue(1000)).toBe("1.0K");
|
||||
});
|
||||
|
||||
it("formats exactly 1000000", () => {
|
||||
expect(formatChartValue(1000000)).toBe("1.0M");
|
||||
});
|
||||
|
||||
it("formats 999 as integer", () => {
|
||||
expect(formatChartValue(999)).toBe("999");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatChartLabel ───
|
||||
|
||||
describe("formatChartLabel", () => {
|
||||
it("returns empty string for null", () => {
|
||||
expect(formatChartLabel(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatChartLabel(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns short strings unchanged", () => {
|
||||
expect(formatChartLabel("Active")).toBe("Active");
|
||||
});
|
||||
|
||||
it("truncates long strings", () => {
|
||||
const long = "A".repeat(25);
|
||||
expect(formatChartLabel(long)).toBe("A".repeat(18) + "...");
|
||||
});
|
||||
|
||||
it("shortens ISO date strings", () => {
|
||||
expect(formatChartLabel("2025-06-15T10:30:00Z")).toBe("2025-06-15");
|
||||
});
|
||||
|
||||
it("shortens full datetime strings", () => {
|
||||
expect(formatChartLabel("2025-06-15 10:30:00.000")).toBe("2025-06-15");
|
||||
});
|
||||
|
||||
it("does not shorten non-date long strings", () => {
|
||||
const notDate = "This is definitely not a date string at all";
|
||||
expect(formatChartLabel(notDate)).toBe("This is definitely..." );
|
||||
});
|
||||
|
||||
it("handles numbers by converting to string", () => {
|
||||
expect(formatChartLabel(42)).toBe("42");
|
||||
});
|
||||
|
||||
it("handles exactly 20-char string (no truncation)", () => {
|
||||
expect(formatChartLabel("12345678901234567890")).toBe("12345678901234567890");
|
||||
});
|
||||
|
||||
it("truncates 21-char string", () => {
|
||||
expect(formatChartLabel("123456789012345678901")).toBe("123456789012345678...");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateReportConfig ───
|
||||
|
||||
describe("validateReportConfig", () => {
|
||||
const validConfig = {
|
||||
version: 1,
|
||||
title: "Test",
|
||||
panels: [
|
||||
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: { xAxis: "x" } },
|
||||
],
|
||||
};
|
||||
|
||||
it("returns null for valid config", () => {
|
||||
expect(validateReportConfig(validConfig)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for valid config with filters", () => {
|
||||
expect(validateReportConfig({
|
||||
...validConfig,
|
||||
filters: [{ id: "f1", type: "dateRange", label: "Date", column: "created_at" }],
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects null config", () => {
|
||||
expect(validateReportConfig(null)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-object config", () => {
|
||||
expect(validateReportConfig("string")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing title", () => {
|
||||
expect(validateReportConfig({ panels: [] })).toContain("title");
|
||||
});
|
||||
|
||||
it("rejects empty title", () => {
|
||||
expect(validateReportConfig({ title: "", panels: [] })).toContain("title");
|
||||
});
|
||||
|
||||
it("rejects missing panels", () => {
|
||||
expect(validateReportConfig({ title: "Test" })).toContain("panels");
|
||||
});
|
||||
|
||||
it("rejects non-array panels", () => {
|
||||
expect(validateReportConfig({ title: "Test", panels: "not-array" })).toContain("panels");
|
||||
});
|
||||
|
||||
it("accepts empty panels array", () => {
|
||||
expect(validateReportConfig({ title: "Test", panels: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects panel without id", () => {
|
||||
const config = { title: "Test", panels: [{ title: "P", type: "bar", sql: "SELECT 1", mapping: {} }] };
|
||||
expect(validateReportConfig(config)).toContain("Panel 0");
|
||||
expect(validateReportConfig(config)).toContain("id");
|
||||
});
|
||||
|
||||
it("rejects panel without title", () => {
|
||||
const config = { title: "Test", panels: [{ id: "p", type: "bar", sql: "SELECT 1", mapping: {} }] };
|
||||
expect(validateReportConfig(config)).toContain("title");
|
||||
});
|
||||
|
||||
it("rejects panel without type", () => {
|
||||
const config = { title: "Test", panels: [{ id: "p", title: "P", sql: "SELECT 1", mapping: {} }] };
|
||||
expect(validateReportConfig(config)).toContain("type");
|
||||
});
|
||||
|
||||
it("rejects panel without sql", () => {
|
||||
const config = { title: "Test", panels: [{ id: "p", title: "P", type: "bar", mapping: {} }] };
|
||||
expect(validateReportConfig(config)).toContain("sql");
|
||||
});
|
||||
|
||||
it("rejects panel without mapping", () => {
|
||||
const config = { title: "Test", panels: [{ id: "p", title: "P", type: "bar", sql: "SELECT 1" }] };
|
||||
expect(validateReportConfig(config)).toContain("mapping");
|
||||
});
|
||||
|
||||
it("validates multiple panels", () => {
|
||||
const config = {
|
||||
title: "Test",
|
||||
panels: [
|
||||
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} },
|
||||
{ id: "p2", title: "P2", type: "pie", sql: "SELECT 2", mapping: {} },
|
||||
],
|
||||
};
|
||||
expect(validateReportConfig(config)).toBeNull();
|
||||
});
|
||||
|
||||
it("reports correct panel index on validation error", () => {
|
||||
const config = {
|
||||
title: "Test",
|
||||
panels: [
|
||||
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} },
|
||||
{ id: "p2", type: "pie", sql: "SELECT 2", mapping: {} }, // missing title
|
||||
],
|
||||
};
|
||||
expect(validateReportConfig(config)).toContain("Panel 1");
|
||||
});
|
||||
});
|
||||
95
apps/web/lib/report-utils.ts
Normal file
95
apps/web/lib/report-utils.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Utility functions for report identification and helpers.
|
||||
* Extracted for testability.
|
||||
*/
|
||||
|
||||
/** Check if a filename is a report file (.report.json). */
|
||||
export function isReportFile(filename: string): boolean {
|
||||
return filename.endsWith(".report.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a file's type for the tree display.
|
||||
* Returns "report", "database", "document", or "file".
|
||||
*/
|
||||
export function classifyFileType(
|
||||
name: string,
|
||||
isDatabaseFile: (n: string) => boolean,
|
||||
): "report" | "database" | "document" | "file" {
|
||||
if (isReportFile(name)) {return "report";}
|
||||
if (isDatabaseFile(name)) {return "database";}
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
if (ext === "md" || ext === "mdx") {return "document";}
|
||||
return "file";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a report title for use as a filename.
|
||||
*/
|
||||
export function reportTitleToSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the CSS grid column span class for a panel size.
|
||||
*/
|
||||
export function panelColSpan(size?: string): string {
|
||||
switch (size) {
|
||||
case "full":
|
||||
return "col-span-6";
|
||||
case "third":
|
||||
return "col-span-2";
|
||||
case "half":
|
||||
default:
|
||||
return "col-span-3";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a numeric value for chart display.
|
||||
*/
|
||||
export function formatChartValue(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
if (typeof val === "number") {
|
||||
if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
|
||||
if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(2);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a label for chart display (truncates long strings, shortens dates).
|
||||
*/
|
||||
export function formatChartLabel(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
const str = String(val);
|
||||
if (str.length > 16 && !isNaN(Date.parse(str))) {
|
||||
return str.slice(0, 10);
|
||||
}
|
||||
if (str.length > 20) {return str.slice(0, 18) + "...";}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a report config structure. Returns error message or null if valid.
|
||||
*/
|
||||
export function validateReportConfig(config: unknown): string | null {
|
||||
if (!config || typeof config !== "object") {return "Config must be an object";}
|
||||
const c = config as Record<string, unknown>;
|
||||
if (typeof c.title !== "string" || !c.title) {return "Missing title";}
|
||||
if (!Array.isArray(c.panels)) {return "panels must be an array";}
|
||||
for (let i = 0; i < c.panels.length; i++) {
|
||||
const p = c.panels[i] as Record<string, unknown>;
|
||||
if (!p.id || typeof p.id !== "string") {return `Panel ${i}: missing id`;}
|
||||
if (!p.title || typeof p.title !== "string") {return `Panel ${i}: missing title`;}
|
||||
if (!p.type || typeof p.type !== "string") {return `Panel ${i}: missing type`;}
|
||||
if (!p.sql || typeof p.sql !== "string") {return `Panel ${i}: missing sql`;}
|
||||
if (!p.mapping || typeof p.mapping !== "object") {return `Panel ${i}: missing mapping`;}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
1966
apps/web/package-lock.json
generated
1966
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3100",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3100"
|
||||
"start": "next start --port 3100",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.75",
|
||||
@ -14,6 +16,7 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -22,6 +25,7 @@
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
14
apps/web/vitest.config.ts
Normal file
14
apps/web/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["lib/**/*.test.ts"],
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
@ -613,6 +613,170 @@ You MUST complete ALL steps below after ANY schema mutation (create/update/delet
|
||||
|
||||
These steps ensure the filesystem always mirrors DuckDB. The sidebar depends on `.object.yaml` files — if they are missing, objects will not appear.
|
||||
|
||||
## Report Generation (Analytics / Charts)
|
||||
|
||||
Reports are JSON config files (`.report.json`) that the web app renders as live interactive dashboards using Recharts. The agent creates these files to give the user visual analytics over their CRM data.
|
||||
|
||||
### Report file format
|
||||
|
||||
Store reports as `.report.json` files in `dench/reports/` (create the directory if needed). The JSON schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Report Title",
|
||||
"description": "Brief description of what this report shows",
|
||||
"panels": [
|
||||
{
|
||||
"id": "unique-panel-id",
|
||||
"title": "Panel Title",
|
||||
"type": "bar",
|
||||
"sql": "SELECT ... FROM v_{object} ...",
|
||||
"mapping": { "xAxis": "column_name", "yAxis": ["value_column"] },
|
||||
"size": "half"
|
||||
}
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"id": "filter-id",
|
||||
"type": "dateRange",
|
||||
"label": "Date Range",
|
||||
"column": "created_at"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Chart types
|
||||
|
||||
| Type | Best for | Required mapping |
|
||||
| --------- | ---------------------------- | ------------------------------- |
|
||||
| `bar` | Comparing categories | `xAxis`, `yAxis` |
|
||||
| `line` | Trends over time | `xAxis`, `yAxis` |
|
||||
| `area` | Volume trends | `xAxis`, `yAxis` |
|
||||
| `pie` | Distribution/share | `nameKey`, `valueKey` |
|
||||
| `donut` | Distribution (with center) | `nameKey`, `valueKey` |
|
||||
| `radar` | Multi-dimensional comparison | `xAxis` (or `nameKey`), `yAxis` |
|
||||
| `scatter` | Correlation | `xAxis`, `yAxis` |
|
||||
| `funnel` | Pipeline/conversion | `nameKey`, `valueKey` |
|
||||
|
||||
### Panel sizes
|
||||
|
||||
- `"full"` — spans full width (6 columns)
|
||||
- `"half"` — spans half width (3 columns) — **default**
|
||||
- `"third"` — spans one third (2 columns)
|
||||
|
||||
### Filter types
|
||||
|
||||
- `dateRange` — date picker (from/to), filters on `column`
|
||||
- `select` — single-select dropdown, needs `sql` to fetch options
|
||||
- `multiSelect` — multi-select chips, needs `sql` to fetch options
|
||||
- `number` — min/max numeric range
|
||||
|
||||
### SQL query rules for reports
|
||||
|
||||
- Always use the auto-generated `v_{object}` PIVOT views — never raw EAV queries
|
||||
- SQL must be SELECT-only (no INSERT/UPDATE/DELETE)
|
||||
- Cast numeric fields: `"Amount"::NUMERIC` or `CAST("Amount" AS NUMERIC)`
|
||||
- Use `DATE_TRUNC('month', created_at)` for time-series grouping
|
||||
- Always include `ORDER BY` for consistent chart rendering
|
||||
- Use aggregate functions: `COUNT(*)`, `SUM(...)`, `AVG(...)`, `MIN(...)`, `MAX(...)`
|
||||
|
||||
### Example reports
|
||||
|
||||
**Pipeline Funnel:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Deal Pipeline",
|
||||
"description": "Deal count and value by stage",
|
||||
"panels": [
|
||||
{
|
||||
"id": "deals-by-stage",
|
||||
"title": "Deals by Stage",
|
||||
"type": "funnel",
|
||||
"sql": "SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\" ORDER BY count DESC",
|
||||
"mapping": { "nameKey": "Stage", "valueKey": "count" },
|
||||
"size": "half"
|
||||
},
|
||||
{
|
||||
"id": "revenue-by-stage",
|
||||
"title": "Revenue by Stage",
|
||||
"type": "bar",
|
||||
"sql": "SELECT \"Stage\", SUM(\"Amount\"::NUMERIC) as total FROM v_deal GROUP BY \"Stage\" ORDER BY total DESC",
|
||||
"mapping": { "xAxis": "Stage", "yAxis": ["total"] },
|
||||
"size": "half"
|
||||
}
|
||||
],
|
||||
"filters": [
|
||||
{ "id": "date", "type": "dateRange", "label": "Created", "column": "created_at" },
|
||||
{
|
||||
"id": "assignee",
|
||||
"type": "select",
|
||||
"label": "Assigned To",
|
||||
"sql": "SELECT DISTINCT \"Assigned To\" as value FROM v_deal WHERE \"Assigned To\" IS NOT NULL",
|
||||
"column": "Assigned To"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Contact Growth:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Contact Growth",
|
||||
"description": "New contacts over time",
|
||||
"panels": [
|
||||
{
|
||||
"id": "growth-trend",
|
||||
"title": "Contacts Over Time",
|
||||
"type": "area",
|
||||
"sql": "SELECT DATE_TRUNC('month', created_at) as month, COUNT(*) as count FROM v_people GROUP BY month ORDER BY month",
|
||||
"mapping": { "xAxis": "month", "yAxis": ["count"] },
|
||||
"size": "full"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Inline chat reports
|
||||
|
||||
When a user asks for analytics in chat (without explicitly asking to save a report), emit the report JSON inside a fenced code block with language `report-json`. The web UI will render interactive charts inline:
|
||||
|
||||
````
|
||||
Here's your pipeline analysis:
|
||||
|
||||
```report-json
|
||||
{"version":1,"title":"Deals by Stage","panels":[{"id":"p1","title":"Deal Count","type":"bar","sql":"SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\" ORDER BY count DESC","mapping":{"xAxis":"Stage","yAxis":["count"]},"size":"full"}]}
|
||||
```
|
||||
|
||||
Most deals are currently in the Discovery stage.
|
||||
````
|
||||
|
||||
The user can then "Pin" the inline report to save it as a `.report.json` file.
|
||||
|
||||
### Post-report checklist
|
||||
|
||||
After creating a `.report.json` file:
|
||||
|
||||
- [ ] Verify the report JSON is valid and all SQL queries work: test each panel's SQL individually
|
||||
- [ ] Ensure `dench/reports/` directory exists
|
||||
- [ ] Write the file: `dench/reports/{slug}.report.json`
|
||||
- [ ] Tell the user they can view it in the workspace sidebar under "Reports"
|
||||
|
||||
### Choosing the right chart type
|
||||
|
||||
- **Comparing categories** (status breakdown, source distribution): `bar` or `pie`
|
||||
- **Time series** (growth, trends, revenue over time): `line` or `area`
|
||||
- **Pipeline/conversion** (deal stages, lead funnel): `funnel`
|
||||
- **Distribution/proportion** (market share, segment split): `pie` or `donut`
|
||||
- **Multi-metric comparison** (performance scores): `radar`
|
||||
- **Correlation** (price vs. size, score vs. revenue): `scatter`
|
||||
- When in doubt, `bar` is the safest default
|
||||
|
||||
## Critical Reminders
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
|
||||
@ -622,6 +786,8 @@ These steps ensure the filesystem always mirrors DuckDB. The sidebar depends on
|
||||
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
|
||||
- Never assume field names — verify with `SELECT * FROM fields WHERE object_id = ?`
|
||||
- Extract ALL data from user messages — don't leave information unused
|
||||
- **REPORTS vs DOCUMENTS**: When the user asks for "reports", "analytics", "charts", "graphs", "metrics", "insights", or "breakdown" — use `.report.json` format (see Report Generation section above), NOT markdown. Only use markdown `.md` for SOPs, guides, notes, and prose documents. Reports render as interactive Recharts dashboards; markdown does not.
|
||||
- **INLINE CHART ARTIFACTS**: When answering analytics questions in chat, ALWAYS emit a `report-json` fenced code block so the UI renders interactive charts inline. Do NOT describe data in plain text when you can show it as a chart.
|
||||
- **NOTES**: Always use type "richtext" for Notes fields
|
||||
- **USER FIELDS**: Resolve member name to ID from `workspace_context.yaml` BEFORE inserting
|
||||
- **ENUM FIELDS**: Use type "enum" with `enum_values` JSON array
|
||||
|
||||
@ -11,6 +11,10 @@ vi.mock("chokidar", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./bundled-dir.js", () => ({
|
||||
resolveBundledSkillsDir: () => "/mock/package/root/skills",
|
||||
}));
|
||||
|
||||
describe("ensureSkillsWatcher", () => {
|
||||
it("ignores node_modules, dist, and .git by default", async () => {
|
||||
const mod = await import("./refresh.js");
|
||||
@ -28,4 +32,18 @@ describe("ensureSkillsWatcher", () => {
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("includes bundled skills dir in watch paths", async () => {
|
||||
watchMock.mockClear();
|
||||
const mod = await import("./refresh.js");
|
||||
// Force a fresh watcher by using a different workspace dir
|
||||
mod.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace-bundled-test" });
|
||||
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
const watchedPaths = watchMock.mock.calls[0]?.[0] as string[];
|
||||
|
||||
// Should include workspace skills, managed skills, and bundled skills
|
||||
expect(watchedPaths).toContain("/tmp/workspace-bundled-test/skills");
|
||||
expect(watchedPaths).toContain("/mock/package/root/skills");
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||
import { resolveBundledSkillsDir } from "./bundled-dir.js";
|
||||
import { resolvePluginSkillDirs } from "./plugin-skills.js";
|
||||
|
||||
type SkillsChangeEvent = {
|
||||
@ -52,6 +53,12 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin
|
||||
paths.push(path.join(workspaceDir, "skills"));
|
||||
}
|
||||
paths.push(path.join(CONFIG_DIR, "skills"));
|
||||
// Also watch the bundled skills directory so changes to repo-level skills
|
||||
// (e.g. skills/dench/SKILL.md) trigger snapshot refreshes without a restart.
|
||||
const bundledDir = resolveBundledSkillsDir();
|
||||
if (bundledDir) {
|
||||
paths.push(bundledDir);
|
||||
}
|
||||
const extraDirsRaw = config?.skills?.load?.extraDirs ?? [];
|
||||
const extraDirs = extraDirsRaw
|
||||
.map((d) => (typeof d === "string" ? d.trim() : ""))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user