mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add local Cloud Upstream sync (#6548)
## Thinking Path > - Paperclip is the control plane for AI-agent companies. > - Operators need a path to move local company state toward Paperclip Cloud without losing local-first control. > - The Cloud Upstream flow needs API, persistence, CLI, and board UI surfaces that agree on the same manifest/run model. > - The existing branch had the feature work plus UX and error-handling follow-ups. > - This pull request packages the remaining Cloud Upstream sync work into one standalone branch. > - The benefit is an inspectable local-to-cloud sync workflow with preview, conflicts, activation, and captured UX review states. ## What Changed - Added Cloud Upstream shared types, server routes/services, and persisted run schema/migration. - Added Paperclip Cloud CLI sync helpers and local connection storage. - Added the Cloud Upstream board UI, settings entry points, query keys, and UX lab page. - Added preview/activation checklist behavior, redirect handling, manifest-only preview support, friendly errors, in-flight hints, and entity count summaries. ## Verification - `pnpm --filter @paperclipai/plugin-sdk build` - `NODE_ENV=test pnpm exec vitest run cli/src/__tests__/cloud.test.ts server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-settings-service.test.ts ui/src/pages/CloudUpstream.test.tsx ui/src/components/CompanySettingsSidebar.test.tsx` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/cloud-upstreams.test.ts` Worktree setup note: the isolated worktree install skipped native sqlite build scripts, so I copied the already-built local sqlite binding from the main checkout before running `server/src/__tests__/cloud-upstreams.test.ts`. The test then passed. ## Risks - Medium: this adds a database migration and a broad feature path across CLI/server/UI. - Merge order: this is the only PR in this split with a DB migration; merge it before any future Cloud Upstream migration follow-up. - Mitigation: the PR is based directly on current `origin/master`, has targeted route/service/UI tests, and keeps the feature behind existing experimental Cloud Sync settings. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session; exact context window not exposed by this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, screenshot artifacts are intentionally omitted per reviewer request - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
a1835cfa5e
commit
e43b392a79
36 changed files with 5592 additions and 7 deletions
|
|
@ -32,6 +32,8 @@ import { CompanySettings } from "./pages/CompanySettings";
|
|||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { CloudUpstream } from "./pages/CloudUpstream";
|
||||
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
|
|
@ -72,6 +74,7 @@ function boardRoutes() {
|
|||
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
|
|
@ -279,6 +282,7 @@ export function App() {
|
|||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
<Route path="ux-lab/cloud-upstream" element={<CloudUpstreamUxLab />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
|
|
|
|||
40
ui/src/api/cloudUpstreams.ts
Normal file
40
ui/src/api/cloudUpstreams.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type {
|
||||
CloudUpstreamActivationEntityType,
|
||||
CloudUpstreamConnectStartResponse,
|
||||
CloudUpstreamConnection,
|
||||
CloudUpstreamPreview,
|
||||
CloudUpstreamRun,
|
||||
CloudUpstreamsState,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const cloudUpstreamsApi = {
|
||||
list: (companyId: string) =>
|
||||
api.get<CloudUpstreamsState>(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`),
|
||||
startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) =>
|
||||
api.post<CloudUpstreamConnectStartResponse>("/cloud-upstreams/connect/start", input),
|
||||
finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) =>
|
||||
api.post<CloudUpstreamConnection>("/cloud-upstreams/connect/finish", input),
|
||||
preview: (connectionId: string, input: { companyId: string }) =>
|
||||
api.post<CloudUpstreamPreview>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, input),
|
||||
createRun: (connectionId: string, input: { companyId: string; retryOfRunId?: string | null }) =>
|
||||
api.post<CloudUpstreamRun>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}),
|
||||
getRun: (connectionId: string, runId: string, companyId: string) =>
|
||||
api.get<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}?companyId=${encodeURIComponent(companyId)}`,
|
||||
),
|
||||
cancelRun: (connectionId: string, runId: string, input: { companyId: string }) =>
|
||||
api.post<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`,
|
||||
input,
|
||||
),
|
||||
activateEntities: (
|
||||
connectionId: string,
|
||||
runId: string,
|
||||
input: { companyId: string; entityType: CloudUpstreamActivationEntityType },
|
||||
) =>
|
||||
api.post<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/activation`,
|
||||
input,
|
||||
),
|
||||
};
|
||||
|
|
@ -11,6 +11,9 @@ const mockSidebarBadgesApi = vi.hoisted(() => ({
|
|||
get: vi.fn(),
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
|
|
@ -66,6 +69,10 @@ vi.mock("@/plugins/slots", () => ({
|
|||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -93,6 +100,9 @@ describe("CompanySettingsSidebar", () => {
|
|||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -121,6 +131,7 @@ describe("CompanySettingsSidebar", () => {
|
|||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
|
|
@ -210,4 +221,36 @@ describe("CompanySettingsSidebar", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cloud upstream only when cloud sync is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: true,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Cloud upstream");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/cloud-upstream",
|
||||
label: "Cloud upstream",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
|
||||
import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -35,6 +36,11 @@ export function CompanySettingsSidebar() {
|
|||
retry: false,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const showCloudUpstream = experimentalSettings?.enableCloudSync === true;
|
||||
|
||||
return (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
|
|
@ -66,6 +72,14 @@ export function CompanySettingsSidebar() {
|
|||
icon={MonitorCog}
|
||||
end
|
||||
/>
|
||||
{showCloudUpstream ? (
|
||||
<SidebarNavItem
|
||||
to="/company/settings/cloud-upstream"
|
||||
label="Cloud upstream"
|
||||
icon={CloudUpload}
|
||||
end
|
||||
/>
|
||||
) : null}
|
||||
<SidebarNavItem
|
||||
to="/company/settings/members"
|
||||
label="Members"
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export const queryKeys = {
|
|||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||
experimentalSettings: ["instance", "experimental-settings"] as const,
|
||||
},
|
||||
cloudUpstreams: (companyId: string) => ["cloud-upstreams", companyId] as const,
|
||||
health: ["health"] as const,
|
||||
secrets: {
|
||||
list: (companyId: string) => ["secrets", companyId] as const,
|
||||
|
|
|
|||
408
ui/src/pages/CloudUpstream.test.tsx
Normal file
408
ui/src/pages/CloudUpstream.test.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CloudUpstream, buildActivationRows } from "./CloudUpstream";
|
||||
|
||||
const mockCloudUpstreamsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
startConnect: vi.fn(),
|
||||
finishConnect: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
createRun: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
activateEntities: vi.fn(),
|
||||
}));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as
|
||||
| { id: string; name: string; issuePrefix: string | null }
|
||||
| null,
|
||||
selectedCompanyId: "company-1" as string | null,
|
||||
}));
|
||||
const mockLocationState = vi.hoisted(() => ({
|
||||
pathname: "/PAP/company/settings/cloud-upstream",
|
||||
search: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/api/cloudUpstreams", () => ({
|
||||
cloudUpstreamsApi: mockCloudUpstreamsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: mockCompanyState.selectedCompany,
|
||||
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => (
|
||||
<a href={to} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: mockLocationState.pathname, search: mockLocationState.search }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CloudUpstream", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: true });
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "succeeded" })));
|
||||
mockCloudUpstreamsApi.activateEntities.mockImplementation((_connectionId, _runId, input) =>
|
||||
Promise.resolve(buildRun({
|
||||
status: "succeeded",
|
||||
report: {
|
||||
activationChecklist: {
|
||||
[input.entityType]: {
|
||||
entityType: input.entityType,
|
||||
count: input.entityType === "agents" ? 2 : 1,
|
||||
status: "activated",
|
||||
activatedAt: "2026-05-18T19:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
mockCloudUpstreamsApi.createRun.mockResolvedValue(buildRun({ status: "running" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("binds the succeeded run activation checklist to imported category counts", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Re-run");
|
||||
expect(container.textContent).not.toContain("Retry");
|
||||
expect(container.textContent).toContain("Activation checklist");
|
||||
expect(container.textContent).toContain("2 paused");
|
||||
expect(container.textContent).toContain("1 paused");
|
||||
expect(container.textContent).toContain("0 imported monitors in this run.");
|
||||
expect(container.textContent).toContain("Keep paused");
|
||||
|
||||
const activateButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Activate") as HTMLButtonElement | undefined;
|
||||
expect(activateButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
activateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.activateEntities).toHaveBeenCalledWith(
|
||||
"connection-1",
|
||||
"run-1",
|
||||
{ companyId: "company-1", entityType: "agents" },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a company-prefixed redirectUri when starting Connect", async () => {
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.startConnect.mockResolvedValue({
|
||||
pendingConnectionId: "pending-1",
|
||||
authorizationUrl: "https://cloud.example/upstream-consent?state=abc",
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>("input[aria-label='Paperclip Cloud stack URL']");
|
||||
expect(input).toBeTruthy();
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input!, "https://cloud.example/PAP/dashboard");
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const connectButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Connect") as HTMLButtonElement | undefined;
|
||||
expect(connectButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.startConnect).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://cloud.example/PAP/dashboard",
|
||||
redirectUri: `${window.location.origin}/PAP/company/settings/cloud-upstream`,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the URL pathname prefix when cleaning up the callback URL with no company context", async () => {
|
||||
mockCompanyState.selectedCompany = null;
|
||||
mockCompanyState.selectedCompanyId = null;
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "?code=cb-code&state=cb-state";
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.finishConnect.mockResolvedValue({
|
||||
id: "connection-1",
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://cloud.example/PAP",
|
||||
target: {
|
||||
stackId: "stack-1",
|
||||
stackSlug: "stack",
|
||||
stackDisplayName: "Paperclip Cloud",
|
||||
companyId: "cloud-company-1",
|
||||
primaryHost: "cloud.example",
|
||||
origin: "https://cloud.example",
|
||||
product: "Paperclip Cloud",
|
||||
schemaMajor: 1,
|
||||
maxChunkBytes: 1024,
|
||||
},
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:00:00.000Z",
|
||||
lastRunId: null,
|
||||
});
|
||||
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledWith({
|
||||
pendingConnectionId: "pending-1",
|
||||
code: "cb-code",
|
||||
state: "cb-state",
|
||||
});
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/PAP/company/settings/cloud-upstream");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
replaceStateSpy.mockRestore();
|
||||
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry the OAuth callback finish mutation after an error", async () => {
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "?code=cb-code&state=cb-state";
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.finishConnect.mockRejectedValue(new Error("state expired"));
|
||||
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("state expired");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps retry only for failed or cancelled runs", async () => {
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Retry");
|
||||
expect(container.textContent).not.toContain("Re-run");
|
||||
expect(container.textContent).not.toContain("Activation checklist");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivationRows", () => {
|
||||
it("reads activation decisions from the run report", () => {
|
||||
const rows = buildActivationRows(buildRun({
|
||||
status: "succeeded",
|
||||
report: {
|
||||
activationChecklist: {
|
||||
agents: {
|
||||
entityType: "agents",
|
||||
count: 2,
|
||||
status: "activated",
|
||||
activatedAt: "2026-05-18T19:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(rows[0]).toMatchObject({ key: "agents", count: 2, status: "activated", statusLabel: "2 activated" });
|
||||
expect(rows[2]).toMatchObject({ key: "monitors", count: 0, status: "paused", statusLabel: "0 imported" });
|
||||
});
|
||||
});
|
||||
|
||||
function stateWithRun(run: CloudUpstreamRun): CloudUpstreamsState {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: "connection-1",
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://paperclip.example/PAP",
|
||||
target: {
|
||||
stackId: "stack-1",
|
||||
stackSlug: "stack",
|
||||
stackDisplayName: "Paperclip Cloud",
|
||||
companyId: "cloud-company-1",
|
||||
primaryHost: "paperclip.example",
|
||||
origin: "https://paperclip.example",
|
||||
product: "Paperclip Cloud",
|
||||
schemaMajor: 1,
|
||||
maxChunkBytes: 1024,
|
||||
},
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:00:00.000Z",
|
||||
lastRunId: run.id,
|
||||
},
|
||||
],
|
||||
runs: [run],
|
||||
};
|
||||
}
|
||||
|
||||
function buildRun(input: {
|
||||
status: CloudUpstreamRun["status"];
|
||||
report?: Record<string, unknown>;
|
||||
}): CloudUpstreamRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
connectionId: "connection-1",
|
||||
companyId: "company-1",
|
||||
status: input.status,
|
||||
activeStep: input.status === "succeeded" ? "activate" : "push",
|
||||
progressPercent: input.status === "running" ? 70 : 100,
|
||||
dryRun: false,
|
||||
summary: [
|
||||
{ key: "agents", label: "Agents", count: 2 },
|
||||
{ key: "routines", label: "Routines", count: 1 },
|
||||
{ key: "issues", label: "Issues", count: 7 },
|
||||
],
|
||||
warnings: [],
|
||||
conflicts: [],
|
||||
events: [
|
||||
{
|
||||
id: "event-1",
|
||||
at: "2026-05-18T18:30:00.000Z",
|
||||
phase: input.status === "succeeded" ? "activate" : "push",
|
||||
type: input.status === "failed" ? "failed" : "completed",
|
||||
message: input.status === "failed" ? "Push failed." : "Activation checklist is ready.",
|
||||
},
|
||||
],
|
||||
targetUrl: "https://paperclip.example",
|
||||
report: input.report ?? {},
|
||||
retryOfRunId: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:30:00.000Z",
|
||||
completedAt: input.status === "running" ? null : "2026-05-18T18:30:00.000Z",
|
||||
};
|
||||
}
|
||||
646
ui/src/pages/CloudUpstream.tsx
Normal file
646
ui/src/pages/CloudUpstream.tsx
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CloudUpload,
|
||||
ExternalLink,
|
||||
FileJson,
|
||||
History,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
CloudUpstreamActivationDecision,
|
||||
CloudUpstreamActivationEntityType,
|
||||
CloudUpstreamPreview,
|
||||
CloudUpstreamRun,
|
||||
CloudUpstreamStep,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cloudUpstreamsApi } from "@/api/cloudUpstreams";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const PENDING_CONNECTION_KEY = "paperclip-cloud-upstream-pending-connection";
|
||||
const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
|
||||
{ key: "connect", label: "Connect" },
|
||||
{ key: "scan", label: "Scan" },
|
||||
{ key: "preview", label: "Preview" },
|
||||
{ key: "push", label: "Push" },
|
||||
{ key: "verify", label: "Verify" },
|
||||
{ key: "activate", label: "Activate" },
|
||||
];
|
||||
const ACTIVATION_CATEGORIES: Array<{
|
||||
key: CloudUpstreamActivationEntityType;
|
||||
label: string;
|
||||
singular: string;
|
||||
detail: string;
|
||||
}> = [
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agents",
|
||||
singular: "agent",
|
||||
detail: "Confirm cloud secrets and adapter credentials before unpausing imported agents.",
|
||||
},
|
||||
{
|
||||
key: "routines",
|
||||
label: "Routines",
|
||||
singular: "routine",
|
||||
detail: "Review schedules and trigger settings before enabling imported routines.",
|
||||
},
|
||||
{
|
||||
key: "monitors",
|
||||
label: "Monitors",
|
||||
singular: "monitor",
|
||||
detail: "Activate after the target stack has been smoke tested.",
|
||||
},
|
||||
];
|
||||
|
||||
export function CloudUpstream() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const [remoteUrl, setRemoteUrl] = useState("");
|
||||
const [preview, setPreview] = useState<CloudUpstreamPreview | null>(null);
|
||||
const [activeRun, setActiveRun] = useState<CloudUpstreamRun | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Cloud upstream" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const experimentalQuery = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const cloudSyncEnabled = experimentalQuery.data?.enableCloudSync === true;
|
||||
|
||||
const upstreamQuery = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.cloudUpstreams(selectedCompanyId) : ["cloud-upstreams", "__disabled__"],
|
||||
queryFn: () => cloudUpstreamsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && cloudSyncEnabled,
|
||||
});
|
||||
|
||||
const connection = upstreamQuery.data?.connections[0] ?? null;
|
||||
const latestRun = activeRun ?? upstreamQuery.data?.runs[0] ?? null;
|
||||
|
||||
const callbackParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const code = callbackParams.get("code");
|
||||
const state = callbackParams.get("state");
|
||||
const callbackError = callbackParams.get("error");
|
||||
|
||||
const settingsPath = useMemo(() => {
|
||||
const pathPrefix = extractCompanyPrefixFromPath(location.pathname);
|
||||
return applyCompanyPrefix("/company/settings/cloud-upstream", pathPrefix ?? selectedCompany?.issuePrefix ?? null);
|
||||
}, [location.pathname, selectedCompany?.issuePrefix]);
|
||||
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: (input: { pendingConnectionId: string; code: string; state: string }) =>
|
||||
cloudUpstreamsApi.finishConnect(input),
|
||||
onSuccess: async () => {
|
||||
localStorage.removeItem(PENDING_CONNECTION_KEY);
|
||||
setNotice("Cloud upstream connection approved.");
|
||||
setActionError(null);
|
||||
await invalidateUpstreams();
|
||||
window.history.replaceState(null, "", settingsPath);
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to finish connection."),
|
||||
});
|
||||
const {
|
||||
mutate: finishConnect,
|
||||
isError: finishConnectFailed,
|
||||
isPending: finishConnectPending,
|
||||
isSuccess: finishConnectSucceeded,
|
||||
} = finishMutation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!cloudSyncEnabled || !code || !state || finishConnectPending || finishConnectSucceeded || finishConnectFailed) return;
|
||||
const pendingConnectionId = localStorage.getItem(PENDING_CONNECTION_KEY);
|
||||
if (!pendingConnectionId) {
|
||||
setActionError("No pending cloud upstream connection was found. Start the connection again.");
|
||||
return;
|
||||
}
|
||||
finishConnect({ pendingConnectionId, code, state });
|
||||
}, [cloudSyncEnabled, code, finishConnect, finishConnectFailed, finishConnectPending, finishConnectSucceeded, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackError) {
|
||||
setActionError(`Cloud upstream connection was not approved: ${callbackError}`);
|
||||
}
|
||||
}, [callbackError]);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
cloudUpstreamsApi.startConnect({
|
||||
companyId: selectedCompanyId!,
|
||||
remoteUrl,
|
||||
redirectUri: `${window.location.origin}${settingsPath}`,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
localStorage.setItem(PENDING_CONNECTION_KEY, result.pendingConnectionId);
|
||||
setActionError(null);
|
||||
window.location.assign(result.authorizationUrl);
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to start connection."),
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (input: { connectionId: string; companyId: string }) =>
|
||||
cloudUpstreamsApi.preview(input.connectionId, { companyId: input.companyId }),
|
||||
onSuccess: (nextPreview) => {
|
||||
setPreview(nextPreview);
|
||||
setActionError(null);
|
||||
},
|
||||
onError: (error) => setActionError(previewErrorMessage(error)),
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }) =>
|
||||
cloudUpstreamsApi.createRun(input.connectionId, {
|
||||
companyId: input.companyId,
|
||||
retryOfRunId: input.retryOfRunId ?? null,
|
||||
}),
|
||||
onSuccess: async (run) => {
|
||||
setActiveRun(run);
|
||||
setNotice(run.status === "succeeded"
|
||||
? "Push run completed. Review activation before unpausing automations."
|
||||
: "Push run failed. Review the run events and retry after correcting the issue.");
|
||||
setActionError(null);
|
||||
await invalidateUpstreams();
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to run push."),
|
||||
});
|
||||
const activationMutation = useMutation({
|
||||
mutationFn: (input: { run: CloudUpstreamRun; entityType: CloudUpstreamActivationEntityType }) =>
|
||||
cloudUpstreamsApi.activateEntities(input.run.connectionId, input.run.id, {
|
||||
companyId: input.run.companyId,
|
||||
entityType: input.entityType,
|
||||
}),
|
||||
onSuccess: async (run) => {
|
||||
setActiveRun(run);
|
||||
setNotice("Activation checklist updated.");
|
||||
setActionError(null);
|
||||
await invalidateUpstreams();
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to activate imported entities."),
|
||||
});
|
||||
|
||||
async function invalidateUpstreams() {
|
||||
if (!selectedCompanyId) return;
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.cloudUpstreams(selectedCompanyId) });
|
||||
}
|
||||
|
||||
if (!selectedCompanyId || !selectedCompany) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to configure cloud upstream.</div>;
|
||||
}
|
||||
|
||||
if (experimentalQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
|
||||
}
|
||||
|
||||
if (!cloudSyncEnabled) {
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudUpload className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Cloud upstream</h1>
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
|
||||
Cloud sync is disabled. Enable it in{" "}
|
||||
<Link className="text-primary underline-offset-2 hover:underline" to="/instance/settings/experimental">
|
||||
Instance Settings
|
||||
</Link>{" "}
|
||||
to show upstream connection and push tools.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudUpload className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Cloud upstream</h1>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Push {selectedCompany.name} into a Paperclip Cloud stack. Automations stay paused until activation.
|
||||
</p>
|
||||
</div>
|
||||
{connection?.target.origin ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={connection.target.origin} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open cloud
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Stepper activeStep={latestRun?.activeStep ?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect")} />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Connection</div>
|
||||
<div className="rounded-md border border-border px-4 py-4">
|
||||
{connection ? (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => previewMutation.mutate({ connectionId: connection.id, companyId: connection.companyId })}
|
||||
disabled={previewMutation.isPending || connection.tokenStatus !== "connected"}
|
||||
>
|
||||
{previewMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||
Preview push
|
||||
</Button>
|
||||
{previewMutation.isPending ? <PreviewProgressHint /> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<Input
|
||||
value={remoteUrl}
|
||||
onChange={(event) => setRemoteUrl(event.target.value)}
|
||||
placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
|
||||
aria-label="Paperclip Cloud stack URL"
|
||||
/>
|
||||
<Button onClick={() => startMutation.mutate()} disabled={startMutation.isPending || !remoteUrl.trim()}>
|
||||
{startMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{preview ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Preview</div>
|
||||
<Button
|
||||
onClick={() => runMutation.mutate({ connectionId: preview.connectionId, companyId: preview.sourceCompanyId })}
|
||||
disabled={runMutation.isPending || !preview.schemaCompatible}
|
||||
>
|
||||
{runMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
|
||||
Push to cloud
|
||||
</Button>
|
||||
</div>
|
||||
<SummaryGrid summary={preview.summary} />
|
||||
<WarningsPanel warnings={preview.warnings} />
|
||||
<ConflictTable conflicts={preview.conflicts} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{latestRun ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Progress and finish</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => downloadRunReport(latestRun)}>
|
||||
<FileJson className="h-4 w-4" />
|
||||
Download report
|
||||
</Button>
|
||||
{latestRun.status === "failed" || latestRun.status === "cancelled" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({
|
||||
connectionId: latestRun.connectionId,
|
||||
companyId: latestRun.companyId,
|
||||
retryOfRunId: latestRun.id,
|
||||
})}
|
||||
disabled={runMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
) : latestRun.status === "succeeded" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId, companyId: latestRun.companyId })}
|
||||
disabled={runMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Re-run
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium capitalize">{latestRun.status}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt ? `completed ${formatDate(latestRun.completedAt)}` : "in progress"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm tabular-nums">{latestRun.progressPercent}%</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-full bg-muted">
|
||||
<div className="h-2 rounded-full bg-primary" style={{ width: `${latestRun.progressPercent}%` }} />
|
||||
</div>
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{latestRun.events.map((event) => (
|
||||
<div key={event.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[7rem_8rem_1fr]">
|
||||
<span className="text-xs text-muted-foreground">{formatDate(event.at)}</span>
|
||||
<span className="text-xs capitalize text-muted-foreground">{event.phase}</span>
|
||||
<span>{event.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestRun.status === "succeeded" ? (
|
||||
<ActivationChecklist
|
||||
run={latestRun}
|
||||
pendingEntityType={activationMutation.variables?.entityType ?? null}
|
||||
isPending={activationMutation.isPending}
|
||||
onActivate={(entityType) => activationMutation.mutate({ run: latestRun, entityType })}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{upstreamQuery.data?.runs.length ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
History
|
||||
</div>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{upstreamQuery.data.runs.map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
className="grid w-full gap-1 px-4 py-3 text-left text-sm hover:bg-accent/40 sm:grid-cols-[1fr_auto]"
|
||||
onClick={() => setActiveRun(run)}
|
||||
>
|
||||
<span>Run {run.id.slice(0, 8)} · {run.status}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(run.createdAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewProgressHint() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
const interval = window.setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
const message = elapsed < 15
|
||||
? "Building manifest..."
|
||||
: elapsed < 45
|
||||
? `Building manifest... ${elapsed}s. Large companies can take up to a minute.`
|
||||
: `Still building manifest... ${elapsed}s. PAP-scale companies routinely take ~60s.`;
|
||||
return <div className="text-xs text-muted-foreground">{message}</div>;
|
||||
}
|
||||
|
||||
function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
|
||||
const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border border-border px-3 py-3 sm:grid-cols-6">
|
||||
{STEPS.map((step, index) => {
|
||||
const complete = index < activeIndex;
|
||||
const active = index === activeIndex;
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2 text-xs">
|
||||
{complete ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
<span className={active ? "h-4 w-4 rounded-full border-2 border-primary" : "h-4 w-4 rounded-full border border-border"} />
|
||||
)}
|
||||
<span className={active ? "font-medium text-foreground" : "text-muted-foreground"}>{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryGrid({ summary }: { summary: CloudUpstreamPreview["summary"] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div key={item.key} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">{item.count}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningsPanel({ warnings }: { warnings: CloudUpstreamPreview["warnings"] }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
Warnings
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{warnings.map((warning) => (
|
||||
<div key={warning.code} className="grid gap-2 py-2 sm:grid-cols-[1.25rem_12rem_1fr]">
|
||||
<AlertTriangle className={warning.severity === "blocker" ? "h-4 w-4 text-destructive" : "h-4 w-4 text-amber-600"} />
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{warning.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictTable({ conflicts }: { conflicts: CloudUpstreamPreview["conflicts"] }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div className="mb-2 text-sm font-medium">Conflicts</div>
|
||||
{conflicts.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No target conflicts detected for this preview.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{conflicts.map((conflict) => (
|
||||
<div key={conflict.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_1fr_8rem]">
|
||||
<span className="text-muted-foreground">{conflict.entityType}</span>
|
||||
<span>{conflict.sourceLabel}</span>
|
||||
<span>{conflict.targetLabel}</span>
|
||||
<span className="capitalize">{conflict.plannedAction}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivationChecklist({
|
||||
run,
|
||||
pendingEntityType,
|
||||
isPending,
|
||||
onActivate,
|
||||
}: {
|
||||
run: CloudUpstreamRun;
|
||||
pendingEntityType: CloudUpstreamActivationEntityType | null;
|
||||
isPending: boolean;
|
||||
onActivate: (entityType: CloudUpstreamActivationEntityType) => void;
|
||||
}) {
|
||||
const rows = buildActivationRows(run);
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div className="mb-2 text-sm font-medium">Activation checklist</div>
|
||||
<div className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const pending = isPending && pendingEntityType === row.key;
|
||||
const activated = row.status === "activated";
|
||||
return (
|
||||
<div key={row.key} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto] sm:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{row.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.statusLabel}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant={activated ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={() => onActivate(row.key)}
|
||||
disabled={row.count === 0 || activated || isPending}
|
||||
>
|
||||
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{activated ? "Activated" : "Activate"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={activated || isPending}>
|
||||
Keep paused
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildActivationRows(run: CloudUpstreamRun) {
|
||||
const activationChecklist = activationChecklistFromReport(run.report);
|
||||
return ACTIVATION_CATEGORIES.map((category) => {
|
||||
const decision = activationChecklist[category.key];
|
||||
const count = summaryCount(run.summary, category.key);
|
||||
const status = decision?.status === "activated" ? "activated" : "paused";
|
||||
const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
|
||||
return {
|
||||
...category,
|
||||
count,
|
||||
pluralLabel,
|
||||
status,
|
||||
detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
|
||||
statusLabel: status === "activated"
|
||||
? `${count} activated`
|
||||
: count === 0
|
||||
? "0 imported"
|
||||
: `${count} paused`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summaryCount(summary: CloudUpstreamRun["summary"], key: CloudUpstreamActivationEntityType): number {
|
||||
return summary.find((item) => item.key === key)?.count ?? 0;
|
||||
}
|
||||
|
||||
function activationChecklistFromReport(report: CloudUpstreamRun["report"]): Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> {
|
||||
const value = optionalRecord(report.activationChecklist);
|
||||
const decisions: Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> = {};
|
||||
for (const key of ["agents", "routines", "monitors"] as const) {
|
||||
const item = optionalRecord(value[key]);
|
||||
if (!item) continue;
|
||||
decisions[key] = {
|
||||
entityType: key,
|
||||
count: typeof item.count === "number" ? item.count : 0,
|
||||
status: item.status === "activated" ? "activated" : "paused",
|
||||
activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
|
||||
};
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function downloadRunReport(run: CloudUpstreamRun) {
|
||||
const blob = new Blob([JSON.stringify(run.report, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `cloud-upstream-run-${run.id}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
|
||||
if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
function previewErrorMessage(error: unknown): string {
|
||||
const code = error instanceof Error ? error.message : null;
|
||||
if (code === "payload_too_large" || code === "bad_request") {
|
||||
return "Local company is too large to preview as a single request. Click Push to continue (the Push step uploads in chunks), or see the docs for chunked-preview options.";
|
||||
}
|
||||
return code ?? "Failed to preview push.";
|
||||
}
|
||||
822
ui/src/pages/CloudUpstreamUxLab.tsx
Normal file
822
ui/src/pages/CloudUpstreamUxLab.tsx
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CloudUpload,
|
||||
ExternalLink,
|
||||
FileJson,
|
||||
History,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
CloudUpstreamActivationDecision,
|
||||
CloudUpstreamActivationEntityType,
|
||||
CloudUpstreamConflict,
|
||||
CloudUpstreamConnection,
|
||||
CloudUpstreamPreview,
|
||||
CloudUpstreamRun,
|
||||
CloudUpstreamStep,
|
||||
CloudUpstreamSummaryCount,
|
||||
CloudUpstreamWarning,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useLocation } from "@/lib/router";
|
||||
|
||||
type FixtureStateKey =
|
||||
| "settings-pane"
|
||||
| "connect-wizard"
|
||||
| "schema-mismatch"
|
||||
| "preview"
|
||||
| "preview-clean"
|
||||
| "progress"
|
||||
| "retry"
|
||||
| "finish";
|
||||
|
||||
const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
|
||||
{ key: "connect", label: "Connect" },
|
||||
{ key: "scan", label: "Scan" },
|
||||
{ key: "preview", label: "Preview" },
|
||||
{ key: "push", label: "Push" },
|
||||
{ key: "verify", label: "Verify" },
|
||||
{ key: "activate", label: "Activate" },
|
||||
];
|
||||
|
||||
const ACTIVATION_CATEGORIES: Array<{
|
||||
key: CloudUpstreamActivationEntityType;
|
||||
label: string;
|
||||
singular: string;
|
||||
detail: string;
|
||||
}> = [
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agents",
|
||||
singular: "agent",
|
||||
detail: "Keep paused until cloud secrets and adapter credentials are verified.",
|
||||
},
|
||||
{
|
||||
key: "routines",
|
||||
label: "Routines",
|
||||
singular: "routine",
|
||||
detail: "Review schedules before enabling triggers.",
|
||||
},
|
||||
{
|
||||
key: "monitors",
|
||||
label: "Monitors",
|
||||
singular: "monitor",
|
||||
detail: "Activate after the target instance has been smoke tested.",
|
||||
},
|
||||
];
|
||||
|
||||
const FIXTURE_LABELS: Record<FixtureStateKey, string> = {
|
||||
"settings-pane": "1 · Settings → Cloud upstream pane (enabled)",
|
||||
"connect-wizard": "2 · Connect wizard — remote URL entry + PKCE launch",
|
||||
"schema-mismatch": "3 · Connect wizard — schema-mismatch hard block",
|
||||
preview: "4 · Preview — conflicts, warnings, planned actions",
|
||||
"preview-clean": "5 · Preview — clean run with no conflicts",
|
||||
progress: "6 · Durable progress — mid-run from run events",
|
||||
retry: "7 · Retry without duplicating ledger entries",
|
||||
finish: "8 · Finish / activation checklist with run report",
|
||||
};
|
||||
|
||||
const PARSE_ORDER: FixtureStateKey[] = [
|
||||
"settings-pane",
|
||||
"connect-wizard",
|
||||
"schema-mismatch",
|
||||
"preview",
|
||||
"preview-clean",
|
||||
"progress",
|
||||
"retry",
|
||||
"finish",
|
||||
];
|
||||
|
||||
export function CloudUpstreamUxLab() {
|
||||
const location = useLocation();
|
||||
const { state, showChrome } = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const raw = (params.get("state") ?? "settings-pane") as FixtureStateKey;
|
||||
return {
|
||||
state: PARSE_ORDER.includes(raw) ? raw : "settings-pane",
|
||||
showChrome: params.get("chrome") === "on",
|
||||
};
|
||||
}, [location.search]);
|
||||
|
||||
const fixture = useMemo(() => buildFixture(state), [state]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 p-6">
|
||||
{showChrome ? <FixtureNav active={state} /> : null}
|
||||
<CloudUpstreamRender fixture={fixture} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FixtureNav({ active }: { active: FixtureStateKey }) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide">UX lab · cloud upstream</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{PARSE_ORDER.map((key) => (
|
||||
<a
|
||||
key={key}
|
||||
href={`?state=${key}`}
|
||||
className={
|
||||
active === key
|
||||
? "rounded bg-primary/10 px-2 py-0.5 font-medium text-primary"
|
||||
: "rounded px-2 py-0.5 hover:bg-accent/40"
|
||||
}
|
||||
>
|
||||
{FIXTURE_LABELS[key]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
selectedCompanyName: string;
|
||||
connection: CloudUpstreamConnection | null;
|
||||
preview: CloudUpstreamPreview | null;
|
||||
latestRun: CloudUpstreamRun | null;
|
||||
history: CloudUpstreamRun[];
|
||||
notice: string | null;
|
||||
actionError: string | null;
|
||||
}
|
||||
|
||||
function CloudUpstreamRender({ fixture }: { fixture: Fixture }) {
|
||||
const { connection, preview, latestRun, history, notice, actionError, selectedCompanyName } = fixture;
|
||||
const activeStep: CloudUpstreamStep = latestRun?.activeStep
|
||||
?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudUpload className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Cloud upstream</h1>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Push {selectedCompanyName} into a Paperclip Cloud stack. Automations stay paused until activation.
|
||||
</p>
|
||||
</div>
|
||||
{connection?.target.origin ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={connection.target.origin} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open cloud
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Stepper activeStep={activeStep} />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Connection</div>
|
||||
<div className="rounded-md border border-border px-4 py-4">
|
||||
{connection ? (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Preview push
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<Input
|
||||
defaultValue="https://paperclip.paperclip.app/PC521D/dashboard"
|
||||
placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
|
||||
aria-label="Paperclip Cloud stack URL"
|
||||
autoFocus
|
||||
/>
|
||||
<Button disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Discovering
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{preview ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Preview</div>
|
||||
<Button disabled={!preview.schemaCompatible}>
|
||||
<CloudUpload className="h-4 w-4" />
|
||||
Push to cloud
|
||||
</Button>
|
||||
</div>
|
||||
<SummaryGrid summary={preview.summary} />
|
||||
<WarningsPanel warnings={preview.warnings} />
|
||||
<ConflictTable conflicts={preview.conflicts} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{latestRun ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Progress and finish</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<FileJson className="h-4 w-4" />
|
||||
Download report
|
||||
</Button>
|
||||
{latestRun.status === "failed" || latestRun.status === "cancelled" ? (
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
) : latestRun.status === "succeeded" ? (
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Re-run
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium capitalize">{latestRun.status}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt
|
||||
? `completed ${formatDate(latestRun.completedAt)}`
|
||||
: latestRun.status === "running"
|
||||
? "in progress"
|
||||
: "in progress"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm tabular-nums">{latestRun.progressPercent}%</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-full bg-muted">
|
||||
<div className="h-2 rounded-full bg-primary" style={{ width: `${latestRun.progressPercent}%` }} />
|
||||
</div>
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{latestRun.events.map((event) => (
|
||||
<div key={event.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[7rem_8rem_1fr]">
|
||||
<span className="text-xs text-muted-foreground">{formatDate(event.at)}</span>
|
||||
<span className="text-xs capitalize text-muted-foreground">{event.phase}</span>
|
||||
<span>{event.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestRun.status === "succeeded" ? <ActivationChecklist run={latestRun} /> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{history.length ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
History
|
||||
</div>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{history.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="grid w-full gap-1 px-4 py-3 text-left text-sm hover:bg-accent/40 sm:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<span>Run {run.id.slice(0, 8)} · {run.status}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(run.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
|
||||
const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border border-border px-3 py-3 sm:grid-cols-6">
|
||||
{STEPS.map((step, index) => {
|
||||
const complete = index < activeIndex;
|
||||
const active = index === activeIndex;
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2 text-xs">
|
||||
{complete ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
<span className={active ? "h-4 w-4 rounded-full border-2 border-primary" : "h-4 w-4 rounded-full border border-border"} />
|
||||
)}
|
||||
<span className={active ? "font-medium text-foreground" : "text-muted-foreground"}>{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryGrid({ summary }: { summary: CloudUpstreamSummaryCount[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div key={item.key} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">{item.count}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningsPanel({ warnings }: { warnings: CloudUpstreamWarning[] }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
Warnings
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{warnings.map((warning) => (
|
||||
<div key={warning.code} className="grid gap-2 py-2 sm:grid-cols-[1.25rem_12rem_1fr]">
|
||||
<AlertTriangle className={warning.severity === "blocker" ? "h-4 w-4 text-destructive" : "h-4 w-4 text-amber-600"} />
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{warning.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictTable({ conflicts }: { conflicts: CloudUpstreamConflict[] }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div className="mb-2 text-sm font-medium">Conflicts</div>
|
||||
{conflicts.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No target conflicts detected for this preview.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{conflicts.map((conflict) => (
|
||||
<div key={conflict.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_1fr_8rem]">
|
||||
<span className="text-muted-foreground">{conflict.entityType}</span>
|
||||
<span>{conflict.sourceLabel}</span>
|
||||
<span>{conflict.targetLabel}</span>
|
||||
<span className="capitalize">{conflict.plannedAction}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivationChecklist({ run }: { run: CloudUpstreamRun }) {
|
||||
const rows = buildActivationRows(run);
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<div className="mb-2 text-sm font-medium">Activation checklist</div>
|
||||
<div className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const activated = row.status === "activated";
|
||||
return (
|
||||
<div key={row.key} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto] sm:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{row.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.statusLabel}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button variant={activated ? "secondary" : "default"} size="sm" disabled={row.count === 0 || activated}>
|
||||
{activated ? "Activated" : "Activate"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={activated}>
|
||||
Keep paused
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildActivationRows(run: CloudUpstreamRun) {
|
||||
const decisions = decisionsFromReport(run.report);
|
||||
return ACTIVATION_CATEGORIES.map((category) => {
|
||||
const decision = decisions[category.key];
|
||||
const count = summaryCount(run.summary, category.key);
|
||||
const status = decision?.status === "activated" ? "activated" : "paused";
|
||||
const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
|
||||
return {
|
||||
...category,
|
||||
count,
|
||||
pluralLabel,
|
||||
status,
|
||||
detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
|
||||
statusLabel: status === "activated"
|
||||
? `${count} activated`
|
||||
: count === 0
|
||||
? "0 imported"
|
||||
: `${count} paused`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function decisionsFromReport(report: Record<string, unknown>): Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> {
|
||||
const value = optionalRecord(report.activationChecklist);
|
||||
const decisions: Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> = {};
|
||||
for (const key of ["agents", "routines", "monitors"] as const) {
|
||||
const item = optionalRecord(value[key]);
|
||||
if (!item) continue;
|
||||
decisions[key] = {
|
||||
entityType: key,
|
||||
count: typeof item.count === "number" ? item.count : 0,
|
||||
status: item.status === "activated" ? "activated" : "paused",
|
||||
activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
|
||||
};
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
|
||||
function summaryCount(summary: CloudUpstreamSummaryCount[], key: CloudUpstreamActivationEntityType): number {
|
||||
return summary.find((item) => item.key === key)?.count ?? 0;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
|
||||
if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
const STACK_TARGET = {
|
||||
stackId: "stk_2vKqz9D8mNFqQ7Rp",
|
||||
stackSlug: "paperclip-prod",
|
||||
stackDisplayName: "Paperclip Prod",
|
||||
companyId: "co_4hT2yX",
|
||||
primaryHost: "paperclip.paperclip.app",
|
||||
origin: "https://paperclip.paperclip.app",
|
||||
product: "paperclip-cloud",
|
||||
schemaMajor: 7,
|
||||
maxChunkBytes: 5 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const STACK_TARGET_SCHEMA_BEHIND = {
|
||||
...STACK_TARGET,
|
||||
schemaMajor: 5,
|
||||
};
|
||||
|
||||
function connectedConnection(target = STACK_TARGET): CloudUpstreamConnection {
|
||||
return {
|
||||
id: "cu_conn_8d3f1b6a",
|
||||
companyId: "co_4hT2yX",
|
||||
remoteUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
|
||||
target,
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream.push", "upstream.preview"],
|
||||
authorizedGlobalUserId: "user_9pXqYzAbCdEf",
|
||||
expiresAt: "2026-08-18T19:00:00.000Z",
|
||||
createdAt: "2026-05-18T18:45:00.000Z",
|
||||
updatedAt: "2026-05-18T19:02:18.000Z",
|
||||
lastRunId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const PREVIEW_SUMMARY: CloudUpstreamSummaryCount[] = [
|
||||
{ key: "users", label: "Users", count: 14 },
|
||||
{ key: "agents", label: "Agents", count: 6 },
|
||||
{ key: "routines", label: "Routines", count: 4 },
|
||||
{ key: "monitors", label: "Monitors", count: 2 },
|
||||
];
|
||||
|
||||
const PREVIEW_WARNINGS_NORMAL: CloudUpstreamWarning[] = [
|
||||
{
|
||||
code: "imported_automations_paused",
|
||||
severity: "warning",
|
||||
title: "Automations stay paused",
|
||||
detail: "Imported agents, routines, and monitors require explicit activation after the push.",
|
||||
},
|
||||
{
|
||||
code: "unmatched_users_import_as_historical_authors",
|
||||
severity: "warning",
|
||||
title: "Unmatched users become historical authors",
|
||||
detail: "Invite now remains a secondary action after the transfer is complete.",
|
||||
},
|
||||
{
|
||||
code: "secret_values_redacted",
|
||||
severity: "warning",
|
||||
title: "Secret values are not transferred",
|
||||
detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.",
|
||||
},
|
||||
];
|
||||
|
||||
const PREVIEW_WARNINGS_SCHEMA: CloudUpstreamWarning[] = [
|
||||
{
|
||||
code: "schema_mismatch",
|
||||
severity: "blocker",
|
||||
title: "Cloud stack upgrade required",
|
||||
detail: "This local build uses upstream schema 7, but the cloud stack reports schema 5.",
|
||||
},
|
||||
...PREVIEW_WARNINGS_NORMAL,
|
||||
];
|
||||
|
||||
const PREVIEW_CONFLICTS: CloudUpstreamConflict[] = [
|
||||
{
|
||||
id: "conflict_user_serena",
|
||||
entityType: "user",
|
||||
sourceLabel: "serena@magicmachine.co (unmatched)",
|
||||
targetLabel: "→ historical author Serena R.",
|
||||
plannedAction: "create",
|
||||
reason: "Target stack has no matching identity. Will arrive as historical author; invite available after push.",
|
||||
},
|
||||
{
|
||||
id: "conflict_user_dotta",
|
||||
entityType: "user",
|
||||
sourceLabel: "dotta@magicmachine.co",
|
||||
targetLabel: "↦ dotta@magicmachine.co (cloud)",
|
||||
plannedAction: "update",
|
||||
reason: "Existing cloud identity matches local user; will be merged.",
|
||||
},
|
||||
{
|
||||
id: "conflict_agent_qa",
|
||||
entityType: "agent",
|
||||
sourceLabel: "QA · qa-bot",
|
||||
targetLabel: "↦ QA · qa-bot (cloud)",
|
||||
plannedAction: "update",
|
||||
reason: "Mapped to existing cloud agent. Imported run history will be appended.",
|
||||
},
|
||||
{
|
||||
id: "conflict_routine_nightly_reports",
|
||||
entityType: "routine",
|
||||
sourceLabel: "Nightly status report",
|
||||
targetLabel: "(new in cloud)",
|
||||
plannedAction: "create",
|
||||
reason: "Routine does not exist in the target stack and will be created in paused state.",
|
||||
},
|
||||
];
|
||||
|
||||
function basePreview(): CloudUpstreamPreview {
|
||||
return {
|
||||
connectionId: "cu_conn_8d3f1b6a",
|
||||
sourceCompanyId: "co_local_pc521d",
|
||||
target: STACK_TARGET,
|
||||
schemaCompatible: true,
|
||||
summary: PREVIEW_SUMMARY,
|
||||
warnings: PREVIEW_WARNINGS_NORMAL,
|
||||
conflicts: PREVIEW_CONFLICTS,
|
||||
generatedAt: "2026-05-18T19:03:14.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function schemaMismatchPreview(): CloudUpstreamPreview {
|
||||
return {
|
||||
...basePreview(),
|
||||
target: STACK_TARGET_SCHEMA_BEHIND,
|
||||
schemaCompatible: false,
|
||||
summary: [],
|
||||
conflicts: [],
|
||||
warnings: PREVIEW_WARNINGS_SCHEMA,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanPreview(): CloudUpstreamPreview {
|
||||
return {
|
||||
...basePreview(),
|
||||
conflicts: [],
|
||||
warnings: PREVIEW_WARNINGS_NORMAL.slice(0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
const PROGRESS_EVENTS = [
|
||||
{ id: "evt_01", at: "2026-05-18T19:10:02.000Z", phase: "scan" as CloudUpstreamStep, type: "completed" as const, message: "Scanned 14 users, 6 agents, 4 routines, 2 monitors." },
|
||||
{ id: "evt_02", at: "2026-05-18T19:10:11.000Z", phase: "preview" as CloudUpstreamStep, type: "completed" as const, message: "Preview generated with 4 conflicts and 3 warnings." },
|
||||
{ id: "evt_03", at: "2026-05-18T19:10:31.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "users · 8 created, 6 mapped to existing identities." },
|
||||
{ id: "evt_04", at: "2026-05-18T19:10:48.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "agents · 4 created paused, 2 updated paused." },
|
||||
{ id: "evt_05", at: "2026-05-18T19:10:58.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "routines · 3 created paused, 1 updated." },
|
||||
{ id: "evt_06", at: "2026-05-18T19:11:09.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "monitors · 2 created paused." },
|
||||
{ id: "evt_07", at: "2026-05-18T19:11:18.000Z", phase: "verify" as CloudUpstreamStep, type: "updated" as const, message: "Verifying transferred ledger checksums…" },
|
||||
];
|
||||
|
||||
function runningRun(): CloudUpstreamRun {
|
||||
return {
|
||||
id: "run_3kQ8mNpW9bX2zL4Y",
|
||||
connectionId: "cu_conn_8d3f1b6a",
|
||||
companyId: "co_local_pc521d",
|
||||
status: "running",
|
||||
activeStep: "push",
|
||||
progressPercent: 62,
|
||||
dryRun: false,
|
||||
summary: PREVIEW_SUMMARY,
|
||||
warnings: PREVIEW_WARNINGS_NORMAL,
|
||||
conflicts: PREVIEW_CONFLICTS,
|
||||
events: PROGRESS_EVENTS,
|
||||
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
|
||||
report: {},
|
||||
retryOfRunId: null,
|
||||
createdAt: "2026-05-18T19:10:01.000Z",
|
||||
updatedAt: "2026-05-18T19:11:18.000Z",
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function failedRun(): CloudUpstreamRun {
|
||||
return {
|
||||
id: "run_5fXqR2bT7aD8zP1K",
|
||||
connectionId: "cu_conn_8d3f1b6a",
|
||||
companyId: "co_local_pc521d",
|
||||
status: "failed",
|
||||
activeStep: "push",
|
||||
progressPercent: 78,
|
||||
dryRun: false,
|
||||
summary: PREVIEW_SUMMARY,
|
||||
warnings: PREVIEW_WARNINGS_NORMAL,
|
||||
conflicts: PREVIEW_CONFLICTS,
|
||||
events: [
|
||||
...PROGRESS_EVENTS,
|
||||
{
|
||||
id: "evt_08",
|
||||
at: "2026-05-18T19:11:30.000Z",
|
||||
phase: "push",
|
||||
type: "failed",
|
||||
message: "Apply rejected: cloud rejected chunk 4 of 6 (HTTP 502). Ledger entries from chunks 1–3 retained; chunk 4 not committed.",
|
||||
},
|
||||
],
|
||||
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
|
||||
report: { ledgerCheckpoint: "chunk-3" },
|
||||
retryOfRunId: null,
|
||||
createdAt: "2026-05-18T19:10:01.000Z",
|
||||
updatedAt: "2026-05-18T19:11:30.000Z",
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function succeededRun(): CloudUpstreamRun {
|
||||
return {
|
||||
id: "run_7aBcD9eFgH2iJ3kL",
|
||||
connectionId: "cu_conn_8d3f1b6a",
|
||||
companyId: "co_local_pc521d",
|
||||
status: "succeeded",
|
||||
activeStep: "activate",
|
||||
progressPercent: 100,
|
||||
dryRun: false,
|
||||
summary: PREVIEW_SUMMARY,
|
||||
warnings: PREVIEW_WARNINGS_NORMAL,
|
||||
conflicts: PREVIEW_CONFLICTS,
|
||||
events: [
|
||||
...PROGRESS_EVENTS,
|
||||
{
|
||||
id: "evt_08",
|
||||
at: "2026-05-18T19:11:25.000Z",
|
||||
phase: "verify",
|
||||
type: "completed",
|
||||
message: "Ledger checksums match. Push committed.",
|
||||
},
|
||||
{
|
||||
id: "evt_09",
|
||||
at: "2026-05-18T19:11:31.000Z",
|
||||
phase: "activate",
|
||||
type: "completed",
|
||||
message: "Activation checklist pending operator approval — automations remain paused.",
|
||||
},
|
||||
],
|
||||
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
|
||||
report: {
|
||||
activationChecklist: {
|
||||
agents: { count: 6, status: "paused", activatedAt: null },
|
||||
routines: { count: 4, status: "paused", activatedAt: null },
|
||||
monitors: { count: 2, status: "paused", activatedAt: null },
|
||||
},
|
||||
},
|
||||
retryOfRunId: null,
|
||||
createdAt: "2026-05-18T19:10:01.000Z",
|
||||
updatedAt: "2026-05-18T19:11:31.000Z",
|
||||
completedAt: "2026-05-18T19:11:31.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function buildFixture(state: FixtureStateKey): Fixture {
|
||||
switch (state) {
|
||||
case "settings-pane":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(),
|
||||
preview: null,
|
||||
latestRun: null,
|
||||
history: [],
|
||||
notice: "Cloud upstream connection approved.",
|
||||
actionError: null,
|
||||
};
|
||||
case "connect-wizard":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: null,
|
||||
preview: null,
|
||||
latestRun: null,
|
||||
history: [],
|
||||
notice: null,
|
||||
actionError: null,
|
||||
};
|
||||
case "schema-mismatch":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(STACK_TARGET_SCHEMA_BEHIND),
|
||||
preview: schemaMismatchPreview(),
|
||||
latestRun: null,
|
||||
history: [],
|
||||
notice: null,
|
||||
actionError: "Cloud stack is on schema 5 but this local build pushes schema 7. Upgrade the cloud stack to continue.",
|
||||
};
|
||||
case "preview":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(),
|
||||
preview: basePreview(),
|
||||
latestRun: null,
|
||||
history: [],
|
||||
notice: null,
|
||||
actionError: null,
|
||||
};
|
||||
case "preview-clean":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(),
|
||||
preview: cleanPreview(),
|
||||
latestRun: null,
|
||||
history: [],
|
||||
notice: "Preview completed. No target conflicts detected.",
|
||||
actionError: null,
|
||||
};
|
||||
case "progress":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(),
|
||||
preview: null,
|
||||
latestRun: runningRun(),
|
||||
history: [],
|
||||
notice: null,
|
||||
actionError: null,
|
||||
};
|
||||
case "retry":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(),
|
||||
preview: null,
|
||||
latestRun: failedRun(),
|
||||
history: [
|
||||
{ ...failedRun(), id: "run_9pYqXwVtSrQ" },
|
||||
],
|
||||
notice: null,
|
||||
actionError: "Push run failed. Review the events. Retry resumes from ledger checkpoint chunk-3 — chunks 1–3 will not be re-applied.",
|
||||
};
|
||||
case "finish":
|
||||
return {
|
||||
selectedCompanyName: "Paperclip · PC521D",
|
||||
connection: connectedConnection(),
|
||||
preview: null,
|
||||
latestRun: succeededRun(),
|
||||
history: [
|
||||
{ ...succeededRun(), id: "run_aZcXvBnMqWeR" },
|
||||
],
|
||||
notice: "Push run completed. Review activation before unpausing automations.",
|
||||
actionError: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
|
|
@ -9,9 +9,10 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Check, Download, Upload } from "lucide-react";
|
||||
import { Settings, Check, CloudUpload, Download, Upload } from "lucide-react";
|
||||
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||
import {
|
||||
Field,
|
||||
|
|
@ -37,6 +38,10 @@ export function CompanySettings() {
|
|||
} = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
// General settings local state
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
|
@ -65,6 +70,7 @@ export function CompanySettings() {
|
|||
Number.isInteger(attachmentMaxBytes)
|
||||
&& attachmentMaxBytes >= BYTES_PER_MIB
|
||||
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
const cloudSyncEnabled = experimentalSettings?.enableCloudSync === true;
|
||||
|
||||
const generalDirty =
|
||||
!!selectedCompany &&
|
||||
|
|
@ -520,7 +526,15 @@ export function CompanySettings() {
|
|||
Import and export have moved to dedicated pages accessible from the{" "}
|
||||
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{cloudSyncEnabled ? (
|
||||
<Button size="sm" asChild>
|
||||
<a href="/company/settings/cloud-upstream">
|
||||
<CloudUpload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Send to Paperclip Cloud
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href="/company/export">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export function InstanceExperimentalSettings() {
|
|||
|
||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
const enableIssueGraphLivenessAutoRecovery =
|
||||
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
||||
|
|
@ -298,6 +299,24 @@ export function InstanceExperimentalSettings() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Cloud Sync</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.
|
||||
Saved connections and run history are preserved when this is disabled.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableCloudSync}
|
||||
onCheckedChange={() => toggleMutation.mutate({ enableCloudSync: !enableCloudSync })}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle cloud sync experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue