@dbx-tools/appkit-lakebox 0.0.0-stage → 0.9.131

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.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -2
  3. package/index.ts +32 -0
  4. package/lib/index.d.ts +28 -0
  5. package/lib/index.js +26 -0
  6. package/lib/src/_package.d.ts +2 -0
  7. package/lib/src/_package.js +11 -0
  8. package/lib/src/_resource-registry.d.ts +41 -0
  9. package/lib/src/_resource-registry.js +128 -0
  10. package/lib/src/_service-snapshots.d.ts +55 -0
  11. package/lib/src/_service-snapshots.js +65 -0
  12. package/lib/src/config.d.ts +40 -0
  13. package/lib/src/config.js +86 -0
  14. package/lib/src/desktop.d.ts +6 -0
  15. package/lib/src/desktop.js +88 -0
  16. package/lib/src/metrics.d.ts +59 -0
  17. package/lib/src/metrics.js +151 -0
  18. package/lib/src/plugin.d.ts +259 -0
  19. package/lib/src/plugin.js +329 -0
  20. package/lib/src/routes.d.ts +75 -0
  21. package/lib/src/routes.js +441 -0
  22. package/lib/src/runtime.d.ts +154 -0
  23. package/lib/src/runtime.js +760 -0
  24. package/lib/src/services.d.ts +101 -0
  25. package/lib/src/services.js +188 -0
  26. package/lib/src/skills.d.ts +5 -0
  27. package/lib/src/skills.js +21 -0
  28. package/lib/src/user.d.ts +46 -0
  29. package/lib/src/user.js +76 -0
  30. package/lib/src/workspace.d.ts +43 -0
  31. package/lib/src/workspace.js +138 -0
  32. package/lib/tsconfig.tsbuildinfo +1 -0
  33. package/package.json +167 -4
  34. package/scripts/desktop-prerequisites.sh +66 -0
  35. package/scripts/desktop-session.sh +307 -0
  36. package/scripts/lakebox-admin.sh +234 -0
  37. package/scripts/lakebox-service.py +449 -0
  38. package/skills/html-page/SKILL.md +40 -0
  39. package/skills/jupyter-lab/SKILL.md +32 -0
  40. package/skills/lakebox-showcase/SKILL.md +66 -0
  41. package/skills/network-speed-test/SKILL.md +29 -0
  42. package/skills/realtime-whiteboard/SKILL.md +28 -0
  43. package/src/_package.ts +11 -0
  44. package/src/_resource-registry.ts +160 -0
  45. package/src/_service-snapshots.ts +84 -0
  46. package/src/config.ts +128 -0
  47. package/src/desktop.ts +97 -0
  48. package/src/metrics.ts +178 -0
  49. package/src/plugin.ts +446 -0
  50. package/src/routes.ts +524 -0
  51. package/src/runtime.ts +952 -0
  52. package/src/services.ts +239 -0
  53. package/src/skills.ts +23 -0
  54. package/src/user.ts +88 -0
  55. package/src/workspace.ts +185 -0
