Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
668 changes: 668 additions & 0 deletions .plans/cost-insights-data-layer.md

Large diffs are not rendered by default.

181 changes: 181 additions & 0 deletions .plans/cost-insights.md

Large diffs are not rendered by default.

251 changes: 251 additions & 0 deletions .specs/cost-insights.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Business-rule specs live in `.specs/`. Before making **any** changes to a domain
| `.specs/kiloclaw-controller.md` | KiloClaw controller/machine lifecycle, bootstrap, Docker image |
| `.specs/kiloclaw-datamodel.md` | KiloClaw data model — instance/subscription tables, invariants |
| `.specs/model-experiments.md` | Model experiment routing, bucketing, lifecycle, prompt retention, and reporting rules |
| `.specs/cost-insights.md` | Cost Insights and Spend Alerts owner scope, anomaly alerts, threshold alerts, and alert acknowledgments |
| `.specs/security-agent.md` | Security Agent Auto Remediation and finding/SLA notification guarantees |
| `.specs/subscription-center.md` | Subscription Center ownership, states, and user-facing behavior |
| `.specs/team-enterprise-seat-billing.md` | Team and Enterprise seat billing, subscription management |
Expand Down
117 changes: 116 additions & 1 deletion CONTEXT.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions apps/storybook/stories/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Building2,
Cable,
ChartColumnIncreasing,
ChartLine,
ChevronLeft,
ChevronRight,
Cloud,
Expand Down Expand Up @@ -108,6 +109,11 @@ const dashboardItems: SidebarStoryItem[] = [
icon: ChartColumnIncreasing,
url: '/usage',
},
{
title: 'Cost Insights',
icon: ChartLine,
url: '/cost-insights',
},
];

