@k2b/cloud 0.20.0 → 0.21.1
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 +3 -3
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/font-preloads.ts +25 -0
- package/src/_internal/nats-connection.ts +3 -10
- package/src/_internal/process-sync.ts +94 -23
- package/src/access/PermissionEditor.tsx +31 -12
- package/src/access/PrincipalPicker.tsx +23 -19
- package/src/access/messages.ts +2 -0
- package/src/access/service-account-kind.ts +28 -0
- package/src/account/EntitySearch.tsx +9 -8
- package/src/ai/chat-quotas.ts +10 -6
- package/src/ai/inference-calls.ts +2 -2
- package/src/ai/model-access.ts +5 -2
- package/src/ai/projects.ts +7 -2
- package/src/ai/quota-report.ts +6 -1
- package/src/ai/quotas.ts +1 -1
- package/src/ai/skills.ts +7 -2
- package/src/cli/access.ts +1 -0
- package/src/shared/ai-quotas.ts +16 -3
- package/src/ssr/LayoutPreferences.island.tsx +1 -1
- package/src/ssr/MinimalLayoutPreferences.island.tsx +9 -5
- package/src/styles/resource-search.css +2 -2
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-markdown-editor.css +2 -2
- package/src/styles/utilities-navigation.css +27 -2
- package/src/types/k2b-ui-fonts.d.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"@tailwindcss/typography": "0.5.20",
|
|
99
99
|
"@k2b/nessi": "0.12.1",
|
|
100
100
|
"@k2b/ssr": "0.14.0",
|
|
101
|
-
"@k2b/ui": "0.
|
|
101
|
+
"@k2b/ui": "0.11.0",
|
|
102
102
|
"@k2b/stdlib": "0.27.0",
|
|
103
103
|
"@k2b/sync": "7.0.0",
|
|
104
104
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"hono": "4.13.9",
|
|
133
133
|
"playwright": "1.63.0",
|
|
134
134
|
"solid-js": "1.9.15",
|
|
135
|
-
"typescript": "
|
|
135
|
+
"typescript": "7.0.2",
|
|
136
136
|
"zod": "4.6.5"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
@@ -73,6 +73,7 @@ import { readBoundedJson } from "./bounded-json";
|
|
|
73
73
|
import { appRuntimeMetadata } from "./build-metadata";
|
|
74
74
|
import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
|
|
75
75
|
import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
|
|
76
|
+
import { FONT_PRELOAD_LINKS } from "./font-preloads";
|
|
76
77
|
import { createHeartbeat } from "./heartbeat";
|
|
77
78
|
import { compileHelp } from "./help";
|
|
78
79
|
import { createPageResponses } from "./page-responses";
|
|
@@ -356,6 +357,7 @@ export const defineApp = <
|
|
|
356
357
|
<link rel="icon" href="${appFaviconHref(opts.id, v)}">
|
|
357
358
|
<style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
|
|
358
359
|
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
360
|
+
${FONT_PRELOAD_LINKS}
|
|
359
361
|
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
360
362
|
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
361
363
|
<link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference path="../types/k2b-ui-fonts.d.ts" />
|
|
2
|
+
import plexPreset from "@k2b/ui/fonts/plex.css" with { type: "text" };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preload tags for the IBM Plex Sans faces that every page sets in its first
|
|
6
|
+
* frame: Latin text at 400, 500 (buttons, labels) and 600 (headings).
|
|
7
|
+
*
|
|
8
|
+
* The preset declares its faces with `font-display: swap`. Without a preload
|
|
9
|
+
* the browser requests a face only when layout needs it, paints the fallback
|
|
10
|
+
* font and re-wraps the text once Plex arrives. That moves every box after the
|
|
11
|
+
* first changed line, and on a vertically centered page such as sign-in the
|
|
12
|
+
* whole form. Preloaded, the faces download next to the render-blocking
|
|
13
|
+
* stylesheets and the first frame already uses them.
|
|
14
|
+
*
|
|
15
|
+
* Core serves the faces under `/public/fonts/` with the preset's
|
|
16
|
+
* content-hashed file names (`packages/core/scripts/font-assets.ts`). The
|
|
17
|
+
* preset is bundled as text, so production servers need no `node_modules`.
|
|
18
|
+
*/
|
|
19
|
+
export const FONT_PRELOAD_LINKS = ["400", "500", "600"]
|
|
20
|
+
.map((weight) => {
|
|
21
|
+
const file = new RegExp(`url\\(\\./fonts/(ibm-plex-sans-latin-${weight}-normal-[0-9a-f]+\\.woff2)\\)`).exec(plexPreset)?.[1];
|
|
22
|
+
if (!file) throw new Error(`The @k2b/ui Plex preset has no Latin IBM Plex Sans ${weight} face`);
|
|
23
|
+
return `<link rel="preload" href="/public/fonts/${file}" as="font" type="font/woff2" crossorigin>`;
|
|
24
|
+
})
|
|
25
|
+
.join("\n ");
|
|
@@ -12,15 +12,9 @@ export type ConnectNatsOptions = {
|
|
|
12
12
|
name: string;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`NATS_SERVERS is not set (connection "${name}"). @k2b/sync needs a comma-separated list of ` +
|
|
19
|
-
"nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return connect({
|
|
15
|
+
/** Dial `NATS_SERVERS` once. `startProcessSync()` requires the list and retries while NATS does not answer. */
|
|
16
|
+
export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> =>
|
|
17
|
+
connect({
|
|
24
18
|
servers: env.NATS_SERVERS,
|
|
25
19
|
name,
|
|
26
20
|
ignoreClusterUpdates: env.NATS_IGNORE_CLUSTER_UPDATES,
|
|
@@ -31,4 +25,3 @@ export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsCon
|
|
|
31
25
|
...(env.NATS_CREDS_FILE ? { authenticator: credsAuthenticator(readFileSync(env.NATS_CREDS_FILE)) } : {}),
|
|
32
26
|
...(env.NATS_TLS_CA_FILE ? { tls: { caFile: env.NATS_TLS_CA_FILE } } : {}),
|
|
33
27
|
});
|
|
34
|
-
};
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* nothing touches sync during module evaluation.
|
|
9
9
|
*/
|
|
10
10
|
import { hostname } from "node:os";
|
|
11
|
-
import { createSync, type Sync } from "@k2b/sync";
|
|
11
|
+
import { createSync, type Sync, SyncError } from "@k2b/sync";
|
|
12
|
+
import { expBackoff } from "@k2b/sync/retry";
|
|
13
|
+
import type { NatsConnection } from "@nats-io/transport-node";
|
|
12
14
|
import { env } from "../config/env";
|
|
15
|
+
import { logger } from "../services/logging";
|
|
13
16
|
import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
|
|
14
17
|
import { connectNats } from "./nats-connection";
|
|
15
18
|
import { withSyncBudgets } from "./sync-budget";
|
|
@@ -65,10 +68,93 @@ export type ProcessSync = {
|
|
|
65
68
|
stop: () => Promise<void>;
|
|
66
69
|
};
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* How long a starting process waits for NATS and JetStream. After a host or
|
|
73
|
+
* stack restart, NATS nodes come up in any order and JetStream must recover
|
|
74
|
+
* its streams and elect a leader before its API answers; five minutes covers
|
|
75
|
+
* that. A process that still gets no answer exits instead of staying alive
|
|
76
|
+
* without readiness, so Docker or Kubernetes restarts it and the outage shows
|
|
77
|
+
* up as restarts.
|
|
78
|
+
*/
|
|
79
|
+
const NATS_STARTUP_BUDGET_MS = 5 * 60_000;
|
|
80
|
+
// About 1 s, doubling to 20 s, each ±50 % so processes that restarted together spread their retries.
|
|
81
|
+
const NATS_STARTUP_BACKOFF = { baseMs: 1_000, maxMs: 20_000, jitter: 0.5 };
|
|
82
|
+
|
|
83
|
+
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run `attempt` until it succeeds, with exponential backoff and jitter. A
|
|
87
|
+
* `SyncError` describes the server or the declarations (unsupported version,
|
|
88
|
+
* JetStream disabled, drift), which waiting does not change, so it fails at
|
|
89
|
+
* once. Every other failure (refused or unresolved address, timeout, JetStream
|
|
90
|
+
* without a leader) is logged and retried until `budgetMs` is spent; then the
|
|
91
|
+
* process exits with status 1.
|
|
92
|
+
*/
|
|
93
|
+
export const waitForNats = async <T>(
|
|
94
|
+
attempt: () => Promise<T>,
|
|
95
|
+
{ application, budgetMs = NATS_STARTUP_BUDGET_MS }: { application: string; budgetMs?: number },
|
|
96
|
+
): Promise<T> => {
|
|
97
|
+
const log = logger("sync");
|
|
98
|
+
const deadline = Date.now() + budgetMs;
|
|
99
|
+
for (let attempts = 1; ; attempts++) {
|
|
100
|
+
try {
|
|
101
|
+
const result = await attempt();
|
|
102
|
+
if (attempts > 1) log.info("NATS and JetStream answered", { application, attempts });
|
|
103
|
+
return result;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof SyncError) throw error;
|
|
106
|
+
const remainingMs = deadline - Date.now();
|
|
107
|
+
if (remainingMs <= 0) {
|
|
108
|
+
log.error("NATS and JetStream did not answer within the startup budget; exiting so the process is restarted", {
|
|
109
|
+
application,
|
|
110
|
+
attempts,
|
|
111
|
+
budgetMs,
|
|
112
|
+
error: errorMessage(error),
|
|
113
|
+
});
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const retryInMs = Math.min(remainingMs, expBackoff(attempts, NATS_STARTUP_BACKOFF));
|
|
117
|
+
log.warn("NATS or JetStream is not ready; retrying", { application, attempt: attempts, retryInMs, error: errorMessage(error) });
|
|
118
|
+
await Bun.sleep(retryInMs);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** One start attempt: connect, bind a new Sync instance, and wait for `ready()`; release both when it fails. */
|
|
124
|
+
const connectReadySync = async (application: string): Promise<{ sync: Sync; connection: NatsConnection }> => {
|
|
125
|
+
const connection = await connectNats({ name: `${application}@${hostname()}` });
|
|
126
|
+
let sync: Sync | undefined;
|
|
127
|
+
try {
|
|
128
|
+
sync = withSyncBudgets(
|
|
129
|
+
createSync({
|
|
130
|
+
connection,
|
|
131
|
+
namespace: env.SYNC_NAMESPACE,
|
|
132
|
+
application,
|
|
133
|
+
defaults: { replicas: env.SYNC_REPLICAS },
|
|
134
|
+
observe: (event) => observeSyncEvent(event, application),
|
|
135
|
+
}),
|
|
136
|
+
{ connection, namespace: env.SYNC_NAMESPACE },
|
|
137
|
+
);
|
|
138
|
+
bindProcessSync(sync);
|
|
139
|
+
await sync.ready();
|
|
140
|
+
return { sync, connection };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (current === sync) unbindProcessSync();
|
|
143
|
+
await flushSyncTraceEvents();
|
|
144
|
+
await connection.close();
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
68
149
|
/**
|
|
69
150
|
* Connect NATS, create and bind the process Sync instance, and wait until it is
|
|
70
151
|
* ready. Existing streams of its jobs, queues, and topics take the byte
|
|
71
152
|
* limits they declare before first use (see `sync-budget.ts`).
|
|
153
|
+
*
|
|
154
|
+
* While NATS or JetStream does not answer, it retries with a fresh connection
|
|
155
|
+
* and instance (see `waitForNats`), and exits the process once
|
|
156
|
+
* `NATS_STARTUP_BUDGET_MS` is spent. Callers are process entry points; nothing
|
|
157
|
+
* serves readiness before this returns.
|
|
72
158
|
*/
|
|
73
159
|
export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
|
|
74
160
|
if (!env.SYNC_NAMESPACE.trim()) {
|
|
@@ -77,31 +163,16 @@ export const startProcessSync = async ({ application }: { application: string })
|
|
|
77
163
|
"must share the same @k2b/sync namespace.",
|
|
78
164
|
);
|
|
79
165
|
}
|
|
166
|
+
if (env.NATS_SERVERS.length === 0) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`NATS_SERVERS is not set (application "${application}"). @k2b/sync needs a comma-separated list of ` +
|
|
169
|
+
"nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
80
172
|
if (current || starting) throw new Error("A Sync instance is already starting or bound to this process");
|
|
81
173
|
starting = true;
|
|
82
174
|
try {
|
|
83
|
-
const connection = await
|
|
84
|
-
let sync: Sync | undefined;
|
|
85
|
-
try {
|
|
86
|
-
sync = withSyncBudgets(
|
|
87
|
-
createSync({
|
|
88
|
-
connection,
|
|
89
|
-
namespace: env.SYNC_NAMESPACE,
|
|
90
|
-
application,
|
|
91
|
-
defaults: { replicas: env.SYNC_REPLICAS },
|
|
92
|
-
observe: (event) => observeSyncEvent(event, application),
|
|
93
|
-
}),
|
|
94
|
-
{ connection, namespace: env.SYNC_NAMESPACE },
|
|
95
|
-
);
|
|
96
|
-
bindProcessSync(sync);
|
|
97
|
-
await sync.ready();
|
|
98
|
-
} catch (error) {
|
|
99
|
-
if (current === sync) unbindProcessSync();
|
|
100
|
-
await flushSyncTraceEvents();
|
|
101
|
-
await connection.close();
|
|
102
|
-
throw error;
|
|
103
|
-
}
|
|
104
|
-
const active = sync;
|
|
175
|
+
const { sync: active, connection } = await waitForNats(() => connectReadySync(application), { application });
|
|
105
176
|
let stopPromise: Promise<void> | undefined;
|
|
106
177
|
return {
|
|
107
178
|
sync: active,
|
|
@@ -2,10 +2,11 @@ import { mutation } from "@k2b/stdlib/solid";
|
|
|
2
2
|
import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createSignal, For, Show } from "solid-js";
|
|
4
4
|
import { CloudAvatar } from "../account/Avatar";
|
|
5
|
-
import type { AccessEntry, PermissionLevel, Principal } from "../contracts/shared";
|
|
5
|
+
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
6
6
|
import { groupDisplayName } from "../shared/account-display";
|
|
7
7
|
import { accessMessages } from "./messages";
|
|
8
8
|
import PrincipalPicker from "./PrincipalPicker";
|
|
9
|
+
import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
9
10
|
|
|
10
11
|
// ─────────────────────────────────────────────────────────────────────────
|
|
11
12
|
// Public API
|
|
@@ -31,8 +32,13 @@ type PermissionEditorProps = {
|
|
|
31
32
|
canEdit?: boolean;
|
|
32
33
|
|
|
33
34
|
/** Grant access. The caller closes over the resource id. The optional
|
|
34
|
-
* display metadata lets deferred form drafts retain the selected name
|
|
35
|
-
|
|
35
|
+
* display metadata lets deferred form drafts retain the selected name
|
|
36
|
+
* and, for service accounts, the kind shown in the row. */
|
|
37
|
+
grantAccess: (
|
|
38
|
+
principal: Principal,
|
|
39
|
+
permission: GrantableLevel,
|
|
40
|
+
display?: { displayName: string; serviceAccountKind?: ServiceAccountKind },
|
|
41
|
+
) => Promise<AccessEntry>;
|
|
36
42
|
|
|
37
43
|
/** Update an existing entry's permission level. */
|
|
38
44
|
updateAccess: (accessId: string, permission: GrantableLevel) => Promise<void>;
|
|
@@ -122,14 +128,14 @@ const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMess
|
|
|
122
128
|
return entry.principal.groupId;
|
|
123
129
|
};
|
|
124
130
|
|
|
125
|
-
const getPrincipalIcon = (
|
|
126
|
-
switch (principal.type) {
|
|
131
|
+
const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
|
|
132
|
+
switch (entry.principal.type) {
|
|
127
133
|
case "user":
|
|
128
134
|
return "ti-user";
|
|
129
135
|
case "group":
|
|
130
136
|
return "ti-users-group";
|
|
131
137
|
case "service_account":
|
|
132
|
-
return "ti-key";
|
|
138
|
+
return entry.serviceAccountKind ? serviceAccountKindDisplay(entry.serviceAccountKind, t).icon : "ti-key";
|
|
133
139
|
case "authenticated":
|
|
134
140
|
return "ti-lock-open-2";
|
|
135
141
|
case "public":
|
|
@@ -162,8 +168,11 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
const grantMut = mutation.create({
|
|
165
|
-
mutation: async (data: {
|
|
166
|
-
|
|
171
|
+
mutation: async (data: {
|
|
172
|
+
principal: Principal;
|
|
173
|
+
permission: GrantableLevel;
|
|
174
|
+
display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
|
|
175
|
+
}) => props.grantAccess(data.principal, data.permission, data.display),
|
|
167
176
|
onSuccess: (newEntry) => {
|
|
168
177
|
setEntries([...entries(), newEntry as AccessEntry]);
|
|
169
178
|
},
|
|
@@ -288,7 +297,7 @@ function AccessEntryRow(props: {
|
|
|
288
297
|
when={props.entry.principal.type === "user"}
|
|
289
298
|
fallback={
|
|
290
299
|
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
291
|
-
<i class={`ti ${getPrincipalIcon(props.entry
|
|
300
|
+
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
292
301
|
</div>
|
|
293
302
|
}
|
|
294
303
|
>
|
|
@@ -301,11 +310,21 @@ function AccessEntryRow(props: {
|
|
|
301
310
|
/>
|
|
302
311
|
</Show>
|
|
303
312
|
|
|
304
|
-
{/* Display name
|
|
305
|
-
|
|
313
|
+
{/* Display name. Name and label share one line so every row keeps its height on phones:
|
|
314
|
+
when both do not fit, each gets an equal share and the shorter one stays whole. */}
|
|
315
|
+
<div class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1">
|
|
306
316
|
<span class="truncate text-sm">{displayName()}</span>
|
|
307
317
|
<Show when={props.entry.principal.type === "public"}>
|
|
308
|
-
<span class="
|
|
318
|
+
<span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
|
|
319
|
+
</Show>
|
|
320
|
+
<Show
|
|
321
|
+
when={
|
|
322
|
+
props.entry.principal.type === "service_account" &&
|
|
323
|
+
props.entry.serviceAccountKind &&
|
|
324
|
+
serviceAccountKindDisplay(props.entry.serviceAccountKind, t()).label
|
|
325
|
+
}
|
|
326
|
+
>
|
|
327
|
+
{(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
|
|
309
328
|
</Show>
|
|
310
329
|
</div>
|
|
311
330
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Combobox, type ComboboxOption, useLocale } from "@k2b/ui";
|
|
2
|
-
import type { Principal } from "../contracts/shared";
|
|
2
|
+
import type { Principal, ServiceAccountKind } from "../contracts/shared";
|
|
3
3
|
import { groupDisplayName } from "../shared/account-display";
|
|
4
4
|
import { accessMessages } from "./messages";
|
|
5
|
+
import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
5
6
|
|
|
6
7
|
export const principalKey = (p: Principal) =>
|
|
7
8
|
p.type === "user"
|
|
@@ -19,7 +20,7 @@ type Entity =
|
|
|
19
20
|
serviceAccount: {
|
|
20
21
|
id: string;
|
|
21
22
|
name: string;
|
|
22
|
-
kind:
|
|
23
|
+
kind: ServiceAccountKind;
|
|
23
24
|
appId: string | null;
|
|
24
25
|
resourceType: string | null;
|
|
25
26
|
resourceId: string | null;
|
|
@@ -32,13 +33,15 @@ export default function PrincipalPicker(props: {
|
|
|
32
33
|
allowAuthenticated?: boolean;
|
|
33
34
|
allowServiceAccounts?: boolean;
|
|
34
35
|
disabled?: boolean;
|
|
35
|
-
|
|
36
|
+
/** `serviceAccountKind` is set for service accounts so a new row can show the same kind as the result. */
|
|
37
|
+
onSelect: (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => void;
|
|
36
38
|
}) {
|
|
37
39
|
const locale = useLocale(),
|
|
38
40
|
t = () => accessMessages.resolve([locale()]).t;
|
|
39
|
-
|
|
41
|
+
type Entry = { principal: Principal; label: string; icon: string; description?: string; serviceAccountKind?: ServiceAccountKind };
|
|
42
|
+
let selectable = new Map<string, Entry>();
|
|
40
43
|
const load = async (search: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
|
|
41
|
-
const entries:
|
|
44
|
+
const entries: Entry[] = [];
|
|
42
45
|
if (props.allowAuthenticated !== false)
|
|
43
46
|
entries.push({ principal: { type: "authenticated" }, label: t().allUsers, icon: "ti ti-lock-open-2" });
|
|
44
47
|
if (props.allowPublic) entries.push({ principal: { type: "public" }, label: t().public, icon: "ti ti-world" });
|
|
@@ -71,26 +74,27 @@ export default function PrincipalPicker(props: {
|
|
|
71
74
|
icon: "ti ti-users-group",
|
|
72
75
|
description: e.group.description ?? undefined,
|
|
73
76
|
});
|
|
74
|
-
else
|
|
77
|
+
else {
|
|
78
|
+
const account = e.serviceAccount;
|
|
79
|
+
const kind = serviceAccountKindDisplay(account.kind, t());
|
|
75
80
|
entries.push({
|
|
76
|
-
principal: { type: "service_account", serviceAccountId:
|
|
77
|
-
label:
|
|
81
|
+
principal: { type: "service_account", serviceAccountId: account.id },
|
|
82
|
+
label: account.name,
|
|
83
|
+
// Resource-bound accounts often share a name; their binding tells them apart.
|
|
78
84
|
description:
|
|
79
|
-
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
? t().standaloneServiceAccount
|
|
85
|
-
: [e.serviceAccount.appId, e.serviceAccount.resourceType, e.serviceAccount.resourceId].filter(Boolean).join(" · "),
|
|
86
|
-
icon: e.serviceAccount.kind === "agent" ? "ti ti-robot" : "ti ti-key",
|
|
85
|
+
account.kind === "resource_bound"
|
|
86
|
+
? [account.appId, account.resourceType, account.resourceId].filter(Boolean).join(" · ")
|
|
87
|
+
: kind.label,
|
|
88
|
+
icon: `ti ${kind.icon}`,
|
|
89
|
+
serviceAccountKind: account.kind,
|
|
87
90
|
});
|
|
91
|
+
}
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
const existing = new Set(props.existing?.map(principalKey));
|
|
91
95
|
const filtered = entries.filter((e) => !existing.has(principalKey(e.principal)));
|
|
92
96
|
if (signal.aborted) return [];
|
|
93
|
-
|
|
97
|
+
selectable = new Map(filtered.map((e) => [principalKey(e.principal), e]));
|
|
94
98
|
return filtered.map((e) => ({ id: principalKey(e.principal), label: e.label, icon: e.icon, description: e.description }));
|
|
95
99
|
};
|
|
96
100
|
return (
|
|
@@ -107,8 +111,8 @@ export default function PrincipalPicker(props: {
|
|
|
107
111
|
}
|
|
108
112
|
fetchData={load}
|
|
109
113
|
onSelect={(option) => {
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
114
|
+
const entry = selectable.get(option.id);
|
|
115
|
+
if (entry) props.onSelect(entry.principal, { displayName: entry.label, serviceAccountKind: entry.serviceAccountKind });
|
|
112
116
|
}}
|
|
113
117
|
/>
|
|
114
118
|
);
|
package/src/access/messages.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const accessMessages = i18n.define({
|
|
|
15
15
|
removeAccessConfirm: ({ name }: { name: string }) => `Remove access for ${name}?`,
|
|
16
16
|
removeAccess: "Remove access",
|
|
17
17
|
userBoundServiceAccount: "User-bound service account",
|
|
18
|
+
resourceBoundServiceAccount: "Resource-bound service account",
|
|
18
19
|
standaloneServiceAccount: "Service account",
|
|
19
20
|
agentServiceAccount: "Agent",
|
|
20
21
|
noDirectGrants: "No direct access granted yet.",
|
|
@@ -74,6 +75,7 @@ export const accessMessages = i18n.define({
|
|
|
74
75
|
removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
|
|
75
76
|
removeAccess: "Zugriff entfernen",
|
|
76
77
|
userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
|
|
78
|
+
resourceBoundServiceAccount: "Ressourcengebundenes Dienstkonto",
|
|
77
79
|
standaloneServiceAccount: "Dienstkonto",
|
|
78
80
|
agentServiceAccount: "Agent",
|
|
79
81
|
noDirectGrants: "Noch kein direkter Zugriff erteilt.",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ServiceAccountKind } from "../contracts/shared";
|
|
2
|
+
import type { accessMessages } from "./messages";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one icon and label for each service-account kind, shared by the
|
|
6
|
+
* permission editor rows, the principal picker, and entity search so the same
|
|
7
|
+
* account never appears under different names.
|
|
8
|
+
*
|
|
9
|
+
* Apps bundle their own copy of this mapping, so a kind added later by Core
|
|
10
|
+
* falls back to the key icon without a label instead of breaking the list.
|
|
11
|
+
*/
|
|
12
|
+
export const serviceAccountKindDisplay = (
|
|
13
|
+
kind: ServiceAccountKind,
|
|
14
|
+
t: ReturnType<typeof accessMessages.resolve>["t"],
|
|
15
|
+
): { icon: string; label?: string } => {
|
|
16
|
+
switch (kind) {
|
|
17
|
+
case "agent":
|
|
18
|
+
return { icon: "ti-robot", label: t.agentServiceAccount };
|
|
19
|
+
case "user_delegated":
|
|
20
|
+
return { icon: "ti-user-key", label: t.userBoundServiceAccount };
|
|
21
|
+
case "resource_bound":
|
|
22
|
+
return { icon: "ti-box", label: t.resourceBoundServiceAccount };
|
|
23
|
+
case "standalone":
|
|
24
|
+
return { icon: "ti-key", label: t.standaloneServiceAccount };
|
|
25
|
+
default:
|
|
26
|
+
return { icon: "ti-key" };
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -2,6 +2,8 @@ import { timed } from "@k2b/stdlib/solid";
|
|
|
2
2
|
import { Button, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
3
3
|
import type { JSX } from "solid-js";
|
|
4
4
|
import { createSignal, For, Show } from "solid-js";
|
|
5
|
+
import { accessMessages } from "../access/messages";
|
|
6
|
+
import { serviceAccountKindDisplay } from "../access/service-account-kind";
|
|
5
7
|
import { groupDisplayName } from "../shared/account-display";
|
|
6
8
|
import { CloudAvatar } from "./Avatar";
|
|
7
9
|
|
|
@@ -115,6 +117,8 @@ const EntitySearch = (props: EntitySearchProps) => {
|
|
|
115
117
|
const [groups, setGroups] = createSignal<ApiGroup[]>([]);
|
|
116
118
|
const [serviceAccounts, setServiceAccounts] = createSignal<ApiServiceAccount[]>([]);
|
|
117
119
|
const [loading, setLoading] = createSignal(false);
|
|
120
|
+
const serviceAccountKind = (serviceAccount: ApiServiceAccount) =>
|
|
121
|
+
serviceAccountKindDisplay(serviceAccount.kind, accessMessages.resolve([locale()]).t);
|
|
118
122
|
|
|
119
123
|
// Defensive dev-warning: at least one principal kind must be enabled,
|
|
120
124
|
// otherwise the component is decorative-only and the caller probably
|
|
@@ -290,16 +294,13 @@ const EntitySearch = (props: EntitySearchProps) => {
|
|
|
290
294
|
<For each={serviceAccounts()}>
|
|
291
295
|
{(serviceAccount) => (
|
|
292
296
|
<ResultRow
|
|
293
|
-
icon={serviceAccount.
|
|
297
|
+
icon={serviceAccountKind(serviceAccount).icon}
|
|
294
298
|
title={serviceAccount.name}
|
|
295
299
|
subtitle={
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
: serviceAccount.kind === "standalone"
|
|
301
|
-
? "Service account"
|
|
302
|
-
: [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
|
|
300
|
+
// Resource-bound accounts often share a name; their binding tells them apart.
|
|
301
|
+
serviceAccount.kind === "resource_bound"
|
|
302
|
+
? [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
|
|
303
|
+
: serviceAccountKind(serviceAccount).label
|
|
303
304
|
}
|
|
304
305
|
disabled={props.disabled}
|
|
305
306
|
onSelect={() =>
|
package/src/ai/chat-quotas.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { AccessSubject } from "../server/services/access";
|
|
2
2
|
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
3
|
-
import type { AiChatQuotaSnapshot } from "../shared/ai-quotas";
|
|
3
|
+
import type { AiChatQuotaBalance, AiChatQuotaSnapshot, AiQuotaBalance } from "../shared/ai-quotas";
|
|
4
4
|
import { listAssistantAiModels } from "./assistant-models";
|
|
5
5
|
import { aiQuotas } from "./quotas";
|
|
6
6
|
import { readAiSettingsState } from "./settings";
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Reduces a balance to what the chat shows. Amounts, limits, and the unit never leave the server on this path. */
|
|
9
|
+
const usage = (b: AiQuotaBalance): AiChatQuotaBalance => {
|
|
10
|
+
if (b.bypassed || b.limit === null) return { scope: b.scope, unlimited: true, usedPercent: null, resetsAt: null };
|
|
11
|
+
const usedPercent = b.unknown ? null : b.used >= b.limit ? 100 : Math.max(0, Math.min(99, Math.floor((100 * b.used) / b.limit)));
|
|
12
|
+
return { scope: b.scope, unlimited: false, usedPercent, resetsAt: b.resetsAt };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Own allowances only, as usage shares. Grant identities and unavailable model profiles stay private. */
|
|
9
16
|
export async function getAiChatQuotas(subject: AccessSubject): Promise<AiChatQuotaSnapshot> {
|
|
10
17
|
const config = await aiQuotas.config();
|
|
11
18
|
if (!config.enabled) return { enabled: false, balances: [] };
|
|
@@ -14,12 +21,9 @@ export async function getAiChatQuotas(subject: AccessSubject): Promise<AiChatQuo
|
|
|
14
21
|
const profiles = (await readAiSettingsState()).profiles;
|
|
15
22
|
return {
|
|
16
23
|
enabled: snapshot.enabled,
|
|
17
|
-
unit: config.unit ?? "EUR",
|
|
18
24
|
unlimitedModels: models
|
|
19
25
|
.filter((model) => !hasBillableAiPricing(profiles.find((profile) => profile.id === model.id)?.pricing))
|
|
20
26
|
.map((model) => model.id),
|
|
21
|
-
balances: snapshot.balances
|
|
22
|
-
.filter((b) => b.scope === "*" || models.some((m) => m.id === b.scope))
|
|
23
|
-
.map(({ sources: _sources, sourceDetails: _sourceDetails, ...balance }) => balance),
|
|
27
|
+
balances: snapshot.balances.filter((b) => b.scope === "*" || models.some((m) => m.id === b.scope)).map(usage),
|
|
24
28
|
};
|
|
25
29
|
}
|
|
@@ -98,7 +98,7 @@ export async function beginAiCall(
|
|
|
98
98
|
return {
|
|
99
99
|
error: new AiQuotaError(
|
|
100
100
|
"quota_usage_unknown",
|
|
101
|
-
"Chat
|
|
101
|
+
"Chat usage could not be measured. Reset the allowance or wait for the next window.",
|
|
102
102
|
),
|
|
103
103
|
};
|
|
104
104
|
const rule = config.rules.find((rule) => rule.scope === balance.scope)!;
|
|
@@ -127,7 +127,7 @@ export async function beginAiCall(
|
|
|
127
127
|
error:
|
|
128
128
|
context.kind === "background"
|
|
129
129
|
? new AiBackgroundAdmissionError(backgroundHeadroom !== undefined && inputCost + outputPrice <= backgroundHeadroom)
|
|
130
|
-
: new AiQuotaError("quota_exhausted", "Chat
|
|
130
|
+
: new AiQuotaError("quota_exhausted", "Chat usage limit reached."),
|
|
131
131
|
};
|
|
132
132
|
const reserveOutput = Math.min(requestedOutput ?? outputBound ?? affordable, affordable);
|
|
133
133
|
// Preserve adapter defaults unless an explicit maximum or budget requires a cap.
|
package/src/ai/model-access.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { type AccessEntry, type Principal, PrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { type AccessEntry, type Principal, PrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
4
|
import { type AccessSubject, buildAccessPrincipalCondition, createAccess } from "../server/services/access";
|
|
5
5
|
import { toPgTextArray } from "../services/postgres";
|
|
6
6
|
import type { AiSettingsError } from "./types";
|
|
@@ -114,11 +114,13 @@ export const aiModelAccess = {
|
|
|
114
114
|
service_account_id: string | null;
|
|
115
115
|
authenticated_only: boolean | null;
|
|
116
116
|
display_name: string | null;
|
|
117
|
+
service_account_kind: ServiceAccountKind | null;
|
|
117
118
|
}[]
|
|
118
119
|
>`
|
|
119
120
|
SELECT resource.profile_id, resource.revision, access.id, access.created_at,
|
|
120
121
|
access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
121
|
-
COALESCE(users.display_name, groups.name, service_accounts.name) AS display_name
|
|
122
|
+
COALESCE(users.display_name, groups.name, service_accounts.name) AS display_name,
|
|
123
|
+
service_accounts.kind AS service_account_kind
|
|
122
124
|
FROM ai.model_access_resources resource LEFT JOIN ai.model_access model ON model.profile_id = resource.profile_id
|
|
123
125
|
LEFT JOIN auth.access access ON access.id = model.access_id
|
|
124
126
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
|
@@ -146,6 +148,7 @@ export const aiModelAccess = {
|
|
|
146
148
|
permission: "read",
|
|
147
149
|
createdAt: row.created_at.toISOString(),
|
|
148
150
|
...(row.display_name ? { displayName: row.display_name } : {}),
|
|
151
|
+
...(row.service_account_kind ? { serviceAccountKind: row.service_account_kind } : {}),
|
|
149
152
|
});
|
|
150
153
|
}
|
|
151
154
|
return Object.fromEntries(result);
|
package/src/ai/projects.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
2
2
|
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
3
|
-
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
4
|
import type { AccessSubject } from "../server";
|
|
5
5
|
import {
|
|
6
6
|
buildAccessPrincipalCondition,
|
|
@@ -82,6 +82,8 @@ export type AiProjectAccess = {
|
|
|
82
82
|
principal: Principal;
|
|
83
83
|
permission: AiProjectPermission;
|
|
84
84
|
displayName?: string;
|
|
85
|
+
/** Kind of a `service_account` principal; presentation only. */
|
|
86
|
+
serviceAccountKind?: ServiceAccountKind;
|
|
85
87
|
createdAt: string;
|
|
86
88
|
};
|
|
87
89
|
|
|
@@ -155,6 +157,7 @@ type ProjectAccessRow = {
|
|
|
155
157
|
permission: AiProjectPermission;
|
|
156
158
|
created_at: Date | string;
|
|
157
159
|
display_name: string | null;
|
|
160
|
+
service_account_kind: ServiceAccountKind | null;
|
|
158
161
|
};
|
|
159
162
|
|
|
160
163
|
type AdminProjectRow = ProjectRow & {
|
|
@@ -257,6 +260,7 @@ const toProjectAccess = (row: ProjectAccessRow): AiProjectAccess => ({
|
|
|
257
260
|
: { type: "public" },
|
|
258
261
|
permission: row.permission,
|
|
259
262
|
displayName: row.display_name ?? undefined,
|
|
263
|
+
serviceAccountKind: row.service_account_kind ?? undefined,
|
|
260
264
|
createdAt: iso(row.created_at),
|
|
261
265
|
});
|
|
262
266
|
|
|
@@ -287,7 +291,8 @@ const listProjectAccess = async (projectId: string, db: SQL = sql): Promise<AiPr
|
|
|
287
291
|
SELECT project_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
288
292
|
access.permission, access.created_at,
|
|
289
293
|
COALESCE(users.display_name, groups.name, service_accounts.name,
|
|
290
|
-
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name
|
|
294
|
+
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
|
|
295
|
+
service_accounts.kind AS service_account_kind
|
|
291
296
|
FROM ai.project_access project_access
|
|
292
297
|
JOIN auth.access access ON access.id = project_access.access_id
|
|
293
298
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
package/src/ai/quota-report.ts
CHANGED
|
@@ -18,7 +18,12 @@ export async function quotaAdminConfig() {
|
|
|
18
18
|
...rule,
|
|
19
19
|
grants: rule.grants.map(() => {
|
|
20
20
|
const grant = grants[offset++]!;
|
|
21
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
principal: grant.principal,
|
|
23
|
+
limit: grant.limit,
|
|
24
|
+
displayName: grant.displayName,
|
|
25
|
+
serviceAccountKind: grant.serviceAccountKind,
|
|
26
|
+
};
|
|
22
27
|
}),
|
|
23
28
|
})),
|
|
24
29
|
};
|
package/src/ai/quotas.ts
CHANGED
|
@@ -124,7 +124,7 @@ export const aiQuotas = {
|
|
|
124
124
|
"quota_usage_unknown",
|
|
125
125
|
"Chat usage could not be measured. Contact an administrator or wait for the quota reset.",
|
|
126
126
|
);
|
|
127
|
-
if (b.used >= b.limit) throw new AiQuotaError("quota_exhausted", `Chat
|
|
127
|
+
if (b.used >= b.limit) throw new AiQuotaError("quota_exhausted", `Chat usage limit reached. Resets at ${b.resetsAt}.`);
|
|
128
128
|
}
|
|
129
129
|
return snapshot.balances.some((b) => !b.bypassed && b.limit !== null);
|
|
130
130
|
},
|
package/src/ai/skills.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
3
|
-
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
4
|
import type { AccessSubject } from "../server";
|
|
5
5
|
import {
|
|
6
6
|
buildAccessPrincipalCondition,
|
|
@@ -58,6 +58,8 @@ export type AiSkillAccess = {
|
|
|
58
58
|
principal: Principal;
|
|
59
59
|
permission: AiSkillPermission;
|
|
60
60
|
displayName?: string;
|
|
61
|
+
/** Kind of a `service_account` principal; presentation only. */
|
|
62
|
+
serviceAccountKind?: ServiceAccountKind;
|
|
61
63
|
createdAt: string;
|
|
62
64
|
};
|
|
63
65
|
|
|
@@ -128,6 +130,7 @@ type SkillAccessRow = {
|
|
|
128
130
|
permission: AiSkillPermission;
|
|
129
131
|
created_at: Date | string;
|
|
130
132
|
display_name: string | null;
|
|
133
|
+
service_account_kind: ServiceAccountKind | null;
|
|
131
134
|
};
|
|
132
135
|
|
|
133
136
|
type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
|
|
@@ -282,7 +285,8 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
282
285
|
SELECT skill_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
283
286
|
access.permission, access.created_at,
|
|
284
287
|
COALESCE(users.display_name, groups.name, service_accounts.name,
|
|
285
|
-
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name
|
|
288
|
+
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
|
|
289
|
+
service_accounts.kind AS service_account_kind
|
|
286
290
|
FROM ai.skill_access skill_access
|
|
287
291
|
JOIN auth.access access ON access.id = skill_access.access_id
|
|
288
292
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
|
@@ -305,6 +309,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
305
309
|
: { type: "public" },
|
|
306
310
|
permission: row.permission,
|
|
307
311
|
displayName: row.display_name ?? undefined,
|
|
312
|
+
serviceAccountKind: row.service_account_kind ?? undefined,
|
|
308
313
|
createdAt: iso(row.created_at),
|
|
309
314
|
}));
|
|
310
315
|
};
|
package/src/cli/access.ts
CHANGED
|
@@ -238,6 +238,7 @@ const entryTypeLabel = (entry: AccessEntry): string => {
|
|
|
238
238
|
if (entry.principal.type !== "service_account") return entry.principal.type;
|
|
239
239
|
if (entry.serviceAccountKind === "agent") return "agent";
|
|
240
240
|
if (entry.serviceAccountKind === "resource_bound") return "resource-bound";
|
|
241
|
+
if (entry.serviceAccountKind === "user_delegated") return "user-bound";
|
|
241
242
|
return "service account";
|
|
242
243
|
};
|
|
243
244
|
|
package/src/shared/ai-quotas.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PrincipalSchema } from "../contracts/shared";
|
|
2
|
+
import { PrincipalSchema, ServiceAccountKindSchema } from "../contracts/shared";
|
|
3
3
|
import { AiPriceSchema } from "./ai-costs";
|
|
4
4
|
|
|
5
5
|
export const AiBackgroundBudgetSchema = z
|
|
@@ -22,6 +22,8 @@ export const AiQuotaRuleSchema = z
|
|
|
22
22
|
.object({
|
|
23
23
|
principal: PrincipalSchema.refine((p) => p.type !== "public", "Chat quotas require an identity."),
|
|
24
24
|
displayName: z.string().max(300).optional(),
|
|
25
|
+
/** Presentation only, like `displayName`: service-account grants show the same kind as the principal picker. */
|
|
26
|
+
serviceAccountKind: ServiceAccountKindSchema.optional(),
|
|
25
27
|
limit: AiPriceSchema.nullable(),
|
|
26
28
|
})
|
|
27
29
|
.strict(),
|
|
@@ -71,11 +73,22 @@ export const AiQuotaUsersQuerySchema = z
|
|
|
71
73
|
})
|
|
72
74
|
.strict();
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* One own chat allowance as the browser sees it. It carries no amounts, limits, or unit:
|
|
78
|
+
* `usedPercent` is a whole number from 0 to 100 and reaches 100 only when the allowance is used up.
|
|
79
|
+
* Below 100 the server can still reject a call that the rest of the allowance cannot cover.
|
|
80
|
+
* It is `null` when the allowance is unlimited or its usage cannot be measured right now.
|
|
81
|
+
*/
|
|
82
|
+
export type AiChatQuotaBalance = {
|
|
83
|
+
scope: string;
|
|
84
|
+
unlimited: boolean;
|
|
85
|
+
usedPercent: number | null;
|
|
86
|
+
resetsAt: string | null;
|
|
87
|
+
};
|
|
74
88
|
export type AiChatQuotaSnapshot = {
|
|
75
|
-
unit?: string;
|
|
76
89
|
unlimitedModels?: string[];
|
|
77
90
|
enabled: boolean;
|
|
78
|
-
balances:
|
|
91
|
+
balances: AiChatQuotaBalance[];
|
|
79
92
|
};
|
|
80
93
|
|
|
81
94
|
export const AiQuotaReportQuerySchema = z.object({
|
|
@@ -13,7 +13,7 @@ export default function LayoutPreferences(props: LayoutPreferencesProps) {
|
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
15
|
<Dropdown.Root items={preferences.items()} label={preferences.messages().preferencesMenuLabel} position={props.position} width="14rem">
|
|
16
|
-
<Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm"
|
|
16
|
+
<Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
|
|
17
17
|
<i class="ti ti-adjustments-horizontal" aria-hidden="true" />
|
|
18
18
|
</Dropdown.Trigger>
|
|
19
19
|
</Dropdown.Root>
|
|
@@ -7,7 +7,10 @@ import { createPreferenceController } from "./preference-controller";
|
|
|
7
7
|
/**
|
|
8
8
|
* Labeled language and theme controls for the MinimalLayout footer. The
|
|
9
9
|
* language trigger names the current language; the theme button names the
|
|
10
|
-
* mode it switches to, like every other Cloud preference menu.
|
|
10
|
+
* mode it switches to, like every other Cloud preference menu. Below `md` both
|
|
11
|
+
* are compact, the language code and the theme icon with the full names as
|
|
12
|
+
* accessible labels, so they fit next to the legal links on a phone. CSS picks
|
|
13
|
+
* the form, so the server response is already final.
|
|
11
14
|
*/
|
|
12
15
|
export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
|
|
13
16
|
const locale = useLocale();
|
|
@@ -35,13 +38,14 @@ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThe
|
|
|
35
38
|
>
|
|
36
39
|
<Dropdown.Trigger label={`${t().language}: ${languageName()}`} size="sm" tooltip={false} variant="ghost">
|
|
37
40
|
<i class="ti ti-language" aria-hidden="true" />
|
|
38
|
-
{languageName()}
|
|
39
|
-
<
|
|
41
|
+
<span class="hidden md:inline">{languageName()}</span>
|
|
42
|
+
<span class="md:hidden">{language().toUpperCase()}</span>
|
|
43
|
+
<i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
|
|
40
44
|
</Dropdown.Trigger>
|
|
41
45
|
</Dropdown.Root>
|
|
42
|
-
<Button onClick={preferences.toggleTheme} size="sm" variant="ghost">
|
|
46
|
+
<Button aria-label={preferences.themeLabel()} onClick={preferences.toggleTheme} size="sm" variant="ghost">
|
|
43
47
|
<i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
|
|
44
|
-
{preferences.themeLabel()}
|
|
48
|
+
<span class="hidden md:inline">{preferences.themeLabel()}</span>
|
|
45
49
|
</Button>
|
|
46
50
|
</div>
|
|
47
51
|
);
|
|
@@ -345,8 +345,8 @@
|
|
|
345
345
|
display: none;
|
|
346
346
|
}
|
|
347
347
|
.cloud-resource-search :is(.cloud-resource-search__result, .cloud-resource-search__tag-option, .cloud-resource-search__tag):focus-visible {
|
|
348
|
-
outline:
|
|
349
|
-
outline-offset: -
|
|
348
|
+
outline: var(--k2b-focus-width) solid var(--color-blue-500);
|
|
349
|
+
outline-offset: calc(-1 * var(--k2b-focus-width));
|
|
350
350
|
}
|
|
351
351
|
@media (min-width: 48rem) {
|
|
352
352
|
.cloud-resource-search {
|
|
@@ -219,8 +219,8 @@
|
|
|
219
219
|
|
|
220
220
|
@media (forced-colors: active) {
|
|
221
221
|
.prompt-search-surface:focus-within {
|
|
222
|
-
outline:
|
|
223
|
-
outline-offset: -
|
|
222
|
+
outline: var(--k2b-focus-width) solid CanvasText;
|
|
223
|
+
outline-offset: calc(-1 * var(--k2b-focus-width));
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -433,8 +433,8 @@
|
|
|
433
433
|
|
|
434
434
|
.md-link-widget:focus-visible {
|
|
435
435
|
border-radius: 0.125rem;
|
|
436
|
-
outline:
|
|
437
|
-
outline-offset:
|
|
436
|
+
outline: var(--k2b-focus-width) solid var(--ui-app-accent-border);
|
|
437
|
+
outline-offset: var(--k2b-focus-offset);
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
.md-link-icon {
|
|
@@ -957,8 +957,8 @@
|
|
|
957
957
|
}
|
|
958
958
|
|
|
959
959
|
.minimal-layout-footer__links a:focus-visible {
|
|
960
|
-
outline:
|
|
961
|
-
outline-offset:
|
|
960
|
+
outline: var(--k2b-focus-width) solid var(--k2b-focus-ring);
|
|
961
|
+
outline-offset: var(--k2b-focus-offset);
|
|
962
962
|
}
|
|
963
963
|
|
|
964
964
|
.k2b-ui .minimal-layout-footer .k2b-button[data-size] {
|
|
@@ -984,6 +984,31 @@
|
|
|
984
984
|
}
|
|
985
985
|
}
|
|
986
986
|
|
|
987
|
+
/* The one centered card of a standalone page (sign-in, a public share, an
|
|
988
|
+
unlock form). Phones have no room for a frame inside the page padding, so
|
|
989
|
+
below Tailwind's `md` breakpoint the card loses border, radius, shadow,
|
|
990
|
+
surface and its own padding, and its content sits on the page background.
|
|
991
|
+
Pure CSS on the same markup: the server response is already in its final
|
|
992
|
+
form. The second selector outranks an elevated @k2b/ui Paper.
|
|
993
|
+
The query is the exact opposite of `md:`, so a page that moves its own
|
|
994
|
+
padding or columns with `md:` switches at the same width as the card, also
|
|
995
|
+
with a larger default font size. `(width < 48rem)` is not: the build lowers
|
|
996
|
+
it to `max-width: calc(48rem - .001px)`, which Chromium still matches at
|
|
997
|
+
48rem. Without a frame there is nothing left to clip, so the card also
|
|
998
|
+
stops clipping: its content then reaches the card edge, and a clipping card
|
|
999
|
+
would cut the outer focus ring of a full-width button or field. */
|
|
1000
|
+
@media not all and (min-width: 48rem) {
|
|
1001
|
+
.standalone-card,
|
|
1002
|
+
.k2b-ui .k2b-paper.standalone-card {
|
|
1003
|
+
padding: 0;
|
|
1004
|
+
border: 0;
|
|
1005
|
+
border-radius: 0;
|
|
1006
|
+
background: transparent;
|
|
1007
|
+
box-shadow: none;
|
|
1008
|
+
overflow: visible;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
987
1012
|
/* Workspace navigation chrome and resource identity. */
|
|
988
1013
|
.workspace-sidebar-header {
|
|
989
1014
|
min-height: var(--ui-control-md);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The @k2b/ui Plex preset, imported as text (`with { type: "text" }`). The
|
|
2
|
+
// importing module references this file, so npm consumers that type-check
|
|
3
|
+
// Cloud's sources get the declaration without adding it to their tsconfig.
|
|
4
|
+
declare module "@k2b/ui/fonts/plex.css" {
|
|
5
|
+
const content: string;
|
|
6
|
+
export default content;
|
|
7
|
+
}
|