Archived
feat: add local machine registry foundation
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { activityApi, api, filesApi, gitApi, machinesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
parseFileTreeResponse,
|
||||
parseGitDiffResponse,
|
||||
parseGitStatusResponse,
|
||||
parseMachine,
|
||||
parseMachinesResponse,
|
||||
parseMessagePage,
|
||||
parseModelSelectionResponse,
|
||||
parseOAuthFlowState,
|
||||
@@ -36,6 +38,12 @@ export const piWebApi = {
|
||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||
};
|
||||
|
||||
export const machinesApi = {
|
||||
machines: () => request("/api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export const activityApi = {
|
||||
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
|
||||
};
|
||||
@@ -144,6 +152,7 @@ export const gitApi = {
|
||||
|
||||
export const api = {
|
||||
...piWebApi,
|
||||
...machinesApi,
|
||||
...activityApi,
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -57,6 +57,42 @@ export function parseMessagePage(value: unknown): MessagePage {
|
||||
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
||||
}
|
||||
|
||||
export function parseMachinesResponse(value: unknown): Machine[] {
|
||||
const record = requireRecord(value);
|
||||
return arrayOf(parseMachine)(record["machines"]);
|
||||
}
|
||||
|
||||
export function parseMachine(value: unknown): Machine {
|
||||
const record = requireRecord(value);
|
||||
const kind = requireMachineKind(record, "kind");
|
||||
const baseUrl = optionalString(record, "baseUrl");
|
||||
const status = optionalMachineStatus(record, "status");
|
||||
const statusMessage = optionalString(record, "statusMessage");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
name: requireString(record, "name"),
|
||||
kind,
|
||||
...(baseUrl === undefined ? {} : { baseUrl }),
|
||||
createdAt: requireString(record, "createdAt"),
|
||||
updatedAt: requireString(record, "updatedAt"),
|
||||
...(status === undefined ? {} : { status }),
|
||||
...(statusMessage === undefined ? {} : { statusMessage }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind {
|
||||
const value = requireString(record, key);
|
||||
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalMachineStatus(record: Record<string, unknown>, key: string): MachineStatus | undefined {
|
||||
const value = optionalString(record, key);
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== "unknown" && value !== "online" && value !== "offline" && value !== "error") throw new Error(`Expected machine status field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseProject(value: unknown): Project {
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
|
||||
export interface AppState {
|
||||
machines: Machine[];
|
||||
selectedMachine: Machine | undefined;
|
||||
isLoadingMachines: boolean;
|
||||
machineStatuses: Record<string, MachineHealth>;
|
||||
projects: Project[];
|
||||
workspaces: Workspace[];
|
||||
sessions: SessionInfo[];
|
||||
@@ -91,6 +95,10 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
|
||||
|
||||
export function initialAppState(): AppState {
|
||||
return {
|
||||
machines: [],
|
||||
selectedMachine: undefined,
|
||||
isLoadingMachines: false,
|
||||
machineStatuses: {},
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
sessions: [],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { Machine } from "../api";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@customElement("machine-list")
|
||||
export class MachineList extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${this.collapsed ? null : this.machines.map((machine) => html`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${machine.baseUrl ?? machine.name}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl}</small>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHeading() {
|
||||
if (!this.collapsible) return "Machines";
|
||||
const selectedSummary = this.selected?.name ?? "No machine selected";
|
||||
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
|
||||
}
|
||||
|
||||
static override styles = listStyles;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||
import { piWebApi, terminalsApi, type Machine, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -8,6 +8,7 @@ import { ActivityController } from "../controllers/activityController";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
import { MachineController } from "../controllers/machineController";
|
||||
import { ProjectController } from "../controllers/projectController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||
@@ -25,6 +26,7 @@ import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMo
|
||||
import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
import "./MachineList";
|
||||
import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
import "./SessionList";
|
||||
@@ -42,7 +44,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { appStyles } from "./shared";
|
||||
|
||||
type NavigationSection = "projects" | "workspaces" | "sessions";
|
||||
type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
|
||||
|
||||
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
||||
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
@@ -86,6 +88,12 @@ export class PiWebApp extends LitElement {
|
||||
(patch) => { this.setState(patch); },
|
||||
this.workspaces,
|
||||
);
|
||||
private readonly machines = new MachineController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
() => { this.updateUrl(); },
|
||||
this.projects,
|
||||
);
|
||||
private readonly files = new FileExplorerController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
@@ -252,6 +260,8 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private async loadProjectsAndRestoreRoute() {
|
||||
const route = readRoute();
|
||||
await this.machines.loadMachines(route.machineId);
|
||||
await this.projects.loadProjects();
|
||||
await this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
@@ -368,6 +378,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private updateUrl(options?: { replace?: boolean | undefined }) {
|
||||
writeRoute({
|
||||
machineId: this.state.selectedMachine?.id,
|
||||
projectId: this.state.selectedProject?.id,
|
||||
workspaceId: this.state.selectedWorkspace?.id,
|
||||
sessionId: this.state.selectedSession?.id,
|
||||
@@ -528,6 +539,17 @@ export class PiWebApp extends LitElement {
|
||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
|
||||
</div>
|
||||
</header>
|
||||
<machine-list
|
||||
.machines=${this.state.machines}
|
||||
.selected=${this.state.selectedMachine}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("machines")}
|
||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("machines"); }}
|
||||
.onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => {
|
||||
this.expandNavigationSection("projects");
|
||||
await this.machines.selectMachine(machine);
|
||||
})}
|
||||
></machine-list>
|
||||
<project-list
|
||||
.projects=${this.state.projects}
|
||||
.selected=${this.state.selectedProject}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { api, type Machine } from "../api";
|
||||
import { resetWorkspaceScopedState } from "../appState";
|
||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||
import type { ProjectController } from "./projectController";
|
||||
|
||||
export class MachineController {
|
||||
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: ProjectController) {}
|
||||
|
||||
async loadMachines(routeMachineId?: string): Promise<void> {
|
||||
this.setState({ error: "", isLoadingMachines: true });
|
||||
try {
|
||||
const machines = await api.machines();
|
||||
const selectedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")) ?? machines.find((machine) => machine.id === "local") ?? machines[0];
|
||||
this.setState({ machines, selectedMachine });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
} finally {
|
||||
this.setState({ isLoadingMachines: false });
|
||||
}
|
||||
}
|
||||
|
||||
async selectMachine(machine: Machine): Promise<void> {
|
||||
if (this.getState().selectedMachine?.id === machine.id) return;
|
||||
this.setState({
|
||||
selectedMachine: machine,
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
selectedProject: undefined,
|
||||
selectedWorkspace: undefined,
|
||||
selectedSession: undefined,
|
||||
messages: [],
|
||||
messagePageStart: 0,
|
||||
messagePageTotal: 0,
|
||||
status: undefined,
|
||||
activity: undefined,
|
||||
...resetWorkspaceScopedState(),
|
||||
});
|
||||
this.updateUrl();
|
||||
await this.projects.loadProjects();
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,10 @@ function installWindow(href: string): { pushed: string[] } {
|
||||
|
||||
describe("route helpers", () => {
|
||||
it("reads only supported route fields from the current URL", () => {
|
||||
installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
|
||||
installWindow("http://localhost/app?machine=remote&project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
|
||||
|
||||
expect(readRoute()).toEqual({
|
||||
machineId: "remote",
|
||||
projectId: "p1",
|
||||
workspaceId: "w1",
|
||||
sessionId: "s1",
|
||||
@@ -53,6 +54,7 @@ describe("route helpers", () => {
|
||||
it("writes compact URLs and preserves path/hash", () => {
|
||||
const { pushed } = installWindow("http://localhost/app?old=1#section");
|
||||
const route: AppRoute = {
|
||||
machineId: "remote",
|
||||
projectId: "project/id",
|
||||
workspaceId: "workspace id",
|
||||
sessionId: "",
|
||||
@@ -62,13 +64,13 @@ describe("route helpers", () => {
|
||||
|
||||
writeRoute(route);
|
||||
|
||||
expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
|
||||
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
|
||||
});
|
||||
|
||||
it("does not push history when the route is unchanged", () => {
|
||||
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
|
||||
|
||||
writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
|
||||
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
|
||||
|
||||
expect(pushed).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { QualifiedContributionId } from "./plugins/types";
|
||||
|
||||
export interface AppRoute {
|
||||
machineId: string | undefined;
|
||||
projectId: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
sessionId: string | undefined;
|
||||
@@ -11,6 +12,7 @@ export interface AppRoute {
|
||||
export function readRoute(): AppRoute {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
machineId: params.get("machine") ?? undefined,
|
||||
projectId: params.get("project") ?? undefined,
|
||||
workspaceId: params.get("workspace") ?? undefined,
|
||||
sessionId: params.get("session") ?? undefined,
|
||||
@@ -21,11 +23,13 @@ export function readRoute(): AppRoute {
|
||||
|
||||
export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("machine");
|
||||
url.searchParams.delete("project");
|
||||
url.searchParams.delete("workspace");
|
||||
url.searchParams.delete("session");
|
||||
url.searchParams.delete("tool");
|
||||
url.searchParams.delete("view");
|
||||
if (route.machineId !== undefined && route.machineId !== "" && route.machineId !== "local") url.searchParams.set("machine", route.machineId);
|
||||
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
|
||||
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
|
||||
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildApp } from "./app.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
@@ -20,6 +22,7 @@ beforeEach(async () => {
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
workspaces: new WorkspaceService(),
|
||||
machines: new MachineService(new MachineStore(join(tempDir, "machines.json"))),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||
@@ -35,6 +38,21 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("buildApp", () => {
|
||||
it("lists synthesized local machine through the HTTP contract", async () => {
|
||||
const response = await app.inject({ method: "GET", url: "/api/machines" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
|
||||
});
|
||||
|
||||
it("adds remote machines without exposing tokens", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
|
||||
|
||||
expect(addResponse.statusCode).toBe(200);
|
||||
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
|
||||
expect(addResponse.json()).not.toHaveProperty("token");
|
||||
});
|
||||
|
||||
it("adds, lists, and closes projects through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -15,10 +15,13 @@ import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { getPiWebStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
|
||||
export interface AppDependencies {
|
||||
projects?: ProjectService;
|
||||
workspaces?: WorkspaceService;
|
||||
machines?: MachineService;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -31,6 +34,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const machines = deps.machines ?? new MachineService();
|
||||
|
||||
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
||||
|
||||
@@ -42,6 +46,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
|
||||
app.get("/api/pi-web/status", async () => getPiWebStatus());
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
|
||||
app.get("/api/projects", async () => projects.list());
|
||||
|
||||
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { MachineService, type CreateMachineInput, type UpdateMachineInput } from "./machineService.js";
|
||||
|
||||
export function registerMachineRoutes(app: FastifyInstance, machines = new MachineService()): void {
|
||||
app.get("/api/machines", async () => ({ machines: await machines.list() }));
|
||||
|
||||
app.post<{ Body: CreateMachineInput }>("/api/machines", async (request, reply) => {
|
||||
try {
|
||||
return await machines.add(request.body);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
|
||||
const machine = await machines.get(request.params.machineId);
|
||||
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
return machine;
|
||||
});
|
||||
|
||||
app.patch<{ Params: { machineId: string }; Body: UpdateMachineInput }>("/api/machines/:machineId", async (request, reply) => {
|
||||
try {
|
||||
const machine = await machines.update(request.params.machineId, request.body);
|
||||
if (machine === undefined) return await reply.code(404).send({ error: "Machine not found" });
|
||||
return machine;
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
|
||||
try {
|
||||
const removed = await machines.remove(request.params.machineId);
|
||||
if (!removed) return await reply.code(404).send({ error: "Machine not found" });
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { MachineService } from "./machineService.js";
|
||||
import { MachineStore, machineStorePath } from "./machineStore.js";
|
||||
|
||||
let tempDir: string;
|
||||
let storePath: string;
|
||||
let service: MachineService;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-machines-test-"));
|
||||
storePath = join(tempDir, "machines.json");
|
||||
service = new MachineService(new MachineStore(storePath));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("MachineService", () => {
|
||||
it("synthesizes local machine without persisting it", async () => {
|
||||
expect(await service.list()).toEqual([
|
||||
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds remote machines and omits secrets from public responses", async () => {
|
||||
const machine = await service.add({ name: " Dev Box ", baseUrl: "https://devbox.example.test/", token: "secret" });
|
||||
|
||||
expect(machine).toMatchObject({ name: "Dev Box", kind: "remote", baseUrl: "https://devbox.example.test" });
|
||||
expect(machine).not.toHaveProperty("token");
|
||||
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), machine]);
|
||||
|
||||
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
|
||||
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
|
||||
});
|
||||
|
||||
it("rejects invalid remote base URLs", async () => {
|
||||
await expect(service.add({ name: "Bad", baseUrl: "ftp://example.test" })).rejects.toThrow("http or https");
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://[email protected]" })).rejects.toThrow("credentials");
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://example.test/path?q=1" })).rejects.toThrow("query or hash");
|
||||
});
|
||||
|
||||
it("does not allow local machine mutation", async () => {
|
||||
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
|
||||
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
|
||||
});
|
||||
|
||||
it("supports PI_WEB_MACHINES_FILE path overrides", () => {
|
||||
const env: NodeJS.ProcessEnv = { PI_WEB_MACHINES_FILE: "data/machines.json" };
|
||||
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Machine } from "../../shared/apiTypes.js";
|
||||
import { MachineStore, type StoredMachine } from "./machineStore.js";
|
||||
|
||||
export interface CreateMachineInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type UpdateMachineInput = Partial<CreateMachineInput>;
|
||||
|
||||
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
|
||||
|
||||
export class MachineService {
|
||||
constructor(private readonly store = new MachineStore()) {}
|
||||
|
||||
async list(): Promise<Machine[]> {
|
||||
return [localMachine(), ...(await this.store.list()).map(publicMachine)];
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Machine | undefined> {
|
||||
if (id === "local") return localMachine();
|
||||
const machine = (await this.store.list()).find((stored) => stored.id === id);
|
||||
return machine === undefined ? undefined : publicMachine(machine);
|
||||
}
|
||||
|
||||
async add(input: CreateMachineInput): Promise<Machine> {
|
||||
const name = validateName(input.name);
|
||||
const baseUrl = validateBaseUrl(input.baseUrl);
|
||||
const stored = await this.store.add({ name, baseUrl, ...optionalSecrets(input) });
|
||||
return publicMachine(stored);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateMachineInput): Promise<Machine | undefined> {
|
||||
if (id === "local") throw new Error("Local machine cannot be changed");
|
||||
const patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">> = {};
|
||||
if (input.name !== undefined) patch.name = validateName(input.name);
|
||||
if (input.baseUrl !== undefined) patch.baseUrl = validateBaseUrl(input.baseUrl);
|
||||
if (input.token !== undefined) patch.token = input.token;
|
||||
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
|
||||
const stored = await this.store.update(id, patch);
|
||||
return stored === undefined ? undefined : publicMachine(stored);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
if (id === "local") throw new Error("Local machine cannot be deleted");
|
||||
return await this.store.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function localMachine(): Machine {
|
||||
return { id: "local", name: "Local", kind: "local", createdAt: LOCAL_MACHINE_TIMESTAMP, updatedAt: LOCAL_MACHINE_TIMESTAMP };
|
||||
}
|
||||
|
||||
function publicMachine(machine: StoredMachine): Machine {
|
||||
return { id: machine.id, name: machine.name, kind: "remote", baseUrl: machine.baseUrl, createdAt: machine.createdAt, updatedAt: machine.updatedAt };
|
||||
}
|
||||
|
||||
function validateName(value: string | undefined): string {
|
||||
const name = value?.trim();
|
||||
if (name === undefined || name === "") throw new Error("Machine name is required");
|
||||
return name;
|
||||
}
|
||||
|
||||
function validateBaseUrl(value: string | undefined): string {
|
||||
const raw = value?.trim();
|
||||
if (raw === undefined || raw === "") throw new Error("Machine baseUrl is required");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error("Machine baseUrl must be a valid URL");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Machine baseUrl must use http or https");
|
||||
if (url.username !== "" || url.password !== "") throw new Error("Machine baseUrl must not include credentials");
|
||||
if (url.search !== "" || url.hash !== "") throw new Error("Machine baseUrl must not include query or hash");
|
||||
return url.href.replace(/\/$/u, "");
|
||||
}
|
||||
|
||||
function optionalSecrets(input: CreateMachineInput): { token?: string; headers?: Record<string, string> } {
|
||||
return {
|
||||
...(input.token === undefined ? {} : { token: input.token }),
|
||||
...(input.headers === undefined ? {} : { headers: validateHeaders(input.headers) }),
|
||||
};
|
||||
}
|
||||
|
||||
function validateHeaders(value: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, headerValue]) => {
|
||||
if (typeof headerValue !== "string") throw new Error("Machine headers must be strings");
|
||||
return [key, headerValue];
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { piWebDataDir } from "../../config.js";
|
||||
|
||||
export interface StoredMachine {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "remote";
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MachineFile {
|
||||
machines: StoredMachine[];
|
||||
}
|
||||
|
||||
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
||||
return join(piWebDataDir(env, cwd), "machines.json");
|
||||
}
|
||||
|
||||
export function machineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
||||
const configured = env["PI_WEB_MACHINES_FILE"];
|
||||
if (configured === undefined || configured === "") return defaultMachineStorePath(env, cwd);
|
||||
return resolve(cwd, configured);
|
||||
}
|
||||
|
||||
export class MachineStore {
|
||||
constructor(private readonly filePath = machineStorePath()) {}
|
||||
|
||||
async list(): Promise<StoredMachine[]> {
|
||||
return (await this.read()).machines;
|
||||
}
|
||||
|
||||
async add(input: { name: string; baseUrl: string; token?: string; headers?: Record<string, string> }): Promise<StoredMachine> {
|
||||
const data = await this.read();
|
||||
const now = new Date().toISOString();
|
||||
const machine: StoredMachine = {
|
||||
id: randomUUID(),
|
||||
name: input.name,
|
||||
kind: "remote",
|
||||
baseUrl: input.baseUrl,
|
||||
...(input.token === undefined ? {} : { token: input.token }),
|
||||
...(input.headers === undefined ? {} : { headers: input.headers }),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.machines.push(machine);
|
||||
await this.write(data);
|
||||
return machine;
|
||||
}
|
||||
|
||||
async update(id: string, patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">>): Promise<StoredMachine | undefined> {
|
||||
const data = await this.read();
|
||||
const index = data.machines.findIndex((machine) => machine.id === id);
|
||||
if (index < 0) return undefined;
|
||||
const current = data.machines[index];
|
||||
if (current === undefined) return undefined;
|
||||
const next: StoredMachine = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
||||
data.machines[index] = next;
|
||||
await this.write(data);
|
||||
return next;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const data = await this.read();
|
||||
const machines = data.machines.filter((machine) => machine.id !== id);
|
||||
if (machines.length === data.machines.length) return false;
|
||||
await this.write({ machines });
|
||||
return true;
|
||||
}
|
||||
|
||||
private async read(): Promise<MachineFile> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
|
||||
return parseMachineFile(value);
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async write(data: MachineFile): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function parseMachineFile(value: unknown): MachineFile {
|
||||
if (!isRecord(value) || !Array.isArray(value["machines"])) throw new Error("Invalid machine file");
|
||||
return { machines: value["machines"].map(parseStoredMachine) };
|
||||
}
|
||||
|
||||
function parseStoredMachine(value: unknown): StoredMachine {
|
||||
if (!isRecord(value)) throw new Error("Invalid machine");
|
||||
const id = value["id"];
|
||||
const name = value["name"];
|
||||
const kind = value["kind"];
|
||||
const baseUrl = value["baseUrl"];
|
||||
const createdAt = value["createdAt"];
|
||||
const updatedAt = value["updatedAt"];
|
||||
if (typeof id !== "string" || typeof name !== "string" || kind !== "remote" || typeof baseUrl !== "string" || typeof createdAt !== "string" || typeof updatedAt !== "string") throw new Error("Invalid machine");
|
||||
const token = optionalString(value["token"], "token");
|
||||
const headers = optionalStringRecord(value["headers"], "headers");
|
||||
return { id, name, kind, baseUrl, createdAt, updatedAt, ...(token === undefined ? {} : { token }), ...(headers === undefined ? {} : { headers }) };
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, key: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string") throw new Error(`Invalid machine ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalStringRecord(value: unknown, key: string): Record<string, string> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) throw new Error(`Invalid machine ${key}`);
|
||||
return Object.fromEntries(Object.entries(value).map(([header, headerValue]) => {
|
||||
if (typeof headerValue !== "string") throw new Error(`Invalid machine ${key}`);
|
||||
return [header, headerValue];
|
||||
}));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
export type MachineKind = "local" | "remote";
|
||||
export type MachineStatus = "unknown" | "online" | "offline" | "error";
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: MachineKind;
|
||||
baseUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status?: MachineStatus;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
export interface MachineHealth {
|
||||
machineId: string;
|
||||
ok: boolean;
|
||||
checkedAt: string;
|
||||
status?: MachineStatus;
|
||||
web?: PiWebComponentStatus;
|
||||
sessiond?: PiWebComponentStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user