const kiloClawItems: SidebarStoryItem[] = [
Expand Down
28 changes: 28 additions & 0 deletions apps/storybook/stories/cost-insights/AskKilo.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/nextjs';
import { CostInsightsAskKiloView, CostInsightsShellView } from '@/components/cost-insights';
import { personalOwner } from './costInsightsFixtures';

const meta: Meta<typeof CostInsightsAskKiloView> = {
title: 'Cost Insights/Ask Kilo',
component: CostInsightsAskKiloView,
parameters: { layout: 'fullscreen' },
};

export default meta;
type Story = StoryObj<typeof CostInsightsAskKiloView>;

function AskKiloStory({ initialQuestion }: { initialQuestion?: string }) {
return (
<CostInsightsShellView owner={personalOwner} activePage="ask">
<CostInsightsAskKiloView initialQuestion={initialQuestion} />
</CostInsightsShellView>
);
}

export const DisabledPreview: Story = {
render: () => <AskKiloStory />,
};

export const DisabledPreviewWithQuestion: Story = {
render: () => <AskKiloStory initialQuestion="Show my spend for the last 30 days" />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/nextjs';
import { CostInsightsAlertBar } from '@/components/cost-insights';
import { organizationOwner } from './costInsightsFixtures';

const meta = {
title: 'Cost Insights/In-App Alert Bar',
component: CostInsightsAlertBar,
parameters: { layout: 'fullscreen' },
args: {
owner: organizationOwner,
alertCount: 2,
},
decorators: [
Story => (
<div className="bg-background min-h-screen">
<header className="border-border bg-surface-raised flex h-14 items-center border-b px-4 type-body font-semibold md:px-6">
Kilo Cloud
</header>
<Story />
<main className="mx-auto max-w-[1140px] p-4 md:p-6">
<div className="type-heading">Account overview</div>
</main>
</div>
),
],
} satisfies Meta<typeof CostInsightsAlertBar>;

export default meta;
type Story = StoryObj<typeof meta>;

export const AlertsNeedReview: Story = {};
129 changes: 129 additions & 0 deletions apps/storybook/stories/cost-insights/EventHistory.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/nextjs';
import {
CostInsightsEventHistoryView,
CostInsightsShellView,
type ActivityFilter,
type CostInsightsOwner,
type CostInsightEvent,
} from '@/components/cost-insights';
import {
allEvents,
longLabelEvents,
organizationOwner,
personalOwner,
threshold7DayEvent,
} from './costInsightsFixtures';

const paginatedEvents = Array.from({ length: 23 }, (_, index): CostInsightEvent => {
const event = allEvents[index % allEvents.length];
if (!event) throw new Error('Activity fixture requires at least one event');
return {
...event,
id: `${event.id}-${index}`,
occurredAt:
index < 5
? event.occurredAt
: new Date(
new Date(event.occurredAt).getTime() - (Math.floor(index / 5) + 1) * 24 * 60 * 60 * 1000
).toISOString(),
};
});

const meta: Meta<typeof CostInsightsEventHistoryView> = {
title: 'Cost Insights/Activity',
component: CostInsightsEventHistoryView,
parameters: { layout: 'fullscreen' },
};

export default meta;
type Story = StoryObj<typeof CostInsightsEventHistoryView>;

function ActivityStory({
events,
owner,
empty,
}: {
events: CostInsightEvent[];
owner: CostInsightsOwner;
empty: boolean;
}) {
const [filter, setFilter] = useState<ActivityFilter>('all');
const [page, setPage] = useState(1);
const filteredEvents = events.filter(event => {
if (filter === 'alerts') return ['anomaly_alert', 'threshold_crossed'].includes(event.type);
if (filter === 'suggestions')
return ['suggestion_created', 'suggestion_dismissed'].includes(event.type);
if (filter === 'reviews') return event.type === 'reviewed';
if (filter === 'settings') return ['config_changed', 'disabled'].includes(event.type);
return true;
});
const pageCount = Math.max(1, Math.ceil(filteredEvents.length / 10));
const currentPage = Math.min(page, pageCount);
const pageEvents = filteredEvents.slice((currentPage - 1) * 10, currentPage * 10);
const basePath =
owner.type === 'organization'
? '/organizations/acme-cost-insights/cost-insights'
: '/cost-insights';

return (
<CostInsightsShellView owner={owner} activePage="events" basePath={basePath}>
<CostInsightsEventHistoryView
events={pageEvents}
empty={empty}
filter={filter}
page={currentPage}
pageCount={pageCount}
totalCount={filteredEvents.length}
onFilterChange={nextFilter => {
setFilter(nextFilter);
setPage(1);
}}
onPageChange={setPage}
/>
</CostInsightsShellView>
);
}

function renderActivity(
events: CostInsightEvent[],
owner: CostInsightsOwner = personalOwner,
empty = false
) {
return <ActivityStory events={events} owner={owner} empty={empty} />;
}

export const ActivityHistory: Story = {
render: () => renderActivity(paginatedEvents, organizationOwner),
};

export const SevenDayThresholdActivity: Story = {
render: () => renderActivity([threshold7DayEvent], organizationOwner),
};

export const Empty: Story = {
render: () => renderActivity([], personalOwner, true),
};

export const Loading: Story = {
render: () => (
<CostInsightsShellView owner={personalOwner} activePage="events">
<CostInsightsEventHistoryView events={[]} isLoading />
</CostInsightsShellView>
),
};

export const LoadError: Story = {
render: () => (
<CostInsightsShellView owner={personalOwner} activePage="events">
<CostInsightsEventHistoryView events={[]} isError />
</CostInsightsShellView>
),
};

export const Mobile: Story = {
render: () => renderActivity(longLabelEvents, organizationOwner),
globals: {
viewport: { value: 'mobile2', isRotated: false },
},
};
180 changes: 180 additions & 0 deletions apps/storybook/stories/cost-insights/Overview.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/nextjs';
import {
CostInsightsAskKiloView,
CostInsightsDashboardView,
CostInsightsShellView,
type CostInsightsDashboardData,
type CostInsightsPage,
} from '@/components/cost-insights';
import {
anomalyAlert,
anomalyMetrics,
codingPlanSuggestion,
dashboardData,
emptyDashboardData,
evidenceAnomaly,
kiloPassSuggestion,
longLabelDrivers,
organizationOwner,
spendDriversByRange,
threshold7DayAlert,
thresholdAlert,
} from './costInsightsFixtures';

const meta: Meta<typeof CostInsightsDashboardView> = {
title: 'Cost Insights/Overview',
component: CostInsightsDashboardView,
parameters: { layout: 'fullscreen' },
};

export default meta;
type Story = StoryObj<typeof CostInsightsDashboardView>;

type OverviewStoryOptions = {
isLoading?: boolean;
isError?: boolean;
attention?: 'none' | 'alert';
pendingSuggestionId?: string;
};

function CostInsightsOverviewStory({
data,
options = {},
initialPage = 'dashboard',
}: {
data: CostInsightsDashboardData;
options?: OverviewStoryOptions;
initialPage?: CostInsightsPage;
}) {
const [activePage, setActivePage] = useState<CostInsightsPage>(initialPage);

const basePath =
data.owner.type === 'organization'
? '/organizations/acme-cost-insights/cost-insights'
: '/cost-insights';

return (
<CostInsightsShellView
owner={data.owner}
activePage={activePage}
attention={options.attention ?? (data.alerts.length > 0 ? 'alert' : 'none')}
basePath={basePath}
onPageChange={setActivePage}
>
{activePage === 'ask' ? (
<CostInsightsAskKiloView />
) : (
<CostInsightsDashboardView
data={data}
isLoading={options.isLoading}
isError={options.isError}
activityHref={`${basePath}/activity`}
pendingSuggestionId={options.pendingSuggestionId}
/>
)}
</CostInsightsShellView>
);
}

function renderDashboard(data: CostInsightsDashboardData, options: OverviewStoryOptions = {}) {
return <CostInsightsOverviewStory data={data} options={options} />;
}

export const PersonalOverview: Story = {
render: () => renderDashboard(dashboardData()),
};

export const AlertsNotSetUp: Story = {
render: () =>
renderDashboard(
dashboardData({
enabled: false,
alerts: [],
})
),
};

export const NoSpendYet: Story = {
render: () => renderDashboard(emptyDashboardData()),
};

export const AlertsNeedReview: Story = {
render: () =>
renderDashboard(
dashboardData({
alerts: [anomalyAlert, thresholdAlert],
metrics: anomalyMetrics(),
evidence: evidenceAnomaly,
}),
{ attention: 'alert' }
),
};

export const SevenDayThresholdAlert: Story = {
render: () =>
renderDashboard(dashboardData({ alerts: [threshold7DayAlert] }), { attention: 'alert' }),
};

export const KiloPassSuggestion: Story = {
render: () => renderDashboard(dashboardData({ suggestions: [kiloPassSuggestion] })),
};

export const CodingPlanSuggestion: Story = {
render: () => renderDashboard(dashboardData({ suggestions: [codingPlanSuggestion] })),
};

export const SuggestionDismissPending: Story = {
render: () =>
renderDashboard(dashboardData({ suggestions: [kiloPassSuggestion] }), {
pendingSuggestionId: kiloPassSuggestion.id,
}),
};

export const AlertAndSuggestion: Story = {
render: () =>
renderDashboard(
dashboardData({
alerts: [anomalyAlert],
suggestions: [kiloPassSuggestion],
metrics: anomalyMetrics(),
evidence: evidenceAnomaly,
}),
{ attention: 'alert' }
),
};

export const ReadOnlyAdmin: Story = {
render: () =>
renderDashboard(
dashboardData({
owner: { ...organizationOwner, authorizedRole: 'admin' },
alerts: [thresholdAlert],
suggestions: [kiloPassSuggestion],
metrics: anomalyMetrics(),
}),
{ attention: 'alert' }
),
};

export const Loading: Story = {
render: () => renderDashboard(dashboardData(), { isLoading: true }),
};

export const LoadError: Story = {
render: () => renderDashboard(dashboardData(), { isError: true }),
};

export const MobileOrganizationOverview: Story = {
render: () =>
renderDashboard(
dashboardData({
owner: organizationOwner,
driversByRange: spendDriversByRange(longLabelDrivers),
memberLimitsHref: '/organizations/acme/members/limits',
})
),
globals: {
viewport: { value: 'mobile2', isRotated: false },
},
};
Loading
Loading