diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 02280c89445..112a25eff93 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -31,6 +31,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CloudUpstream } from "./pages/CloudUpstream"; +import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab"; import { CompanyAccess } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; @@ -278,6 +279,7 @@ export function App() { } /> } /> } /> + } /> }> } /> diff --git a/ui/src/pages/CloudUpstreamUxLab.tsx b/ui/src/pages/CloudUpstreamUxLab.tsx new file mode 100644 index 00000000000..976c3d865a1 --- /dev/null +++ b/ui/src/pages/CloudUpstreamUxLab.tsx @@ -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 = { + "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 ( +
+ {showChrome ? : null} + +
+ ); +} + +function FixtureNav({ active }: { active: FixtureStateKey }) { + return ( +
+
UX lab · cloud upstream
+
+ {PARSE_ORDER.map((key) => ( + + {FIXTURE_LABELS[key]} + + ))} +
+
+ ); +} + +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 ( +
+
+
+
+ +

Cloud upstream

+
+

+ Push {selectedCompanyName} into a Paperclip Cloud stack. Automations stay paused until activation. +

+
+ {connection?.target.origin ? ( + + ) : null} +
+ + {notice ? ( +
+ {notice} +
+ ) : null} + {actionError ? ( +
+ {actionError} +
+ ) : null} + + + +
+
Connection
+
+ {connection ? ( +
+
+
+ {connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId} +
+
+ {connection.target.product} · {connection.target.origin} · token {connection.tokenStatus} +
+
+ Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}. +
+
+ +
+ ) : ( +
+ + +
+ )} +
+
+ + {preview ? ( +
+
+
Preview
+ +
+ + + +
+ ) : null} + + {latestRun ? ( +
+
+
Progress and finish
+
+ + {latestRun.status === "failed" || latestRun.status === "cancelled" ? ( + + ) : latestRun.status === "succeeded" ? ( + + ) : null} +
+
+
+
+
+
{latestRun.status}
+
+ Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt + ? `completed ${formatDate(latestRun.completedAt)}` + : latestRun.status === "running" + ? "in progress" + : "in progress"} +
+
+
{latestRun.progressPercent}%
+
+
+
+
+
+ {latestRun.events.map((event) => ( +
+ {formatDate(event.at)} + {event.phase} + {event.message} +
+ ))} +
+
+ + {latestRun.status === "succeeded" ? : null} +
+ ) : null} + + {history.length ? ( +
+
+ + History +
+
+ {history.map((run) => ( +
+ Run {run.id.slice(0, 8)} · {run.status} + {formatDate(run.createdAt)} +
+ ))} +
+
+ ) : null} +
+ ); +} + +function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) { + const activeIndex = STEPS.findIndex((step) => step.key === activeStep); + return ( +
+ {STEPS.map((step, index) => { + const complete = index < activeIndex; + const active = index === activeIndex; + return ( +
+ {complete ? ( + + ) : ( + + )} + {step.label} +
+ ); + })} +
+ ); +} + +function SummaryGrid({ summary }: { summary: CloudUpstreamSummaryCount[] }) { + return ( +
+ {summary.map((item) => ( +
+
{item.count}
+
{item.label}
+
+ ))} +
+ ); +} + +function WarningsPanel({ warnings }: { warnings: CloudUpstreamWarning[] }) { + return ( +
+
+ + Warnings +
+
+ {warnings.map((warning) => ( +
+ +
{warning.title}
+
{warning.detail}
+
+ ))} +
+
+ ); +} + +function ConflictTable({ conflicts }: { conflicts: CloudUpstreamConflict[] }) { + return ( +
+
Conflicts
+ {conflicts.length === 0 ? ( +
No target conflicts detected for this preview.
+ ) : ( +
+ {conflicts.map((conflict) => ( +
+ {conflict.entityType} + {conflict.sourceLabel} + {conflict.targetLabel} + {conflict.plannedAction} +
+ ))} +
+ )} +
+ ); +} + +function ActivationChecklist({ run }: { run: CloudUpstreamRun }) { + const rows = buildActivationRows(run); + return ( +
+
Activation checklist
+
+ {rows.map((row) => { + const activated = row.status === "activated"; + return ( +
+
+
{row.label}
+
{row.statusLabel}
+
+
+ {row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail} +
+
+ + +
+
+ ); + })} +
+
+ ); +} + +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): Partial> { + const value = optionalRecord(report.activationChecklist); + const decisions: Partial> = {}; + 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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +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, + }; + } +}