@cosmicdrift/kumiko-bundled-features 0.224.1 → 0.225.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -9
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +66 -0
- package/src/admin-shell/feature.ts +30 -3
- package/src/admin-shell/i18n.ts +2 -0
- package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +197 -0
- package/src/cap-overview/__tests__/cap-overview.boot.test.ts +120 -0
- package/src/cap-overview/__tests__/usage-math.test.ts +60 -0
- package/src/cap-overview/changes.json +8 -0
- package/src/cap-overview/constants.ts +26 -0
- package/src/cap-overview/feature.ts +77 -0
- package/src/cap-overview/handlers/caps-usage.query.ts +86 -0
- package/src/cap-overview/handlers/tenant-caps-list.query.ts +224 -0
- package/src/cap-overview/handlers/tenant-options.query.ts +33 -0
- package/src/cap-overview/i18n.ts +25 -0
- package/src/cap-overview/index.ts +20 -0
- package/src/cap-overview/screens.ts +101 -0
- package/src/cap-overview/types.ts +51 -0
- package/src/cap-overview/usage-math.ts +32 -0
- package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +198 -0
- package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +62 -0
- package/src/cap-overview/web/cap-cards-panel.tsx +99 -0
- package/src/cap-overview/web/cap-usage-bar.tsx +38 -0
- package/src/cap-overview/web/cap-usage-cell.tsx +20 -0
- package/src/cap-overview/web/client-plugin.tsx +27 -0
- package/src/cap-overview/web/index.ts +10 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// cap-overview — read-only tier/cap-usage visibility per tenant.
|
|
2
|
+
//
|
|
3
|
+
// **What this feature does:**
|
|
4
|
+
// 1. tenant-caps:list query + tenant-cap-list screen — SystemAdmin-only
|
|
5
|
+
// platform-wide table of every tenant's tier, billing status, and
|
|
6
|
+
// usage against a configurable set of caps.
|
|
7
|
+
// 2. caps:usage query + my-caps / platform-tenant-caps dashboards —
|
|
8
|
+
// per-tenant usage cards. TenantAdmin sees their own tenant;
|
|
9
|
+
// SystemAdmin can additionally view any tenant via `tenantId`.
|
|
10
|
+
//
|
|
11
|
+
// **What this feature does NOT do:**
|
|
12
|
+
// - No writes. Reads tier-engine's read_tier_assignments, billing-
|
|
13
|
+
// foundation's read_subscriptions, tenant's read_tenants, plus
|
|
14
|
+
// app-owned usage tables via the caller-supplied `CapSpec` callbacks.
|
|
15
|
+
// - No nav wiring — a separate cut adds `r.nav(...)` entries.
|
|
16
|
+
//
|
|
17
|
+
// **Boot-Dependencies:** tenant, tier-engine, billing-foundation.
|
|
18
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { CAP_OVERVIEW_FEATURE } from "./constants";
|
|
20
|
+
import { createCapsUsageQuery } from "./handlers/caps-usage.query";
|
|
21
|
+
import { createTenantCapsListQuery } from "./handlers/tenant-caps-list.query";
|
|
22
|
+
import { tenantOptionsQuery } from "./handlers/tenant-options.query";
|
|
23
|
+
import { CAP_OVERVIEW_I18N } from "./i18n";
|
|
24
|
+
import { createTenantCapListScreen, myCapsScreen, platformTenantCapsScreen } from "./screens";
|
|
25
|
+
import type { CapSpec } from "./types";
|
|
26
|
+
|
|
27
|
+
export type CreateCapOverviewOptions = {
|
|
28
|
+
readonly caps: readonly CapSpec[];
|
|
29
|
+
/** Caps shown as columns on the platform-wide tenant-cap-list screen.
|
|
30
|
+
* Defaults to the first three caps when omitted. */
|
|
31
|
+
readonly listCaps?: readonly string[];
|
|
32
|
+
/** Options for the tenant-cap-list tier facet. Omitted → no facet — the
|
|
33
|
+
* engine itself carries no enumerated tier vocabulary to derive one
|
|
34
|
+
* from (see screens.ts doc). "Filter by tier" was explicitly requested;
|
|
35
|
+
* omitting `tiers` silently drops that filter, not a supported default. */
|
|
36
|
+
readonly tiers?: readonly string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createCapOverviewFeature(opts: CreateCapOverviewOptions): FeatureDefinition {
|
|
40
|
+
if (opts.caps.length === 0) {
|
|
41
|
+
throw new Error("createCapOverviewFeature: `caps` must not be empty.");
|
|
42
|
+
}
|
|
43
|
+
const capIds = new Set(opts.caps.map((cap) => cap.id));
|
|
44
|
+
const listCaps = opts.listCaps ?? opts.caps.slice(0, 3).map((cap) => cap.id);
|
|
45
|
+
for (const id of listCaps) {
|
|
46
|
+
if (!capIds.has(id)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`createCapOverviewFeature: listCaps references unknown cap id "${id}" — known ids: ${[...capIds].join(", ")}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
|
|
54
|
+
r.describe(
|
|
55
|
+
"Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; TenantAdmin gets their own usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
|
|
56
|
+
);
|
|
57
|
+
r.uiHints({
|
|
58
|
+
displayLabel: "Cap Overview · Tier & Usage Visibility",
|
|
59
|
+
category: "operations",
|
|
60
|
+
recommended: false,
|
|
61
|
+
});
|
|
62
|
+
r.systemScope();
|
|
63
|
+
r.requires("tenant");
|
|
64
|
+
r.requires("tier-engine");
|
|
65
|
+
r.requires("billing-foundation");
|
|
66
|
+
|
|
67
|
+
r.queryHandler(createTenantCapsListQuery(opts.caps, listCaps));
|
|
68
|
+
r.queryHandler(createCapsUsageQuery(opts.caps));
|
|
69
|
+
r.queryHandler(tenantOptionsQuery);
|
|
70
|
+
|
|
71
|
+
r.screen(createTenantCapListScreen(opts.caps, listCaps, opts.tiers));
|
|
72
|
+
r.screen(myCapsScreen);
|
|
73
|
+
r.screen(platformTenantCapsScreen);
|
|
74
|
+
|
|
75
|
+
r.translations({ keys: CAP_OVERVIEW_I18N });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
crossTenantOverrideDenied,
|
|
6
|
+
defineQueryHandler,
|
|
7
|
+
type QueryHandlerDefinition,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { tierAssignmentEntity } from "../../tier-engine";
|
|
12
|
+
import type { CapSpec, CapUsageWithMeta } from "../types";
|
|
13
|
+
import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math";
|
|
14
|
+
|
|
15
|
+
type TierAssignmentRow = { readonly tenantId: string; readonly tier: string };
|
|
16
|
+
|
|
17
|
+
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
18
|
+
|
|
19
|
+
export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefinition {
|
|
20
|
+
return defineQueryHandler({
|
|
21
|
+
name: "caps:usage",
|
|
22
|
+
schema: z.object({ tenantId: z.string().min(1).optional() }),
|
|
23
|
+
access: { roles: access.admin },
|
|
24
|
+
handler: async (query, ctx) => {
|
|
25
|
+
if (!ctx.systemDb) {
|
|
26
|
+
throw new InternalError({
|
|
27
|
+
message: "cap-overview:query:caps:usage requires ctx.systemDb — is r.systemScope() set?",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const override = query.payload.tenantId;
|
|
31
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
32
|
+
query.user,
|
|
33
|
+
override,
|
|
34
|
+
"cap-overview.errors.tenantOverrideRequiresSystemAdmin",
|
|
35
|
+
);
|
|
36
|
+
if (overrideDenied) throw overrideDenied;
|
|
37
|
+
|
|
38
|
+
const targetTenantId = override ?? query.user.tenantId;
|
|
39
|
+
// Both branches return the SAME unfiltered system-mode db —
|
|
40
|
+
// assertTenantMatch is a self-check on the caller, not a query
|
|
41
|
+
// filter (see tenant-db.ts). Every read below carries its own
|
|
42
|
+
// explicit `tenantId` WHERE regardless of which branch ran.
|
|
43
|
+
const db =
|
|
44
|
+
override !== undefined
|
|
45
|
+
? ctx.systemDb.acknowledgeCrossTenant(
|
|
46
|
+
`cap-overview:caps:usage — SystemAdmin cross-tenant read for tenant ${targetTenantId}`,
|
|
47
|
+
)
|
|
48
|
+
: ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
49
|
+
|
|
50
|
+
const assignmentRows = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {
|
|
51
|
+
tenantId: [targetTenantId],
|
|
52
|
+
});
|
|
53
|
+
// Defense-in-depth on the own-tenant path only: assertRowsTenant
|
|
54
|
+
// checks rows against the CALLER's own tenantId, which is only
|
|
55
|
+
// meaningful when the caller is reading their own tenant — on the
|
|
56
|
+
// SystemAdmin override path the target tenant legitimately differs
|
|
57
|
+
// from the caller's own tenantId, so the check would misfire there.
|
|
58
|
+
const checkedRows =
|
|
59
|
+
override === undefined
|
|
60
|
+
? ctx.systemDb.assertRowsTenant(assignmentRows, "tenantId")
|
|
61
|
+
: assignmentRows;
|
|
62
|
+
const tier = checkedRows[0]?.tier ?? "";
|
|
63
|
+
|
|
64
|
+
const rows: CapUsageWithMeta[] = await Promise.all(
|
|
65
|
+
caps.map(async (cap) => {
|
|
66
|
+
const used = await cap.usage(db, targetTenantId);
|
|
67
|
+
const limit = cap.limit(tier);
|
|
68
|
+
const fraction = computeFraction(used, limit);
|
|
69
|
+
return {
|
|
70
|
+
id: cap.id,
|
|
71
|
+
label: cap.label,
|
|
72
|
+
used,
|
|
73
|
+
limit,
|
|
74
|
+
fraction,
|
|
75
|
+
tone: computeTone(fraction),
|
|
76
|
+
percent: Math.round(computeUnclampedFraction(used, limit) * 100),
|
|
77
|
+
...(cap.icon !== undefined && { icon: cap.icon }),
|
|
78
|
+
...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
|
|
79
|
+
};
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return { rows };
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { buildEntityTable, decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { definePagedQueryHandler, MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { subscriptionsProjectionTable } from "../../billing-foundation";
|
|
7
|
+
import { tenantTable } from "../../tenant";
|
|
8
|
+
import { tierAssignmentEntity } from "../../tier-engine";
|
|
9
|
+
import { capFieldName } from "../constants";
|
|
10
|
+
import type { CapSpec, CapUsage } from "../types";
|
|
11
|
+
import { computeFraction } from "../usage-math";
|
|
12
|
+
|
|
13
|
+
type TenantRow = { readonly id: string; readonly name: string };
|
|
14
|
+
type TierAssignmentRow = {
|
|
15
|
+
readonly tenantId: string;
|
|
16
|
+
readonly tier: string;
|
|
17
|
+
readonly source: string | null;
|
|
18
|
+
};
|
|
19
|
+
type SubscriptionRow = {
|
|
20
|
+
readonly tenantId: string;
|
|
21
|
+
readonly providerName: string;
|
|
22
|
+
readonly status: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type TenantCapsListRow = {
|
|
26
|
+
readonly tenantId: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly tier: string;
|
|
29
|
+
readonly billing: string;
|
|
30
|
+
readonly [capField: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// tier-resolver.ts rebuilds the same drizzle table locally rather than
|
|
34
|
+
// importing a pre-built one — tier-engine's public barrel only exports the
|
|
35
|
+
// entity, not a built table (see tier-engine/tier-resolver.ts).
|
|
36
|
+
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
37
|
+
|
|
38
|
+
function billingLabel(
|
|
39
|
+
subscription: SubscriptionRow | undefined,
|
|
40
|
+
tierSource: string | null,
|
|
41
|
+
): string {
|
|
42
|
+
if (subscription) return `${subscription.providerName} · ${subscription.status}`;
|
|
43
|
+
if (tierSource === "manual") return "manual";
|
|
44
|
+
return "—";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
|
|
48
|
+
type FilterOp = z.infer<typeof FILTER_OP>;
|
|
49
|
+
|
|
50
|
+
const SORTABLE_FIELDS = ["name", "tier", "billing"] as const;
|
|
51
|
+
type SortableField = (typeof SORTABLE_FIELDS)[number];
|
|
52
|
+
|
|
53
|
+
function isSortableField(field: string): field is SortableField {
|
|
54
|
+
return (SORTABLE_FIELDS as readonly string[]).includes(field);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// tier is a plain string with no defined ordering — lt/gt have no meaning
|
|
58
|
+
// on it and are rejected rather than silently treated as "no filter".
|
|
59
|
+
function assertSupportedTierFilterOp(op: FilterOp): void {
|
|
60
|
+
if (op === "lt" || op === "gt") {
|
|
61
|
+
throw new ValidationError({
|
|
62
|
+
fields: [
|
|
63
|
+
{
|
|
64
|
+
path: "filters",
|
|
65
|
+
code: "unsupported_op",
|
|
66
|
+
i18nKey: "cap-overview.errors.tierFilterOpUnsupported",
|
|
67
|
+
params: { op },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchesTierFilter(
|
|
75
|
+
tier: string,
|
|
76
|
+
filter: { readonly op: FilterOp; readonly value: unknown } | undefined,
|
|
77
|
+
): boolean {
|
|
78
|
+
if (filter === undefined) return true;
|
|
79
|
+
switch (filter.op) {
|
|
80
|
+
case "eq":
|
|
81
|
+
return tier === filter.value;
|
|
82
|
+
case "ne":
|
|
83
|
+
return tier !== filter.value;
|
|
84
|
+
case "in":
|
|
85
|
+
return Array.isArray(filter.value) && filter.value.includes(tier);
|
|
86
|
+
case "lt":
|
|
87
|
+
case "gt":
|
|
88
|
+
// unreachable in practice — assertSupportedTierFilterOp rejects these before this runs
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: readonly string[]) {
|
|
94
|
+
const listedCaps = caps.filter((cap) => listCaps.includes(cap.id));
|
|
95
|
+
|
|
96
|
+
return definePagedQueryHandler({
|
|
97
|
+
name: "tenant-caps:list",
|
|
98
|
+
schema: z.object({
|
|
99
|
+
cursor: z.string().optional(),
|
|
100
|
+
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
|
|
101
|
+
sort: z.string().optional(),
|
|
102
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
103
|
+
search: z.string().optional(),
|
|
104
|
+
filters: z
|
|
105
|
+
.array(z.object({ field: z.string(), op: FILTER_OP, value: z.unknown() }))
|
|
106
|
+
.optional(),
|
|
107
|
+
totalCount: z.boolean().optional(),
|
|
108
|
+
}),
|
|
109
|
+
access: { roles: ["SystemAdmin"] },
|
|
110
|
+
handler: async (query, ctx) => {
|
|
111
|
+
if (!ctx.systemDb) {
|
|
112
|
+
throw new InternalError({
|
|
113
|
+
message:
|
|
114
|
+
"cap-overview:query:tenant-caps:list requires ctx.systemDb — is r.systemScope() set?",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
118
|
+
"cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const tenants = await selectMany<TenantRow>(db, tenantTable, {});
|
|
122
|
+
const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
|
|
123
|
+
const subscriptions = await selectMany<SubscriptionRow>(db, subscriptionsProjectionTable, {});
|
|
124
|
+
|
|
125
|
+
const assignmentByTenant = new Map(assignments.map((row) => [row.tenantId, row]));
|
|
126
|
+
const subscriptionByTenant = new Map(subscriptions.map((row) => [row.tenantId, row]));
|
|
127
|
+
|
|
128
|
+
// Case-insensitive substring search has no `ilike` operator in the
|
|
129
|
+
// query builder (only `like`, SQL-LIKE case-sensitive) — filtered here
|
|
130
|
+
// in JS, same as tenant/team-list.query.ts's search.
|
|
131
|
+
const search = query.payload.search?.trim().toLowerCase();
|
|
132
|
+
const filters = query.payload.filters ?? [];
|
|
133
|
+
const tierFilter = filters.find((f) => f.field === "tier");
|
|
134
|
+
if (tierFilter !== undefined) assertSupportedTierFilterOp(tierFilter.op);
|
|
135
|
+
|
|
136
|
+
const merged = tenants
|
|
137
|
+
.filter(
|
|
138
|
+
(tenant) =>
|
|
139
|
+
search === undefined || search === "" || tenant.name.toLowerCase().includes(search),
|
|
140
|
+
)
|
|
141
|
+
.map((tenant) => {
|
|
142
|
+
const assignment = assignmentByTenant.get(tenant.id);
|
|
143
|
+
return {
|
|
144
|
+
tenantId: tenant.id,
|
|
145
|
+
name: tenant.name,
|
|
146
|
+
tier: assignment?.tier ?? "",
|
|
147
|
+
source: assignment?.source ?? null,
|
|
148
|
+
billing: billingLabel(subscriptionByTenant.get(tenant.id), assignment?.source ?? null),
|
|
149
|
+
};
|
|
150
|
+
})
|
|
151
|
+
.filter((row) => matchesTierFilter(row.tier, tierFilter));
|
|
152
|
+
|
|
153
|
+
const sortField = query.payload.sort ?? "name";
|
|
154
|
+
if (!isSortableField(sortField)) {
|
|
155
|
+
throw new ValidationError({
|
|
156
|
+
fields: [
|
|
157
|
+
{
|
|
158
|
+
path: "sort",
|
|
159
|
+
code: "unsupported_field",
|
|
160
|
+
i18nKey: "cap-overview.errors.sortFieldUnsupported",
|
|
161
|
+
params: { field: sortField },
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const sortDirection = query.payload.sortDirection ?? "asc";
|
|
167
|
+
const directionMultiplier = sortDirection === "asc" ? 1 : -1;
|
|
168
|
+
const sorted = [...merged].sort(
|
|
169
|
+
(a, b) => a[sortField].localeCompare(b[sortField]) * directionMultiplier,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// ponytail: base64-wrapped offset, not a SQL keyset — rows come from a
|
|
173
|
+
// JS merge of three tables (tenant/tier-assignment/subscription), same
|
|
174
|
+
// rationale as tenant/team-list.query.ts. Holds up to tier-engine's
|
|
175
|
+
// documented "single-pass scan" scale (a few thousand tenants); past
|
|
176
|
+
// that the fix is a real combined read-projection, not a cursor
|
|
177
|
+
// bolted onto this merge.
|
|
178
|
+
const offset = query.payload.cursor ? Number(decodeCursor(query.payload.cursor)) : 0;
|
|
179
|
+
const limit = query.payload.limit;
|
|
180
|
+
const page = sorted.slice(offset, offset + limit);
|
|
181
|
+
|
|
182
|
+
// N+1 avoidance: usage is computed only for this page's tenant ids,
|
|
183
|
+
// never for the full tenant set.
|
|
184
|
+
const pageTenantIds = page.map((row) => row.tenantId);
|
|
185
|
+
const usageByCap = new Map<string, Map<string, number>>();
|
|
186
|
+
for (const cap of listedCaps) {
|
|
187
|
+
if (cap.usageBatch) {
|
|
188
|
+
usageByCap.set(cap.id, await cap.usageBatch(db, pageTenantIds));
|
|
189
|
+
} else {
|
|
190
|
+
const perTenant = new Map<string, number>();
|
|
191
|
+
for (const tenantId of pageTenantIds) {
|
|
192
|
+
perTenant.set(tenantId, await cap.usage(db, tenantId));
|
|
193
|
+
}
|
|
194
|
+
usageByCap.set(cap.id, perTenant);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const rows: TenantCapsListRow[] = page.map((row) => {
|
|
199
|
+
const capFields: Record<string, CapUsage> = {};
|
|
200
|
+
for (const cap of listedCaps) {
|
|
201
|
+
const used = usageByCap.get(cap.id)?.get(row.tenantId) ?? 0;
|
|
202
|
+
const limit = cap.limit(row.tier);
|
|
203
|
+
capFields[capFieldName(cap.id)] = { used, limit, fraction: computeFraction(used, limit) };
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
tenantId: row.tenantId,
|
|
207
|
+
name: row.name,
|
|
208
|
+
tier: row.tier,
|
|
209
|
+
billing: row.billing,
|
|
210
|
+
...capFields,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const nextCursor =
|
|
215
|
+
offset + limit < sorted.length ? encodeCursor(String(offset + limit)) : null;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
rows,
|
|
219
|
+
nextCursor,
|
|
220
|
+
...(query.payload.totalCount === true && { total: merged.length }),
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { tenantTable } from "../../tenant";
|
|
6
|
+
|
|
7
|
+
type TenantRow = { readonly id: string; readonly name: string };
|
|
8
|
+
|
|
9
|
+
export const tenantOptionsQuery = defineQueryHandler({
|
|
10
|
+
name: "tenant-options",
|
|
11
|
+
schema: z.object({}),
|
|
12
|
+
access: { roles: ["SystemAdmin"] },
|
|
13
|
+
handler: async (_query, ctx) => {
|
|
14
|
+
if (!ctx.systemDb) {
|
|
15
|
+
throw new InternalError({
|
|
16
|
+
message:
|
|
17
|
+
"cap-overview:query:tenant-options requires ctx.systemDb — is r.systemScope() set?",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
21
|
+
"cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options",
|
|
22
|
+
);
|
|
23
|
+
const tenants = await selectMany<TenantRow>(
|
|
24
|
+
db,
|
|
25
|
+
tenantTable,
|
|
26
|
+
{},
|
|
27
|
+
{
|
|
28
|
+
orderBy: { col: "name", direction: "asc" },
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
return { rows: tenants.map((tenant) => ({ value: tenant.id, label: tenant.name })) };
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type LocalizedString = { readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const CAP_OVERVIEW_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"screen:tenant-cap-list.title": { en: "Tenant caps" },
|
|
5
|
+
"screen:my-caps.title": { en: "My usage" },
|
|
6
|
+
"screen:platform-tenant-caps.title": { en: "Tenant dashboard" },
|
|
7
|
+
"cap-overview.list.col.name": { en: "Tenant" },
|
|
8
|
+
"cap-overview.list.col.tier": { en: "Tier" },
|
|
9
|
+
"cap-overview.list.col.billing": { en: "Billing" },
|
|
10
|
+
"cap-overview.list.filter.tier": { en: "Tier" },
|
|
11
|
+
"cap-overview.list.action.open": { en: "Open dashboard" },
|
|
12
|
+
"cap-overview.platform.filter.tenant": { en: "Tenant" },
|
|
13
|
+
"cap-overview.cards.empty": { en: "No caps configured for this tenant." },
|
|
14
|
+
"cap-overview.cards.loading": { en: "Loading usage…" },
|
|
15
|
+
"cap-overview.errors.progressPrimitiveMissing": {
|
|
16
|
+
en: "Usage bar unavailable — Progress primitive is not registered.",
|
|
17
|
+
},
|
|
18
|
+
"cap-overview.errors.sortFieldUnsupported": { en: "Sorting by this field is not supported." },
|
|
19
|
+
"cap-overview.errors.tenantOverrideRequiresSystemAdmin": {
|
|
20
|
+
en: "Only SystemAdmin may query another tenant's data.",
|
|
21
|
+
},
|
|
22
|
+
"cap-overview.errors.tierFilterOpUnsupported": {
|
|
23
|
+
en: "This filter operator is not supported for tier.",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Public API of the cap-overview bundled-feature.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
CAP_CARDS_PANEL_COMPONENT,
|
|
5
|
+
CAP_OVERVIEW_FEATURE,
|
|
6
|
+
CAP_USAGE_CELL_COMPONENT,
|
|
7
|
+
CapOverviewQueries,
|
|
8
|
+
capFieldName,
|
|
9
|
+
MY_CAPS_SCREEN_ID,
|
|
10
|
+
PLATFORM_TENANT_CAPS_SCREEN_ID,
|
|
11
|
+
TENANT_CAP_LIST_SCREEN_ID,
|
|
12
|
+
} from "./constants";
|
|
13
|
+
export { type CreateCapOverviewOptions, createCapOverviewFeature } from "./feature";
|
|
14
|
+
export {
|
|
15
|
+
createTenantCapListScreen,
|
|
16
|
+
myCapsScreen,
|
|
17
|
+
platformTenantCapsScreen,
|
|
18
|
+
} from "./screens";
|
|
19
|
+
export type { CapSpec, CapUsage, CapUsageTone, CapUsageWithMeta } from "./types";
|
|
20
|
+
export { computeFraction, computeTone } from "./usage-math";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { access, type ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
CAP_CARDS_PANEL_COMPONENT,
|
|
4
|
+
CAP_USAGE_CELL_COMPONENT,
|
|
5
|
+
CapOverviewQueries,
|
|
6
|
+
capFieldName,
|
|
7
|
+
MY_CAPS_SCREEN_ID,
|
|
8
|
+
PLATFORM_TENANT_CAPS_SCREEN_ID,
|
|
9
|
+
TENANT_CAP_LIST_SCREEN_ID,
|
|
10
|
+
} from "./constants";
|
|
11
|
+
import type { CapSpec } from "./types";
|
|
12
|
+
|
|
13
|
+
// `tiers` has no room for a separate per-tier display label (it's just
|
|
14
|
+
// `readonly string[]`) — tier-engine itself has no enumerated tier list to
|
|
15
|
+
// derive one from either (it's generic over tier values, see tier-engine's
|
|
16
|
+
// own doc comment). The tier string is used verbatim as the facet option
|
|
17
|
+
// label, same as how tier values already surface untranslated elsewhere
|
|
18
|
+
// (tier-engine's TierMap keys).
|
|
19
|
+
export function createTenantCapListScreen(
|
|
20
|
+
caps: readonly CapSpec[],
|
|
21
|
+
listCaps: readonly string[],
|
|
22
|
+
tiers?: readonly string[],
|
|
23
|
+
): ScreenDefinition {
|
|
24
|
+
const listedCaps = caps.filter((cap) => listCaps.includes(cap.id));
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
id: TENANT_CAP_LIST_SCREEN_ID,
|
|
28
|
+
type: "projectionList",
|
|
29
|
+
query: CapOverviewQueries.tenantCapsList,
|
|
30
|
+
columns: [
|
|
31
|
+
{ field: "name", label: "cap-overview.list.col.name" },
|
|
32
|
+
{ field: "tier", label: "cap-overview.list.col.tier" },
|
|
33
|
+
{ field: "billing", label: "cap-overview.list.col.billing" },
|
|
34
|
+
...listedCaps.map((cap) => ({
|
|
35
|
+
field: capFieldName(cap.id),
|
|
36
|
+
label: cap.label,
|
|
37
|
+
renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
|
|
38
|
+
})),
|
|
39
|
+
],
|
|
40
|
+
searchable: true,
|
|
41
|
+
...(tiers !== undefined &&
|
|
42
|
+
tiers.length > 0 && {
|
|
43
|
+
facets: [
|
|
44
|
+
{
|
|
45
|
+
field: "tier",
|
|
46
|
+
type: "select" as const,
|
|
47
|
+
label: "cap-overview.list.filter.tier",
|
|
48
|
+
options: tiers.map((tier) => ({ value: tier, label: tier })),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
// params.map's key ("tenantId") must match platform-tenant-caps's own
|
|
53
|
+
// `filter.id` — that's how useFilterParams (dashboard-body.tsx) seeds
|
|
54
|
+
// the tenant filter from the URL on arrival, landing straight on the
|
|
55
|
+
// clicked tenant's dashboard instead of an empty one (boot-validated).
|
|
56
|
+
rowActions: [
|
|
57
|
+
{
|
|
58
|
+
kind: "navigate" as const,
|
|
59
|
+
id: "open-dashboard",
|
|
60
|
+
label: "cap-overview.list.action.open",
|
|
61
|
+
screen: PLATFORM_TENANT_CAPS_SCREEN_ID,
|
|
62
|
+
params: { map: { tenantId: "tenantId" } },
|
|
63
|
+
rowClick: true,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
67
|
+
access: { roles: ["SystemAdmin"] },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const myCapsScreen: ScreenDefinition = {
|
|
72
|
+
id: MY_CAPS_SCREEN_ID,
|
|
73
|
+
type: "dashboard",
|
|
74
|
+
panels: [
|
|
75
|
+
{
|
|
76
|
+
kind: "custom",
|
|
77
|
+
id: "cap-cards",
|
|
78
|
+
component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
access: { roles: access.admin },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const platformTenantCapsScreen: ScreenDefinition = {
|
|
85
|
+
id: PLATFORM_TENANT_CAPS_SCREEN_ID,
|
|
86
|
+
type: "dashboard",
|
|
87
|
+
panels: [
|
|
88
|
+
{
|
|
89
|
+
kind: "custom",
|
|
90
|
+
id: "cap-cards",
|
|
91
|
+
component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
filter: {
|
|
95
|
+
id: "tenantId",
|
|
96
|
+
label: "cap-overview.platform.filter.tenant",
|
|
97
|
+
kind: "select",
|
|
98
|
+
optionsQuery: CapOverviewQueries.tenantOptions,
|
|
99
|
+
},
|
|
100
|
+
access: { roles: ["SystemAdmin"] },
|
|
101
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
4
|
+
// Closed vocabulary for the dashboard cards' icon chip — cap-cards-panel.tsx
|
|
5
|
+
// holds the matching lucide-react lookup. Small on purpose (YAGNI): extend
|
|
6
|
+
// both together when a cap needs a shape not covered here.
|
|
7
|
+
export type CapIconKey = "file" | "hash" | "database" | "users" | "mail" | "gauge";
|
|
8
|
+
|
|
9
|
+
// Consumer-owned cap definition. `usage`/`usageBatch` receive the SAME
|
|
10
|
+
// unfiltered system-mode TenantDb the handler itself reads through — the
|
|
11
|
+
// callback is responsible for scoping its own app-owned tables by tenantId,
|
|
12
|
+
// same obligation the rest of this feature carries (see feature.ts SECURITY
|
|
13
|
+
// note).
|
|
14
|
+
export type CapSpec = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly label: string;
|
|
17
|
+
readonly limit: (tier: string) => number;
|
|
18
|
+
readonly usage: (db: TenantDb, tenantId: TenantId) => Promise<number>;
|
|
19
|
+
// Batched usage lookup for the tenant-caps:list screen — called once per
|
|
20
|
+
// cap with the current page's tenant ids instead of once per (cap, row).
|
|
21
|
+
// Optional: falls back to per-row `usage()` when absent.
|
|
22
|
+
readonly usageBatch?: (
|
|
23
|
+
db: TenantDb,
|
|
24
|
+
tenantIds: readonly TenantId[],
|
|
25
|
+
) => Promise<Map<TenantId, number>>;
|
|
26
|
+
readonly unit?: "count" | "mb" | "tokens";
|
|
27
|
+
/** Icon chip on the dashboard cards. Optional — omitted renders the card
|
|
28
|
+
* without an icon, same as before this field existed. */
|
|
29
|
+
readonly icon?: CapIconKey;
|
|
30
|
+
/** Icon-chip accent — same raw-CSS-value contract as DashboardStatPanel's
|
|
31
|
+
* `accentColor` (packages/types/src/screen.ts): a theme token
|
|
32
|
+
* (`var(--color-status-ok)`), not a literal palette hex. Optional. */
|
|
33
|
+
readonly accentColor?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CapUsageTone = "default" | "warn" | "danger";
|
|
37
|
+
|
|
38
|
+
export type CapUsage = {
|
|
39
|
+
readonly used: number;
|
|
40
|
+
readonly limit: number;
|
|
41
|
+
readonly fraction: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type CapUsageWithMeta = CapUsage & {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly label: string;
|
|
47
|
+
readonly tone: CapUsageTone;
|
|
48
|
+
readonly percent: number;
|
|
49
|
+
readonly icon?: CapIconKey;
|
|
50
|
+
readonly accentColor?: string;
|
|
51
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure math with no runtime deps — both the query handlers (runtime → client
|
|
3
|
+
// is allowed by the compat matrix) and cap-usage-bar.tsx (client → client)
|
|
4
|
+
// import it.
|
|
5
|
+
import type { CapUsageTone } from "./types";
|
|
6
|
+
|
|
7
|
+
const WARN_THRESHOLD = 0.8;
|
|
8
|
+
const DANGER_THRESHOLD = 1;
|
|
9
|
+
|
|
10
|
+
// limit <= 0 means "not part of this tier" — 0, never NaN/Infinity.
|
|
11
|
+
function rawFraction(used: number, limit: number): number {
|
|
12
|
+
if (limit <= 0) return 0;
|
|
13
|
+
const fraction = used / limit;
|
|
14
|
+
return Number.isFinite(fraction) ? fraction : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function computeFraction(used: number, limit: number): number {
|
|
18
|
+
return Math.min(1, Math.max(0, rawFraction(used, limit)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Unclamped — for display of the over-limit case (e.g. "140%"). Bar width
|
|
22
|
+
// and tone still use the clamped computeFraction; only percent shows the
|
|
23
|
+
// real ratio.
|
|
24
|
+
export function computeUnclampedFraction(used: number, limit: number): number {
|
|
25
|
+
return rawFraction(used, limit);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function computeTone(fraction: number): CapUsageTone {
|
|
29
|
+
if (fraction >= DANGER_THRESHOLD) return "danger";
|
|
30
|
+
if (fraction >= WARN_THRESHOLD) return "warn";
|
|
31
|
+
return "default";
|
|
32
|
+
}
|