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
51 changes: 48 additions & 3 deletions packages/mocks/src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
* Test factories for Coder SDK workspace types.
*/

import type { AgentMetadataState } from "@repo/shared";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
WorkspaceBuild,
WorkspaceResource,
} from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -51,13 +53,21 @@ const defaultBuild: WorkspaceBuild = {
template_version_preset_id: null,
};

/** Create a Workspace with sensible defaults for a running task workspace. */
/**
* Create a Workspace with sensible defaults for a running task workspace.
* `agents` puts them on a single resource, the common shape in tests.
*/
export function workspace(
overrides: Omit<Partial<Workspace>, "latest_build"> & {
latest_build?: Partial<WorkspaceBuild>;
agents?: WorkspaceAgent[];
} = {},
): Workspace {
const { latest_build: buildOverrides, ...rest } = overrides;
const { latest_build: buildOverrides, agents, ...rest } = overrides;
const build = { ...defaultBuild, ...buildOverrides };
if (agents) {
build.resources = [resource({ agents })];
}
return {
id: "workspace-1",
created_at: "2024-01-01T00:00:00Z",
Expand All @@ -75,7 +85,7 @@ export function workspace(
template_active_version_id: "version-1",
template_require_active_version: false,
template_use_classic_parameter_flow: false,
latest_build: { ...defaultBuild, ...buildOverrides },
latest_build: build,
latest_app_status: null,
outdated: false,
name: "test-workspace",
Expand Down Expand Up @@ -126,6 +136,41 @@ export function agent(overrides: Partial<WorkspaceAgent> = {}): WorkspaceAgent {
};
}

/** Create a WorkspaceAgentMetadata report with sensible defaults. */
export function agentMetadata(
overrides: {
result?: Partial<WorkspaceAgentMetadata["result"]>;
description?: Partial<WorkspaceAgentMetadata["description"]>;
} = {},
): WorkspaceAgentMetadata {
return {
result: {
collected_at: "2024-01-01T00:00:00Z",
age: 0,
value: "42",
error: "",
...overrides.result,
},
description: {
display_name: "CPU",
key: "cpu",
script: "cpu.sh",
interval: 5,
timeout: 1,
...overrides.description,
},
};
}

/** An agent whose socket is open, but which has not reported yet. */
export const PENDING_METADATA: AgentMetadataState = { kind: "pending" };

/** An agent that reported `agentMetadata()`. */
export const REPORTED_METADATA: AgentMetadataState = {
kind: "reported",
metadata: [agentMetadata()],
};

/** Create a WorkspaceResource with sensible defaults. */
export function resource(
overrides: Partial<WorkspaceResource> = {},
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ export type {
NetcheckSeverity,
} from "./netcheck/types";

// Workspaces API
// Workspaces types and API
export * from "./workspaces/types";
export { WorkspacesApi } from "./workspaces/api";
32 changes: 31 additions & 1 deletion packages/shared/src/workspaces/api.ts
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
export const WorkspacesApi = {} as const;
/**
* Workspaces API - Type-safe message definitions for the Workspaces webview.
*
* The extension owns the data and pushes it; the webview renders what it is
* given and sends back the actions the user takes.
*/

import { defineCommand, defineNotification } from "../ipc/protocol";

import type {
OpenWorkspaceParams,
SetFilterParams,
ViewInDashboardParams,
WatchAgentsParams,
WorkspacesState,
} from "./types";

export const WorkspacesApi = {
// Notifications
/** The whole state, whenever any of it changes */
stateChanged: defineNotification<WorkspacesState>("stateChanged"),
// Commands
/** Webview signals its subscription is live and asks for the state */
ready: defineCommand<void>("ready"),
openWorkspace: defineCommand<OpenWorkspaceParams>("openWorkspace"),
viewInDashboard: defineCommand<ViewInDashboardParams>("viewInDashboard"),
refresh: defineCommand<void>("refresh"),
setFilter: defineCommand<SetFilterParams>("setFilter"),
/** Watch metadata for these agents only, so idle rows cost nothing */
watchAgents: defineCommand<WatchAgentsParams>("watchAgents"),
} as const;
70 changes: 70 additions & 0 deletions packages/shared/src/workspaces/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
} from "coder/site/src/api/typesGenerated";

// Re-export SDK types for convenience
export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata };

export type WorkspaceFilter = "mine" | "shared" | "all";

/** A workspace page in the dashboard, opened in the browser. */
export type DashboardPage = "workspace" | "settings";

/** What the panel may offer for the current session. */
export interface WorkspacesCapabilities {
readonly authenticated: boolean;
/** Filters the user may select, in display order. */
readonly filters: readonly WorkspaceFilter[];
}

/**
* What the list is doing. `loading` is set only for a list the user waits on:
* the first one for a filter, or a refresh. Polls never set it.
*/
export type WorkspaceListStatus =
| { readonly kind: "loading" }
| { readonly kind: "ready" }
| { readonly kind: "failed"; readonly error: string };

/** What one agent reports. A failure replaces its metadata in the UI. */
export type AgentMetadataState =
| { readonly kind: "pending" }
| {
readonly kind: "reported";
readonly metadata: readonly WorkspaceAgentMetadata[];
}
| { readonly kind: "failed"; readonly error: string };

/** Keyed by agent id. */
export type AgentMetadataMap = Readonly<Record<string, AgentMetadataState>>;

/** Everything the panel renders. Pushed whole whenever any of it changes. */
export interface WorkspacesState {
readonly capabilities: WorkspacesCapabilities;
readonly filter: WorkspaceFilter;
readonly workspaces: readonly Workspace[];
readonly status: WorkspaceListStatus;
readonly metadata: AgentMetadataMap;
}

export interface OpenWorkspaceParams {
readonly workspaceId: string;
/** Which agent to connect to. Picked interactively when omitted. */
readonly agentId?: string;
}

export interface ViewInDashboardParams {
readonly workspaceId: string;
readonly page: DashboardPage;
}

export interface SetFilterParams {
readonly filter: WorkspaceFilter;
}

export interface WatchAgentsParams {
/** The agents whose metadata the webview is showing. */
readonly agentIds: readonly string[];
}
10 changes: 9 additions & 1 deletion packages/workspaces/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { useWorkspaces } from "./hooks/useWorkspaces";

/** Placeholder: renders the pushed state until the panel UI lands. */
export default function App() {
return <div>TODO</div>;
const { state } = useWorkspaces();

if (!state) {
return <p>Loading workspaces...</p>;
}
return <pre>{JSON.stringify(state, null, 2)}</pre>;
}
24 changes: 24 additions & 0 deletions packages/workspaces/src/hooks/useWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
buildApiHook,
WorkspacesApi,
type WorkspacesState,
} from "@repo/shared";
import { useIpc } from "@repo/webview-shared/react";
import { useEffect, useState } from "react";

