mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Implement local cloud upstream UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
65fc0a33ac
commit
51902dc6a3
19 files changed
+1246
-3
No files matched your search
@@ -30,6 +30,7 @@ import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CloudUpstream } from "./pages/CloudUpstream";
|
||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
@@ -69,6 +70,7 @@ function boardRoutes() {
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
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) =>
|
||||
api.post<CloudUpstreamPreview>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, {}),
|
||||
createRun: (connectionId: string, input?: { retryOfRunId?: string | null }) =>
|
||||
api.post<CloudUpstreamRun>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}),
|
||||
getRun: (connectionId: string, runId: string) =>
|
||||
api.get<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}`,
|
||||
),
|
||||
cancelRun: (connectionId: string, runId: string) =>
|
||||
api.post<CloudUpstreamRun>(
|
||||
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
@@ -10,6 +10,9 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
@@ -61,6 +64,10 @@ vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -83,6 +90,9 @@ describe("CompanySettingsSidebar", () => {
|
||||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,6 +120,7 @@ describe("CompanySettingsSidebar", () => {
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
@@ -154,4 +165,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, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } 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";
|
||||
@@ -29,6 +30,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">
|
||||
@@ -60,6 +66,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/access"
|
||||
label="Access"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
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 { 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 { 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" },
|
||||
];
|
||||
|
||||
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 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, "", "/company/settings/cloud-upstream");
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to finish connection."),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!cloudSyncEnabled || !code || !state || finishMutation.isPending || finishMutation.isSuccess) return;
|
||||
const pendingConnectionId = localStorage.getItem(PENDING_CONNECTION_KEY);
|
||||
if (!pendingConnectionId) {
|
||||
setActionError("No pending cloud upstream connection was found. Start the connection again.");
|
||||
return;
|
||||
}
|
||||
finishMutation.mutate({ pendingConnectionId, code, state });
|
||||
}, [cloudSyncEnabled, code, state, finishMutation]);
|
||||
|
||||
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}/company/settings/cloud-upstream`,
|
||||
}),
|
||||
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: (connectionId: string) => cloudUpstreamsApi.preview(connectionId),
|
||||
onSuccess: (nextPreview) => {
|
||||
setPreview(nextPreview);
|
||||
setActionError(null);
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to preview push."),
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (input: { connectionId: string; retryOfRunId?: string | null }) =>
|
||||
cloudUpstreamsApi.createRun(input.connectionId, { retryOfRunId: input.retryOfRunId ?? null }),
|
||||
onSuccess: async (run) => {
|
||||
setActiveRun(run);
|
||||
setNotice("Push run completed. Review activation before unpausing automations.");
|
||||
setActionError(null);
|
||||
await invalidateUpstreams();
|
||||
},
|
||||
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to run push."),
|
||||
});
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => previewMutation.mutate(connection.id)}
|
||||
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>
|
||||
</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 })}
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId, retryOfRunId: latestRun.id })}
|
||||
disabled={runMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<ActivationChecklist />
|
||||
</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 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() {
|
||||
const rows = [
|
||||
["Agents", "Keep paused until cloud secrets and adapter credentials are verified."],
|
||||
["Routines", "Review schedules before enabling triggers."],
|
||||
["Monitors", "Activate after the target instance has been smoke tested."],
|
||||
];
|
||||
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(([label, detail]) => (
|
||||
<div key={label} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto]">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-muted-foreground">{detail}</span>
|
||||
<Button variant="outline" size="sm">Activate later</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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 &&
|
||||
@@ -521,6 +527,14 @@ export function CompanySettings() {
|
||||
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
|
||||
</p>
|
||||
<div className="mt-3 flex 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">
|
||||
|
||||
Reference in new issue
Block a user