Skip to content

Commit 6abec13

Browse files
adityathebemoshloop
authored andcommitted
fix(playbooks): show small AI action costs
Playbook run pages rounded AI generation costs to two decimal places, so small but non-zero costs appeared as /usr/sbin/bash.00. Format sub-cent AI costs with enough precision to remain visible, and add Shadcn tooltips that show the exact cost plus token details for run totals. Cover the run total and action tab states with Storybook stories and focused formatter/component tests.
1 parent 879a96f commit 6abec13

9 files changed

Lines changed: 320 additions & 24 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
Tooltip,
3+
TooltipContent,
4+
TooltipProvider,
5+
TooltipTrigger
6+
} from "@flanksource-ui/components/ui/tooltip";
7+
import type { ReactElement, ReactNode } from "react";
8+
import { formatExactAICost } from "./cost";
9+
10+
type AICostTooltipProps = {
11+
children: ReactElement;
12+
cost: number;
13+
details?: ReactNode;
14+
};
15+
16+
export function AICostTooltip({ children, cost, details }: AICostTooltipProps) {
17+
return (
18+
<TooltipProvider delayDuration={0}>
19+
<Tooltip>
20+
<TooltipTrigger asChild>{children}</TooltipTrigger>
21+
<TooltipContent side="top" className="max-w-none">
22+
<div className="space-y-1">
23+
<div>
24+
Cost: <span className="font-mono">{formatExactAICost(cost)}</span>
25+
</div>
26+
{details && <div className="text-gray-300">{details}</div>}
27+
</div>
28+
</TooltipContent>
29+
</Tooltip>
30+
</TooltipProvider>
31+
);
32+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { PlaybookRunWithActions } from "@flanksource-ui/api/types/playbooks";
2+
import { HttpResponse, http } from "msw";
3+
import { ComponentProps } from "react";
4+
import { Meta, StoryFn } from "@storybook/react";
5+
import PlaybookRunDetailView from "./PlaybookRunsActions";
6+
7+
const aiAction = {
8+
playbook_run_id: "run-1",
9+
id: "action-ai",
10+
name: "analyse",
11+
status: "completed" as const,
12+
start_time: "2026-07-08T12:53:40Z",
13+
end_time: "2026-07-08T12:53:49Z",
14+
type: "ai" as const,
15+
result: {
16+
json: JSON.stringify(
17+
{
18+
headline: "flanksource-ui/Release Unhealthy: Repository Access Blocked",
19+
summary:
20+
"The workflow is unhealthy because repository access is blocked.",
21+
recommended_fix:
22+
"Verify repository access and GitHub token permissions."
23+
},
24+
null,
25+
2
26+
),
27+
generationInfo: [
28+
{
29+
cost: 0.0018809,
30+
model: "gemini-2.5-flash",
31+
inputTokens: 778,
32+
outputTokens: 131,
33+
reasoningTokens: 528
34+
},
35+
{
36+
cost: 0.0017708999999999997,
37+
model: "gemini-2.5-flash",
38+
inputTokens: 4053,
39+
outputTokens: 10,
40+
reasoningTokens: 212
41+
}
42+
]
43+
}
44+
};
45+
46+
const notificationAction = {
47+
playbook_run_id: "run-1",
48+
id: "action-notification",
49+
name: "send recommended playbooks",
50+
status: "completed" as const,
51+
start_time: "2026-07-08T12:53:49Z",
52+
end_time: "2026-07-08T12:53:50Z",
53+
type: "notification" as const,
54+
result: {
55+
title: "Recommended playbooks",
56+
message: "Recommendation sent"
57+
}
58+
};
59+
60+
const playbookRun = {
61+
id: "run-1",
62+
playbook_id: "playbook-1",
63+
spec: {
64+
actions: [
65+
{ name: "send recommended playbooks", notification: {} },
66+
{ name: "analyse", ai: {} }
67+
]
68+
},
69+
playbooks: {
70+
id: "playbook-1",
71+
name: "Recommend Playbooks",
72+
title: "Recommend Playbooks",
73+
source: "UI",
74+
created_at: "2026-07-08T12:53:00Z",
75+
updated_at: "2026-07-08T12:53:00Z",
76+
spec: {
77+
actions: [
78+
{ name: "send recommended playbooks", notification: {} },
79+
{ name: "analyse", ai: {} }
80+
]
81+
}
82+
},
83+
status: "completed",
84+
created_at: "2026-07-08T12:53:40Z",
85+
scheduled_time: "2026-07-08T12:53:40Z",
86+
start_time: "2026-07-08T12:53:40Z",
87+
end_time: "2026-07-08T12:53:50Z",
88+
config: {
89+
id: "config-1",
90+
name: "flanksource-ui/Release",
91+
type: "GitHubAction",
92+
config_class: "Deployment"
93+
},
94+
actions: [notificationAction, aiAction]
95+
} satisfies PlaybookRunWithActions;
96+
97+
export default {
98+
title: "Components/PlaybookRunDetailView",
99+
component: PlaybookRunDetailView,
100+
parameters: {
101+
msw: {
102+
handlers: [
103+
http.get("/api/db/playbook_run_actions", () => {
104+
return HttpResponse.json([aiAction]);
105+
})
106+
]
107+
}
108+
}
109+
} satisfies Meta<ComponentProps<typeof PlaybookRunDetailView>>;
110+
111+
const Template: StoryFn<ComponentProps<typeof PlaybookRunDetailView>> = (
112+
args
113+
) => <PlaybookRunDetailView {...args} />;
114+
115+
export const SmallTotalCost = Template.bind({});
116+
SmallTotalCost.args = {
117+
data: playbookRun
118+
};
119+
SmallTotalCost.decorators = [
120+
(Story) => (
121+
<div className="h-[620px] w-[1120px] pt-12">
122+
<Story />
123+
</div>
124+
)
125+
];

src/components/Playbooks/Runs/Actions/PlaybookRunsActions.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { VscFileCode } from "react-icons/vsc";
1616
import { FaCog } from "react-icons/fa";
1717
import { Link } from "react-router-dom";
1818
import { FaFileAlt } from "react-icons/fa";
19-
import { Tooltip } from "react-tooltip";
2019
import PlaybookSpecIcon from "../../Settings/PlaybookSpecIcon";
2120
import { ApprovePlaybookButton } from "../ApprovePlaybookButton";
2221
import { CancelPlaybookButton } from "../CancelPlaybookButton";
@@ -36,6 +35,8 @@ import { AiFeatureRequest } from "@flanksource-ui/ui/Layout/AiFeatureLoader";
3635
import { useFeatureFlagsContext } from "@flanksource-ui/context/FeatureFlagsContext";
3736
import { features } from "@flanksource-ui/services/permissions/features";
3837
import { Button } from "@flanksource-ui/ui/Buttons/Button";
38+
import { formatAICost } from "./cost";
39+
import { AICostTooltip } from "./AICostTooltip";
3940

4041
const LazyDiagnoseButton = lazy(() =>
4142
import("../DiagnosePlaybookFailureButton").then((module) => ({
@@ -146,6 +147,8 @@ export default function PlaybookRunDetailView({
146147
};
147148
}, [data.actions]);
148149

150+
const totalTokens = totalInputTokens + totalOutputTokens;
151+
149152
return (
150153
<div className="flex flex-1 flex-col gap-2 pl-2">
151154
<div className="flex flex-wrap py-4">
@@ -335,14 +338,15 @@ export default function PlaybookRunDetailView({
335338
<div className="mb-2 ml-[-25px] mr-[-15px] flex flex-row items-center justify-between border-t border-gray-200 py-2 pl-[25px]">
336339
<div className="font-semibold text-gray-600">
337340
Actions{" "}
338-
{(totalInputTokens > 0 || totalOutputTokens > 0) && (
339-
<span
340-
data-tooltip-id="action-cost-tooltip"
341-
data-tooltip-content={`${(totalInputTokens + totalOutputTokens).toLocaleString()} tokens (${totalInputTokens.toLocaleString()} input + ${totalOutputTokens.toLocaleString()} output)`}
342-
className="font-mono text-sm text-gray-500"
341+
{totalTokens > 0 && (
342+
<AICostTooltip
343+
cost={runCost}
344+
details={`${totalTokens.toLocaleString()} tokens (${totalInputTokens.toLocaleString()} input + ${totalOutputTokens.toLocaleString()} output)`}
343345
>
344-
(${runCost.toFixed(2)})
345-
</span>
346+
<span className="cursor-help font-mono text-sm text-gray-500">
347+
({formatAICost(runCost)})
348+
</span>
349+
</AICostTooltip>
346350
)}
347351
</div>
348352
</div>
@@ -466,9 +470,6 @@ export default function PlaybookRunDetailView({
466470
</div>
467471
</div>
468472

469-
{/* Tooltips */}
470-
<Tooltip id="action-cost-tooltip" />
471-
472473
{/* Modals */}
473474
<ViewPlaybookSpecModal
474475
data={data}

src/components/Playbooks/Runs/Actions/PlaybooksActionsResults.stories.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,53 @@ UndefinedResult.args = {
7474
result: undefined
7575
}
7676
};
77+
78+
export const AIWithSmallCost = Template.bind({});
79+
AIWithSmallCost.args = {
80+
action: {
81+
name: "Analyze incident",
82+
status: "completed",
83+
id: "1",
84+
start_time: "2026-07-08T12:53:40Z",
85+
end_time: "2026-07-08T12:53:49Z",
86+
playbook_run_id: "1",
87+
type: "ai",
88+
result: {
89+
json: JSON.stringify(
90+
{
91+
headline:
92+
"flanksource-ui/Release Unhealthy: Repository Access Blocked",
93+
summary:
94+
"The workflow is unhealthy because repository access is blocked.",
95+
recommended_fix:
96+
"Verify repository access and GitHub token permissions."
97+
},
98+
null,
99+
2
100+
),
101+
generationInfo: [
102+
{
103+
cost: 0.0018809,
104+
model: "gemini-2.5-flash",
105+
inputTokens: 778,
106+
outputTokens: 131,
107+
reasoningTokens: 528
108+
},
109+
{
110+
cost: 0.0017708999999999997,
111+
model: "gemini-2.5-flash",
112+
inputTokens: 4053,
113+
outputTokens: 10,
114+
reasoningTokens: 212
115+
}
116+
]
117+
}
118+
}
119+
};
120+
AIWithSmallCost.decorators = [
121+
(Story) => (
122+
<div className="h-[500px] w-[780px] pt-16">
123+
<Story />
124+
</div>
125+
)
126+
];

src/components/Playbooks/Runs/Actions/PlaybooksActionsResults.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Convert from "ansi-to-html";
22
import linkifyHtml from "linkify-html";
33
import { Opts } from "linkifyjs";
44
import clsx from "clsx";
5-
import { useMemo, useState, useRef } from "react";
5+
import { useMemo, useState, useRef, type ReactNode } from "react";
66
import { useQuery } from "@tanstack/react-query";
77
import {
88
PlaybookArtifact,
@@ -25,6 +25,8 @@ import { darkTheme } from "@flanksource-ui/ui/Code/JSONViewerTheme";
2525
import { JSONViewer } from "@flanksource-ui/ui/Code/JSONViewer";
2626
import path from "path";
2727
import { LogsTable } from "@flanksource-ui/components/Logs/Table/LogsTable";
28+
import { formatAICost } from "./cost";
29+
import { AICostTooltip } from "./AICostTooltip";
2830

2931
const options = {
3032
className: "text-blue-500 hover:underline pointer",
@@ -125,6 +127,7 @@ type DisplayContentType = string;
125127

126128
type PlaybookActionTab = {
127129
label: string;
130+
labelContent?: ReactNode;
128131
type?: "artifact" | "error";
129132
contentSize?: string;
130133
content: any;
@@ -163,13 +166,14 @@ export default function PlaybooksRunActionsResults({
163166
action,
164167
className = "whitespace-pre-wrap break-all"
165168
}: Props) {
166-
const { result, error, artifacts } = action;
169+
const { result: actionResult, error, artifacts } = action;
167170
const [activeTab, setActiveTab] = useState<string | null>(null);
168171
const activeTabContentRef = useRef<HTMLDivElement>(null);
169172

170173
const availableTabs = useMemo(() => {
171174
const tabs: PlaybookActionTab[] = [];
172-
if (result) {
175+
if (actionResult) {
176+
const result = { ...actionResult };
173177
// AI action can have a cost
174178
let actionCost = 0;
175179
if (action.type === "ai" && result["generationInfo"]) {
@@ -233,7 +237,13 @@ export default function PlaybooksRunActionsResults({
233237
};
234238

235239
if (actionCost) {
236-
tab.label = `${tab.label} ($${actionCost.toFixed(2)})`;
240+
const label = `${tab.label} (${formatAICost(actionCost)})`;
241+
tab.label = label;
242+
tab.labelContent = (
243+
<AICostTooltip cost={actionCost}>
244+
<span className="cursor-help">{label}</span>
245+
</AICostTooltip>
246+
);
237247
}
238248

239249
// Pre-process the content for certain types
@@ -342,7 +352,7 @@ export default function PlaybooksRunActionsResults({
342352
});
343353

344354
return tabs;
345-
}, [result, error, artifacts, action.type]);
355+
}, [actionResult, error, artifacts, action.type]);
346356

347357
useMemo(() => {
348358
if (availableTabs.length > 0 && !activeTab) {
@@ -364,7 +374,7 @@ export default function PlaybooksRunActionsResults({
364374
contentClassName="flex-1 overflow-y-auto border border-t-0 border-gray-600 bg-black p-4"
365375
>
366376
{availableTabs.map((tab) => {
367-
let label = tab.label;
377+
let label: ReactNode = tab.labelContent ?? tab.label;
368378
if (tab.contentSize) {
369379
label = `${label} (${tab.contentSize})`;
370380
}

src/components/Playbooks/Runs/Actions/__tests__/PlaybooksActionsResults.unit.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,4 +341,35 @@ describe("PlaybooksRunActionsResults", () => {
341341
expect(screen.queryByText("ContentType")).not.toBeInTheDocument();
342342
expect(screen.queryByText("text/markdown")).not.toBeInTheDocument();
343343
});
344+
345+
it("shows small AI action costs and exact cost on hover", async () => {
346+
const action = {
347+
id: "1",
348+
name: "ai action",
349+
status: "completed" as const,
350+
playbook_run_id: "1",
351+
start_time: "2024-01-01",
352+
type: "ai" as const,
353+
result: {
354+
json: '{ "answer": "ok" }',
355+
generationInfo: [
356+
{
357+
cost: 0.0018809
358+
},
359+
{
360+
cost: 0.0017708999999999997
361+
}
362+
]
363+
}
364+
};
365+
366+
render(<PlaybooksRunActionsResults action={action} />);
367+
368+
fireEvent.pointerMove(screen.getByText("AI ($0.0037)"), {
369+
pointerType: "mouse"
370+
});
371+
372+
expect(screen.getByText("AI ($0.0037)")).toBeInTheDocument();
373+
expect(await screen.findAllByText("$0.0036518")).toHaveLength(2);
374+
});
344375
});

0 commit comments

Comments
 (0)