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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@inversifyjs/strongly-typed": "2.2.0",
"@json-render/core": "^0.19.0",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
AUTH_TOKEN_CIPHER,
AUTH_TOKEN_OVERRIDE,
} from "@posthog/core/auth/identifiers";
import type { CANVAS_GEN_SERVICE } from "@posthog/core/canvas/identifiers";
import type {
CLOUD_TASK_AUTH,
ICloudTaskAuth,
Expand Down Expand Up @@ -93,6 +94,7 @@ import type {
GIT_PR_STATUS_PROVIDER,
IGitPrStatus,
} from "@posthog/host-router/ports/git-pr-status";
import type { CanvasGenService } from "@posthog/host-router/services/canvas-gen.service";
import type {
ANALYTICS_SERVICE,
IAnalytics,
Expand Down Expand Up @@ -417,6 +419,7 @@ export interface MainBindings {
[SECURE_STORE_SERVICE]: ISecureStoreService;
[LOGS_SERVICE]: ILogsService;
[MAIN_ENCRYPTION_SERVICE]: EncryptionService;
[CANVAS_GEN_SERVICE]: CanvasGenService;

// ws-server git service (bound to(GitService))
[WS_GIT_SERVICE]: GitService;
Expand Down
11 changes: 11 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
AUTH_TOKEN_CIPHER,
AUTH_TOKEN_OVERRIDE,
} from "@posthog/core/auth/identifiers";
import { canvasCoreModule } from "@posthog/core/canvas/canvas.module";
import { CANVAS_GEN_SERVICE } from "@posthog/core/canvas/identifiers";
import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module";
import {
CLOUD_TASK_AUTH,
Expand Down Expand Up @@ -87,6 +89,7 @@ import {
GIT_PR_STATUS_PROVIDER,
type IGitPrStatus,
} from "@posthog/host-router/ports/git-pr-status";
import { CanvasGenService } from "@posthog/host-router/services/canvas-gen.service";
import { ANALYTICS_SERVICE } from "@posthog/platform/analytics";
import { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle";
import { APP_META_SERVICE } from "@posthog/platform/app-meta";
Expand Down Expand Up @@ -690,3 +693,11 @@ container.bind(LOGS_SERVICE).toDynamicValue((ctx) => {
};
});
container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService);

// Canvas / dashboards (project-bluebird). The host-agnostic dashboard services
// live in @posthog/core (bound via canvasCoreModule); CanvasGenService is the
// desktop-bound agent surface (a singleton holding per-thread agent state + a
// forwarding loop for app life). Both resolve through ctx.container in the
// host-router routers.
container.load(canvasCoreModule);
container.bind(CANVAS_GEN_SERVICE).to(CanvasGenService).inSingletonScope();
4 changes: 4 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { agentRouter } from "@posthog/host-router/routers/agent.router";
import { analyticsRouter } from "@posthog/host-router/routers/analytics.router";
import { archiveRouter } from "@posthog/host-router/routers/archive.router";
import { authRouter } from "@posthog/host-router/routers/auth.router";
import { canvasGenRouter } from "@posthog/host-router/routers/canvas-gen.router";
import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router";
import { connectivityRouter } from "@posthog/host-router/routers/connectivity.router";
import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router";
import { dashboardsRouter } from "@posthog/host-router/routers/dashboards.router";
import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router";
import { enrichmentRouter } from "@posthog/host-router/routers/enrichment.router";
import { environmentRouter } from "@posthog/host-router/routers/environment.router";
Expand Down Expand Up @@ -47,6 +49,8 @@ export const trpcRouter = router({
analytics: analyticsRouter,
archive: archiveRouter,
auth: authRouter,
canvasGen: canvasGenRouter,
dashboards: dashboardsRouter,
cloudTask: cloudTaskRouter,
connectivity: connectivityRouter,
contextMenu: contextMenuRouter,
Expand Down
98 changes: 98 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,104 @@ export class PostHogAPIClient {
return data;
}

// Desktop file system — the backend surface that backs canvas channels
// (top-level folders) and dashboards. These routes aren't in the generated
// OpenAPI client, so we use the raw fetcher.
async getDesktopFileSystem(): Promise<Schemas.FileSystem[]> {
const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50;
const teamId = await this.getTeamId();
const all: Schemas.FileSystem[] = [];
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`;
for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) {
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(
`Failed to fetch desktop file system: ${response.statusText}`,
);
}
const page = (await response.json()) as Schemas.PaginatedFileSystemList;
all.push(...page.results);
if (!page.next) return all;
const nextUrl = new URL(page.next);
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
}
log.warn(
`getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
{ returned: all.length },
);
return all;
}

// Create a top-level channel (a folder row whose path is a single segment).
async createDesktopFileSystemChannel(
name: string,
): Promise<Schemas.FileSystem> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: urlPath,
overrides: {
body: JSON.stringify({ path: name, type: "folder", depth: 1 }),
},
});
if (!response.ok) {
throw new Error(
`Failed to create desktop file system channel: ${response.statusText}`,
);
}
return (await response.json()) as Schemas.FileSystem;
}

// Rename a top-level channel: PATCH its path (a single segment) to the new
// name. The backend recomputes depth from the path.
async renameDesktopFileSystemChannel(
id: string,
name: string,
): Promise<Schemas.FileSystem> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "patch",
url,
path: urlPath,
overrides: {
body: JSON.stringify({ path: name }),
},
});
if (!response.ok) {
throw new Error(
`Failed to rename desktop file system channel: ${response.statusText}`,
);
}
return (await response.json()) as Schemas.FileSystem;
}

// Delete a desktop file system entry by id (used to remove top-level channels).
async deleteDesktopFileSystem(id: string): Promise<void> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "delete",
url,
path: urlPath,
});
if (!response.ok && response.status !== 404) {
throw new Error(
`Failed to delete desktop file system channel: ${response.statusText}`,
);
}
}

async getGithubLogin(): Promise<string | null> {
const data = (await this.api.get("/api/users/{uuid}/github_login/", {
path: { uuid: "@me" },
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/canvas/canvas.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ContainerModule } from "inversify";
import { DashboardQueryService } from "./dashboardQueryService";
import { DashboardsService } from "./dashboardsService";
import { DASHBOARD_QUERY_SERVICE, DASHBOARDS_SERVICE } from "./identifiers";

// Host-agnostic canvas services (dashboards + their HogQL refresh). They only
// need AuthService + fetch, so they live in @posthog/core and any host (desktop,
// web, server) can bind them by loading this module.
export const canvasCoreModule = new ContainerModule(({ bind }) => {
bind(DashboardQueryService).toSelf().inSingletonScope();
bind(DASHBOARD_QUERY_SERVICE).toService(DashboardQueryService);

bind(DashboardsService).toSelf().inSingletonScope();
bind(DASHBOARDS_SERVICE).toService(DashboardsService);
});
126 changes: 126 additions & 0 deletions packages/core/src/canvas/dashboardQueryService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { AuthService } from "@posthog/core/auth/auth";
import { AUTH_SERVICE } from "@posthog/core/auth/auth.module";
import {
ROOT_LOGGER,
type RootLogger,
type ScopedLogger,
} from "@posthog/di/logger";
import { inject, injectable } from "inversify";
import type {
DashboardQuery,
DashboardQueryResult,
DashboardQueryRunInput,
} from "./querySchemas";

// Run at most this many HogQL queries at once so a wide dashboard doesn't
// hammer the query endpoint.
const CONCURRENCY = 5;

interface HogQLResponse {
results?: unknown[];
columns?: string[];
error?: string | null;
}

// Executes the HogQL queries stored on a dashboard's data points and returns a
// single scalar value per point. Used by the dashboard refresh flow.
@injectable()
export class DashboardQueryService {
private readonly log: ScopedLogger;

constructor(
@inject(AUTH_SERVICE)
private readonly authService: AuthService,
@inject(ROOT_LOGGER)
rootLogger: RootLogger,
) {
this.log = rootLogger.scope("dashboard-query");
}

async run(input: DashboardQueryRunInput): Promise<DashboardQueryResult[]> {
const { queries } = input;
if (queries.length === 0) return [];

const { apiHost } = await this.authService.getValidAccessToken();
const projectId = this.authService.getState().currentProjectId;
if (projectId == null) {
return queries.map((q) => this.fail(q, "No PostHog project selected"));
}

const url = `${apiHost}/api/projects/${projectId}/query/`;
const results: DashboardQueryResult[] = [];

// Simple capped batches; preserves input order in the output.
for (let i = 0; i < queries.length; i += CONCURRENCY) {
const batch = queries.slice(i, i + CONCURRENCY);
const settled = await Promise.allSettled(
batch.map((q) => this.runOne(url, q)),
);
settled.forEach((s, j) => {
results.push(
s.status === "fulfilled"
? s.value
: this.fail(batch[j], errorMessage(s.reason)),
);
});
}

return results;
}

private async runOne(
url: string,
q: DashboardQuery,
): Promise<DashboardQueryResult> {
const response = await this.authService.authenticatedFetch(fetch, url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: { kind: "HogQLQuery", query: q.query } }),
});

if (!response.ok) {
return this.fail(q, `Query failed (${response.status})`);
}

const body = (await response.json()) as HogQLResponse;
if (body.error) return this.fail(q, body.error);

const rows = body.results;
if (!Array.isArray(rows) || rows.length === 0) {
return this.fail(q, "Query returned no rows");
}

const firstRow = rows[0];
if (!Array.isArray(firstRow)) {
return this.fail(q, "Unexpected result shape");
}

// Read the named column if given, else the first cell of the first row.
const colIndex =
q.column && body.columns ? body.columns.indexOf(q.column) : 0;
const cell = firstRow[colIndex >= 0 ? colIndex : 0];

if (typeof cell === "number" || typeof cell === "string") {
return {
ok: true,
elementKey: q.elementKey,
propPath: q.propPath,
value: cell,
};
}
return this.fail(q, "Unsupported value type");
}

private fail(q: DashboardQuery, error: string): DashboardQueryResult {
this.log.warn("Dashboard query failed", {
elementKey: q.elementKey,
propPath: q.propPath,
error,
});
return { ok: false, elementKey: q.elementKey, propPath: q.propPath, error };
}
}

function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}
69 changes: 69 additions & 0 deletions packages/core/src/canvas/dashboardSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { z } from "zod";

// A json-render Spec (root + flat element map). Stored verbatim; null = empty.
export const dashboardSpecSchema = z.record(z.string(), z.unknown()).nullable();

export const dashboardRecordSchema = z.object({
id: z.string(),
// The channel (desktop file-system folder) this dashboard belongs to.
// Defaults to "" so dashboards saved before channel scoping still parse;
// they read as orphans and get adopted into the default channel on load.
channelId: z.string().default(""),
name: z.string(),
spec: dashboardSpecSchema,
createdAt: z.number(),
updatedAt: z.number(),
});
export type DashboardRecord = z.infer<typeof dashboardRecordSchema>;

// What a dashboard stores in its desktop file-system row's free-form `meta` JSON
// blob. The FileSystem row itself carries id/path/type/created_at; everything
// below is our own payload that the model has no columns for. Documenting the
// shape here keeps the otherwise-untyped `meta` honest.
export const dashboardFileMetaSchema = z.object({
// The json-render Spec (root + flat element map). null/absent = empty board.
spec: dashboardSpecSchema.optional(),
// The channel folder's stable file-system id. Stored here rather than derived
// from the path so renaming/moving the channel folder can't reparent the board.
channelId: z.string().optional(),
// Epoch ms. createdAt mirrors the row's created_at; updatedAt is ours because
// the FileSystem row has no updated_at column to sort the dashboards list by.
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
});
export type DashboardFileMeta = z.infer<typeof dashboardFileMetaSchema>;

export const dashboardSummarySchema = z.object({
id: z.string(),
channelId: z.string(),
name: z.string(),
updatedAt: z.number(),
// The full spec is already loaded when listing (it rides in the FS row's
// meta), so include it here to render grid previews without an N+1 of get()s.
spec: dashboardSpecSchema,
});
export type DashboardSummary = z.infer<typeof dashboardSummarySchema>;

export const listDashboardsInput = z.object({ channelId: z.string().min(1) });

export const createDashboardInput = z.object({
channelId: z.string().min(1),
name: z.string().min(1),
spec: dashboardSpecSchema,
});

export const updateDashboardInput = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
spec: dashboardSpecSchema,
});

export const dashboardIdInput = z.object({ id: z.string().min(1) });

export const refreshDashboardInput = z.object({
id: z.string().min(1),
// Limit the refresh to these elements' subtrees (per-card refresh).
elementKeys: z.array(z.string()).optional(),
// Skip bumping updatedAt (e.g. for background polling) to avoid reordering.
touchUpdatedAt: z.boolean().optional(),
});
Loading
Loading