@@ -0,0 +1,160 @@
1
+ import { CacheManager } from "@databricks/appkit";
2
+ import { LakeboxApiError, type LakeboxEntry } from "@dbx-tools/lakebox/rest";
3
+ import { withAdvisoryLock, type PgPoolLike } from "@dbx-tools/postgres";
4
+ import { log } from "@dbx-tools/shared-core";
5
+ import { z } from "zod";
6
+
7
+ const CACHE_NAMESPACE = "appkit-lakebox:resource";
8
+ const RESOURCE_TTL_SECONDS = 365 * 24 * 60 * 60;
9
+ const LEGACY_PREFIX = "dbx-lakebox-";
10
+ const logger = log.logger("appkit-lakebox:resource");
11
+
12
+ const LakeboxResourceReferenceSchema = z
13
+ .object({ sandboxId: z.string().min(1).describe("Databricks Lakebox identifier.") })
14
+ .strict()
15
+ .describe("Persistent pointer to the Databricks-assigned app Lakebox.");
16
+
17
+ interface ResourceCache {
18
+ generateKey(parts: (string | number | object)[], userKey: string): string;
19
+ get<T>(key: string): Promise<T | null>;
20
+ set<T>(key: string, value: T, options?: { ttl?: number }): Promise<void>;
21
+ delete(key: string): Promise<void>;
22
+ }
23
+
24
+ /** Lakebox client capabilities required by the persistent resource registry. */
25
+ export interface LakeboxResourceClient {
26
+ workspaceScope(): Promise<string>;
27
+ list(signal?: AbortSignal): Promise<LakeboxEntry[]>;
28
+ get(id: string, signal?: AbortSignal): Promise<LakeboxEntry>;
29
+ create(signal?: AbortSignal): Promise<LakeboxEntry>;
30
+ delete(id: string, signal?: AbortSignal): Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * Persists the Databricks-assigned Lakebox ID and serializes first creation
35
+ * across app replicas with the Lakebase service-principal pool.
36
+ */
37
+ export class LakeboxResourceRegistry {
38
+ private readonly pending = new Map<string, Promise<LakeboxEntry>>();
39
+ private readonly legacyChecked = new Set<string>();
40
+
41
+ constructor(
42
+ private readonly application: string,
43
+ private readonly pool?: PgPoolLike,
44
+ private readonly cache = (): ResourceCache => CacheManager.getInstanceSync(),
45
+ ) {}
46
+
47
+ /** Resolves the current app Lakebox without creating one. */
48
+ async read(
49
+ client: LakeboxResourceClient,
50
+ signal?: AbortSignal,
51
+ ): Promise<LakeboxEntry | undefined> {
52
+ const scope = await client.workspaceScope();
53
+ return this.resolve(client, scope, signal);
54
+ }
55
+
56
+ /** Returns the current app Lakebox or creates one with a Databricks-assigned name. */
57
+ async getOrCreate(client: LakeboxResourceClient, signal?: AbortSignal): Promise<LakeboxEntry> {
58
+ const scope = await client.workspaceScope();
59
+ const current = this.pending.get(scope);
60
+ if (current) return current;
61
+ const pending = this.withLock(scope, async () => {
62
+ const existing = await this.resolve(client, scope, signal);
63
+ if (existing) return existing;
64
+ const created = await client.create(signal);
65
+ try {
66
+ await this.write(scope, created.sandboxId);
67
+ } catch (error) {
68
+ await client.delete(created.sandboxId, signal).catch(() => undefined);
69
+ throw error;
70
+ }
71
+ logger.info("created app Lakebox", { id: created.sandboxId });
72
+ return created;
73
+ }).finally(() => this.pending.delete(scope));
74
+ this.pending.set(scope, pending);
75
+ return pending;
76
+ }
77
+
78
+ /** Clears the persisted pointer after the managed Lakebox is deleted. */
79
+ async clear(client: LakeboxResourceClient): Promise<void> {
80
+ const scope = await client.workspaceScope();
81
+ await this.cache().delete(this.key(scope));
82
+ this.legacyChecked.add(scope);
83
+ }
84
+
85
+ private async resolve(
86
+ client: LakeboxResourceClient,
87
+ scope: string,
88
+ signal?: AbortSignal,
89
+ ): Promise<LakeboxEntry | undefined> {
90
+ const key = this.key(scope);
91
+ const value = await this.cache().get<unknown>(key);
92
+ const parsed = LakeboxResourceReferenceSchema.safeParse(value);
93
+ if (value !== null && !parsed.success) {
94
+ logger.warn("discarded invalid cached Lakebox resource reference", {
95
+ application: this.application,
96
+ });
97
+ await this.cache().delete(key);
98
+ }
99
+ if (parsed.success) {
100
+ try {
101
+ return await client.get(parsed.data.sandboxId, signal);
102
+ } catch (error) {
103
+ if (!(error instanceof LakeboxApiError) || error.status !== 404) throw error;
104
+ await this.cache().delete(key);
105
+ this.legacyChecked.add(scope);
106
+ return undefined;
107
+ }
108
+ }
109
+ if (this.legacyChecked.has(scope)) return undefined;
110
+ this.legacyChecked.add(scope);
111
+ const legacy = selectLegacyLakebox(
112
+ await client.list(signal),
113
+ legacyLakeboxName(this.application),
114
+ );
115
+ if (!legacy) return undefined;
116
+ await this.write(scope, legacy.sandboxId);
117
+ logger.info("adopted legacy named app Lakebox", { id: legacy.sandboxId });
118
+ return legacy;
119
+ }
120
+
121
+ private async write(scope: string, sandboxId: string): Promise<void> {
122
+ await this.cache().set(this.key(scope), LakeboxResourceReferenceSchema.parse({ sandboxId }), {
123
+ ttl: RESOURCE_TTL_SECONDS,
124
+ });
125
+ }
126
+
127
+ private key(scope: string): string {
128
+ return this.cache().generateKey([CACHE_NAMESPACE, this.application], scope);
129
+ }
130
+
131
+ private withLock<T>(scope: string, fn: () => Promise<T>): Promise<T> {
132
+ if (!this.pool) return fn();
133
+ return withAdvisoryLock(this.pool, [CACHE_NAMESPACE, this.application, scope], fn);
134
+ }
135
+ }
136
+
137
+ function legacyLakeboxName(application: string): string {
138
+ const slug = application
139
+ .trim()
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, "-")
142
+ .replace(/^-|-$/g, "")
143
+ .slice(0, 200);
144
+ if (!slug) throw new Error("A managed Lakebox requires a non-empty application key");
145
+ return `${LEGACY_PREFIX}${slug}`;
146
+ }
147
+
148
+ function selectLegacyLakebox(
149
+ entries: readonly LakeboxEntry[],
150
+ name: string,
151
+ ): LakeboxEntry | undefined {
152
+ return entries
153
+ .filter((entry) => entry.name === name)
154
+ .sort(
155
+ (left, right) =>
156
+ (Date.parse(left.createTime ?? "") || Number.MAX_SAFE_INTEGER) -
157
+ (Date.parse(right.createTime ?? "") || Number.MAX_SAFE_INTEGER) ||
158
+ left.sandboxId.localeCompare(right.sandboxId),
159
+ )[0];
160
+ }
@@ -0,0 +1,84 @@
1
+ import { CacheManager } from "@databricks/appkit";
2
+ import { errorUtils, log } from "@dbx-tools/shared-core";
3
+ import { z } from "zod";
4
+ import { LakeboxControllerDefinitionSchema, type LakeboxControllerService } from "./services.ts";
5
+
6
+ const CACHE_NAMESPACE = "appkit-lakebox:service-snapshot";
7
+ const SNAPSHOT_TTL_SECONDS = 365 * 24 * 60 * 60;
8
+ const logger = log.logger("appkit-lakebox:service-snapshot");
9
+
10
+ export const LakeboxServiceSnapshotSchema = z
11
+ .object({
12
+ observedAt: z.string().datetime().describe("Time the controller last returned this list."),
13
+ definitions: z
14
+ .array(LakeboxControllerDefinitionSchema)
15
+ .describe("Last observed saved service definitions; never authoritative live state."),
16
+ })
17
+ .strict()
18
+ .describe("Best-effort last-known Lakebox service definitions for an unavailable machine.");
19
+
20
+ export type LakeboxServiceSnapshot = z.infer<typeof LakeboxServiceSnapshotSchema>;
21
+
22
+ /** Projects saved definitions into explicitly non-live stopped service records. */
23
+ export function stoppedSnapshotServices(
24
+ snapshot: LakeboxServiceSnapshot,
25
+ ): Map<string, LakeboxControllerService> {
26
+ return new Map(
27
+ snapshot.definitions.map((definition) => [
28
+ definition.name,
29
+ { name: definition.name, state: "stopped", definition },
30
+ ]),
31
+ );
32
+ }
33
+
34
+ interface SnapshotCache {
35
+ generateKey(parts: (string | number | object)[], userKey: string): string;
36
+ get<T>(key: string): Promise<T | null>;
37
+ set<T>(key: string, value: T, options?: { ttl?: number }): Promise<void>;
38
+ }
39
+
40
+ /** Best-effort persistent snapshots backed by AppKit's configured cache. */
41
+ export class LakeboxServiceSnapshots {
42
+ constructor(private readonly cache = (): SnapshotCache => CacheManager.getInstanceSync()) {}
43
+
44
+ async read(lakeboxId: string, userHash: string): Promise<LakeboxServiceSnapshot | undefined> {
45
+ try {
46
+ const value = await this.cache().get<unknown>(this.key(lakeboxId, userHash));
47
+ if (value === null) return undefined;
48
+ const parsed = LakeboxServiceSnapshotSchema.safeParse(value);
49
+ if (parsed.success) return parsed.data;
50
+ logger.warn("ignored invalid cached service snapshot", { lakeboxId, userHash });
51
+ } catch (error) {
52
+ logger.warn("unable to read cached service snapshot", {
53
+ lakeboxId,
54
+ userHash,
55
+ error: errorUtils.toError(error),
56
+ });
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ async write(
62
+ lakeboxId: string,
63
+ userHash: string,
64
+ snapshot: LakeboxServiceSnapshot,
65
+ ): Promise<void> {
66
+ try {
67
+ await this.cache().set(
68
+ this.key(lakeboxId, userHash),
69
+ LakeboxServiceSnapshotSchema.parse(snapshot),
70
+ { ttl: SNAPSHOT_TTL_SECONDS },
71
+ );
72
+ } catch (error) {
73
+ logger.warn("unable to cache service snapshot", {
74
+ lakeboxId,
75
+ userHash,
76
+ error: errorUtils.toError(error),
77
+ });
78
+ }
79
+ }
80
+
81
+ private key(lakeboxId: string, userHash: string): string {
82
+ return this.cache().generateKey([CACHE_NAMESPACE, lakeboxId], userHash);
83
+ }
84
+ }
package/src/config.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { basename } from "node:path";
2
+ import type { BasePluginConfig } from "@databricks/appkit";
3
+ import { configUtils, projectUtils } from "@dbx-tools/core";
4
+ import { Ssh2PatchModeSchema, SshTransportModeSchema } from "@dbx-tools/lakebox/transport";
5
+ import { LAKEBOX_DEFAULT_ROUTE_PREFIX } from "@dbx-tools/shared-lakebox";
6
+ import { z } from "zod";
7
+
8
+ const APPLICATION_CONFIG_KEY = "LAKEBOX_APPLICATION";
9
+ const IDLE_TIMEOUT_CONFIG_KEY = "LAKEBOX_IDLE_TIMEOUT_SECONDS";
10
+ const SSH_TRANSPORT_CONFIG_KEY = "LAKEBOX_SSH_TRANSPORT";
11
+ const SSH2_PATCH_CONFIG_KEY = "LAKEBOX_SSH_SSH2_PATCH";
12
+
13
+ export const LakeboxPluginConfigSchema = z
14
+ .object({
15
+ auto: z
16
+ .boolean()
17
+ .default(true)
18
+ .describe(
19
+ "Whether commands, services, and routes create or start the app Lakebox on demand. Defaults to true.",
20
+ ),
21
+ application: z
22
+ .string()
23
+ .trim()
24
+ .min(1)
25
+ .describe(
26
+ `Stable application key for the persisted Lakebox resource ID. Defaults to ${configUtils.name(APPLICATION_CONFIG_KEY)}, then DATABRICKS_APP_NAME, then the project directory name.`,
27
+ ),
28
+ idleTimeoutSeconds: z
29
+ .number()
30
+ .int()
31
+ .min(60)
32
+ .max(86_400)
33
+ .default(3_600)
34
+ .describe(
35
+ `Platform idle timeout that stops the Lakebox after inactivity, from 60 to 86400 seconds. Defaults to 3600; configure with ${configUtils.name(IDLE_TIMEOUT_CONFIG_KEY)}.`,
36
+ ),
37
+ routePrefix: z
38
+ .string()
39
+ .startsWith("/")
40
+ .default(LAKEBOX_DEFAULT_ROUTE_PREFIX)
41
+ .describe("AppKit root path for user service routes. Defaults to /lakebox."),
42
+ gatewayPort: z
43
+ .number()
44
+ .int()
45
+ .min(1)
46
+ .max(65535)
47
+ .default(2222)
48
+ .describe("Lakebox SSH gateway port. Defaults to 2222."),
49
+ connectTimeoutMs: z
50
+ .number()
51
+ .int()
52
+ .positive()
53
+ .default(30_000)
54
+ .describe("SSH connection timeout in milliseconds. Defaults to 30000."),
55
+ commandTimeoutMs: z
56
+ .number()
57
+ .int()
58
+ .positive()
59
+ .default(120_000)
60
+ .describe("Default command timeout in milliseconds. Defaults to 120000."),
61
+ metricsIntervalSeconds: z
62
+ .number()
63
+ .int()
64
+ .min(1)
65
+ .max(300)
66
+ .default(5)
67
+ .describe("Interval between metrics samples while the Lakebox runs. Defaults to 5."),
68
+ sshTransport: SshTransportModeSchema.describe(
69
+ `SSH transport: auto, openssh, or ssh2. Configure with ${configUtils.name(SSH_TRANSPORT_CONFIG_KEY)}.`,
70
+ ),
71
+ ssh2Patch: Ssh2PatchModeSchema.describe(
72
+ `ssh2 missing-exec-reply workaround: auto, true, or false. Configure with ${configUtils.name(SSH2_PATCH_CONFIG_KEY)}.`,
73
+ ),
74
+ keyDirectory: z
75
+ .string()
76
+ .min(1)
77
+ .optional()
78
+ .describe("Server-side directory for the app SSH key and gateway host keys."),
79
+ })
80
+ .strict()
81
+ .describe("Serializable Lakebox AppKit plugin configuration.");
82
+
83
+ export type SerializableLakeboxPluginConfig = z.input<typeof LakeboxPluginConfigSchema>;
84
+ export type ResolvedLakeboxPluginConfig = z.output<typeof LakeboxPluginConfigSchema>;
85
+
86
+ /**
87
+ * Configuration accepted by the Lakebox AppKit plugin.
88
+ *
89
+ * Pass a shared runtime when the plugin and the Mastra workspace resolver must
90
+ * use the same sessions, routes, and metrics.
91
+ */
92
+ export type LakeboxPluginConfig = BasePluginConfig &
93
+ Partial<SerializableLakeboxPluginConfig> & {
94
+ runtime?: import("./runtime.ts").LakeboxRuntime;
95
+ };
96
+
97
+ /**
98
+ * Resolves defaults through configUtils and removes AppKit-only fields.
99
+ *
100
+ * @throws A Zod validation error when a field has an invalid type or range.
101
+ */
102
+ export function resolveLakeboxPluginConfig(
103
+ config: LakeboxPluginConfig = {},
104
+ ): ResolvedLakeboxPluginConfig {
105
+ const {
106
+ runtime: _runtime,
107
+ name: _name,
108
+ host: _host,
109
+ context: _context,
110
+ telemetry: _telemetry,
111
+ streamConfig: _stream,
112
+ application,
113
+ idleTimeoutSeconds,
114
+ sshTransport,
115
+ ssh2Patch,
116
+ ...serializable
117
+ } = config;
118
+ return LakeboxPluginConfigSchema.parse({
119
+ ...serializable,
120
+ application:
121
+ configUtils.string(application, APPLICATION_CONFIG_KEY) ??
122
+ configUtils.string(undefined, "DATABRICKS_APP_NAME", configUtils.ENV_ONLY) ??
123
+ basename(projectUtils.root() ?? process.cwd()),
124
+ idleTimeoutSeconds: configUtils.positiveInt(idleTimeoutSeconds, IDLE_TIMEOUT_CONFIG_KEY, 3_600),
125
+ sshTransport: configUtils.string(sshTransport, SSH_TRANSPORT_CONFIG_KEY),
126
+ ssh2Patch: configUtils.string(ssh2Patch, SSH2_PATCH_CONFIG_KEY),
127
+ });
128
+ }
package/src/desktop.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { brandUtils } from "@dbx-tools/shared-core";
2
+ import { dbxToolsAssetDataUrls, dbxToolsAssetSvg } from "@dbx-tools/ui/branding/assets";
3
+
4
+ const BRAND = brandUtils.defaultBrandContext;
5
+ const BRAND_COLORS = BRAND.colors;
6
+ const WALLPAPER_LOGO = dbxToolsAssetSvg.logoDark.replace(
7
+ "<svg ",
8
+ '<svg x="500" y="378" width="600" height="135" ',
9
+ );
10
+
11
+ const DBX_TOOLS_WALLPAPER =
12
+ // ============================================================================
13
+ /*xml*/ `
14
+ <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
15
+ <defs>
16
+ <linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
17
+ <stop offset="0" stop-color="#0B161B"/>
18
+ <stop offset="0.55" stop-color="${BRAND_COLORS.primary}"/>
19
+ <stop offset="1" stop-color="${BRAND_COLORS.primaryHover}"/>
20
+ </linearGradient>
21
+ <pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse">
22
+ <path d="M64 0H0V64" fill="none" stroke="${BRAND_COLORS.surface}" stroke-opacity="0.045"/>
23
+ </pattern>
24
+ </defs>
25
+ <rect width="1600" height="1000" fill="url(#background)"/>
26
+ <rect width="1600" height="1000" fill="url(#grid)"/>
27
+ <path d="M0 780C310 650 475 920 780 775s500-220 820-55v280H0Z" fill="${BRAND_COLORS.accent}" fill-opacity="0.13"/>
28
+ <path d="M0 835c300-125 510 105 790-10s500-170 810-65" fill="none" stroke="${BRAND_COLORS.accent}" stroke-width="4" stroke-opacity="0.7"/>
29
+ ${WALLPAPER_LOGO}
30
+ <text x="800" y="558" text-anchor="middle" fill="${BRAND_COLORS.muted}" font-family="${BRAND.typography.sans}" font-size="24" letter-spacing="5">LAKEBOX DESKTOP</text>
31
+ </svg>
32
+ `
33
+ // ============================================================================
34
+ .trim();
35
+
36
+ const DBX_TOOLS_TERMINAL_ICON =
37
+ // ============================================================================
38
+ /*xml*/ `
39
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
40
+ <rect x="4" y="6" width="56" height="52" rx="12" fill="${BRAND_COLORS.primary}"/>
41
+ <rect x="8" y="10" width="48" height="44" rx="8" fill="${BRAND_COLORS.primaryHover}"/>
42
+ <path d="m18 23 9 9-9 9" fill="none" stroke="${BRAND_COLORS.surface}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
43
+ <path d="M34 41h12" fill="none" stroke="${BRAND_COLORS.accent}" stroke-width="4" stroke-linecap="round"/>
44
+ </svg>
45
+ `
46
+ // ============================================================================
47
+ .trim();
48
+
49
+ const DESKTOP_BRAND_HEAD =
50
+ // ============================================================================
51
+ /*html*/ `
52
+ <style id="dbx-tools-desktop-brand">
53
+ html,body{width:100%;max-width:100%;height:100%;max-height:100%;min-width:0;min-height:0;overflow:hidden}
54
+ body{position:fixed;inset:0;width:var(--dbx-tools-viewport-width,100vw);height:var(--dbx-tools-viewport-height,100dvh)}
55
+ #noVNC_container{width:100%!important;max-width:100%;height:100%!important;max-height:100%;overflow:hidden}
56
+ .noVNC_logo,h1.noVNC_logo,a[title="KasmVNC Learn More"]{display:none!important}
57
+ #noVNC_fallback_errormsg{overflow-wrap:anywhere}
58
+ </style>
59
+ <link data-dbx-tools-desktop-icon rel="icon" href="${dbxToolsAssetDataUrls.iconDark}">
60
+ <script id="dbx-tools-kasm-mobile-guard">(()=>{const ignored=(error)=>error instanceof ReferenceError&&/^Can't find variable: W[0-9A-F]{32}$/.test(error.message);window.addEventListener("error",(event)=>{if(ignored(event.error)){event.preventDefault();event.stopImmediatePropagation()}},true);window.addEventListener("unhandledrejection",(event)=>{if(ignored(event.reason)){event.preventDefault();event.stopImmediatePropagation()}},true);let timer;const sync=()=>{const viewport=window.visualViewport;document.documentElement.style.setProperty("--dbx-tools-viewport-width",String(Math.round(viewport?.width??window.innerWidth))+"px");document.documentElement.style.setProperty("--dbx-tools-viewport-height",String(Math.round(viewport?.height??window.innerHeight))+"px");window.dispatchEvent(new Event("resize"))};const schedule=()=>{clearTimeout(timer);sync();setTimeout(sync,250);timer=setTimeout(sync,750)};window.visualViewport?.addEventListener("resize",schedule);window.visualViewport?.addEventListener("scroll",schedule);window.addEventListener("orientationchange",schedule);window.addEventListener("pageshow",schedule);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",schedule,{once:true}):schedule()})();</script>
61
+ <script id="dbx-tools-desktop-brand-script">(()=>{const icon=${JSON.stringify(dbxToolsAssetDataUrls.iconDark)};const rewrite=(root)=>{if(root.nodeType===Node.TEXT_NODE){if(root.nodeValue?.includes("KasmVNC"))root.nodeValue=root.nodeValue.replaceAll("KasmVNC","dbx tools Desktop");return}const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let node;while(node=walker.nextNode())if(node.nodeValue?.includes("KasmVNC"))node.nodeValue=node.nodeValue.replaceAll("KasmVNC","dbx tools Desktop")};const apply=()=>{document.title="dbx tools Desktop";for(const link of document.querySelectorAll('link[rel~="icon"],link[rel="apple-touch-icon"]'))link.setAttribute("href",icon);if(document.body)rewrite(document.body)};if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",apply,{once:true});else apply();new MutationObserver((entries)=>{for(const entry of entries){if(entry.type==="characterData")rewrite(entry.target);else for(const node of entry.addedNodes)rewrite(node)}}).observe(document.documentElement,{childList:true,subtree:true,characterData:true})})();</script>
62
+ `
63
+ // ============================================================================
64
+ .trim();
65
+
66
+ /** Markup injected into the KasmVNC client page to apply dbx-tools branding. */
67
+ export const desktopHtmlHead = DESKTOP_BRAND_HEAD;
68
+
69
+ /** Browser entry URL for the built-in KasmVNC desktop. */
70
+ export function desktopOpenUrl(path: string): string {
71
+ const normalized = path.replace(/\/+$/, "");
72
+ const websocketPath = `${normalized.replace(/^\/+/, "")}/websockify`;
73
+ const parameters = new URLSearchParams({
74
+ autoconnect: "1",
75
+ resize: "remote",
76
+ show_dot: "1",
77
+ clipboard_seamless: "0",
78
+ enable_webp: "0",
79
+ kasmvnc_mode_preference: "-1027",
80
+ path: websocketPath,
81
+ });
82
+ return `${normalized}/?${parameters.toString()}`;
83
+ }
84
+
85
+ /** Branding environment consumed by the packaged desktop session entrypoint. */
86
+ export function desktopEnvironment(): Readonly<Record<string, string>> {
87
+ return {
88
+ DBX_TOOLS_DESKTOP_WALLPAPER_BASE64: Buffer.from(DBX_TOOLS_WALLPAPER).toString("base64"),
89
+ DBX_TOOLS_DESKTOP_BRAND_ICON_BASE64: Buffer.from(dbxToolsAssetSvg.iconDark).toString("base64"),
90
+ DBX_TOOLS_DESKTOP_TERMINAL_ICON_BASE64: Buffer.from(DBX_TOOLS_TERMINAL_ICON).toString("base64"),
91
+ DBX_TOOLS_DESKTOP_HOMEPAGE_BASE64: Buffer.from(BRAND.links.website).toString("base64"),
92
+ DBX_TOOLS_DESKTOP_PRIMARY: BRAND_COLORS.primary,
93
+ DBX_TOOLS_DESKTOP_PRIMARY_HOVER: BRAND_COLORS.primaryHover,
94
+ DBX_TOOLS_DESKTOP_SURFACE: BRAND_COLORS.surface,
95
+ DBX_TOOLS_DESKTOP_FONT: "DejaVu Sans",
96
+ };
97
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { errorUtils, log } from "@dbx-tools/shared-core";
3
+ import type { LakeboxMetricsSnapshot } from "@dbx-tools/shared-lakebox";
4
+
5
+ const logger = log.logger("appkit-lakebox:metrics");
6
+ const DEFAULT_CAPACITY = 120;
7
+
8
+ /** Raw cumulative counters read from one sample. */
9
+ export interface LakeboxCounters {
10
+ at: number;
11
+ cores: number;
12
+ cpuBusy: number;
13
+ cpuTotal: number;
14
+ load1: number;
15
+ memoryTotalBytes: number;
16
+ memoryAvailableBytes: number;
17
+ storageTotalBytes: number;
18
+ storageUsedBytes: number;
19
+ rxBytes: number;
20
+ txBytes: number;
21
+ }
22
+
23
+ /** Events emitted by {@link LakeboxMetrics}. */
24
+ export interface LakeboxMetricsEvents {
25
+ snapshot: [LakeboxMetricsSnapshot];
26
+ }
27
+
28
+ /**
29
+ * In-process metrics emitter for the app Lakebox.
30
+ *
31
+ * Sampling runs only between {@link start} and {@link stop}, which the runtime
32
+ * ties to the Lakebox running state. Samples contain machine-level CPU,
33
+ * memory, persistent storage, and network counters; they never contain
34
+ * process names, arguments, environment, or per-process usage.
35
+ */
36
+ export class LakeboxMetrics extends EventEmitter<LakeboxMetricsEvents> {
37
+ private readonly samples: LakeboxMetricsSnapshot[] = [];
38
+ private previous?: LakeboxCounters;
39
+ private timer?: ReturnType<typeof setTimeout>;
40
+ private controller?: AbortController;
41
+
42
+ /** Creates a stopped emitter that reads raw counters with `read`. */
43
+ constructor(
44
+ private readonly read: (signal: AbortSignal) => Promise<string>,
45
+ private readonly intervalMs: number,
46
+ private readonly capacity = DEFAULT_CAPACITY,
47
+ ) {
48
+ super();
49
+ }
50
+
51
+ /** Whether sampling is active. */
52
+ get running(): boolean {
53
+ return this.controller !== undefined;
54
+ }
55
+
56
+ /** Recent samples, oldest first. */
57
+ series(): readonly LakeboxMetricsSnapshot[] {
58
+ return [...this.samples];
59
+ }
60
+
61
+ /** Seeds one validated sample without starting the recurring sampler or emitting an event. */
62
+ seed(output: string, at = Date.now()): LakeboxMetricsSnapshot {
63
+ const snapshot = this.record(parseLakeboxCounters(output, at), false);
64
+ return snapshot;
65
+ }
66
+
67
+ /** Starts sampling. Repeated calls are ignored. */
68
+ start(): void {
69
+ if (this.controller) return;
70
+ this.controller = new AbortController();
71
+ void this.tick(this.controller.signal);
72
+ }
73
+
74
+ /** Stops sampling and clears the series. */
75
+ stop(): void {
76
+ this.controller?.abort();
77
+ this.controller = undefined;
78
+ if (this.timer) clearTimeout(this.timer);
79
+ this.timer = undefined;
80
+ this.previous = undefined;
81
+ this.samples.length = 0;
82
+ }
83
+
84
+ private async tick(signal: AbortSignal): Promise<void> {
85
+ try {
86
+ const counters = parseLakeboxCounters(await this.read(signal));
87
+ if (signal.aborted) return;
88
+ this.record(counters, true);
89
+ } catch (error) {
90
+ if (!signal.aborted) {
91
+ logger.debug("Lakebox metrics sample failed", { error: errorUtils.toError(error) });
92
+ }
93
+ }
94
+ if (signal.aborted) return;
95
+ this.timer = setTimeout(() => void this.tick(signal), this.intervalMs);
96
+ this.timer.unref?.();
97
+ }
98
+
99
+ private record(counters: LakeboxCounters, emit: boolean): LakeboxMetricsSnapshot {
100
+ const snapshot = toSnapshot(counters, this.previous);
101
+ this.previous = counters;
102
+ this.samples.push(snapshot);
103
+ if (this.samples.length > this.capacity) this.samples.shift();
104
+ if (emit) this.emit("snapshot", snapshot);
105
+ return snapshot;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Parses the package-owned metrics command output.
111
+ *
112
+ * @throws When a required counter is missing.
113
+ */
114
+ export function parseLakeboxCounters(output: string, at = Date.now()): LakeboxCounters {
115
+ const lines = new Map<string, number[]>();
116
+ for (const line of output.split("\n")) {
117
+ const [key, ...values] = line.trim().split(/\s+/);
118
+ if (key) lines.set(key.replace(/:$/, ""), values.map(Number));
119
+ }
120
+ const value = (key: string, index = 0): number => {
121
+ const entry = lines.get(key)?.[index];
122
+ if (entry === undefined || !Number.isFinite(entry)) {
123
+ throw new Error(`Lakebox metrics output is missing ${key}`);
124
+ }
125
+ return entry;
126
+ };
127
+ const cpu = lines.get("cpu") ?? [];
128
+ const idle = (cpu[3] ?? 0) + (cpu[4] ?? 0);
129
+ const total = cpu
130
+ .slice(0, 8)
131
+ .reduce((sum, entry) => sum + (Number.isFinite(entry) ? entry : 0), 0);
132
+ return {
133
+ at,
134
+ cores: value("cores"),
135
+ cpuBusy: total - idle,
136
+ cpuTotal: total,
137
+ load1: value("load"),
138
+ memoryTotalBytes: value("MemTotal") * 1024,
139
+ memoryAvailableBytes: value("MemAvailable") * 1024,
140
+ storageTotalBytes: value("storage", 0),
141
+ storageUsedBytes: value("storage", 1),
142
+ rxBytes: value("network", 0),
143
+ txBytes: value("network", 1),
144
+ };
145
+ }
146
+
147
+ /** Converts cumulative counters to one snapshot using the previous sample for rates. */
148
+ export function toSnapshot(
149
+ current: LakeboxCounters,
150
+ previous?: LakeboxCounters,
151
+ ): LakeboxMetricsSnapshot {
152
+ const seconds = previous ? Math.max((current.at - previous.at) / 1_000, 0.001) : 0;
153
+ const cpuTotal = previous ? current.cpuTotal - previous.cpuTotal : 0;
154
+ const cpuBusy = previous ? current.cpuBusy - previous.cpuBusy : 0;
155
+ const rate = (now: number, before: number | undefined) =>
156
+ previous && before !== undefined ? Math.max(0, (now - before) / seconds) : 0;
157
+ return {
158
+ at: new Date(current.at).toISOString(),
159
+ cpu: {
160
+ cores: current.cores,
161
+ usedPercent: cpuTotal > 0 ? clamp((cpuBusy / cpuTotal) * 100) : 0,
162
+ load1: current.load1,
163
+ },
164
+ memory: {
165
+ totalBytes: current.memoryTotalBytes,
166
+ usedBytes: Math.max(0, current.memoryTotalBytes - current.memoryAvailableBytes),
167
+ },
168
+ storage: { totalBytes: current.storageTotalBytes, usedBytes: current.storageUsedBytes },
169
+ network: {
170
+ rxBytesPerSecond: rate(current.rxBytes, previous?.rxBytes),
171
+ txBytesPerSecond: rate(current.txBytes, previous?.txBytes),
172
+ },
173
+ };
174
+ }
175
+
176
+ function clamp(value: number): number {
177
+ return Math.min(100, Math.max(0, Math.round(value * 10) / 10));
178
+ }