merge: resolve conflict in issue-detail breadcrumb
Keep identifier removed from breadcrumbs per design decision. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
3a8aec7d08
34 changed files with 1606 additions and 1191 deletions
|
|
@ -65,6 +65,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1130,21 +1131,15 @@ export default function AgentsPage() {
|
|||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [runtimes, setRuntimes] = useState<RuntimeDevice[]>([]);
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_agents_layout",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
setRuntimes([]);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.listRuntimes({ workspace_id: workspace.id })
|
||||
.then(setRuntimes)
|
||||
.catch(() => setRuntimes([]));
|
||||
}, [workspace]);
|
||||
if (workspace) fetchRuntimes();
|
||||
}, [workspace, fetchRuntimes]);
|
||||
|
||||
// Select first agent on initial load
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ vi.mock("@/shared/api", () => ({
|
|||
const mockIssue: Issue = {
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "TES-1",
|
||||
title: "Implement authentication",
|
||||
description: "Add JWT auth to the backend",
|
||||
status: "in_progress",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ const mockIssues: Issue[] = [
|
|||
...issueDefaults,
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "TES-1",
|
||||
title: "Implement auth",
|
||||
description: "Add JWT authentication",
|
||||
status: "todo",
|
||||
|
|
@ -227,6 +229,8 @@ const mockIssues: Issue[] = [
|
|||
...issueDefaults,
|
||||
id: "issue-2",
|
||||
workspace_id: "ws-1",
|
||||
number: 2,
|
||||
identifier: "TES-2",
|
||||
title: "Design landing page",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
|
|
@ -243,6 +247,8 @@ const mockIssues: Issue[] = [
|
|||
...issueDefaults,
|
||||
id: "issue-3",
|
||||
workspace_id: "ws-1",
|
||||
number: 3,
|
||||
identifier: "TES-3",
|
||||
title: "Write tests",
|
||||
description: null,
|
||||
status: "backlog",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function ListRow({ issue }: { issue: Issue }) {
|
|||
>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
{issue.id.slice(0, 8)}
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
{issue.due_date && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { useMemo } from "react";
|
||||
import type { RuntimeUsage } from "@/shared/types";
|
||||
import { formatTokens } from "../../utils";
|
||||
|
||||
const HEATMAP_WEEKS = 13;
|
||||
const CELL_SIZE = 11;
|
||||
const CELL_GAP = 2;
|
||||
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
|
||||
|
||||
function getHeatmapColor(level: number): string {
|
||||
const colors = [
|
||||
"var(--color-muted, hsl(var(--muted)))",
|
||||
"hsl(var(--chart-3) / 0.3)",
|
||||
"hsl(var(--chart-3) / 0.5)",
|
||||
"hsl(var(--chart-3) / 0.75)",
|
||||
"hsl(var(--chart-3) / 1)",
|
||||
];
|
||||
return colors[level] ?? colors[0]!;
|
||||
}
|
||||
|
||||
export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) {
|
||||
const { cells, monthLabels } = useMemo(() => {
|
||||
const dateTokens = new Map<string, number>();
|
||||
for (const u of usage) {
|
||||
const total =
|
||||
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||
dateTokens.set(u.date, (dateTokens.get(u.date) ?? 0) + total);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const todayDay = today.getDay();
|
||||
const startOffset = todayDay + (HEATMAP_WEEKS - 1) * 7;
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - startOffset);
|
||||
|
||||
const allCells: {
|
||||
date: string;
|
||||
dayOfWeek: number;
|
||||
week: number;
|
||||
tokens: number;
|
||||
}[] = [];
|
||||
const d = new Date(startDate);
|
||||
for (let i = 0; i <= startOffset; i++) {
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
const dayOfWeek = d.getDay();
|
||||
const week = Math.floor(i / 7);
|
||||
allCells.push({
|
||||
date: dateStr,
|
||||
dayOfWeek,
|
||||
week,
|
||||
tokens: dateTokens.get(dateStr) ?? 0,
|
||||
});
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
const nonZero = allCells
|
||||
.filter((c) => c.tokens > 0)
|
||||
.map((c) => c.tokens);
|
||||
nonZero.sort((a, b) => a - b);
|
||||
const getLevel = (tokens: number) => {
|
||||
if (tokens === 0) return 0;
|
||||
if (nonZero.length <= 1) return 4;
|
||||
const p = nonZero.indexOf(tokens) / (nonZero.length - 1);
|
||||
if (p <= 0.25) return 1;
|
||||
if (p <= 0.5) return 2;
|
||||
if (p <= 0.75) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const cellsWithLevel = allCells.map((c) => ({
|
||||
...c,
|
||||
level: getLevel(c.tokens),
|
||||
}));
|
||||
|
||||
const months: { label: string; week: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
for (const c of cellsWithLevel) {
|
||||
const month = new Date(c.date + "T00:00:00").getMonth();
|
||||
if (month !== lastMonth && c.dayOfWeek === 0) {
|
||||
months.push({
|
||||
label: new Date(c.date + "T00:00:00").toLocaleString("en", {
|
||||
month: "short",
|
||||
}),
|
||||
week: c.week,
|
||||
});
|
||||
lastMonth = month;
|
||||
}
|
||||
}
|
||||
|
||||
return { cells: cellsWithLevel, monthLabels: months };
|
||||
}, [usage]);
|
||||
|
||||
const labelWidth = 28;
|
||||
const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP);
|
||||
const svgHeight = 14 + 7 * (CELL_SIZE + CELL_GAP);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Activity</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={svgWidth} height={svgHeight} className="block">
|
||||
{monthLabels.map((m) => (
|
||||
<text
|
||||
key={`${m.label}-${m.week}`}
|
||||
x={labelWidth + m.week * (CELL_SIZE + CELL_GAP)}
|
||||
y={10}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{m.label}
|
||||
</text>
|
||||
))}
|
||||
{DAY_LABELS.map((label, i) =>
|
||||
label ? (
|
||||
<text
|
||||
key={i}
|
||||
x={0}
|
||||
y={14 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
{cells.map((c) => (
|
||||
<rect
|
||||
key={c.date}
|
||||
x={labelWidth + c.week * (CELL_SIZE + CELL_GAP)}
|
||||
y={14 + c.dayOfWeek * (CELL_SIZE + CELL_GAP)}
|
||||
width={CELL_SIZE}
|
||||
height={CELL_SIZE}
|
||||
rx={2}
|
||||
fill={getHeatmapColor(c.level)}
|
||||
className="transition-colors"
|
||||
>
|
||||
<title>
|
||||
{c.date}:{" "}
|
||||
{c.tokens > 0
|
||||
? formatTokens(c.tokens) + " tokens"
|
||||
: "No activity"}
|
||||
</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex items-center justify-end gap-1 text-[10px] text-muted-foreground">
|
||||
<span>Less</span>
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className="h-[10px] w-[10px] rounded-[2px]"
|
||||
style={{ backgroundColor: getHeatmapColor(level) }}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DailyCostData } from "../../utils";
|
||||
|
||||
const costChartConfig = {
|
||||
cost: { label: "Cost", color: "hsl(var(--chart-1))" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function DailyCostChart({ data }: { data: DailyCostData[] }) {
|
||||
if (data.every((d) => d.cost === 0)) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Estimated Cost</h4>
|
||||
<ChartContainer config={costChartConfig} className="aspect-[2.5/1] w-full">
|
||||
<BarChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(v: number) => `$${v}`}
|
||||
width={50}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? `$${value.toFixed(2)}` : String(value)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="var(--color-cost)" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DailyTokenData } from "../../utils";
|
||||
import { formatTokens } from "../../utils";
|
||||
|
||||
const tokenChartConfig = {
|
||||
input: { label: "Input", color: "hsl(var(--chart-1))" },
|
||||
output: { label: "Output", color: "hsl(var(--chart-2))" },
|
||||
cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" },
|
||||
cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function DailyTokenChart({ data }: { data: DailyTokenData[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Token Usage</h4>
|
||||
<ChartContainer config={tokenChartConfig} className="aspect-[2.5/1] w-full">
|
||||
<AreaChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(v: number) => formatTokens(v)}
|
||||
width={50}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatTokens(value) : String(value)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="input"
|
||||
stackId="1"
|
||||
stroke="var(--color-input)"
|
||||
fill="var(--color-input)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="output"
|
||||
stackId="1"
|
||||
stroke="var(--color-output)"
|
||||
fill="var(--color-output)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cacheRead"
|
||||
stackId="1"
|
||||
stroke="var(--color-cacheRead)"
|
||||
fill="var(--color-cacheRead)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cacheWrite"
|
||||
stackId="1"
|
||||
stroke="var(--color-cacheWrite)"
|
||||
fill="var(--color-cacheWrite)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { api } from "@/shared/api";
|
||||
import type { RuntimeHourlyActivity } from "@/shared/types";
|
||||
|
||||
const hourlyChartConfig = {
|
||||
count: { label: "Tasks", color: "hsl(var(--chart-2))" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function HourlyActivityChart({ runtimeId }: { runtimeId: string }) {
|
||||
const [data, setData] = useState<RuntimeHourlyActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getRuntimeTaskActivity(runtimeId)
|
||||
.then(setData)
|
||||
.catch(() => setData([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [runtimeId]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const map = new Map(data.map((d) => [d.hour, d.count]));
|
||||
return Array.from({ length: 24 }, (_, i) => ({
|
||||
hour: i,
|
||||
label: `${i.toString().padStart(2, "0")}:00`,
|
||||
count: map.get(i) ?? 0,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const hasData = chartData.some((d) => d.count > 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Hourly Distribution</h4>
|
||||
{loading ? (
|
||||
<div className="flex h-[140px] items-center justify-center text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<div className="flex h-[140px] flex-col items-center justify-center">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">No task data yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={hourlyChartConfig} className="aspect-[2.5/1] w-full">
|
||||
<BarChart data={chartData} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval={2}
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/web/features/runtimes/components/charts/index.ts
Normal file
5
apps/web/features/runtimes/components/charts/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { DailyTokenChart } from "./daily-token-chart";
|
||||
export { DailyCostChart } from "./daily-cost-chart";
|
||||
export { ModelDistributionChart } from "./model-distribution-chart";
|
||||
export { ActivityHeatmap } from "./activity-heatmap";
|
||||
export { HourlyActivityChart } from "./hourly-activity-chart";
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { PieChart, Pie, Cell, Label } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ModelDistribution } from "../../utils";
|
||||
import { formatTokens } from "../../utils";
|
||||
|
||||
const MODEL_COLORS = [
|
||||
"hsl(var(--chart-1))",
|
||||
"hsl(var(--chart-2))",
|
||||
"hsl(var(--chart-3))",
|
||||
"hsl(var(--chart-4))",
|
||||
"hsl(var(--chart-5))",
|
||||
];
|
||||
|
||||
export function ModelDistributionChart({ data }: { data: ModelDistribution[] }) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const totalTokens = data.reduce((sum, d) => sum + d.tokens, 0);
|
||||
const chartConfig = Object.fromEntries(
|
||||
data.map((d, i) => [
|
||||
d.model,
|
||||
{ label: d.model, color: MODEL_COLORS[i % MODEL_COLORS.length] },
|
||||
]),
|
||||
) satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Token Usage by Model</h4>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[200px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatTokens(value) : String(value)
|
||||
}
|
||||
nameKey="model"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="tokens"
|
||||
nameKey="model"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((entry, i) => (
|
||||
<Cell
|
||||
key={entry.model}
|
||||
fill={MODEL_COLORS[i % MODEL_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-lg font-bold">
|
||||
{formatTokens(totalTokens)}
|
||||
</tspan>
|
||||
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 18} className="fill-muted-foreground text-xs">
|
||||
tokens
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
{/* Model legend with cost */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{data.map((d, i) => (
|
||||
<div key={d.model} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: MODEL_COLORS[i % MODEL_COLORS.length] }}
|
||||
/>
|
||||
<span className="truncate font-mono">{d.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-muted-foreground tabular-nums">
|
||||
<span>{formatTokens(d.tokens)}</span>
|
||||
{d.cost > 0 && <span>${d.cost.toFixed(2)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/web/features/runtimes/components/ping-section.tsx
Normal file
120
apps/web/features/runtimes/components/ping-section.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/shared/api";
|
||||
import type { RuntimePingStatus } from "@/shared/types";
|
||||
|
||||
const pingStatusConfig: Record<
|
||||
RuntimePingStatus,
|
||||
{ label: string; icon: typeof Loader2; color: string }
|
||||
> = {
|
||||
pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" },
|
||||
running: { label: "Running test...", icon: Loader2, color: "text-info" },
|
||||
completed: { label: "Connected", icon: CheckCircle2, color: "text-success" },
|
||||
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
|
||||
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
|
||||
};
|
||||
|
||||
export function PingSection({ runtimeId }: { runtimeId: string }) {
|
||||
const [status, setStatus] = useState<RuntimePingStatus | null>(null);
|
||||
const [output, setOutput] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [durationMs, setDurationMs] = useState<number | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const handleTest = async () => {
|
||||
cleanup();
|
||||
setTesting(true);
|
||||
setStatus("pending");
|
||||
setOutput("");
|
||||
setError("");
|
||||
setDurationMs(null);
|
||||
|
||||
try {
|
||||
const ping = await api.pingRuntime(runtimeId);
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await api.getPingResult(runtimeId, ping.id);
|
||||
setStatus(result.status as RuntimePingStatus);
|
||||
|
||||
if (result.status === "completed") {
|
||||
setOutput(result.output ?? "");
|
||||
setDurationMs(result.duration_ms ?? null);
|
||||
setTesting(false);
|
||||
cleanup();
|
||||
} else if (result.status === "failed" || result.status === "timeout") {
|
||||
setError(result.error ?? "Unknown error");
|
||||
setDurationMs(result.duration_ms ?? null);
|
||||
setTesting(false);
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
setStatus("failed");
|
||||
setError("Failed to initiate test");
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const config = status ? pingStatusConfig[status] : null;
|
||||
const Icon = config?.icon;
|
||||
const isActive = status === "pending" || status === "running";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-3 w-3" />
|
||||
)}
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
|
||||
{config && Icon && (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${config.color}`}>
|
||||
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
|
||||
{config.label}
|
||||
{durationMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
({(durationMs / 1000).toFixed(1)}s)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "completed" && output && (
|
||||
<div className="rounded-lg border bg-success/5 px-3 py-2">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "failed" || status === "timeout") && error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/web/features/runtimes/components/runtime-detail.tsx
Normal file
90
apps/web/features/runtimes/components/runtime-detail.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { AgentRuntime } from "@/shared/types";
|
||||
import { formatLastSeen } from "../utils";
|
||||
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
|
||||
import { PingSection } from "./ping-section";
|
||||
import { UsageSection } from "./usage-section";
|
||||
|
||||
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
|
||||
runtime.status === "online" ? "bg-success/10" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<RuntimeModeIcon mode={runtime.runtime_mode} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">{runtime.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={runtime.status} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoField label="Runtime Mode" value={runtime.runtime_mode} />
|
||||
<InfoField label="Provider" value={runtime.provider} />
|
||||
<InfoField label="Status" value={runtime.status} />
|
||||
<InfoField
|
||||
label="Last Seen"
|
||||
value={formatLastSeen(runtime.last_seen_at)}
|
||||
/>
|
||||
{runtime.device_info && (
|
||||
<InfoField label="Device" value={runtime.device_info} />
|
||||
)}
|
||||
{runtime.daemon_id && (
|
||||
<InfoField label="Daemon ID" value={runtime.daemon_id} mono />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection Test */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-3">
|
||||
Connection Test
|
||||
</h3>
|
||||
<PingSection runtimeId={runtime.id} />
|
||||
</div>
|
||||
|
||||
{/* Usage */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-3">
|
||||
Token Usage
|
||||
</h3>
|
||||
<UsageSection runtimeId={runtime.id} />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{runtime.metadata && Object.keys(runtime.metadata).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Metadata
|
||||
</h3>
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(runtime.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="grid grid-cols-2 gap-4 border-t pt-4">
|
||||
<InfoField
|
||||
label="Created"
|
||||
value={new Date(runtime.created_at).toLocaleString()}
|
||||
/>
|
||||
<InfoField
|
||||
label="Updated"
|
||||
value={new Date(runtime.updated_at).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/features/runtimes/components/runtime-list.tsx
Normal file
89
apps/web/features/runtimes/components/runtime-list.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Server } from "lucide-react";
|
||||
import type { AgentRuntime } from "@/shared/types";
|
||||
import { RuntimeModeIcon } from "./shared";
|
||||
|
||||
function RuntimeListItem({
|
||||
runtime,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
runtime: AgentRuntime;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
runtime.status === "online" ? "bg-success/10" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<RuntimeModeIcon mode={runtime.runtime_mode} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{runtime.name}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{runtime.provider} · {runtime.runtime_mode}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeList({
|
||||
runtimes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
runtimes: AgentRuntime[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Runtimes</h1>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{runtimes.filter((r) => r.status === "online").length}/
|
||||
{runtimes.length} online
|
||||
</span>
|
||||
</div>
|
||||
{runtimes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Server className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
No runtimes registered
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Run{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">
|
||||
multica daemon start
|
||||
</code>{" "}
|
||||
to register a local runtime.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{runtimes.map((runtime) => (
|
||||
<RuntimeListItem
|
||||
key={runtime.id}
|
||||
runtime={runtime}
|
||||
isSelected={runtime.id === selectedId}
|
||||
onClick={() => onSelect(runtime.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
57
apps/web/features/runtimes/components/shared.tsx
Normal file
57
apps/web/features/runtimes/components/shared.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Monitor, Cloud, Wifi, WifiOff } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function RuntimeModeIcon({ mode }: { mode: string }) {
|
||||
return mode === "cloud" ? (
|
||||
<Cloud className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const isOnline = status === "online";
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={isOnline ? "bg-success/10 text-success" : ""}
|
||||
>
|
||||
{isOnline ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
)}
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoField({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div
|
||||
className={`mt-0.5 text-sm truncate ${mono ? "font-mono text-xs" : ""}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
apps/web/features/runtimes/components/usage-section.tsx
Normal file
172
apps/web/features/runtimes/components/usage-section.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import type { RuntimeUsage } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { formatTokens, estimateCost, aggregateByDate } from "../utils";
|
||||
import { TokenCard } from "./shared";
|
||||
import {
|
||||
ActivityHeatmap,
|
||||
HourlyActivityChart,
|
||||
DailyTokenChart,
|
||||
DailyCostChart,
|
||||
ModelDistributionChart,
|
||||
} from "./charts";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "30d", days: 30 },
|
||||
{ label: "90d", days: 90 },
|
||||
] as const;
|
||||
|
||||
type TimeRange = (typeof TIME_RANGES)[number]["days"];
|
||||
|
||||
export function UsageSection({ runtimeId }: { runtimeId: string }) {
|
||||
const [usage, setUsage] = useState<RuntimeUsage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [days, setDays] = useState<TimeRange>(30);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getRuntimeUsage(runtimeId, { days: 90 }) // always fetch 90d, filter client-side
|
||||
.then(setUsage)
|
||||
.catch(() => setUsage([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [runtimeId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">Loading usage...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (usage.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border border-dashed py-6">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">No usage data yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by selected time range
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
const cutoff = cutoffDate.toISOString().slice(0, 10);
|
||||
const filtered = usage.filter((u) => u.date >= cutoff);
|
||||
|
||||
// Compute totals
|
||||
const totals = filtered.reduce(
|
||||
(acc, u) => ({
|
||||
input: acc.input + u.input_tokens,
|
||||
output: acc.output + u.output_tokens,
|
||||
cacheRead: acc.cacheRead + u.cache_read_tokens,
|
||||
cacheWrite: acc.cacheWrite + u.cache_write_tokens,
|
||||
cost: acc.cost + estimateCost(u),
|
||||
}),
|
||||
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
||||
);
|
||||
|
||||
const { dailyTokens, dailyCost, modelDist } = aggregateByDate(filtered);
|
||||
|
||||
// Group by date for the table
|
||||
const byDate = new Map<string, RuntimeUsage[]>();
|
||||
for (const u of filtered) {
|
||||
const existing = byDate.get(u.date) ?? [];
|
||||
existing.push(u);
|
||||
byDate.set(u.date, existing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Time range selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
{TIME_RANGES.map((range) => (
|
||||
<button
|
||||
key={range.days}
|
||||
onClick={() => setDays(range.days)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
days === range.days
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<TokenCard label="Input" value={formatTokens(totals.input)} />
|
||||
<TokenCard label="Output" value={formatTokens(totals.output)} />
|
||||
<TokenCard label="Cache Read" value={formatTokens(totals.cacheRead)} />
|
||||
<TokenCard label="Cache Write" value={formatTokens(totals.cacheWrite)} />
|
||||
</div>
|
||||
|
||||
{totals.cost > 0 && (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Estimated cost ({days}d):{" "}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
${totals.cost.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heatmap + Hourly */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<ActivityHeatmap usage={usage} />
|
||||
<HourlyActivityChart runtimeId={runtimeId} />
|
||||
</div>
|
||||
|
||||
{/* Token & Cost charts */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<DailyTokenChart data={dailyTokens} />
|
||||
<DailyCostChart data={dailyCost} />
|
||||
</div>
|
||||
|
||||
<ModelDistributionChart data={modelDist} />
|
||||
|
||||
{/* Daily breakdown table */}
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>Date</div>
|
||||
<div>Model</div>
|
||||
<div className="text-right">Input</div>
|
||||
<div className="text-right">Output</div>
|
||||
<div className="text-right">Cache R</div>
|
||||
<div className="text-right">Cache W</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto divide-y">
|
||||
{[...byDate.entries()].map(([date, rows]) =>
|
||||
rows.map((row, i) => (
|
||||
<div
|
||||
key={`${date}-${row.model}-${i}`}
|
||||
className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<div className="text-muted-foreground">{date}</div>
|
||||
<div className="truncate font-mono">{row.model}</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.input_tokens)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.output_tokens)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.cache_read_tokens)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.cache_write_tokens)}
|
||||
</div>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { RuntimesPage } from "./components";
|
||||
export { useRuntimeStore } from "./store";
|
||||
|
|
|
|||
70
apps/web/features/runtimes/store.ts
Normal file
70
apps/web/features/runtimes/store.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { AgentRuntime } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
interface RuntimeState {
|
||||
runtimes: AgentRuntime[];
|
||||
selectedId: string;
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeActions {
|
||||
fetchRuntimes: () => Promise<void>;
|
||||
setSelectedId: (id: string) => void;
|
||||
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
|
||||
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => void;
|
||||
/** Replace the full runtimes list (used on daemon:register events). */
|
||||
setRuntimes: (runtimes: AgentRuntime[]) => void;
|
||||
}
|
||||
|
||||
type RuntimeStore = RuntimeState & RuntimeActions;
|
||||
|
||||
export const useRuntimeStore = create<RuntimeStore>((set, get) => ({
|
||||
// State
|
||||
runtimes: [],
|
||||
selectedId: "",
|
||||
fetching: true,
|
||||
|
||||
// Actions
|
||||
fetchRuntimes: async () => {
|
||||
const workspace = useWorkspaceStore.getState().workspace;
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const data = await api.listRuntimes({ workspace_id: workspace.id });
|
||||
const { selectedId } = get();
|
||||
set({
|
||||
runtimes: data,
|
||||
fetching: false,
|
||||
// Auto-select first if nothing selected
|
||||
selectedId: selectedId && data.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: data[0]?.id ?? "",
|
||||
});
|
||||
} catch {
|
||||
set({ fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedId: (id) => set({ selectedId: id }),
|
||||
|
||||
patchRuntime: (id, updates) => {
|
||||
set((state) => ({
|
||||
runtimes: state.runtimes.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setRuntimes: (runtimes) => {
|
||||
const { selectedId } = get();
|
||||
set({
|
||||
runtimes,
|
||||
selectedId: selectedId && runtimes.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: runtimes[0]?.id ?? "",
|
||||
});
|
||||
},
|
||||
}));
|
||||
141
apps/web/features/runtimes/utils.ts
Normal file
141
apps/web/features/runtimes/utils.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import type { RuntimeUsage } from "@/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatLastSeen(lastSeenAt: string | null): string {
|
||||
if (!lastSeenAt) return "Never";
|
||||
const diff = Date.now() - new Date(lastSeenAt).getTime();
|
||||
if (diff < 60_000) return "Just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost estimation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Pricing per million tokens (USD)
|
||||
const MODEL_PRICING: Record<
|
||||
string,
|
||||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
||||
> = {
|
||||
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
||||
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
};
|
||||
|
||||
export function estimateCost(usage: RuntimeUsage): number {
|
||||
const model = usage.model;
|
||||
let pricing = MODEL_PRICING[model];
|
||||
if (!pricing) {
|
||||
for (const [key, p] of Object.entries(MODEL_PRICING)) {
|
||||
if (model.startsWith(key)) {
|
||||
pricing = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pricing) return 0;
|
||||
|
||||
return (
|
||||
(usage.input_tokens * pricing.input +
|
||||
usage.output_tokens * pricing.output +
|
||||
usage.cache_read_tokens * pricing.cacheRead +
|
||||
usage.cache_write_tokens * pricing.cacheWrite) /
|
||||
1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DailyTokenData {
|
||||
date: string;
|
||||
label: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
}
|
||||
|
||||
export interface DailyCostData {
|
||||
date: string;
|
||||
label: string;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface ModelDistribution {
|
||||
model: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export function aggregateByDate(usage: RuntimeUsage[]): {
|
||||
dailyTokens: DailyTokenData[];
|
||||
dailyCost: DailyCostData[];
|
||||
modelDist: ModelDistribution[];
|
||||
} {
|
||||
const dateMap = new Map<string, Omit<DailyTokenData, "label">>();
|
||||
const costMap = new Map<string, number>();
|
||||
const modelMap = new Map<string, { tokens: number; cost: number }>();
|
||||
|
||||
for (const u of usage) {
|
||||
const existing = dateMap.get(u.date) ?? {
|
||||
date: u.date,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
existing.input += u.input_tokens;
|
||||
existing.output += u.output_tokens;
|
||||
existing.cacheRead += u.cache_read_tokens;
|
||||
existing.cacheWrite += u.cache_write_tokens;
|
||||
dateMap.set(u.date, existing);
|
||||
|
||||
const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u);
|
||||
costMap.set(u.date, dayCost);
|
||||
|
||||
const modelName = u.model || u.provider;
|
||||
const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 };
|
||||
m.tokens +=
|
||||
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||
m.cost += estimateCost(u);
|
||||
modelMap.set(modelName, m);
|
||||
}
|
||||
|
||||
const formatLabel = (d: string) => {
|
||||
const date = new Date(d + "T00:00:00");
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
};
|
||||
|
||||
const dailyTokens = [...dateMap.values()]
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.map((d) => ({ ...d, label: formatLabel(d.date) }));
|
||||
|
||||
const dailyCost = [...costMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, cost]) => ({
|
||||
date,
|
||||
label: formatLabel(date),
|
||||
cost: Math.round(cost * 100) / 100,
|
||||
}));
|
||||
|
||||
const modelDist = [...modelMap.entries()]
|
||||
.map(([model, data]) => ({ model, ...data }))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
return { dailyTokens, dailyCost, modelDist };
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ export type IssueAssigneeType = "member" | "agent";
|
|||
export interface Issue {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
number: number;
|
||||
identifier: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: IssueStatus;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface Workspace {
|
|||
context: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
repos: WorkspaceRepo[];
|
||||
issue_prefix: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const mockWorkspace: Workspace = {
|
|||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "TES",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -179,6 +179,14 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue
|
|||
return db.Issue{}, false
|
||||
}
|
||||
|
||||
// Try identifier format first (e.g., "JIA-42").
|
||||
if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, resolveWorkspaceID(r)); ok {
|
||||
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok {
|
||||
return db.Issue{}, false
|
||||
}
|
||||
return issue, true
|
||||
}
|
||||
|
||||
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "issue not found")
|
||||
|
|
@ -192,6 +200,64 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue
|
|||
return issue, true
|
||||
}
|
||||
|
||||
// resolveIssueByIdentifier tries to look up an issue by "PREFIX-NUMBER" format.
|
||||
func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID string) (db.Issue, bool) {
|
||||
parts := splitIdentifier(id)
|
||||
if parts == nil {
|
||||
return db.Issue{}, false
|
||||
}
|
||||
if workspaceID == "" {
|
||||
return db.Issue{}, false
|
||||
}
|
||||
issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Number: parts.number,
|
||||
})
|
||||
if err != nil {
|
||||
return db.Issue{}, false
|
||||
}
|
||||
return issue, true
|
||||
}
|
||||
|
||||
type identifierParts struct {
|
||||
prefix string
|
||||
number int32
|
||||
}
|
||||
|
||||
func splitIdentifier(id string) *identifierParts {
|
||||
idx := -1
|
||||
for i := len(id) - 1; i >= 0; i-- {
|
||||
if id[i] == '-' {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx <= 0 || idx >= len(id)-1 {
|
||||
return nil
|
||||
}
|
||||
numStr := id[idx+1:]
|
||||
num := 0
|
||||
for _, c := range numStr {
|
||||
if c < '0' || c > '9' {
|
||||
return nil
|
||||
}
|
||||
num = num*10 + int(c-'0')
|
||||
}
|
||||
if num <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &identifierParts{prefix: id[:idx], number: int32(num)}
|
||||
}
|
||||
|
||||
// getIssuePrefix fetches the issue_prefix for a workspace.
|
||||
func (h *Handler) getIssuePrefix(ctx context.Context, workspaceID pgtype.UUID) string {
|
||||
ws, err := h.Queries.GetWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ws.IssuePrefix
|
||||
}
|
||||
|
||||
func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) {
|
||||
if _, ok := requireUserID(w, r); !ok {
|
||||
return db.Agent{}, false
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, s
|
|||
|
||||
var workspaceID string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO workspace (name, slug, description)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO workspace (name, slug, description, issue_prefix)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests").Scan(&workspaceID); err != nil {
|
||||
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests", "HAN").Scan(&workspaceID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import (
|
|||
type IssueResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Number int32 `json:"number"`
|
||||
Identifier string `json:"identifier"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
|
|
@ -41,10 +43,13 @@ type agentTriggerSnapshot struct {
|
|||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
func issueToResponse(i db.Issue) IssueResponse {
|
||||
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
|
||||
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
||||
return IssueResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
Number: i.Number,
|
||||
Identifier: identifier,
|
||||
Title: i.Title,
|
||||
Description: textToPtr(i.Description),
|
||||
Status: i.Status,
|
||||
|
|
@ -109,9 +114,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID))
|
||||
resp := make([]IssueResponse, len(issues))
|
||||
for i, issue := range issues {
|
||||
resp[i] = issueToResponse(issue)
|
||||
resp[i] = issueToResponse(issue, prefix)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
|
|
@ -126,7 +132,8 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, issueToResponse(issue))
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
writeJSON(w, http.StatusOK, issueToResponse(issue, prefix))
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
|
|
@ -196,7 +203,24 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
dueDate = pgtype.Timestamptz{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
// Use a transaction to atomically increment the workspace issue counter
|
||||
// and create the issue with the assigned number.
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create issue")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
slog.Warn("increment issue counter failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create issue")
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Title: req.Title,
|
||||
Description: ptrToText(req.Description),
|
||||
|
|
@ -209,6 +233,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
ParentIssueID: parentIssueID,
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
Number: issueNumber,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
|
|
@ -216,7 +241,13 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := issueToResponse(issue)
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create issue")
|
||||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
|
||||
h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp})
|
||||
|
||||
|
|
@ -326,7 +357,8 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := issueToResponse(issue)
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)
|
||||
|
||||
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -13,6 +14,22 @@ import (
|
|||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`)
|
||||
|
||||
// generateIssuePrefix produces a 2-5 char uppercase prefix from a workspace name.
|
||||
// Examples: "Jiayuan's Workspace" → "JIA", "My Team" → "MYT", "AB" → "AB".
|
||||
func generateIssuePrefix(name string) string {
|
||||
letters := nonAlpha.ReplaceAllString(name, "")
|
||||
if len(letters) == 0 {
|
||||
return "WS"
|
||||
}
|
||||
letters = strings.ToUpper(letters)
|
||||
if len(letters) > 3 {
|
||||
letters = letters[:3]
|
||||
}
|
||||
return letters
|
||||
}
|
||||
|
||||
type WorkspaceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -21,6 +38,7 @@ type WorkspaceResponse struct {
|
|||
Context *string `json:"context"`
|
||||
Settings any `json:"settings"`
|
||||
Repos any `json:"repos"`
|
||||
IssuePrefix string `json:"issue_prefix"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -48,6 +66,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse {
|
|||
Context: textToPtr(w.Context),
|
||||
Settings: settings,
|
||||
Repos: repos,
|
||||
IssuePrefix: w.IssuePrefix,
|
||||
CreatedAt: timestampToString(w.CreatedAt),
|
||||
UpdatedAt: timestampToString(w.UpdatedAt),
|
||||
}
|
||||
|
|
@ -110,6 +129,7 @@ type CreateWorkspaceRequest struct {
|
|||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
IssuePrefix *string `json:"issue_prefix"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -138,12 +158,18 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
issuePrefix := generateIssuePrefix(req.Name)
|
||||
if req.IssuePrefix != nil && strings.TrimSpace(*req.IssuePrefix) != "" {
|
||||
issuePrefix = strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
|
||||
}
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: ptrToText(req.Description),
|
||||
Context: ptrToText(req.Context),
|
||||
IssuePrefix: issuePrefix,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
|
|
@ -179,6 +205,7 @@ type UpdateWorkspaceRequest struct {
|
|||
Context *string `json:"context"`
|
||||
Settings any `json:"settings"`
|
||||
Repos any `json:"repos"`
|
||||
IssuePrefix *string `json:"issue_prefix"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -218,6 +245,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
reposJSON, _ := json.Marshal(req.Repos)
|
||||
params.Repos = reposJSON
|
||||
}
|
||||
if req.IssuePrefix != nil {
|
||||
prefix := strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
|
||||
if prefix != "" {
|
||||
params.IssuePrefix = pgtype.Text{String: prefix, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
|
|
@ -367,15 +369,24 @@ func (s *TaskService) broadcastTaskEvent(ctx context.Context, eventType string,
|
|||
}
|
||||
|
||||
func (s *TaskService) broadcastIssueUpdated(issue db.Issue) {
|
||||
prefix := s.getIssuePrefix(issue.WorkspaceID)
|
||||
s.Bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueUpdated,
|
||||
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
|
||||
ActorType: "system",
|
||||
ActorID: "",
|
||||
Payload: map[string]any{"issue": issueToMap(issue)},
|
||||
Payload: map[string]any{"issue": issueToMap(issue, prefix)},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TaskService) getIssuePrefix(workspaceID pgtype.UUID) string {
|
||||
ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ws.IssuePrefix
|
||||
}
|
||||
|
||||
func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID pgtype.UUID, content, commentType string) {
|
||||
if content == "" {
|
||||
return
|
||||
|
|
@ -416,23 +427,25 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p
|
|||
})
|
||||
}
|
||||
|
||||
func issueToMap(issue db.Issue) map[string]any {
|
||||
func issueToMap(issue db.Issue, issuePrefix string) map[string]any {
|
||||
return map[string]any{
|
||||
"id": util.UUIDToString(issue.ID),
|
||||
"workspace_id": util.UUIDToString(issue.WorkspaceID),
|
||||
"title": issue.Title,
|
||||
"description": util.TextToPtr(issue.Description),
|
||||
"status": issue.Status,
|
||||
"priority": issue.Priority,
|
||||
"assignee_type": util.TextToPtr(issue.AssigneeType),
|
||||
"assignee_id": util.UUIDToPtr(issue.AssigneeID),
|
||||
"creator_type": issue.CreatorType,
|
||||
"creator_id": util.UUIDToString(issue.CreatorID),
|
||||
"id": util.UUIDToString(issue.ID),
|
||||
"workspace_id": util.UUIDToString(issue.WorkspaceID),
|
||||
"number": issue.Number,
|
||||
"identifier": issuePrefix + "-" + strconv.Itoa(int(issue.Number)),
|
||||
"title": issue.Title,
|
||||
"description": util.TextToPtr(issue.Description),
|
||||
"status": issue.Status,
|
||||
"priority": issue.Priority,
|
||||
"assignee_type": util.TextToPtr(issue.AssigneeType),
|
||||
"assignee_id": util.UUIDToPtr(issue.AssigneeID),
|
||||
"creator_type": issue.CreatorType,
|
||||
"creator_id": util.UUIDToString(issue.CreatorID),
|
||||
"parent_issue_id": util.UUIDToPtr(issue.ParentIssueID),
|
||||
"position": issue.Position,
|
||||
"due_date": util.TimestampToPtr(issue.DueDate),
|
||||
"created_at": util.TimestampToString(issue.CreatedAt),
|
||||
"updated_at": util.TimestampToString(issue.UpdatedAt),
|
||||
"position": issue.Position,
|
||||
"due_date": util.TimestampToPtr(issue.DueDate),
|
||||
"created_at": util.TimestampToString(issue.CreatedAt),
|
||||
"updated_at": util.TimestampToString(issue.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
5
server/migrations/020_issue_number.down.sql
Normal file
5
server/migrations/020_issue_number.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
DROP INDEX IF EXISTS idx_issue_workspace_number;
|
||||
ALTER TABLE issue DROP CONSTRAINT IF EXISTS uq_issue_workspace_number;
|
||||
ALTER TABLE issue DROP COLUMN IF EXISTS number;
|
||||
ALTER TABLE workspace DROP COLUMN IF EXISTS issue_prefix;
|
||||
ALTER TABLE workspace DROP COLUMN IF EXISTS issue_counter;
|
||||
36
server/migrations/020_issue_number.up.sql
Normal file
36
server/migrations/020_issue_number.up.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Add issue_prefix and issue_counter to workspace for human-readable issue IDs.
|
||||
ALTER TABLE workspace
|
||||
ADD COLUMN issue_prefix TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN issue_counter INT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Add per-workspace issue number.
|
||||
ALTER TABLE issue
|
||||
ADD COLUMN number INT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Backfill: generate issue_prefix from workspace name (first 3 uppercase chars).
|
||||
UPDATE workspace SET issue_prefix = UPPER(
|
||||
LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3)
|
||||
);
|
||||
|
||||
-- Fallback for workspaces with empty prefix after cleanup.
|
||||
UPDATE workspace SET issue_prefix = 'WS' WHERE issue_prefix = '';
|
||||
|
||||
-- Backfill: assign sequential numbers to existing issues per workspace.
|
||||
WITH numbered AS (
|
||||
SELECT id, workspace_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC) AS rn
|
||||
FROM issue
|
||||
)
|
||||
UPDATE issue SET number = numbered.rn
|
||||
FROM numbered WHERE issue.id = numbered.id;
|
||||
|
||||
-- Update workspace counters to match.
|
||||
UPDATE workspace SET issue_counter = COALESCE(
|
||||
(SELECT MAX(number) FROM issue WHERE issue.workspace_id = workspace.id), 0
|
||||
);
|
||||
|
||||
-- Add unique constraint.
|
||||
ALTER TABLE issue ADD CONSTRAINT uq_issue_workspace_number UNIQUE (workspace_id, number);
|
||||
|
||||
-- Index for fast lookup by workspace + number.
|
||||
CREATE INDEX idx_issue_workspace_number ON issue(workspace_id, number);
|
||||
|
|
@ -15,10 +15,10 @@ const createIssue = `-- name: CreateIssue :one
|
|||
INSERT INTO issue (
|
||||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, position, due_date
|
||||
parent_issue_id, position, due_date, number
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
|
||||
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
|
||||
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
|
||||
`
|
||||
|
||||
type CreateIssueParams struct {
|
||||
|
|
@ -34,6 +34,7 @@ type CreateIssueParams struct {
|
|||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
Number int32 `json:"number"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
|
||||
|
|
@ -50,6 +51,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
|||
arg.ParentIssueID,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
arg.Number,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
@ -70,6 +72,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
|||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -84,7 +87,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error {
|
|||
}
|
||||
|
||||
const getIssue = `-- name: GetIssue :one
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -109,12 +112,49 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
|
|||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getIssueByNumber = `-- name: GetIssueByNumber :one
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE workspace_id = $1 AND number = $2
|
||||
`
|
||||
|
||||
type GetIssueByNumberParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Number int32 `json:"number"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberParams) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, getIssueByNumber, arg.WorkspaceID, arg.Number)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listIssues = `-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND ($4::text IS NULL OR status = $4)
|
||||
AND ($5::text IS NULL OR priority = $5)
|
||||
|
|
@ -166,6 +206,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
|
|||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -189,7 +230,7 @@ UPDATE issue SET
|
|||
due_date = $9,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
|
||||
`
|
||||
|
||||
type UpdateIssueParams struct {
|
||||
|
|
@ -235,6 +276,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
|
|||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -244,7 +286,7 @@ UPDATE issue SET
|
|||
status = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
|
||||
`
|
||||
|
||||
type UpdateIssueStatusParams struct {
|
||||
|
|
@ -273,6 +315,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa
|
|||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ type Issue struct {
|
|||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Number int32 `json:"number"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
|
|
@ -256,13 +257,15 @@ type VerificationCode struct {
|
|||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Settings []byte `json:"settings"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Context pgtype.Text `json:"context"`
|
||||
Repos []byte `json:"repos"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Settings []byte `json:"settings"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Context pgtype.Text `json:"context"`
|
||||
Repos []byte `json:"repos"`
|
||||
IssuePrefix string `json:"issue_prefix"`
|
||||
IssueCounter int32 `json:"issue_counter"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import (
|
|||
)
|
||||
|
||||
const createWorkspace = `-- name: CreateWorkspace :one
|
||||
INSERT INTO workspace (name, slug, description, context)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos
|
||||
INSERT INTO workspace (name, slug, description, context, issue_prefix)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter
|
||||
`
|
||||
|
||||
type CreateWorkspaceParams struct {
|
||||
|
|
@ -22,6 +22,7 @@ type CreateWorkspaceParams struct {
|
|||
Slug string `json:"slug"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Context pgtype.Text `json:"context"`
|
||||
IssuePrefix string `json:"issue_prefix"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
|
||||
|
|
@ -30,6 +31,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams
|
|||
arg.Slug,
|
||||
arg.Description,
|
||||
arg.Context,
|
||||
arg.IssuePrefix,
|
||||
)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
|
|
@ -42,6 +44,8 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams
|
|||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
&i.Repos,
|
||||
&i.IssuePrefix,
|
||||
&i.IssueCounter,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -56,7 +60,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
|
|||
}
|
||||
|
||||
const getWorkspace = `-- name: GetWorkspace :one
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -73,12 +77,14 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace,
|
|||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
&i.Repos,
|
||||
&i.IssuePrefix,
|
||||
&i.IssueCounter,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace
|
||||
WHERE slug = $1
|
||||
`
|
||||
|
||||
|
|
@ -95,12 +101,27 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac
|
|||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
&i.Repos,
|
||||
&i.IssuePrefix,
|
||||
&i.IssueCounter,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const incrementIssueCounter = `-- name: IncrementIssueCounter :one
|
||||
UPDATE workspace SET issue_counter = issue_counter + 1
|
||||
WHERE id = $1
|
||||
RETURNING issue_counter
|
||||
`
|
||||
|
||||
func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, incrementIssueCounter, id)
|
||||
var issue_counter int32
|
||||
err := row.Scan(&issue_counter)
|
||||
return issue_counter, err
|
||||
}
|
||||
|
||||
const listWorkspaces = `-- name: ListWorkspaces :many
|
||||
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos FROM workspace w
|
||||
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos, w.issue_prefix, w.issue_counter FROM workspace w
|
||||
JOIN member m ON m.workspace_id = w.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY w.created_at ASC
|
||||
|
|
@ -125,6 +146,8 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor
|
|||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
&i.Repos,
|
||||
&i.IssuePrefix,
|
||||
&i.IssueCounter,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -143,9 +166,10 @@ UPDATE workspace SET
|
|||
context = COALESCE($4, context),
|
||||
settings = COALESCE($5, settings),
|
||||
repos = COALESCE($6, repos),
|
||||
issue_prefix = COALESCE($7, issue_prefix),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter
|
||||
`
|
||||
|
||||
type UpdateWorkspaceParams struct {
|
||||
|
|
@ -155,6 +179,7 @@ type UpdateWorkspaceParams struct {
|
|||
Context pgtype.Text `json:"context"`
|
||||
Settings []byte `json:"settings"`
|
||||
Repos []byte `json:"repos"`
|
||||
IssuePrefix pgtype.Text `json:"issue_prefix"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
|
||||
|
|
@ -165,6 +190,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
|
|||
arg.Context,
|
||||
arg.Settings,
|
||||
arg.Repos,
|
||||
arg.IssuePrefix,
|
||||
)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
|
|
@ -177,6 +203,8 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
|
|||
&i.UpdatedAt,
|
||||
&i.Context,
|
||||
&i.Repos,
|
||||
&i.IssuePrefix,
|
||||
&i.IssueCounter,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,15 @@ WHERE id = $1;
|
|||
INSERT INTO issue (
|
||||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, position, due_date
|
||||
parent_issue_id, position, due_date, number
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
|
||||
) RETURNING *;
|
||||
|
||||
-- name: GetIssueByNumber :one
|
||||
SELECT * FROM issue
|
||||
WHERE workspace_id = $1 AND number = $2;
|
||||
|
||||
-- name: UpdateIssue :one
|
||||
UPDATE issue SET
|
||||
title = COALESCE(sqlc.narg('title'), title),
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ SELECT * FROM workspace
|
|||
WHERE slug = $1;
|
||||
|
||||
-- name: CreateWorkspace :one
|
||||
INSERT INTO workspace (name, slug, description, context)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO workspace (name, slug, description, context, issue_prefix)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspace :one
|
||||
|
|
@ -24,9 +24,15 @@ UPDATE workspace SET
|
|||
context = COALESCE(sqlc.narg('context'), context),
|
||||
settings = COALESCE(sqlc.narg('settings'), settings),
|
||||
repos = COALESCE(sqlc.narg('repos'), repos),
|
||||
issue_prefix = COALESCE(sqlc.narg('issue_prefix'), issue_prefix),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: IncrementIssueCounter :one
|
||||
UPDATE workspace SET issue_counter = issue_counter + 1
|
||||
WHERE id = $1
|
||||
RETURNING issue_counter;
|
||||
|
||||
-- name: DeleteWorkspace :exec
|
||||
DELETE FROM workspace WHERE id = $1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue