Archived
feat: add manual PI WEB update checks
This commit is contained in:
@@ -13,6 +13,20 @@ const workspace: Workspace = {
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
function piWebStatusResponse() {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
const commandRun: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
@@ -32,17 +46,7 @@ afterEach(() => {
|
||||
|
||||
describe("machine-scoped runtime API", () => {
|
||||
it("reads machine PI WEB status through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
});
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.piWebStatus("remote a");
|
||||
|
||||
@@ -50,6 +54,26 @@ describe("machine-scoped runtime API", () => {
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the local status route", async () => {
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.checkForUpdates();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the selected machine route", async () => {
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.checkForUpdates("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("reads machine runtime through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
|
||||
|
||||
@@ -99,8 +99,13 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef
|
||||
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
|
||||
}
|
||||
|
||||
function piWebStatusUrl(machineId: string): string {
|
||||
return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
|
||||
piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse),
|
||||
checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
|
||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("federated route contract", () => {
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(piWebApi.checkForUpdates(machineId)),
|
||||
ignoreParseFailure(configApi.config(machineId)),
|
||||
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
|
||||
ignoreParseFailure(pluginsApi.plugins(machineId)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
import { MachineController } from "../controllers/machineController";
|
||||
import { ProjectController } from "../controllers/projectController";
|
||||
import { PiWebStatusController } from "../controllers/piWebStatusController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
|
||||
@@ -132,6 +133,11 @@ export class PiWebApp extends LitElement {
|
||||
() => { this.updateUrl(); },
|
||||
this.projects,
|
||||
);
|
||||
private readonly piWebStatusController = new PiWebStatusController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
{ onRefreshError: (machineId, error) => { console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } },
|
||||
);
|
||||
private readonly files = new FileExplorerController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
@@ -298,7 +304,7 @@ export class PiWebApp extends LitElement {
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
this.piWebStatusDeferredTimer = window.setTimeout(() => {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
void this.refreshPiWebStatus();
|
||||
void this.piWebStatusController.refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
@@ -308,17 +314,6 @@ export class PiWebApp extends LitElement {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
}
|
||||
|
||||
private async refreshPiWebStatus(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
const piWebStatus = await piWebApi.piWebStatus(machineId);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
|
||||
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise<void> {
|
||||
try {
|
||||
await this.activity.refresh(machineId);
|
||||
@@ -1573,6 +1568,7 @@ export class PiWebApp extends LitElement {
|
||||
refreshFiles: () => this.files.refreshFiles(),
|
||||
refreshGit: () => this.git.refreshGit(),
|
||||
refreshAppData: () => this.refreshAppData(),
|
||||
checkForPiWebUpdates: () => this.piWebStatusController.checkForUpdates(),
|
||||
reloadPage: () => { this.hardReloadApp(); },
|
||||
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
||||
startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Machine, PiWebReleaseStatus, PiWebStatusResponse } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { PiWebStatusController, type PiWebStatusControllerDependencies } from "./piWebStatusController";
|
||||
|
||||
type StatusApi = NonNullable<PiWebStatusControllerDependencies["api"]>;
|
||||
|
||||
describe("PiWebStatusController", () => {
|
||||
it("targets the selected machine and applies refreshed status", async () => {
|
||||
const harness = createHarness("remote-a");
|
||||
harness.piWebStatus.mockResolvedValue(status("remote"));
|
||||
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(harness.piWebStatus).toHaveBeenCalledWith("remote-a");
|
||||
expect(harness.state().piWebStatus?.generatedAt).toBe("remote");
|
||||
});
|
||||
|
||||
it("does not let an older periodic response overwrite a forced response", async () => {
|
||||
const harness = createHarness();
|
||||
const regular = createDeferred<PiWebStatusResponse>();
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
harness.piWebStatus.mockReturnValue(regular.promise);
|
||||
harness.checkForUpdates.mockReturnValue(forced.promise);
|
||||
|
||||
const regularRequest = harness.controller.refresh();
|
||||
const forcedRequest = harness.controller.checkForUpdates();
|
||||
forced.resolve(status("forced"));
|
||||
await forcedRequest;
|
||||
regular.resolve(status("regular"));
|
||||
await regularRequest;
|
||||
|
||||
expect(harness.state().piWebStatus?.generatedAt).toBe("forced");
|
||||
});
|
||||
|
||||
it("deduplicates forced checks and suppresses periodic refresh while one is pending", async () => {
|
||||
const harness = createHarness();
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
harness.checkForUpdates.mockReturnValue(forced.promise);
|
||||
|
||||
const first = harness.controller.checkForUpdates();
|
||||
const second = harness.controller.checkForUpdates();
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(harness.checkForUpdates).toHaveBeenCalledOnce();
|
||||
expect(harness.piWebStatus).not.toHaveBeenCalled();
|
||||
|
||||
forced.resolve(status("forced"));
|
||||
await first;
|
||||
});
|
||||
|
||||
it("does not apply a response or error after the selected machine changes", async () => {
|
||||
const harness = createHarness("remote-a");
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
harness.checkForUpdates.mockReturnValue(forced.promise);
|
||||
|
||||
const request = harness.controller.checkForUpdates();
|
||||
harness.selectMachine("remote-b");
|
||||
forced.resolve(status("remote-a", { error: "registry unavailable" }));
|
||||
await expect(request).resolves.toBeUndefined();
|
||||
|
||||
expect(harness.state().piWebStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ error: "registry unavailable" }, "PI WEB update check failed: registry unavailable"],
|
||||
[{ skipped: true }, "PI WEB update check was skipped"],
|
||||
] as const)("applies status and rejects an unsuccessful manual check", async (release, message) => {
|
||||
const harness = createHarness();
|
||||
harness.checkForUpdates.mockResolvedValue(status("checked", release));
|
||||
|
||||
await expect(harness.controller.checkForUpdates()).rejects.toThrow(message);
|
||||
|
||||
expect(harness.state().piWebStatus?.generatedAt).toBe("checked");
|
||||
});
|
||||
|
||||
it("clears current status and reports periodic refresh failures", async () => {
|
||||
const harness = createHarness();
|
||||
const error = new Error("offline");
|
||||
harness.setStatus(status("old"));
|
||||
harness.piWebStatus.mockRejectedValue(error);
|
||||
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(harness.state().piWebStatus).toBeUndefined();
|
||||
expect(harness.onRefreshError).toHaveBeenCalledWith("local", error);
|
||||
});
|
||||
});
|
||||
|
||||
function createHarness(machineId = "local") {
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: machine(machineId) };
|
||||
const piWebStatus = vi.fn<StatusApi["piWebStatus"]>();
|
||||
const checkForUpdates = vi.fn<StatusApi["checkForUpdates"]>();
|
||||
const onRefreshError = vi.fn<(machineId: string, error: unknown) => void>();
|
||||
const controller = new PiWebStatusController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
{ api: { piWebStatus, checkForUpdates }, onRefreshError },
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
piWebStatus,
|
||||
checkForUpdates,
|
||||
onRefreshError,
|
||||
state: () => state,
|
||||
setStatus: (piWebStatusValue: PiWebStatusResponse) => { state = { ...state, piWebStatus: piWebStatusValue }; },
|
||||
selectMachine: (id: string) => { state = { ...state, selectedMachine: machine(id) }; },
|
||||
};
|
||||
}
|
||||
|
||||
function machine(id: string): Machine {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: id === "local" ? "local" : "remote",
|
||||
...(id === "local" ? {} : { baseUrl: `https://${id}.example.test` }),
|
||||
createdAt: "now",
|
||||
updatedAt: "now",
|
||||
};
|
||||
}
|
||||
|
||||
function status(generatedAt: string, release: Partial<PiWebReleaseStatus> = {}): PiWebStatusResponse {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt,
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false, ...release },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { piWebApi, type PiWebStatusResponse } from "../api";
|
||||
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||
|
||||
export interface PiWebStatusControllerDependencies {
|
||||
api?: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
|
||||
onRefreshError?: (machineId: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
export class PiWebStatusController {
|
||||
private readonly api: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
|
||||
private readonly onRefreshError: (machineId: string, error: unknown) => void;
|
||||
private requestSequence = 0;
|
||||
private pendingUpdateCheck: { machineId: string; requestSequence: number; promise: Promise<void> } | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
private readonly setState: SetState,
|
||||
dependencies: PiWebStatusControllerDependencies = {},
|
||||
) {
|
||||
this.api = dependencies.api ?? piWebApi;
|
||||
this.onRefreshError = dependencies.onRefreshError ?? (() => undefined);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
if (this.pendingUpdateCheck?.machineId === machineId) return;
|
||||
const requestSequence = ++this.requestSequence;
|
||||
try {
|
||||
const piWebStatus = await this.api.piWebStatus(machineId);
|
||||
if (this.isCurrent(machineId, requestSequence)) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
if (!this.isCurrent(machineId, requestSequence)) return;
|
||||
this.setState({ piWebStatus: undefined });
|
||||
this.onRefreshError(machineId, error);
|
||||
}
|
||||
}
|
||||
|
||||
checkForUpdates(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const existing = this.pendingUpdateCheck;
|
||||
if (existing?.machineId === machineId) return existing.promise;
|
||||
|
||||
const requestSequence = ++this.requestSequence;
|
||||
const promise = this.api.checkForUpdates(machineId)
|
||||
.then((piWebStatus) => {
|
||||
if (!this.isCurrent(machineId, requestSequence)) return;
|
||||
this.setState({ piWebStatus });
|
||||
throwForUnsuccessfulReleaseCheck(piWebStatus);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.isCurrent(machineId, requestSequence)) throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingUpdateCheck?.requestSequence === requestSequence) this.pendingUpdateCheck = undefined;
|
||||
});
|
||||
this.pendingUpdateCheck = { machineId, requestSequence, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
private isCurrent(machineId: string, requestSequence: number): boolean {
|
||||
return selectedMachineId(this.getState()) === machineId && requestSequence === this.requestSequence;
|
||||
}
|
||||
}
|
||||
|
||||
function throwForUnsuccessfulReleaseCheck(status: PiWebStatusResponse): void {
|
||||
if (status.release.error !== undefined) throw new Error(`PI WEB update check failed: ${status.release.error}`);
|
||||
if (status.release.skipped === true) throw new Error("PI WEB update check was skipped because remote version checks are disabled by offline/version-check settings");
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export interface PluginRuntimeContext {
|
||||
refreshFiles: () => void | Promise<void>;
|
||||
refreshGit: () => void | Promise<void>;
|
||||
refreshAppData: () => void | Promise<void>;
|
||||
checkForPiWebUpdates?: () => void | Promise<void>;
|
||||
reloadPage: () => void;
|
||||
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
|
||||
startSession: () => void | Promise<void>;
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface PluginRuntimeContext {
|
||||
refreshFiles: () => void | Promise<void>;
|
||||
refreshGit: () => void | Promise<void>;
|
||||
refreshAppData: () => void | Promise<void>;
|
||||
/** Force a fresh PI WEB release check on the selected machine. Optional for compatibility with older hosts. */
|
||||
checkForPiWebUpdates?: () => void | Promise<void>;
|
||||
reloadPage: () => void;
|
||||
startSession: () => void | Promise<void>;
|
||||
archiveSession: () => void | Promise<void>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
describe("PI WEB status routes", () => {
|
||||
it("forces a fresh status load when refresh is requested", async () => {
|
||||
const get = vi.fn(() => Promise.resolve(status("cached")));
|
||||
const refresh = vi.fn(() => Promise.resolve(status("forced")));
|
||||
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
|
||||
|
||||
try {
|
||||
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
|
||||
const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
|
||||
|
||||
expect(cachedResponse.json<PiWebStatusResponse>().generatedAt).toBe("cached");
|
||||
expect(forcedResponse.json<PiWebStatusResponse>().generatedAt).toBe("forced");
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledWith({ force: true });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function status(generatedAt: string): PiWebStatusResponse {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt,
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("preserves the force-refresh query when proxying update checks", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ ok: true })]),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
+9
-5
@@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
@@ -38,6 +38,7 @@ export interface AppDependencies {
|
||||
sessionDaemon?: SessionProxyDaemon;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
piPackages?: PiPackageService;
|
||||
piWebStatusCache?: PiWebStatusCache;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
|
||||
});
|
||||
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
|
||||
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
|
||||
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
|
||||
);
|
||||
const machines = deps.machines ?? new MachineService(undefined, {
|
||||
localRuntime: () => getPiWebRuntime(sessionDaemon),
|
||||
});
|
||||
@@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
return reply.type(asset.contentType).send(asset.content);
|
||||
});
|
||||
|
||||
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
|
||||
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
|
||||
? piWebStatusCache.refresh({ force: true })
|
||||
: piWebStatusCache.get());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js";
|
||||
|
||||
describe("createPiWebReleaseLookupCache", () => {
|
||||
it("serves a fresh cached release lookup", async () => {
|
||||
let now = 1_000;
|
||||
const load = vi.fn(() => Promise.resolve("1.0.0"));
|
||||
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
|
||||
now = 1_050;
|
||||
await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(load).toHaveBeenCalledWith("0.9.0");
|
||||
});
|
||||
|
||||
it("bypasses a fresh lookup when forced", async () => {
|
||||
let now = 1_000;
|
||||
const load = vi.fn()
|
||||
.mockResolvedValueOnce("1.0.0")
|
||||
.mockResolvedValueOnce("1.1.0");
|
||||
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await cache.get("0.9.0");
|
||||
now = 1_050;
|
||||
|
||||
await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
|
||||
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => {
|
||||
const regular = createDeferred<string>();
|
||||
const forced = createDeferred<string>();
|
||||
const load = vi.fn()
|
||||
.mockImplementationOnce(() => regular.promise)
|
||||
.mockImplementationOnce(() => forced.promise);
|
||||
const cache = createPiWebReleaseLookupCache(load);
|
||||
|
||||
const regularLookup = cache.get("0.9.0");
|
||||
const forcedLookup = cache.get("0.9.0", { force: true });
|
||||
if (completionOrder === "forced-first") {
|
||||
forced.resolve("2.0.0");
|
||||
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
regular.resolve("1.0.0");
|
||||
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
|
||||
} else {
|
||||
regular.resolve("1.0.0");
|
||||
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
|
||||
forced.resolve("2.0.0");
|
||||
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
}
|
||||
|
||||
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes regular callers join a pending forced lookup", async () => {
|
||||
const forced = createDeferred<string>();
|
||||
const load = vi.fn(() => forced.promise);
|
||||
const cache = createPiWebReleaseLookupCache(load);
|
||||
|
||||
const forcedLookup = cache.get("0.9.0", { force: true });
|
||||
const regularLookup = cache.get("0.9.0");
|
||||
|
||||
expect(regularLookup).toBe(forcedLookup);
|
||||
forced.resolve("2.0.0");
|
||||
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export interface PiWebReleaseLookup {
|
||||
checkedAtMs: number;
|
||||
latestVersion?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseLookupCacheOptions {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseLookupOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseLookupCache {
|
||||
get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise<PiWebReleaseLookup>;
|
||||
}
|
||||
|
||||
export function createPiWebReleaseLookupCache(
|
||||
load: (currentVersion: string) => Promise<string>,
|
||||
options: PiWebReleaseLookupCacheOptions = {},
|
||||
): PiWebReleaseLookupCache {
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
let cached: PiWebReleaseLookup | undefined;
|
||||
let pending: { promise: Promise<PiWebReleaseLookup>; force: boolean; sequence: number } | undefined;
|
||||
let loadSequence = 0;
|
||||
|
||||
return {
|
||||
get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise<PiWebReleaseLookup> {
|
||||
const force = lookupOptions.force === true;
|
||||
if (pending?.force === true) return pending.promise;
|
||||
|
||||
const checkedAtMs = now();
|
||||
if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached);
|
||||
if (!force && pending !== undefined) return pending.promise;
|
||||
|
||||
const sequence = ++loadSequence;
|
||||
const promise = Promise.resolve()
|
||||
.then(() => load(currentVersion))
|
||||
.then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion }))
|
||||
.catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) }))
|
||||
.then((lookup) => {
|
||||
if (sequence === loadSequence) cached = lookup;
|
||||
return lookup;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pending?.sequence === sequence) pending = undefined;
|
||||
});
|
||||
pending = { promise, force, sequence };
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,33 @@ describe("PI WEB status", () => {
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
});
|
||||
|
||||
it("bypasses cached npm release data for a forced check", async () => {
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(npmVersionResponse("1.202607.1"))
|
||||
.mockResolvedValueOnce(npmVersionResponse("1.202607.2"));
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202607.0",
|
||||
installedVersion: "1.202607.0",
|
||||
stale: false,
|
||||
available: true,
|
||||
installation: { kind: "docker", dockerMode: "runtime" },
|
||||
});
|
||||
|
||||
const first = await getPiWebStatus(daemon, { forceReleaseCheck: true });
|
||||
const cached = await getPiWebStatus(daemon);
|
||||
const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true });
|
||||
|
||||
expect(first.release.latestVersion).toBe("1.202607.1");
|
||||
expect(cached.release.latestVersion).toBe("1.202607.1");
|
||||
expect(forced.release.latestVersion).toBe("1.202607.2");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
@@ -82,7 +109,7 @@ describe("PI WEB status", () => {
|
||||
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||
});
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
const status = await getPiWebStatus(daemon, { forceReleaseCheck: true });
|
||||
|
||||
expect(status.release.skipped).toBe(true);
|
||||
expect(status.components.sessiond.stale).toBe(true);
|
||||
@@ -192,6 +219,10 @@ describe("PI WEB status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function npmVersionResponse(version: string): Response {
|
||||
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
|
||||
const daemon = new SessionDaemonClient();
|
||||
vi.spyOn(daemon, "request").mockResolvedValue({
|
||||
|
||||
+10
-16
@@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/
|
||||
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
|
||||
const DEFAULT_VERSION = "0.0.0-dev";
|
||||
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||
const VERSION_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
type ServiceId = "sessiond" | "web" | "uiDev";
|
||||
@@ -74,8 +74,11 @@ interface PiWebStatusDaemon {
|
||||
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||
}
|
||||
|
||||
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
|
||||
export interface PiWebStatusOptions {
|
||||
forceReleaseCheck?: boolean;
|
||||
}
|
||||
|
||||
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
|
||||
const runtimePackageInfo = readPackageInfoSync();
|
||||
|
||||
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
|
||||
@@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
|
||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
||||
const { web, sessiond } = versionStatus.components;
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
|
||||
const components = { web, sessiond };
|
||||
const commands = await commandsFor(components);
|
||||
const messages = buildMessages(components, release, commands);
|
||||
@@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus {
|
||||
};
|
||||
}
|
||||
|
||||
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
|
||||
async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise<PiWebReleaseStatus> {
|
||||
const checkedAtMs = Date.now();
|
||||
if (skipVersionCheck()) {
|
||||
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
|
||||
}
|
||||
|
||||
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
|
||||
return releaseStatusFromCache(latestReleaseCache, currentVersion);
|
||||
}
|
||||
|
||||
try {
|
||||
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
|
||||
} catch (error) {
|
||||
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
return releaseStatusFromCache(latestReleaseCache, currentVersion);
|
||||
return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion);
|
||||
}
|
||||
|
||||
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
|
||||
function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus {
|
||||
return {
|
||||
packageName: PI_WEB_PACKAGE_NAME,
|
||||
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js";
|
||||
|
||||
describe("createPiWebStatusCache", () => {
|
||||
it("serves cached status while it is fresh", async () => {
|
||||
@@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => {
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => {
|
||||
const regular = createDeferred<PiWebStatusResponse>();
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise);
|
||||
const cache = createPiWebStatusCache(load);
|
||||
|
||||
const regularRefresh = cache.refresh();
|
||||
const forcedRefresh = cache.refresh({ force: true });
|
||||
if (completionOrder === "forced-first") {
|
||||
forced.resolve(status("forced"));
|
||||
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
|
||||
regular.resolve(status("regular"));
|
||||
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
|
||||
} else {
|
||||
regular.resolve(status("regular"));
|
||||
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
|
||||
forced.resolve(status("forced"));
|
||||
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
|
||||
}
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" });
|
||||
expect(load).toHaveBeenNthCalledWith(1, { force: false });
|
||||
expect(load).toHaveBeenNthCalledWith(2, { force: true });
|
||||
});
|
||||
|
||||
it("makes regular refreshes join a pending forced refresh", async () => {
|
||||
const deferred = createDeferred<PiWebStatusResponse>();
|
||||
const load = vi.fn(() => deferred.promise);
|
||||
const cache = createPiWebStatusCache(load);
|
||||
|
||||
const forced = cache.refresh({ force: true });
|
||||
const regular = cache.refresh();
|
||||
|
||||
expect(regular).toBe(forced);
|
||||
deferred.resolve(status("forced"));
|
||||
await forced;
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains stale status and reports background refresh errors", async () => {
|
||||
let now = 1_000;
|
||||
const refreshError = new Error("refresh failed");
|
||||
|
||||
@@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions {
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface PiWebStatusCache {
|
||||
get(): Promise<PiWebStatusResponse>;
|
||||
refresh(): Promise<PiWebStatusResponse>;
|
||||
export interface PiWebStatusCacheLoadOptions {
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
||||
export interface PiWebStatusCacheRefreshOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebStatusCache {
|
||||
get(): Promise<PiWebStatusResponse>;
|
||||
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
|
||||
}
|
||||
|
||||
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
|
||||
let pending: Promise<PiWebStatusResponse> | undefined;
|
||||
let pending: { promise: Promise<PiWebStatusResponse>; force: boolean; sequence: number } | undefined;
|
||||
let loadSequence = 0;
|
||||
|
||||
const refresh = (): Promise<PiWebStatusResponse> => {
|
||||
pending ??= Promise.resolve()
|
||||
.then(load)
|
||||
const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise<PiWebStatusResponse> => {
|
||||
const force = refreshOptions.force === true;
|
||||
if (pending !== undefined && (!force || pending.force)) return pending.promise;
|
||||
|
||||
const sequence = ++loadSequence;
|
||||
const promise = Promise.resolve()
|
||||
.then(() => load({ force }))
|
||||
.then((status) => {
|
||||
cached = { status, expiresAt: now() + ttlMs };
|
||||
if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
if (pending?.sequence === sequence) pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
pending = { promise, force, sequence };
|
||||
return promise;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user