/**
* The state the extension pushes, and the commands to send back. The state is
* undefined until the extension answers `ready`.
*/
export function useWorkspaces() {
const api = buildApiHook(WorkspacesApi, useIpc());
const [state, setState] = useState<WorkspacesState | undefined>();

useEffect(() => {
const unsubscribe = api.onStateChanged(setState);
api.ready();
return unsubscribe;
}, []);

return { state, api };
}
23 changes: 18 additions & 5 deletions src/api/agentMetadataHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ import {
AgentMetadataEventSchemaArray,
errToStr,
} from "./api-helper";
import { type CoderApi } from "./coderApi";

import type { UnidirectionalStream } from "../websocket/eventStreamConnection";

export interface AgentMetadataClient {
watchAgentMetadata(
agentId: string,
): Promise<UnidirectionalStream<{ data: unknown }>>;
}

export interface AgentMetadataWatcher {
onChange: vscode.EventEmitter<null>["event"];
dispose: () => void;
readonly onChange: vscode.EventEmitter<null>["event"];
readonly dispose: () => void;
metadata?: AgentMetadataEvent[];
error?: unknown;
/** True once the socket closed on its own, so it reports nothing more. */
closed: boolean;
}

/**
Expand All @@ -21,18 +30,21 @@ export interface AgentMetadataWatcher {
*/
export async function createAgentMetadataWatcher(
agentId: WorkspaceAgent["id"],
client: CoderApi,
client: AgentMetadataClient,
): Promise<AgentMetadataWatcher> {
const socket = await client.watchAgentMetadata(agentId);

let disposed = false;
const onChange = new vscode.EventEmitter<null>();
const watcher: AgentMetadataWatcher = {
onChange: onChange.event,
closed: false,
dispose: () => {
if (!disposed) {
socket.close();
disposed = true;
// Listeners go first, so closing the socket reports nothing more.
onChange.dispose();
socket.close();
}
},
};
Expand Down Expand Up @@ -70,6 +82,7 @@ export async function createAgentMetadataWatcher(
socket.addEventListener("error", handleError);

socket.addEventListener("close", (event) => {
watcher.closed = true;
if (event.code !== 1000) {
handleError(
new Error(
Expand Down
6 changes: 6 additions & 0 deletions src/api/api-helper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors";
import {
type User,
type Workspace,
type WorkspaceAgent,
type WorkspaceResource,
Expand Down Expand Up @@ -27,6 +28,11 @@ export function errToStr(error: unknown, def = "No error message provided") {
return def;
}

/** True when the user holds the deployment-wide owner role. */
export function isOwner(user: User | undefined): boolean {
return user?.roles.some((role) => role.name === "owner") ?? false;
}

/**
* Create workspace owner/name identifier
*/
Expand Down
Loading