Skip to content

Commit 5a96aa1

Browse files
kilo-code-bot[bot]alexkgoldjeanduplessis
authored
feat(usage): add Active KiloClaws table to organization page (#3579)
Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: Alex Gold <alex.gold@kilocode.ai> Co-authored-by: Jean du Plessis <jeandp@gmail.com>
1 parent c329c13 commit 5a96aa1

12 files changed

Lines changed: 24804 additions & 1 deletion

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
import { AlertTriangle, Users } from 'lucide-react';
3+
import { useTRPC } from '@/lib/trpc/utils';
4+
import { useQuery } from '@tanstack/react-query';
5+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6+
import { Skeleton } from '@/components/ui/skeleton';
7+
import {
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableHead,
12+
TableHeader,
13+
TableRow,
14+
} from '@/components/ui/table';
15+
16+
type Props = {
17+
organizationId: string;
18+
};
19+
20+
export function OrgActiveKiloclawsCard({ organizationId }: Props) {
21+
const trpc = useTRPC();
22+
const { data, isLoading, isError } = useQuery(
23+
trpc.organizations.kiloclaw.listActiveInstances.queryOptions({ organizationId })
24+
);
25+
26+
const activeEmails = [...new Set(data?.filter(i => !i.isSuspended).map(i => i.userEmail) ?? [])];
27+
28+
return (
29+
<Card>
30+
<CardHeader className="pb-3">
31+
<div className="flex min-h-8 items-center gap-2">
32+
<Users className="h-4 w-4" />
33+
<CardTitle>Active KiloClaws</CardTitle>
34+
</div>
35+
{!isLoading && !isError && (
36+
<CardDescription className="text-xs">
37+
You have {activeEmails.length} active KiloClaw
38+
{activeEmails.length !== 1 ? 's' : ''} in this organization
39+
</CardDescription>
40+
)}
41+
</CardHeader>
42+
<CardContent className="pt-0">
43+
{isLoading ? (
44+
<div className="space-y-2 p-4">
45+
<Skeleton className="h-8 w-full" />
46+
<Skeleton className="h-8 w-full" />
47+
<Skeleton className="h-8 w-4/5" />
48+
</div>
49+
) : isError ? (
50+
<div className="flex items-start gap-3 px-4 pb-4 pt-2 text-sm">
51+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
52+
<p className="text-muted-foreground">
53+
Unable to load active KiloClaw instances. Please try again later.
54+
</p>
55+
</div>
56+
) : activeEmails.length === 0 ? (
57+
<p className="text-muted-foreground px-4 pb-4 pt-2 text-sm">
58+
No active KiloClaw instances in this organization.
59+
</p>
60+
) : (
61+
<Table className="table-fixed">
62+
<TableHeader>
63+
<TableRow>
64+
<TableHead className="w-full px-6 text-muted-foreground text-xs font-normal">
65+
Owner
66+
</TableHead>
67+
</TableRow>
68+
</TableHeader>
69+
<TableBody>
70+
{activeEmails.map(email => (
71+
<TableRow key={email}>
72+
<TableCell className="max-w-0 px-6 text-sm">
73+
<span className="block truncate" title={email}>
74+
{email}
75+
</span>
76+
</TableCell>
77+
</TableRow>
78+
))}
79+
</TableBody>
80+
</Table>
81+
)}
82+
</CardContent>
83+
</Card>
84+
);
85+
}

apps/web/src/components/organizations/OrganizationDashboard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useOrganizationWithMembers } from '@/app/api/organizations/hooks';
1818
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
1919
import { MessageCircleQuestion, Terminal } from 'lucide-react';
2020
import { OrganizationProvidersAndModelsConfigurationCard } from '@/components/organizations/OrganizationProvidersAndModelsConfigurationCard';
21+
import { OrgActiveKiloclawsCard } from '@/components/organizations/OrgActiveKiloclawsCard';
2122
import { OpenInExtensionButton } from '@/components/auth/OpenInExtensionButton';
2223
import Image from 'next/image';
2324
import Link from 'next/link';
@@ -159,6 +160,9 @@ export function OrganizationDashboard({
159160
<OrganizationAdminMembers organizationId={organizationId} />
160161
</LockableContainer>
161162
<SeatUsageCard organizationId={organizationId} />
163+
{(currentRole === 'owner' || currentRole === 'billing_manager') && (
164+
<OrgActiveKiloclawsCard organizationId={organizationId} />
165+
)}
162166
{organizationData?.plan === 'enterprise' && (
163167
<LockableContainer>
164168
<SSOSignupCard organization={organizationData} role={currentRole} />
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
'use client';
2+
import { useTRPC } from '@/lib/trpc/utils';
3+
import { useQuery } from '@tanstack/react-query';
4+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5+
import { Badge } from '@/components/ui/badge';
6+
import { Skeleton } from '@/components/ui/skeleton';
7+
import {
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableHead,
12+
TableHeader,
13+
TableRow,
14+
} from '@/components/ui/table';
15+
import { formatIsoDateString_UsaDateOnlyFormat } from '@/lib/utils';
16+
import { AlertTriangle } from 'lucide-react';
17+
18+
type ActiveKiloclawsTableProps = {
19+
organizationId: string;
20+
};
21+
22+
export function ActiveKiloclawsTable({ organizationId }: ActiveKiloclawsTableProps) {
23+
const trpc = useTRPC();
24+
const { data, isLoading, isError } = useQuery(
25+
trpc.organizations.kiloclaw.listActiveInstances.queryOptions({
26+
organizationId,
27+
})
28+
);
29+
30+
const instanceCount = data?.length ?? 0;
31+
32+
return (
33+
<Card>
34+
<CardHeader className="pb-2">
35+
<CardTitle className="text-base">
36+
Active KiloClaws
37+
{!isLoading && !isError && (
38+
<span className="text-muted-foreground ml-2 text-sm font-normal">
39+
{instanceCount} {instanceCount === 1 ? 'instance' : 'instances'}
40+
</span>
41+
)}
42+
</CardTitle>
43+
</CardHeader>
44+
<CardContent className="p-0">
45+
{isLoading ? (
46+
<div className="space-y-2 p-4">
47+
<Skeleton className="h-8 w-full" />
48+
<Skeleton className="h-8 w-full" />
49+
<Skeleton className="h-8 w-4/5" />
50+
</div>
51+
) : isError ? (
52+
<div className="flex items-start gap-3 px-4 pb-4 pt-2 text-sm">
53+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
54+
<p className="text-muted-foreground">
55+
Unable to load active KiloClaw instances. Please try again later.
56+
</p>
57+
</div>
58+
) : instanceCount === 0 ? (
59+
<p className="text-muted-foreground px-4 pb-4 pt-2 text-sm">
60+
No active KiloClaw instances in this organization.
61+
</p>
62+
) : (
63+
<div className="overflow-x-auto">
64+
<Table className="min-w-[680px] table-fixed">
65+
<TableHeader>
66+
<TableRow>
67+
<TableHead className="w-[40%]">User</TableHead>
68+
<TableHead className="w-[30%]">Instance Name</TableHead>
69+
<TableHead className="w-[18%]">Created</TableHead>
70+
<TableHead className="w-[12%]">Status</TableHead>
71+
</TableRow>
72+
</TableHeader>
73+
<TableBody>
74+
{data?.map(instance => (
75+
<TableRow key={instance.id}>
76+
<TableCell className="max-w-0 text-sm">
77+
<span className="block truncate" title={instance.userEmail}>
78+
{instance.userEmail}
79+
</span>
80+
</TableCell>
81+
<TableCell className="max-w-0 text-sm">
82+
{instance.name ? (
83+
<span className="block truncate" title={instance.name}>
84+
{instance.name}
85+
</span>
86+
) : (
87+
<span className="text-muted-foreground"></span>
88+
)}
89+
</TableCell>
90+
<TableCell className="text-sm">
91+
{formatIsoDateString_UsaDateOnlyFormat(instance.createdAt)}
92+
</TableCell>
93+
<TableCell>
94+
{instance.isSuspended ? (
95+
<Badge variant="destructive" className="text-xs">
96+
Suspended
97+
</Badge>
98+
) : (
99+
<Badge variant="secondary" className="text-xs">
100+
Active
101+
</Badge>
102+
)}
103+
</TableCell>
104+
</TableRow>
105+
))}
106+
</TableBody>
107+
</Table>
108+
</div>
109+
)}
110+
</CardContent>
111+
</Card>
112+
);
113+
}

apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { PrimaryChart } from './PrimaryChart';
2121
import { BreakdownPieChart } from './BreakdownPieChart';
2222
import { BreakdownBarChart } from './BreakdownBarChart';
2323
import { AIAdoptionScoreCard } from './AIAdoptionScoreCard';
24+
import { ActiveKiloclawsTable } from './ActiveKiloclawsTable';
2425
import {
2526
PERSONAL_VIEW_ALL_USAGE,
2627
PERSONAL_VIEW_PERSONAL_ONLY,
@@ -606,6 +607,12 @@ export function UsageAnalyticsDashboard({
606607
</Button>
607608
}
608609
/>
610+
611+
{isOrgContext &&
612+
organizationId &&
613+
(callerRole === 'owner' || callerRole === 'billing_manager') && (
614+
<ActiveKiloclawsTable organizationId={organizationId} />
615+
)}
609616
</div>
610617
</div>
611618
</div>

apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cleanupDbForTest, db } from '@/lib/drizzle';
66
import { insertTestUser } from '@/tests/helpers/user.helper';
77
import { createOrganization } from '@/lib/organizations/organizations';
88
import type { createCallerForUser as TestUtilsCallerFactory } from '@/routers/test-utils';
9+
import { LEGACY_KILOCLAW_PRICE_VERSION } from '@kilocode/db';
910
import {
1011
kiloclaw_image_catalog,
1112
kiloclaw_instances,
@@ -17,6 +18,9 @@ import {
1718
} from '@kilocode/db/schema';
1819
import { and, eq } from 'drizzle-orm';
1920

21+
(kiloclaw_subscriptions.kiloclaw_price_version as { defaultFn: () => string }).defaultFn = () =>
22+
LEGACY_KILOCLAW_PRICE_VERSION;
23+
2024
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2125
type AnyMock = jest.Mock<(...args: any[]) => any>;
2226

@@ -165,6 +169,76 @@ async function addOrganizationSeatEntitlement(organizationId: string): Promise<v
165169
});
166170
}
167171

172+
describe('organizations.kiloclaw.listActiveInstances', () => {
173+
beforeEach(async () => {
174+
await cleanupDbForTest();
175+
});
176+
177+
it('excludes orphan and destroyed organization instances', async () => {
178+
const user = await insertTestUser({
179+
google_user_email: `org-kiloclaw-list-${crypto.randomUUID()}@example.com`,
180+
});
181+
const organization = await createOrganization('Org KiloClaw List Test', user.id);
182+
await createActiveOrgInstance(user.id, organization.id);
183+
const activeInstanceId = await createActiveOrgInstance(user.id, organization.id);
184+
const suspendedInstanceId = await createActiveOrgInstance(user.id, organization.id);
185+
const destroyedInstanceId = await createActiveOrgInstance(user.id, organization.id);
186+
187+
await db.insert(kiloclaw_subscriptions).values([
188+
{
189+
user_id: user.id,
190+
instance_id: activeInstanceId,
191+
plan: 'standard',
192+
status: 'active',
193+
payment_source: 'credits',
194+
cancel_at_period_end: false,
195+
},
196+
{
197+
user_id: user.id,
198+
instance_id: suspendedInstanceId,
199+
plan: 'standard',
200+
status: 'canceled',
201+
payment_source: 'credits',
202+
cancel_at_period_end: false,
203+
suspended_at: '2026-05-28T00:00:00.000Z',
204+
},
205+
{
206+
user_id: user.id,
207+
instance_id: destroyedInstanceId,
208+
plan: 'standard',
209+
status: 'active',
210+
payment_source: 'credits',
211+
cancel_at_period_end: false,
212+
},
213+
]);
214+
await db
215+
.update(kiloclaw_instances)
216+
.set({ destroyed_at: '2026-05-28T00:00:00.000Z' })
217+
.where(eq(kiloclaw_instances.id, destroyedInstanceId));
218+
219+
const caller = await createCallerForUser(user.id);
220+
const result = await caller.organizations.kiloclaw.listActiveInstances({
221+
organizationId: organization.id,
222+
});
223+
224+
expect(result).toHaveLength(2);
225+
expect(result).toEqual(
226+
expect.arrayContaining([
227+
expect.objectContaining({
228+
id: activeInstanceId,
229+
userEmail: user.google_user_email,
230+
isSuspended: false,
231+
}),
232+
expect.objectContaining({
233+
id: suspendedInstanceId,
234+
userEmail: user.google_user_email,
235+
isSuspended: true,
236+
}),
237+
])
238+
);
239+
});
240+
});
241+
168242
describe('organization kiloclaw destroy', () => {
169243
beforeEach(async () => {
170244
await cleanupDbForTest();

apps/web/src/routers/organizations/organization-kiloclaw-router.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ import {
2929
kiloclaw_version_pins,
3030
kiloclaw_image_catalog,
3131
kiloclaw_cli_runs,
32+
kiloclaw_instances,
33+
kiloclaw_subscriptions,
34+
kilocode_users,
3235
} from '@kilocode/db/schema';
33-
import { and, eq, desc, sql } from 'drizzle-orm';
36+
import { and, eq, desc, sql, isNull } from 'drizzle-orm';
3437
import type { KiloClawDashboardStatus, KiloCodeConfigResponse } from '@/lib/kiloclaw/types';
3538
import { cancelCliRun, createCliRun, getCliRunStatus } from '@/lib/kiloclaw/cli-runs';
3639
import { queryDiskUsage } from '@/lib/kiloclaw/disk-usage';
@@ -54,6 +57,7 @@ import { encryptProvisionSecretsForWorker } from '@/lib/kiloclaw/provision-secre
5457
import {
5558
organizationMemberProcedure,
5659
organizationMemberMutationProcedure,
60+
organizationBillingProcedure,
5761
} from '@/routers/organizations/utils';
5862
import { requireOrganizationKiloClawComputeEntitlement } from '@/lib/organizations/trial-middleware';
5963

@@ -1573,4 +1577,38 @@ export const organizationKiloclawRouter = createTRPCRouter({
15731577
handleFileOperationError(err, 'patch openclaw config');
15741578
}
15751579
}),
1580+
1581+
// ── Org-wide instance list (owner / billing_manager only) ─────
1582+
1583+
listActiveInstances: organizationBillingProcedure.query(async ({ input }) => {
1584+
const rows = await db
1585+
.select({
1586+
id: kiloclaw_instances.id,
1587+
name: kiloclaw_instances.name,
1588+
createdAt: kiloclaw_instances.created_at,
1589+
userEmail: kilocode_users.google_user_email,
1590+
suspendedAt: kiloclaw_subscriptions.suspended_at,
1591+
})
1592+
.from(kiloclaw_instances)
1593+
.innerJoin(kilocode_users, eq(kiloclaw_instances.user_id, kilocode_users.id))
1594+
.innerJoin(
1595+
kiloclaw_subscriptions,
1596+
eq(kiloclaw_subscriptions.instance_id, kiloclaw_instances.id)
1597+
)
1598+
.where(
1599+
and(
1600+
eq(kiloclaw_instances.organization_id, input.organizationId),
1601+
isNull(kiloclaw_instances.destroyed_at)
1602+
)
1603+
)
1604+
.orderBy(kiloclaw_instances.created_at);
1605+
1606+
return rows.map(row => ({
1607+
id: row.id,
1608+
name: row.name,
1609+
createdAt: new Date(row.createdAt).toISOString(),
1610+
userEmail: row.userEmail,
1611+
isSuspended: row.suspendedAt !== null,
1612+
}));
1613+
}),
15761614
});

0 commit comments

Comments
 (0)