From ce3a948b31fcae95135bf3377772b6718a2d5de6 Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Tue, 30 Jun 2026 19:23:50 -0700 Subject: [PATCH 1/5] docs(automatic-backups): config screen wizard redesign spec Design spec for reworking the Automatic Environment Backups config screen into a gated, per-step wizard with a single source of truth (saved plugin parameters). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-30-config-wizard-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md diff --git a/automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md b/automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md new file mode 100644 index 00000000..c716cb52 --- /dev/null +++ b/automatic-environment-backups/docs/superpowers/specs/2026-06-30-config-wizard-design.md @@ -0,0 +1,226 @@ +# Config Screen Wizard Redesign — Design Spec + +**Plugin:** `automatic-environment-backups` +**File under redesign:** `src/entrypoints/ConfigScreen.tsx` (currently ~1,863 lines) +**Date:** 2026-06-30 +**Status:** Approved design, pending spec review → writing-plans + +--- + +## 1. Problem + +The current config screen is one monolithic component that presents three flat cards +(Lambda setup, Backup schedule, Backup overview) with no ordering or per-section status. +This produced the issues in the support ticket: + +1. The auth secret has **three out-of-sync representations** — the input field + (`lambdaAuthSecretInput`), an in-memory snapshot (`savedFormValues.lambdaAuthSecret`), + and the persisted plugin parameter. "Connect" reads the input; the overview and + "Backup now" read the snapshot. Editing the secret without a full Save leaves them + desynced → the plugin sends the wrong/old token → HTTP 401. +2. Connection errors surface only as a tiny status dot at the top; the actual error text + is buried several screens down under "Backup overview". +3. New users have no guided order (set secret → deploy → connect → schedule), and no + per-section success/failure feedback. +4. Returning users can't tell at a glance whether their setup is still healthy. + +## 2. Goals / Non-goals + +**Goals** +- A guided, ordered **gated accordion** of setup steps, each with its own status and save. +- **Single source of truth:** the saved plugin parameters, re-read from `ctx`. No React + state snapshot that can lag/race. +- **Explicit per-step saves.** Every user change is committed by an explicit step action + before anything acts on it. +- Clear per-step status: **Current step → OK / Error**. +- A **Status overview** checklist that redundantly surfaces every OK/error condition. +- Auto-generate a strong secret on fresh install; preserve any existing secret. +- Preserve existing users' working setups (persisted-parameter schema unchanged). + +**Non-goals** +- No change to the persisted plugin-parameter schema (backward compatibility). +- No changes to the external lambda repo (`datocms-backups-scheduled-function`). +- No change to backup mechanics (cadences, cron, environment creation). +- **No new test infrastructure** (no jsdom/testing-library). Reuse existing `vitest`. + +## 3. Status vocabulary + +Setup steps (1–3) each resolve to exactly one of: + +| Status | Label | Meaning / display | +|--------|-------|-------------------| +| `ok` | **OK** (green) | Saved params for this step are valid (and, for Connect, the live ping is healthy). Collapses to a one-line summary with **[Edit]**. | +| `current` | **Current step** (neutral, highlighted, expanded) | The first step that is not `ok`. Being worked on; no error yet. | +| `error` | **Error** (red, expanded) | The current step whose last save/test failed, or whose live validation now fails (e.g. secret rotated on the provider). | +| `disabled` | *(no label, grayed)* | A step after the current one. Visible but non-interactive. No "locked" chrome. | + +Flow: the `current` step transitions to `ok` (next step becomes `current`) or `error` +(stays the focus until resolved). **At most one** setup step is `current`/`error` at a +time; earlier steps are `ok`, later steps `disabled`. When all three are `ok` (fully +configured), no step is `current` — every step shows its collapsed **OK** summary. + +Section 4 (**Status overview**) is **not** a gated step — it is an always-visible, +read-only checklist that reflects live state and mirrors per-step errors. + +## 4. Source of truth & save model + +- **`ctx.plugin.attributes.parameters` is authoritative.** All status derivation and all + actions read from it (via typed getters), re-reading from `ctx` at render/action time. +- **Remove `savedFormValues`.** The in-memory snapshot that races today is deleted. +- **Input fields are ephemeral edit-state** (`secretInput`, `urlInput`, cadence selection). + They exist only so the user can type before committing. +- **Every user change requires an explicit per-step save.** No lambda-facing action ever + reads unsaved input. A step action is atomic: *persist the input → then act on the + just-persisted value.* Within a save+act handler, the code acts on the value it just + wrote (equal to what was persisted), never on a lagging read. +- `persistPluginParameters` keeps its authoritative CMA re-read + queued merge, so + concurrent saves serialize and never clobber unrelated keys. + +This structurally eliminates the desync class of bug (#1): there is only one place a +value lives (the params), and only one way to change it (an explicit save). + +## 5. Architecture / file decomposition + +Split the monolith into small, single-purpose units. Pure modules are unit-testable with +the existing `vitest` (no DOM). + +``` +src/entrypoints/ConfigScreen.tsx orchestrator: reads ctx params, derives step + statuses, renders the accordion + Status overview +src/config/ + pluginParams.ts (pure) typed getters/setters over ctx params + (secret, deploymentURL, connection, schedule, debug) + deriveStepStatuses.ts (pure) (params + live connection) → { step1, step2, step3 } + statuses + which step is current + checklist items + generateAuthSecret.ts (pure) 128-bit crypto → 32-char lowercase hex + useBackupsConfig.ts (hook) shared edit-state, per-step save handlers, + mount health ping, overview + env-list loading + StepSection.tsx accordion chrome: header, status badge, expand/ + collapse, disabled state, one-line summary + [Edit] + StepSecret.tsx Step 1 + StepConnect.tsx Step 2 + StepSchedule.tsx Step 3 + StatusOverview.tsx Section 4 (checklist) + StatusBox.tsx neutral / success / error panel (reused in steps) + AdvancedSettings.tsx debug toggle (collapsible, persists on change) +``` + +Reused unchanged: `verifyLambdaHealth`, `lambdaAuth`, `lambdaHttp`, +`fetchLambdaBackupStatus`, `triggerLambdaBackupNow`, `backupSchedule`, +`buildBackupOverviewRows`, `deployProviders`, `pluginParameterMerging`, `debugLogger`. + +## 6. Step specifications + +Each setup step shows a one-sentence "what & why", its inputs, a single primary save +action, and a `StatusBox` reflecting its result. + +### Step 1 — Auth secret & deploy +> *Create a shared secret the plugin and your deployed function use to authenticate with each other, then deploy the scheduler.* + +- **Secret field** (`secretInput`) + **[Generate]** + **[Copy]**. + - Fresh install (no saved secret): auto-generate a value into the field on first load + (unsaved). Existing saved secret: load it as-is. +- **[Save secret]** — primary. Persists `lambdaAuthSecret`. Marks Step 1 `ok`. +- After the secret is saved, reveal **[Deploy to ▾]** (Vercel / Netlify / Cloudflare — + unchanged menu) and a callout: *"Paste this value as `DATOCMS_BACKUPS_SHARED_SECRET` + on your provider, then come back with the deployed URL."* Deploy is **disabled until the + secret is saved** and simply opens the provider tab (no auto-injection — providers can't + receive the value programmatically). +- If a healthy connection already exists and the secret field is edited: inline warning + *"Changing this means updating `DATOCMS_BACKUPS_SHARED_SECRET` on your deployment and + redeploying, or the connection will fail."* Saving a changed secret re-gates Step 2. +- **Complete when:** non-empty `lambdaAuthSecret` is saved. + +### Step 2 — Connect & test connection +> *Tell the plugin where your function is deployed and verify it responds and authenticates.* + +- **URL field** (`urlInput`) + **[Save & test connection]**. +- Action: persist `deploymentURL` (+ legacy `netlifyURL`/`vercelURL` for compat) → run + `verifyLambdaHealth` using the **saved** secret + saved URL → persist the resulting + `lambdaConnection` + `connectionValidationMode`. +- **StatusBox** shows *testing… / ✓ Connected / ✕ Failed* **in the step**, with the exact + reason from the existing connection state machine (`errorCode`, `errorMessage`, + `httpStatus`, response snippet) and remediation. Example (401): + *"Auth failed — the secret here doesn't match `DATOCMS_BACKUPS_SHARED_SECRET` on your + deployment. Update one so they match, then redeploy if you changed the provider."* +- **Complete when:** `deploymentURL` saved **and** live ping `connected`. **Error when:** + live ping fails. + +### Step 3 — Backup cadence +> *Choose how often backups run. The scheduler runs once daily and creates the sandbox backups you enable.* + +- Cadence switches (Daily / Weekly / Bi-weekly / Monthly) + **[Save & continue]**. +- Action: persist `backupSchedule` → run `ensureBackupsExistForCadences` with a + *"Creating initial backups…"* progress state; created/failed environments reported. +- **Complete when:** `backupSchedule.enabledCadences` has ≥1. + +### Section 4 — Status overview (always visible, read-only) +> *Everything at a glance — nothing else to do here.* + +A checklist reflecting live state, redundant with per-step errors on purpose: + +- ✓/✗ Auth secret set *(warn if it's still the example default)* +- ✓/✗ Function reachable & authenticating *(mirrors Step 2 error verbatim if failing)* +- ✓/✗ Backup cadence configured — lists enabled cadences +- ✓/pending Backup environments created — e.g. "2 of 2 created" / "pending" +- Per-cadence rows: last backup / next backup / environment, with **[Backup now]** (kept). +- Summary banner: **"✓ Configured and ready — backups run daily at 02:05 UTC. You can + leave this screen."** or **"Needs attention — see the highlighted step above."** + +## 7. Existing-user & resume behavior + +Derived entirely from saved params + the mount ping (which always re-validates on load): + +- **Fully configured & healthy:** all setup steps `ok` (collapsed summaries), Status + overview all green. Any step expandable via **[Edit]**. +- **Broke since last visit** (the ticket case): mount ping fails → Step 2 becomes `error`, + auto-expanded with the exact reason; Status overview shows the red item too. +- **Partially configured:** first non-`ok` step is `current`/auto-expanded; earlier steps + `ok`, later steps `disabled`. +- Editing an earlier step re-gates later steps (e.g. changing the secret un-verifies the + connection → Step 2 must be re-tested). + +## 8. Persistence & backward compatibility + +- Persisted-parameter schema is **unchanged**: `deploymentURL`, `netlifyURL`, `vercelURL`, + `lambdaConnection`, `connectionValidationMode`, `lambdaAuthSecret`, `backupSchedule`, + `debug`. Existing installs load and work without migration. +- `netlifyURL`/`vercelURL` continue to be written in lockstep with `deploymentURL` (harmless, + preserves any external readers). Reads prefer `deploymentURL`. +- `debug` persists immediately on toggle (Advanced settings), consistent with per-step saves. + +## 9. Secret generation + +- 16 random bytes from `crypto.getRandomValues` → lowercase hex → **32 characters**, + charset `[0-9a-f]` (inherently URL-safe). +- 128 bits of entropy; short and well under every provider's env-var value limit + (Vercel ~64 KB, Netlify/Cloudflare ~5 KB per value). +- `[Generate]` replaces the field value (unsaved until [Save secret]). Regenerating while + connected triggers the Step 1 warning (see §6). + +## 10. Testing + +Use the **existing** `vitest` setup only. New pure modules get unit tests: + +- `generateAuthSecret`: length = 32, charset `[0-9a-f]`, high-probability uniqueness across + calls, uses `crypto.getRandomValues`. +- `deriveStepStatuses`: every permutation — fresh, secret-only, connected, connect-error, + schedule set, fully configured, and "broke since last visit" — asserting the correct + `current`/`ok`/`error`/`disabled` per step and the checklist items. +- A regression guard asserting a per-step save updates the param that all reads consume + (encodes the desync fix — reads come from params, not a snapshot). + +Component/accordion interaction tests are **out of scope** for now (would need a jsdom + +`@testing-library/react` harness we are deliberately not adding yet). + +## 11. Risks / open items + +- **DatoCMS `ctx` staleness within a handler:** immediately after `updatePluginParameters`, + the in-scope `ctx` may not reflect the write until re-render. Mitigation: within a + save+act handler, act on the value just persisted (in hand); rendering/derivation reads + from the re-rendered `ctx`. `persistPluginParameters` already re-reads authoritative + params via the CMA client before merging. +- **StrictMode / run-once effects:** the mount health ping keeps the existing run-once + guard from the loop fix; the decomposition must preserve it. +- **"Still the default secret" detection:** the Status-overview warning compares the saved + secret against the known example default to nudge rotation; purely advisory. From 6c07f8792c1f2eb3ea1bfcc231f4fc8c1ff4fd0f Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Tue, 30 Jun 2026 19:24:04 -0700 Subject: [PATCH 2/5] feat(automatic-backups): rework config screen into a gated step wizard Split the ~1.8k-line ConfigScreen monolith into a thin orchestrator plus focused single-purpose units under src/config/: - Pure, unit-tested modules: generateAuthSecret, pluginParams (typed source-of-truth readers), deriveStepStatuses + buildStatusChecklist. - useBackupsConfig hook holding all edit-state, the queued authoritative persister, per-step save+act handlers, and the run-once mount ping. - Accordion chrome (StepSection, StatusBox) and per-step components (StepSecret, StepConnect, StepSchedule, StatusOverview, AdvancedSettings). Behavior changes: - Saved plugin parameters are the single source of truth; the savedFormValues React snapshot is removed. This eliminates the auth-secret desync that made the plugin send the wrong/default token (HTTP 401). - Explicit per-step saves; no global Save button. - Fresh installs auto-generate a 128-bit hex secret; existing secrets are kept. - Preserves the run-once, StrictMode-safe mount health ping, fixing the config-screen infinite request loop. - Persisted-parameter schema is unchanged, so existing installs keep working. Adds 25 unit tests for the pure modules (60 total, all green). tsc + vitest + vite build all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/config/AdvancedSettings.tsx | 53 + .../src/config/StatusBox.tsx | 78 + .../src/config/StatusOverview.tsx | 225 ++ .../src/config/StepConnect.tsx | 115 + .../src/config/StepSchedule.tsx | 61 + .../src/config/StepSecret.tsx | 229 ++ .../src/config/StepSection.tsx | 210 ++ .../src/config/deriveStepStatuses.test.ts | 166 ++ .../src/config/deriveStepStatuses.ts | 194 ++ .../src/config/generateAuthSecret.test.ts | 22 + .../src/config/generateAuthSecret.ts | 21 + .../src/config/pluginParams.test.ts | 163 ++ .../src/config/pluginParams.ts | 99 + .../src/config/useBackupsConfig.ts | 984 +++++++++ .../src/entrypoints/ConfigScreen.tsx | 1945 +---------------- 15 files changed, 2721 insertions(+), 1844 deletions(-) create mode 100644 automatic-environment-backups/src/config/AdvancedSettings.tsx create mode 100644 automatic-environment-backups/src/config/StatusBox.tsx create mode 100644 automatic-environment-backups/src/config/StatusOverview.tsx create mode 100644 automatic-environment-backups/src/config/StepConnect.tsx create mode 100644 automatic-environment-backups/src/config/StepSchedule.tsx create mode 100644 automatic-environment-backups/src/config/StepSecret.tsx create mode 100644 automatic-environment-backups/src/config/StepSection.tsx create mode 100644 automatic-environment-backups/src/config/deriveStepStatuses.test.ts create mode 100644 automatic-environment-backups/src/config/deriveStepStatuses.ts create mode 100644 automatic-environment-backups/src/config/generateAuthSecret.test.ts create mode 100644 automatic-environment-backups/src/config/generateAuthSecret.ts create mode 100644 automatic-environment-backups/src/config/pluginParams.test.ts create mode 100644 automatic-environment-backups/src/config/pluginParams.ts create mode 100644 automatic-environment-backups/src/config/useBackupsConfig.ts diff --git a/automatic-environment-backups/src/config/AdvancedSettings.tsx b/automatic-environment-backups/src/config/AdvancedSettings.tsx new file mode 100644 index 00000000..8d0c115e --- /dev/null +++ b/automatic-environment-backups/src/config/AdvancedSettings.tsx @@ -0,0 +1,53 @@ +import { Form, Section, SwitchField } from 'datocms-react-ui'; +import { type CSSProperties, useState } from 'react'; + +const switchNoHintGapStyle = { + '--spacing-s': '0', + marginBottom: '0.25rem', +} as CSSProperties; + +/** + * Collapsible advanced settings. The debug toggle persists immediately on + * change (consistent with the per-step save model — no global Save button). + */ +export const AdvancedSettings = ({ + debugEnabled, + onToggleDebug, +}: { + debugEnabled: boolean; + onToggleDebug: (enabled: boolean) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
setIsOpen((current) => !current), + }} + > +
+ +
+

+ This plugin runs in Lambda cron mode only. +

+
+
+ ); +}; diff --git a/automatic-environment-backups/src/config/StatusBox.tsx b/automatic-environment-backups/src/config/StatusBox.tsx new file mode 100644 index 00000000..036d7569 --- /dev/null +++ b/automatic-environment-backups/src/config/StatusBox.tsx @@ -0,0 +1,78 @@ +import type { CSSProperties, ReactNode } from 'react'; + +/** Visual tone of a {@link StatusBox}, mapped to the dashboard soft-color tokens. */ +export type StatusBoxVariant = 'neutral' | 'success' | 'error' | 'warning'; + +type StatusBoxProps = { + variant: StatusBoxVariant; + title?: ReactNode; + children?: ReactNode; + style?: CSSProperties; +}; + +type VariantTokens = { + border: string; + surface: string; + ink: string; +}; + +const VARIANT_TOKENS: Record = { + neutral: { + border: 'var(--color--border)', + surface: 'var(--color--light-bg, var(--color--surface))', + ink: 'var(--color--ink)', + }, + success: { + border: 'var(--color--success-soft--border)', + surface: 'var(--color--success-soft--surface)', + ink: 'var(--color--success-soft--ink)', + }, + error: { + border: 'var(--color--danger-soft--border)', + surface: 'var(--color--danger-soft--surface)', + ink: 'var(--color--danger-soft--ink)', + }, + warning: { + border: 'var(--color--warning-soft--border)', + surface: 'var(--color--warning-soft--surface)', + ink: 'var(--color--warning-soft--ink)', + }, +}; + +/** + * Presentational status panel used across the setup steps and the overview. + * The `variant` selects a soft-color palette matching the DatoCMS dashboard; + * content is arbitrary so callers can render remediation text or lists. + */ +export const StatusBox = ({ variant, title, children, style }: StatusBoxProps) => { + const tokens = VARIANT_TOKENS[variant]; + + return ( +
+ {title && ( +

+ {title} +

+ )} + {children} +
+ ); +}; diff --git a/automatic-environment-backups/src/config/StatusOverview.tsx b/automatic-environment-backups/src/config/StatusOverview.tsx new file mode 100644 index 00000000..e1158e28 --- /dev/null +++ b/automatic-environment-backups/src/config/StatusOverview.tsx @@ -0,0 +1,225 @@ +import { Button } from 'datocms-react-ui'; +import type { CSSProperties } from 'react'; +import type { BackupOverviewRow } from '../types/types'; +import { getCadenceLabel, normalizeBackupScheduleConfig } from '../utils/backupSchedule'; +import { buildBackupOverviewRows } from '../utils/buildBackupOverviewRows'; +import { + buildStatusChecklist, + type ChecklistItem, + type ChecklistStatus, +} from './deriveStepStatuses'; +import { StatusBox } from './StatusBox'; +import type { BackupsConfig } from './useBackupsConfig'; + +const CHECKLIST_MARK: Record = + { + ok: { symbol: '✓', color: 'var(--color--success-soft--ink)' }, + error: { symbol: '✕', color: 'var(--color--danger-soft--ink)' }, + warn: { symbol: '!', color: 'var(--color--warning-soft--ink)' }, + pending: { symbol: '•', color: 'var(--color--ink-subtle)' }, + }; + +const rowStyle: CSSProperties = { + border: '1px solid var(--color--border)', + borderRadius: '6px', + padding: 'var(--spacing-m)', + background: 'var(--color--surface)', + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + columnGap: 'var(--spacing-m)', + alignItems: 'center', +}; + +const rowInfoStyle: CSSProperties = { + margin: 0, + fontSize: 'var(--font-size-s)', +}; + +const ChecklistRow = ({ item }: { item: ChecklistItem }) => { + const mark = CHECKLIST_MARK[item.status]; + return ( +
+ + + {item.label} + {item.detail && ( + + {' '} + — {item.detail} + + )} + +
+ ); +}; + +/** + * Section 4 — always-visible, read-only overview. Renders the live checklist, + * per-cadence backup rows with [Backup now], and a summary banner. Deliberately + * redundant with the per-step errors so a broken setup is impossible to miss. + */ +export const StatusOverview = ({ + config, + isConfiguredAndReady, +}: { + config: BackupsConfig; + isConfiguredAndReady: boolean; +}) => { + const { + params, + projectTimezone, + lambdaBackupStatus, + availableEnvironmentIds, + overviewError, + isLoadingOverview, + backupNowInFlightCadence, + canBackupNow, + backupNow, + onOpenEnvironments, + } = config; + + const checklist = buildStatusChecklist(params, lambdaBackupStatus); + const scheduleConfig = normalizeBackupScheduleConfig({ + value: params?.backupSchedule, + timezoneFallback: projectTimezone, + }).config; + const overviewRows: BackupOverviewRow[] = buildBackupOverviewRows({ + scheduleConfig, + lambdaStatus: lambdaBackupStatus, + availableEnvironmentIds, + }); + + return ( +
+

+ Status overview +

+ + + {isConfiguredAndReady + ? 'Configured and ready — backups run daily at 02:05 UTC. You can leave this screen.' + : 'Needs attention — see the highlighted step above.'} + + +
+ {checklist.map((item) => ( + + ))} +
+ + {isLoadingOverview && ( +

+ Refreshing backup status… +

+ )} + + {overviewError && ( + + {overviewError} + + )} + +
+ {overviewRows.map((row) => { + const isRowLoading = backupNowInFlightCadence === row.scope; + const isRowDisabled = + !canBackupNow || backupNowInFlightCadence !== null; + + return ( +
+
+

+ {getCadenceLabel(row.scope)} +

+

+ Last backup: {row.lastBackup} +

+

+ Next backup: {row.nextBackup} +

+

+ Environment:{' '} + {row.environmentLinked ? ( + { + event.preventDefault(); + void onOpenEnvironments(); + }} + > + {row.environmentName} + + ) : ( + row.environmentName + )} +

+ {row.environmentStatusNote && ( +

+ Status: {row.environmentStatusNote} +

+ )} +
+
+ +
+
+ ); + })} +
+
+ ); +}; diff --git a/automatic-environment-backups/src/config/StepConnect.tsx b/automatic-environment-backups/src/config/StepConnect.tsx new file mode 100644 index 00000000..1acdf5d5 --- /dev/null +++ b/automatic-environment-backups/src/config/StepConnect.tsx @@ -0,0 +1,115 @@ +import { Button, TextField } from 'datocms-react-ui'; +import { useState } from 'react'; +import { StatusBox } from './StatusBox'; +import type { BackupsConfig } from './useBackupsConfig'; + +/** + * Step 2 — point the plugin at the deployed function and verify it responds and + * authenticates. The single action persists the URL triplet, runs the health + * ping with the saved secret, and persists the resulting connection state; the + * `StatusBox` reflects testing / connected / failed with the exact reason. + */ +export const StepConnect = ({ config }: { config: BackupsConfig }) => { + const { + urlInput, + setUrlInput, + saveAndTestConnection, + disconnect, + isConnecting, + isMountChecking, + isDisconnecting, + isConnected, + connection, + connectionErrorDetails, + connectionTestError, + savedUrl, + } = config; + + const [showDetails, setShowDetails] = useState(false); + + const isTesting = isConnecting || isMountChecking; + const hasSavedUrl = savedUrl.trim().length > 0; + const persistedError = + !isConnected && connection?.status === 'disconnected' + ? connection.errorMessage ?? + 'Last connection check failed. Re-test the connection.' + : null; + + return ( + <> + + +
+ + {hasSavedUrl && ( + + )} +
+ + {isTesting ? ( + Testing connection… + ) : connectionTestError ? ( + + {connectionTestError.details.length > 0 && ( +
    + {connectionTestError.details.map((detail) => ( +
  • {detail}
  • + ))} +
+ )} +
+ ) : isConnected ? ( + + The function responds and authenticates. + + ) : persistedError && hasSavedUrl ? ( + + {persistedError} + {connectionErrorDetails.length > 0 && ( +
+ + {showDetails && ( +
    + {connectionErrorDetails.map((detail) => ( +
  • {detail}
  • + ))} +
+ )} +
+ )} +
+ ) : null} + + ); +}; diff --git a/automatic-environment-backups/src/config/StepSchedule.tsx b/automatic-environment-backups/src/config/StepSchedule.tsx new file mode 100644 index 00000000..fd028b39 --- /dev/null +++ b/automatic-environment-backups/src/config/StepSchedule.tsx @@ -0,0 +1,61 @@ +import { Button, SwitchField } from 'datocms-react-ui'; +import type { CSSProperties } from 'react'; +import { BACKUP_CADENCES, getCadenceLabel } from '../utils/backupSchedule'; +import { StatusBox } from './StatusBox'; +import type { BackupsConfig } from './useBackupsConfig'; + +const switchNoHintGapStyle = { + '--spacing-s': '0', +} as CSSProperties; + +/** + * Step 3 — choose how often backups run. Saving persists the normalized backup + * schedule and then ensures a backup environment exists for each enabled + * cadence, reporting progress while the initial sandboxes are created. + */ +export const StepSchedule = ({ config }: { config: BackupsConfig }) => { + const { + cadenceSelection, + setCadenceEnabled, + saveSchedule, + isSavingSchedule, + progressMessage, + } = config; + + const hasSelection = cadenceSelection.length > 0; + + return ( + <> +
+ {BACKUP_CADENCES.map((cadence) => ( +
+ setCadenceEnabled(cadence, newValue)} + /> +
+ ))} +
+ +
+ +
+ + {progressMessage && ( + {progressMessage} + )} + + ); +}; diff --git a/automatic-environment-backups/src/config/StepSecret.tsx b/automatic-environment-backups/src/config/StepSecret.tsx new file mode 100644 index 00000000..baf0b15c --- /dev/null +++ b/automatic-environment-backups/src/config/StepSecret.tsx @@ -0,0 +1,229 @@ +import { Button, TextField } from 'datocms-react-ui'; +import { + type CSSProperties, + type KeyboardEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { + DEPLOY_PROVIDER_OPTIONS, + type DeployProvider, +} from '../utils/deployProviders'; +import { StatusBox } from './StatusBox'; +import type { BackupsConfig } from './useBackupsConfig'; + +const DEPLOY_MENU_ID = 'deploy-provider-menu'; + +const menuContainerStyle: CSSProperties = { + position: 'relative', + flex: '1 1 0', +}; + +const menuStyle: CSSProperties = { + position: 'absolute', + top: 'calc(100% + var(--spacing-xs))', + left: 0, + zIndex: 1000, + minWidth: '180px', + width: '100%', + border: '1px solid var(--color--border)', + borderRadius: '6px', + background: 'var(--color--surface)', + boxShadow: '0 8px 24px rgb(0 0 0 / 12%)', + padding: 'var(--spacing-xs) 0', +}; + +const menuOptionStyle: CSSProperties = { + display: 'block', + width: '100%', + border: 0, + background: 'transparent', + color: 'var(--color--ink)', + cursor: 'pointer', + font: 'inherit', + fontSize: 'var(--font-size-s)', + lineHeight: 1.3, + padding: 'var(--spacing-s) var(--spacing-m)', + textAlign: 'left', +}; + +/** + * Step 1 — create/rotate the shared auth secret and deploy the scheduler. The + * secret is generated (fresh install) or loaded as-is, saved explicitly, then a + * provider deploy menu + paste callout are revealed. Editing a saved secret + * while connected raises a redeploy warning. + */ +export const StepSecret = ({ config }: { config: BackupsConfig }) => { + const { + secretInput, + setSecretInput, + saveSecret, + regenerateSecret, + copySecret, + isSavingSecret, + savedSecret, + isConnected, + } = config; + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); + + const hasSavedSecret = savedSecret.trim().length > 0; + const trimmedInput = secretInput.trim(); + const secretEdited = hasSavedSecret && trimmedInput !== savedSecret.trim(); + const showRedeployWarning = isConnected && secretEdited; + + useEffect(() => { + if (!isMenuOpen) { + return undefined; + } + + const handleOutside = (event: MouseEvent | TouchEvent) => { + const target = event.target; + if (target instanceof Node && !menuRef.current?.contains(target)) { + setIsMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleOutside); + document.addEventListener('touchstart', handleOutside); + return () => { + document.removeEventListener('mousedown', handleOutside); + document.removeEventListener('touchstart', handleOutside); + }; + }, [isMenuOpen]); + + const handleDeployClick = (provider: DeployProvider) => { + const option = DEPLOY_PROVIDER_OPTIONS.find( + (candidate) => candidate.provider === provider, + ); + setIsMenuOpen(false); + if (option) { + window.open(option.url, '_blank', 'noopener,noreferrer'); + } + }; + + const handleMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setIsMenuOpen(false); + } + }; + + return ( + <> + + +
+ + + +
+ + {showRedeployWarning && ( + + Changing this means updating DATOCMS_BACKUPS_SHARED_SECRET{' '} + on your deployment and redeploying, or the connection will fail. + + )} + + {hasSavedSecret && ( + <> + + Paste this value as DATOCMS_BACKUPS_SHARED_SECRET on your + provider, then come back with the deployed URL. + + {savedSecret} + + + +
+ + + {isMenuOpen && ( + + )} +
+ + )} + + ); +}; diff --git a/automatic-environment-backups/src/config/StepSection.tsx b/automatic-environment-backups/src/config/StepSection.tsx new file mode 100644 index 00000000..7a3ce7ed --- /dev/null +++ b/automatic-environment-backups/src/config/StepSection.tsx @@ -0,0 +1,210 @@ +import { Button } from 'datocms-react-ui'; +import type { CSSProperties, ReactNode } from 'react'; +import type { StepStatus } from './deriveStepStatuses'; + +type StepSectionProps = { + stepNumber: number; + title: string; + /** One-line "what & why" shown under the title. */ + description: string; + status: StepStatus; + isExpanded: boolean; + onToggle: () => void; + /** Collapsed one-line summary shown when the step is `ok`. */ + summary?: ReactNode; + children: ReactNode; +}; + +type BadgeTokens = { label: string; ink: string; surface: string; border: string }; + +const STATUS_BADGE: Partial> = { + ok: { + label: 'OK', + ink: 'var(--color--success-soft--ink)', + surface: 'var(--color--success-soft--surface)', + border: 'var(--color--success-soft--border)', + }, + current: { + label: 'Current step', + ink: 'var(--color--primary, #1a56db)', + surface: 'var(--color--light-bg, var(--color--surface))', + border: 'var(--color--primary, #1a56db)', + }, + error: { + label: 'Error', + ink: 'var(--color--danger-soft--ink)', + surface: 'var(--color--danger-soft--surface)', + border: 'var(--color--danger-soft--border)', + }, +}; + +const getNumberCircleColor = (status: StepStatus): string => { + if (status === 'ok') { + return 'var(--color--success-soft--ink)'; + } + if (status === 'error') { + return 'var(--color--danger-soft--ink)'; + } + if (status === 'current') { + return 'var(--color--primary, #1a56db)'; + } + return 'var(--color--ink-subtle)'; +}; + +/** + * Accordion section chrome for a single setup step: numbered header, one-line + * "what & why", a status badge, and an expand/collapse body. Driven entirely by + * `status` and `isExpanded` from the orchestrator — a `disabled` step renders + * grayed and non-interactive (no "locked" chrome), an `ok` step collapses to its + * summary with an `[Edit]` affordance. + */ +export const StepSection = ({ + stepNumber, + title, + description, + status, + isExpanded, + onToggle, + summary, + children, +}: StepSectionProps) => { + const isDisabled = status === 'disabled'; + const badge = STATUS_BADGE[status]; + const numberColor = getNumberCircleColor(status); + + const cardStyle: CSSProperties = { + border: + status === 'current' || status === 'error' + ? `1px solid ${numberColor}` + : '1px solid var(--color--border)', + borderRadius: '6px', + background: 'var(--color--surface)', + marginBottom: 'var(--spacing-l)', + textAlign: 'left', + opacity: isDisabled ? 0.55 : 1, + }; + + const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'flex-start', + gap: 'var(--spacing-m)', + width: '100%', + padding: 'var(--spacing-l)', + border: 0, + background: 'transparent', + textAlign: 'left', + cursor: isDisabled ? 'default' : 'pointer', + font: 'inherit', + color: 'var(--color--ink)', + }; + + const numberBadgeStyle: CSSProperties = { + flex: '0 0 auto', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '28px', + height: '28px', + borderRadius: '999px', + border: `2px solid ${numberColor}`, + color: numberColor, + fontSize: 'var(--font-size-s)', + fontWeight: 600, + lineHeight: 1, + }; + + return ( +
+ + + {!isDisabled && isExpanded && ( +
+ {children} +
+ )} + + {!isDisabled && !isExpanded && status === 'ok' && ( +
+ + {summary} + + +
+ )} +
+ ); +}; diff --git a/automatic-environment-backups/src/config/deriveStepStatuses.test.ts b/automatic-environment-backups/src/config/deriveStepStatuses.test.ts new file mode 100644 index 00000000..27b371db --- /dev/null +++ b/automatic-environment-backups/src/config/deriveStepStatuses.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import type { + BackupScheduleConfig, + LambdaBackupStatus, + LambdaConnectionState, +} from '../types/types'; +import { buildStatusChecklist, deriveStepStatuses } from './deriveStepStatuses'; + +const connected: LambdaConnectionState = { + status: 'connected', + endpoint: 'https://x/api/datocms/plugin-health', + lastCheckedAt: '2026-06-30T00:00:00.000Z', + lastCheckPhase: 'config_connect', +}; + +const failedPing: LambdaConnectionState = { + status: 'disconnected', + endpoint: 'https://x/api/datocms/plugin-health', + lastCheckedAt: '2026-06-30T00:00:00.000Z', + lastCheckPhase: 'config_mount', + errorCode: 'HTTP', + errorMessage: 'HTTP 401: UNAUTHORIZED - Missing or invalid header.', + httpStatus: 401, +}; + +const storedSchedule: BackupScheduleConfig = { + version: 1, + enabledCadences: ['daily', 'weekly'], + timezone: 'UTC', + anchorLocalDate: '2026-06-30', + updatedAt: '2026-06-30T00:00:00.000Z', +}; + +const freshInstall = {}; +const secretOnly = { lambdaAuthSecret: 'a-real-secret' }; +const connectionFailed = { + lambdaAuthSecret: 'a-real-secret', + deploymentURL: 'https://x', + lambdaConnection: failedPing, + connectionValidationMode: null, +}; +const connectedNoSchedule = { + lambdaAuthSecret: 'a-real-secret', + deploymentURL: 'https://x', + lambdaConnection: connected, + connectionValidationMode: 'health', +}; +const fullyConfigured = { + ...connectedNoSchedule, + backupSchedule: storedSchedule, +}; +const brokeSinceLastVisit = { + ...fullyConfigured, + lambdaConnection: failedPing, + connectionValidationMode: null, +}; + +describe('deriveStepStatuses', () => { + it('fresh install: secret is current, later steps disabled', () => { + expect(deriveStepStatuses(freshInstall)).toEqual({ + secret: 'current', + connect: 'disabled', + schedule: 'disabled', + currentStep: 'secret', + }); + }); + + it('secret saved: connect becomes current, schedule still disabled', () => { + expect(deriveStepStatuses(secretOnly)).toEqual({ + secret: 'ok', + connect: 'current', + schedule: 'disabled', + currentStep: 'connect', + }); + }); + + it('failed ping: connect is error and is the current focus', () => { + expect(deriveStepStatuses(connectionFailed)).toEqual({ + secret: 'ok', + connect: 'error', + schedule: 'disabled', + currentStep: 'connect', + }); + }); + + it('connected without a saved schedule: schedule is current', () => { + expect(deriveStepStatuses(connectedNoSchedule)).toEqual({ + secret: 'ok', + connect: 'ok', + schedule: 'current', + currentStep: 'schedule', + }); + }); + + it('fully configured: everything ok, no current step', () => { + expect(deriveStepStatuses(fullyConfigured)).toEqual({ + secret: 'ok', + connect: 'ok', + schedule: 'ok', + currentStep: null, + }); + }); + + it('broke since last visit: connect flips to error and takes focus; schedule re-gates', () => { + const statuses = deriveStepStatuses(brokeSinceLastVisit); + expect(statuses.connect).toBe('error'); + expect(statuses.schedule).toBe('disabled'); + expect(statuses.currentStep).toBe('connect'); + }); +}); + +describe('buildStatusChecklist', () => { + it('fresh install: everything pending', () => { + const items = buildStatusChecklist(freshInstall); + expect(items.map((item) => [item.id, item.status])).toEqual([ + ['secret', 'pending'], + ['connection', 'pending'], + ['cadence', 'pending'], + ['environments', 'pending'], + ]); + }); + + it('warns when the secret is still the example default', () => { + const items = buildStatusChecklist({ + lambdaAuthSecret: 'superSecretToken', + }); + const secret = items.find((item) => item.id === 'secret'); + expect(secret?.status).toBe('warn'); + }); + + it('surfaces the connection error message redundantly', () => { + const connection = buildStatusChecklist(connectionFailed).find( + (item) => item.id === 'connection', + ); + expect(connection?.status).toBe('error'); + expect(connection?.detail).toContain('401'); + }); + + it('reports created environments once a backup status is available', () => { + const backupStatus = { + scheduler: { provider: 'netlify', cadence: 'daily' }, + slots: { + daily: { + scope: 'daily', + executionMode: 'lambda_cron', + lastBackupAt: '2026-06-29T02:05:00.000Z', + nextBackupAt: '2026-06-30T02:05:00.000Z', + }, + weekly: { + scope: 'weekly', + executionMode: 'lambda_cron', + lastBackupAt: null, + nextBackupAt: '2026-07-05T02:05:00.000Z', + }, + }, + checkedAt: '2026-06-30T00:00:00.000Z', + } as LambdaBackupStatus; + + const environments = buildStatusChecklist( + fullyConfigured, + backupStatus, + ).find((item) => item.id === 'environments'); + expect(environments?.status).toBe('pending'); + expect(environments?.detail).toContain('1 of 2'); + }); +}); diff --git a/automatic-environment-backups/src/config/deriveStepStatuses.ts b/automatic-environment-backups/src/config/deriveStepStatuses.ts new file mode 100644 index 00000000..bb98b8d2 --- /dev/null +++ b/automatic-environment-backups/src/config/deriveStepStatuses.ts @@ -0,0 +1,194 @@ +import type { BackupCadence, LambdaBackupStatus } from '../types/types'; +import { getCadenceLabel } from '../utils/backupSchedule'; +import { + type BackupsParameters, + hasStoredBackupSchedule, + isConnectionHealthy, + isDefaultAuthSecret, + readAuthSecret, + readConnection, + readDeploymentUrl, + readEnabledCadences, +} from './pluginParams'; + +/** Per-step status in the gated accordion. `disabled` renders grayed (unreached). */ +export type StepStatus = 'ok' | 'current' | 'error' | 'disabled'; + +export type SetupStepId = 'secret' | 'connect' | 'schedule'; + +export type StepStatuses = { + secret: StepStatus; + connect: StepStatus; + schedule: StepStatus; + /** First non-ok setup step (the one to work on), or null when all are ok. */ + currentStep: SetupStepId | null; +}; + +/** + * Derive the accordion step statuses purely from the saved plugin parameters. + * Exactly one setup step is `current`/`error` at a time; earlier steps are `ok`, + * later steps `disabled`. When all three are `ok`, `currentStep` is null. + */ +export const deriveStepStatuses = (params: BackupsParameters): StepStatuses => { + const secretSet = readAuthSecret(params) !== ''; + const connected = isConnectionHealthy(params); + const urlSet = readDeploymentUrl(params) !== ''; + const hasFailedPing = + urlSet && !connected && readConnection(params)?.status === 'disconnected'; + const scheduleSet = + hasStoredBackupSchedule(params) && readEnabledCadences(params).length > 0; + + const secret: StepStatus = secretSet ? 'ok' : 'current'; + + let connect: StepStatus; + if (!secretSet) { + connect = 'disabled'; + } else if (connected) { + connect = 'ok'; + } else if (hasFailedPing) { + connect = 'error'; + } else { + connect = 'current'; + } + + let schedule: StepStatus; + if (!connected) { + schedule = 'disabled'; + } else if (scheduleSet) { + schedule = 'ok'; + } else { + schedule = 'current'; + } + + const currentStep: SetupStepId | null = + secret !== 'ok' + ? 'secret' + : connect !== 'ok' + ? 'connect' + : schedule !== 'ok' + ? 'schedule' + : null; + + return { secret, connect, schedule, currentStep }; +}; + +export type ChecklistStatus = 'ok' | 'error' | 'pending' | 'warn'; + +export type ChecklistItem = { + id: 'secret' | 'connection' | 'cadence' | 'environments'; + label: string; + status: ChecklistStatus; + detail?: string; +}; + +/** + * Build the Status-overview checklist. Deliberately redundant with per-step + * errors so a broken setup is impossible to miss. Reads params for the first + * three items; the environments item also needs the fetched backup status. + */ +export const buildStatusChecklist = ( + params: BackupsParameters, + backupStatus?: LambdaBackupStatus, +): ChecklistItem[] => { + const secret = readAuthSecret(params); + const connection = readConnection(params); + const connected = isConnectionHealthy(params); + const urlSet = readDeploymentUrl(params) !== ''; + const enabledCadences = readEnabledCadences(params); + const scheduleSet = + hasStoredBackupSchedule(params) && enabledCadences.length > 0; + + const secretItem: ChecklistItem = + secret === '' + ? { id: 'secret', label: 'Auth secret', status: 'pending', detail: 'Not set yet.' } + : isDefaultAuthSecret(secret) + ? { + id: 'secret', + label: 'Auth secret', + status: 'warn', + detail: 'Using the example default — regenerate a unique secret.', + } + : { id: 'secret', label: 'Auth secret', status: 'ok', detail: 'Set.' }; + + const connectionItem: ChecklistItem = !urlSet + ? { + id: 'connection', + label: 'Function reachable & authenticating', + status: 'pending', + detail: 'No deployment URL yet.', + } + : connected + ? { + id: 'connection', + label: 'Function reachable & authenticating', + status: 'ok', + detail: 'Connected.', + } + : { + id: 'connection', + label: 'Function reachable & authenticating', + status: 'error', + detail: + connection?.errorMessage ?? + 'Last connection check failed. Re-test in the Connect step.', + }; + + const cadenceItem: ChecklistItem = scheduleSet + ? { + id: 'cadence', + label: 'Backup cadence', + status: 'ok', + detail: enabledCadences.map(getCadenceLabel).join(', '), + } + : { + id: 'cadence', + label: 'Backup cadence', + status: 'pending', + detail: 'Not configured yet.', + }; + + const environmentsItem = buildEnvironmentsItem( + connected, + enabledCadences, + backupStatus, + ); + + return [secretItem, connectionItem, cadenceItem, environmentsItem]; +}; + +const buildEnvironmentsItem = ( + connected: boolean, + enabledCadences: BackupCadence[], + backupStatus?: LambdaBackupStatus, +): ChecklistItem => { + const label = 'Backup environments'; + if (!connected) { + return { + id: 'environments', + label, + status: 'pending', + detail: 'Waiting for a healthy connection.', + }; + } + + if (!backupStatus) { + return { + id: 'environments', + label, + status: 'pending', + detail: 'Loading backup status…', + }; + } + + const total = enabledCadences.length; + const created = enabledCadences.filter( + (cadence) => backupStatus.slots[cadence]?.lastBackupAt, + ).length; + + return { + id: 'environments', + label, + status: total > 0 && created === total ? 'ok' : 'pending', + detail: `${created} of ${total} created`, + }; +}; diff --git a/automatic-environment-backups/src/config/generateAuthSecret.test.ts b/automatic-environment-backups/src/config/generateAuthSecret.test.ts new file mode 100644 index 00000000..c83f3927 --- /dev/null +++ b/automatic-environment-backups/src/config/generateAuthSecret.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { AUTH_SECRET_HEX_LENGTH, generateAuthSecret } from './generateAuthSecret'; + +describe('generateAuthSecret', () => { + it('returns a string of the documented length (128-bit → 32 hex chars)', () => { + expect(AUTH_SECRET_HEX_LENGTH).toBe(32); + expect(generateAuthSecret()).toHaveLength(32); + }); + + it('contains only lowercase hex characters, so it is URL-safe and portable across providers', () => { + for (let index = 0; index < 20; index += 1) { + expect(generateAuthSecret()).toMatch(/^[0-9a-f]{32}$/); + } + }); + + it('produces a distinct value on each call', () => { + const values = new Set( + Array.from({ length: 100 }, () => generateAuthSecret()), + ); + expect(values.size).toBe(100); + }); +}); diff --git a/automatic-environment-backups/src/config/generateAuthSecret.ts b/automatic-environment-backups/src/config/generateAuthSecret.ts new file mode 100644 index 00000000..5c9a2f1b --- /dev/null +++ b/automatic-environment-backups/src/config/generateAuthSecret.ts @@ -0,0 +1,21 @@ +/** Bytes of entropy (128 bits) behind a generated lambda auth secret. */ +export const AUTH_SECRET_BYTE_LENGTH = 16; + +/** Length of the hex-encoded secret produced by {@link generateAuthSecret}. */ +export const AUTH_SECRET_HEX_LENGTH = AUTH_SECRET_BYTE_LENGTH * 2; + +/** + * Generate a random lambda auth secret: 128 bits of entropy as a 32-character + * lowercase hex string. Hex keeps it URL-safe and comfortably within every + * deploy provider's environment-variable value limit. + * + * Uses the Web Crypto API (`crypto.getRandomValues`), which is available in both + * the browser plugin runtime and Node's test environment. + */ +export const generateAuthSecret = (): string => { + const bytes = new Uint8Array(AUTH_SECRET_BYTE_LENGTH); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +}; diff --git a/automatic-environment-backups/src/config/pluginParams.test.ts b/automatic-environment-backups/src/config/pluginParams.test.ts new file mode 100644 index 00000000..26b09ae1 --- /dev/null +++ b/automatic-environment-backups/src/config/pluginParams.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import type { LambdaConnectionState } from '../types/types'; +import { + DEFAULT_LAMBDA_AUTH_SECRET, + getProjectTimezone, + hasStoredBackupSchedule, + isConnectionHealthy, + isDefaultAuthSecret, + readAuthSecret, + readConnection, + readDebug, + readDeploymentUrl, + readEnabledCadences, + readValidationMode, +} from './pluginParams'; + +const connected: LambdaConnectionState = { + status: 'connected', + endpoint: 'https://x/api/datocms/plugin-health', + lastCheckedAt: '2026-06-30T00:00:00.000Z', + lastCheckPhase: 'config_connect', +}; + +const disconnected: LambdaConnectionState = { + status: 'disconnected', + endpoint: 'https://x/api/datocms/plugin-health', + lastCheckedAt: '2026-06-30T00:00:00.000Z', + lastCheckPhase: 'config_mount', + errorCode: 'HTTP', + errorMessage: 'HTTP 401: UNAUTHORIZED', + httpStatus: 401, +}; + +describe('readAuthSecret', () => { + it('returns the trimmed saved secret', () => { + expect(readAuthSecret({ lambdaAuthSecret: ' abc123 ' })).toBe('abc123'); + }); + + it('returns empty string when unset, blank, or non-string (no superSecretToken default)', () => { + expect(readAuthSecret(undefined)).toBe(''); + expect(readAuthSecret({})).toBe(''); + expect(readAuthSecret({ lambdaAuthSecret: ' ' })).toBe(''); + expect(readAuthSecret({ lambdaAuthSecret: 123 })).toBe(''); + }); +}); + +describe('isDefaultAuthSecret', () => { + it('detects the example default so the UI can nudge a rotation', () => { + expect(isDefaultAuthSecret(DEFAULT_LAMBDA_AUTH_SECRET)).toBe(true); + expect(isDefaultAuthSecret('superSecretToken')).toBe(true); + expect(isDefaultAuthSecret('a-real-secret')).toBe(false); + expect(isDefaultAuthSecret('')).toBe(false); + }); +}); + +describe('readDeploymentUrl', () => { + it('prefers deploymentURL, then netlifyURL, then vercelURL', () => { + expect( + readDeploymentUrl({ + deploymentURL: 'https://d', + netlifyURL: 'https://n', + vercelURL: 'https://v', + }), + ).toBe('https://d'); + expect(readDeploymentUrl({ netlifyURL: 'https://n' })).toBe('https://n'); + expect(readDeploymentUrl({ vercelURL: 'https://v' })).toBe('https://v'); + }); + + it('trims and returns empty when none configured', () => { + expect(readDeploymentUrl({ deploymentURL: ' https://d ' })).toBe( + 'https://d', + ); + expect(readDeploymentUrl({})).toBe(''); + expect(readDeploymentUrl(undefined)).toBe(''); + }); +}); + +describe('readConnection / readValidationMode', () => { + it('returns a well-formed connection object or undefined', () => { + expect(readConnection({ lambdaConnection: connected })).toEqual(connected); + expect(readConnection({ lambdaConnection: { status: 'bogus' } })).toBeUndefined(); + expect(readConnection({})).toBeUndefined(); + }); + + it('only accepts the literal health validation mode', () => { + expect(readValidationMode({ connectionValidationMode: 'health' })).toBe( + 'health', + ); + expect(readValidationMode({ connectionValidationMode: 'other' })).toBeUndefined(); + expect(readValidationMode({})).toBeUndefined(); + }); +}); + +describe('isConnectionHealthy', () => { + it('is true only when connected AND validation mode is health', () => { + expect( + isConnectionHealthy({ + lambdaConnection: connected, + connectionValidationMode: 'health', + }), + ).toBe(true); + expect(isConnectionHealthy({ lambdaConnection: connected })).toBe(false); + expect( + isConnectionHealthy({ + lambdaConnection: disconnected, + connectionValidationMode: 'health', + }), + ).toBe(false); + expect(isConnectionHealthy({})).toBe(false); + }); +}); + +describe('readDebug', () => { + it('is true only for an explicit boolean true', () => { + expect(readDebug({ debug: true })).toBe(true); + expect(readDebug({ debug: false })).toBe(false); + expect(readDebug({ debug: 'true' })).toBe(false); + expect(readDebug({})).toBe(false); + }); +}); + +describe('hasStoredBackupSchedule / readEnabledCadences', () => { + it('detects an explicitly stored schedule (not the derived default)', () => { + expect( + hasStoredBackupSchedule({ + backupSchedule: { + version: 1, + enabledCadences: ['daily'], + timezone: 'UTC', + anchorLocalDate: '2026-06-30', + updatedAt: '2026-06-30T00:00:00.000Z', + }, + }), + ).toBe(true); + expect(hasStoredBackupSchedule({})).toBe(false); + expect(hasStoredBackupSchedule({ backupSchedule: null })).toBe(false); + }); + + it('normalizes enabled cadences, defaulting to daily+weekly when absent', () => { + expect( + readEnabledCadences({ + backupSchedule: { + version: 1, + enabledCadences: ['monthly'], + timezone: 'UTC', + anchorLocalDate: '2026-06-30', + updatedAt: '2026-06-30T00:00:00.000Z', + }, + }), + ).toEqual(['monthly']); + expect(readEnabledCadences({})).toEqual(['daily', 'weekly']); + }); +}); + +describe('getProjectTimezone', () => { + it('reads a trimmed site timezone, defaulting to UTC', () => { + expect(getProjectTimezone({ timezone: ' Europe/Rome ' })).toBe( + 'Europe/Rome', + ); + expect(getProjectTimezone({})).toBe('UTC'); + expect(getProjectTimezone(null)).toBe('UTC'); + }); +}); diff --git a/automatic-environment-backups/src/config/pluginParams.ts b/automatic-environment-backups/src/config/pluginParams.ts new file mode 100644 index 00000000..14abc4e8 --- /dev/null +++ b/automatic-environment-backups/src/config/pluginParams.ts @@ -0,0 +1,99 @@ +import type { + BackupCadence, + ConnectionValidationMode, + LambdaConnectionState, +} from '../types/types'; +import { normalizeBackupScheduleConfig } from '../utils/backupSchedule'; + +/** + * Typed, pure readers over the persisted plugin parameters. These are the single + * source of truth for the config screen: everything is derived from + * `ctx.plugin.attributes.parameters`, never from a separate React snapshot that + * could drift. Keep them free of `ctx`/state so they stay unit-testable. + */ + +/** The example secret shipped in docs and the lambda's server-side fallback. */ +export const DEFAULT_LAMBDA_AUTH_SECRET = 'superSecretToken'; + +export type BackupsParameters = Record | undefined; + +const readTrimmedString = (value: unknown): string => + typeof value === 'string' ? value.trim() : ''; + +/** The saved auth secret, or `''` when unset — no `superSecretToken` default. */ +export const readAuthSecret = (params: BackupsParameters): string => + readTrimmedString(params?.lambdaAuthSecret); + +/** True when a secret is still the shipped example default (worth rotating). */ +export const isDefaultAuthSecret = (secret: string): boolean => + secret === DEFAULT_LAMBDA_AUTH_SECRET; + +/** The saved deployment URL, preferring `deploymentURL` over legacy keys. */ +export const readDeploymentUrl = (params: BackupsParameters): string => + readTrimmedString(params?.deploymentURL) || + readTrimmedString(params?.netlifyURL) || + readTrimmedString(params?.vercelURL); + +export const readConnection = ( + params: BackupsParameters, +): LambdaConnectionState | undefined => { + const value = params?.lambdaConnection; + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Partial; + if ( + (candidate.status === 'connected' || candidate.status === 'disconnected') && + typeof candidate.endpoint === 'string' && + typeof candidate.lastCheckedAt === 'string' && + (candidate.lastCheckPhase === 'finish_installation' || + candidate.lastCheckPhase === 'config_mount' || + candidate.lastCheckPhase === 'config_connect') + ) { + return candidate as LambdaConnectionState; + } + + return undefined; +}; + +export const readValidationMode = ( + params: BackupsParameters, +): ConnectionValidationMode | undefined => + params?.connectionValidationMode === 'health' ? 'health' : undefined; + +/** True when the last recorded ping succeeded under the health contract. */ +export const isConnectionHealthy = (params: BackupsParameters): boolean => + readConnection(params)?.status === 'connected' && + readValidationMode(params) === 'health'; + +export const readDebug = (params: BackupsParameters): boolean => + params?.debug === true; + +/** True when the user has explicitly persisted a backup schedule. */ +export const hasStoredBackupSchedule = (params: BackupsParameters): boolean => + typeof params?.backupSchedule === 'object' && params?.backupSchedule !== null; + +/** Normalized enabled cadences (defaults to daily+weekly when none stored). */ +export const readEnabledCadences = ( + params: BackupsParameters, + timezoneFallback = 'UTC', +): BackupCadence[] => + normalizeBackupScheduleConfig({ + value: params?.backupSchedule, + timezoneFallback, + }).config.enabledCadences; + +export const getProjectTimezone = (site: unknown): string => { + if ( + site && + typeof site === 'object' && + 'timezone' in site && + typeof (site as { timezone?: unknown }).timezone === 'string' && + (site as { timezone: string }).timezone.trim() + ) { + return (site as { timezone: string }).timezone.trim(); + } + + return 'UTC'; +}; diff --git a/automatic-environment-backups/src/config/useBackupsConfig.ts b/automatic-environment-backups/src/config/useBackupsConfig.ts new file mode 100644 index 00000000..9c03f3d0 --- /dev/null +++ b/automatic-environment-backups/src/config/useBackupsConfig.ts @@ -0,0 +1,984 @@ +import { buildClient } from '@datocms/cma-client-browser'; +import type { RenderConfigScreenCtx } from 'datocms-plugin-sdk'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + BackupCadence, + BackupScheduleConfig, + LambdaBackupStatus, +} from '../types/types'; +import { + BACKUP_CADENCES, + BACKUP_SCHEDULE_VERSION, + getCadenceLabel, + normalizeBackupScheduleConfig, + toLocalDateKey, +} from '../utils/backupSchedule'; +import { createDebugLogger } from '../utils/debugLogger'; +import { fetchLambdaBackupStatus } from '../utils/fetchLambdaBackupStatus'; +import { + mergePluginParameterUpdates, + toPluginParameterRecord, +} from '../utils/pluginParameterMerging'; +import { + LambdaBackupNowError, + triggerLambdaBackupNow, +} from '../utils/triggerLambdaBackupNow'; +import { + buildConnectedLambdaConnectionState, + buildDisconnectedLambdaConnectionState, + getLambdaConnectionErrorDetails, + LambdaHealthCheckError, + verifyLambdaHealth, +} from '../utils/verifyLambdaHealth'; +import { generateAuthSecret } from './generateAuthSecret'; +import { + type BackupsParameters, + getProjectTimezone, + isConnectionHealthy, + readAuthSecret, + readConnection, + readDeploymentUrl, + readDebug, + readEnabledCadences, +} from './pluginParams'; + +const MISSING_AUTH_SECRET_MESSAGE = + 'Enter Lambda auth secret before calling lambda endpoints.'; +const BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS = 1200; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'Unknown error'; + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const getPluginIdFromCtx = (ctx: RenderConfigScreenCtx): string | undefined => { + const candidate = (ctx.plugin as { id?: unknown } | undefined)?.id; + return typeof candidate === 'string' && candidate.trim() + ? candidate.trim() + : undefined; +}; + +/** Transient (non-persisted) validation error surfaced by the Connect step. */ +export type ConnectionTestError = { + summary: string; + details: string[]; +}; + +/** + * Central orchestration hook for the config wizard. Holds the ephemeral edit + * state (secret/url/cadence/debug inputs), the queued authoritative-merge + * persister, every per-step save+act handler, the run-once mount health ping, + * and the overview/environment loaders. All persisted values are read via the + * `pluginParams` getters over `ctx.plugin.attributes.parameters` — the single + * source of truth — so no separate React snapshot can drift. + */ +export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { + const params = ctx.plugin.attributes.parameters as BackupsParameters; + const projectTimezone = getProjectTimezone(ctx.site); + + const savedSecret = readAuthSecret(params); + const savedUrl = readDeploymentUrl(params); + const connection = readConnection(params); + const isConnected = isConnectionHealthy(params); + + // Ephemeral edit-state. A fresh install (no saved secret) pre-fills a strong + // generated secret into the field, unsaved until [Save secret]. + const [secretInput, setSecretInput] = useState( + () => savedSecret || generateAuthSecret(), + ); + const [urlInput, setUrlInput] = useState(savedUrl); + const [cadenceSelection, setCadenceSelection] = useState(() => + readEnabledCadences(params, projectTimezone), + ); + const [debugEnabled, setDebugEnabled] = useState(() => readDebug(params)); + + // Activity flags. + const [isSavingSecret, setIsSavingSecret] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [isMountChecking, setIsMountChecking] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [isSavingSchedule, setIsSavingSchedule] = useState(false); + const [backupNowInFlightCadence, setBackupNowInFlightCadence] = + useState(null); + const [progressMessage, setProgressMessage] = useState(null); + + // Connect-step transient error (pre-flight validation not written to params). + const [connectionTestError, setConnectionTestError] = + useState(null); + + // Overview / environment data. + const [lambdaBackupStatus, setLambdaBackupStatus] = useState< + LambdaBackupStatus | undefined + >(undefined); + const [availableEnvironmentIds, setAvailableEnvironmentIds] = useState< + string[] | undefined + >(undefined); + const [overviewError, setOverviewError] = useState(''); + const [isLoadingOverview, setIsLoadingOverview] = useState(false); + + const debugLogger = useMemo( + () => createDebugLogger(debugEnabled, 'ConfigScreen'), + [debugEnabled], + ); + const debugLoggerRef = useRef(debugLogger); + debugLoggerRef.current = debugLogger; + + const persistQueueRef = useRef>(Promise.resolve()); + const hasRunMountCheckRef = useRef(false); + const isMountCheckUnmountedRef = useRef(false); + + // Snapshot of the first-render params so the run-once mount effect always + // validates against the values present at load, regardless of later persists. + const initialMountRef = useRef<{ + secret: string; + url: string; + scheduleNormalization: ReturnType; + } | null>(null); + if (!initialMountRef.current) { + initialMountRef.current = { + secret: savedSecret, + url: savedUrl, + scheduleNormalization: normalizeBackupScheduleConfig({ + value: params?.backupSchedule, + timezoneFallback: projectTimezone, + }), + }; + } + + /** + * Persist a partial parameter update. Serializes concurrent saves through a + * promise queue and re-reads the authoritative parameters from the CMA before + * merging, so unrelated keys are never clobbered by a stale local copy. + */ + const persistPluginParameters = useCallback( + async (updates: Record) => { + const persistTask = async () => { + let latestParameters = toPluginParameterRecord( + ctx.plugin.attributes.parameters, + ); + const pluginId = getPluginIdFromCtx(ctx); + + if (pluginId && ctx.currentUserAccessToken) { + try { + const client = buildClient({ + apiToken: ctx.currentUserAccessToken, + environment: ctx.environment, + baseUrl: ctx.cmaBaseUrl, + }); + const plugin = await client.plugins.find(pluginId); + latestParameters = toPluginParameterRecord(plugin.parameters); + } catch (error) { + debugLogger.warn( + 'Falling back to local plugin parameters because authoritative read failed', + { pluginId, error: getErrorMessage(error) }, + ); + } + } + + await ctx.updatePluginParameters( + mergePluginParameterUpdates(latestParameters, updates), + ); + }; + + const queuedPersist = persistQueueRef.current.then( + persistTask, + persistTask, + ); + persistQueueRef.current = queuedPersist.then( + () => undefined, + () => undefined, + ); + return queuedPersist; + }, + [ctx, debugLogger], + ); + + const refreshLambdaBackupOverview = useCallback( + async (baseUrl?: string) => { + const candidateUrl = (baseUrl || savedUrl).trim(); + const secret = savedSecret.trim(); + const shouldFetch = candidateUrl.length > 0 && secret.length > 0; + + if (!shouldFetch) { + setLambdaBackupStatus(undefined); + setOverviewError( + candidateUrl.length === 0 + ? 'Backup status is unavailable until a Lambda URL is connected.' + : 'Backup status is unavailable until the auth secret is saved.', + ); + setIsLoadingOverview(false); + return; + } + + setIsLoadingOverview(true); + setOverviewError(''); + + try { + const status = await fetchLambdaBackupStatus({ + baseUrl: candidateUrl, + environment: ctx.environment, + lambdaAuthSecret: secret, + }); + setLambdaBackupStatus(status); + } catch (error) { + setLambdaBackupStatus(undefined); + setOverviewError( + error instanceof Error + ? error.message + : 'Could not load backup overview from lambda status endpoint.', + ); + } finally { + setIsLoadingOverview(false); + } + }, + [ctx.environment, savedSecret, savedUrl], + ); + + const fetchAvailableEnvironmentIds = useCallback(async () => { + if (!ctx.currentUserAccessToken) { + return undefined; + } + + try { + const client = buildClient({ + apiToken: ctx.currentUserAccessToken, + environment: ctx.environment, + baseUrl: ctx.cmaBaseUrl, + }); + const environments = await client.environments.list(); + return environments + .map((environment) => environment.id) + .filter((id) => typeof id === 'string' && id.trim().length > 0); + } catch { + return undefined; + } + }, [ctx.currentUserAccessToken, ctx.environment, ctx.cmaBaseUrl]); + + const refreshAvailableEnvironments = useCallback(async () => { + const environmentIds = await fetchAvailableEnvironmentIds(); + setAvailableEnvironmentIds(environmentIds); + }, [fetchAvailableEnvironmentIds]); + + const triggerBackupForSingleCadence = useCallback( + async ({ + baseUrl, + lambdaAuthSecret, + cadence, + }: { + baseUrl: string; + lambdaAuthSecret: string; + cadence: BackupCadence; + }): Promise<{ + success: boolean; + environmentId?: string; + errorMessage?: string; + }> => { + setBackupNowInFlightCadence(cadence); + try { + const result = await triggerLambdaBackupNow({ + baseUrl, + environment: ctx.environment, + scope: cadence, + lambdaAuthSecret, + }); + return { success: true, environmentId: result.createdEnvironmentId }; + } catch (error) { + // A freshly-persisted schedule can race the lambda's own run; a 409 + // means "already creating" — retry once after a short delay. + const isRaceCondition = + error instanceof LambdaBackupNowError && + error.code === 'HTTP' && + error.httpStatus === 409; + + if (isRaceCondition) { + try { + await delay(BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS); + const retryResult = await triggerLambdaBackupNow({ + baseUrl, + environment: ctx.environment, + scope: cadence, + lambdaAuthSecret, + }); + return { + success: true, + environmentId: retryResult.createdEnvironmentId, + }; + } catch (retryError) { + return { + success: false, + errorMessage: `${getCadenceLabel(cadence)}: ${getErrorMessage(retryError)}`, + }; + } + } + + return { + success: false, + errorMessage: `${getCadenceLabel(cadence)}: ${getErrorMessage(error)}`, + }; + } + }, + [ctx.environment], + ); + + const ensureBackupsExistForCadences = useCallback( + async ({ + baseUrl, + lambdaAuthSecret, + cadences, + }: { + baseUrl: string; + lambdaAuthSecret: string; + cadences: BackupCadence[]; + }) => { + if (cadences.length === 0) { + return; + } + + try { + const status = await fetchLambdaBackupStatus({ + baseUrl, + environment: ctx.environment, + lambdaAuthSecret, + }); + + const missing = cadences.filter( + (cadence) => !status.slots[cadence]?.lastBackupAt, + ); + + if (missing.length === 0) { + await refreshLambdaBackupOverview(baseUrl); + await refreshAvailableEnvironments(); + return; + } + + setProgressMessage('Creating initial backups…'); + + const createdEnvironmentIds: string[] = []; + const failedCadences: string[] = []; + + for (const cadence of missing) { + setProgressMessage( + `Creating ${getCadenceLabel(cadence).toLowerCase()} backup…`, + ); + const outcome = await triggerBackupForSingleCadence({ + baseUrl, + lambdaAuthSecret, + cadence, + }); + if (outcome.success && outcome.environmentId) { + createdEnvironmentIds.push(outcome.environmentId); + } else if (outcome.errorMessage) { + failedCadences.push(outcome.errorMessage); + } + } + + if (createdEnvironmentIds.length > 0) { + const plural = createdEnvironmentIds.length > 1 ? 's' : ''; + ctx.notice( + `Created ${createdEnvironmentIds.length} backup environment${plural} for the saved schedule.`, + ); + } + if (failedCadences.length > 0) { + setOverviewError( + `Some automatic backup creations failed: ${failedCadences.join(' | ')}`, + ); + } + + await refreshLambdaBackupOverview(baseUrl); + await refreshAvailableEnvironments(); + } catch (error) { + setOverviewError( + error instanceof Error + ? error.message + : 'Could not automatically create missing backup environments.', + ); + } finally { + setProgressMessage(null); + setBackupNowInFlightCadence(null); + } + }, + [ + ctx, + refreshAvailableEnvironments, + refreshLambdaBackupOverview, + triggerBackupForSingleCadence, + ], + ); + + const runMountHealthCheck = useCallback( + async ({ + configuredDeploymentUrl, + isCancelled, + }: { + configuredDeploymentUrl: string; + isCancelled: () => boolean; + }) => { + const secret = (initialMountRef.current?.secret ?? '').trim(); + + if (!secret) { + const disconnectedState = buildDisconnectedLambdaConnectionState( + new LambdaHealthCheckError({ + code: 'MISSING_AUTH_SECRET', + message: MISSING_AUTH_SECRET_MESSAGE, + phase: 'config_mount', + endpoint: `${configuredDeploymentUrl.replace(/\/+$/, '')}/api/datocms/plugin-health`, + }), + configuredDeploymentUrl, + 'config_mount', + ); + try { + await persistPluginParameters({ + lambdaConnection: disconnectedState, + connectionValidationMode: null, + }); + } catch { + // Ignore persistence failure on mount. + } + if (!isCancelled()) { + setIsMountChecking(false); + } + return; + } + + debugLogger.log('Running mount health check', { + configuredDeploymentUrl, + phase: 'config_mount', + }); + + try { + const verificationResult = await verifyLambdaHealth({ + baseUrl: configuredDeploymentUrl, + environment: ctx.environment, + phase: 'config_mount', + lambdaAuthSecret: secret, + }); + + if (isCancelled()) { + return; + } + + const connectedState = buildConnectedLambdaConnectionState( + verificationResult.endpoint, + verificationResult.checkedAt, + 'config_mount', + ); + + setUrlInput(verificationResult.normalizedBaseUrl); + setConnectionTestError(null); + + await persistPluginParameters({ + deploymentURL: verificationResult.normalizedBaseUrl, + netlifyURL: verificationResult.normalizedBaseUrl, + vercelURL: verificationResult.normalizedBaseUrl, + lambdaConnection: connectedState, + connectionValidationMode: 'health', + lambdaAuthSecret: secret, + }); + } catch (error) { + if (isCancelled()) { + return; + } + + const disconnectedState = buildDisconnectedLambdaConnectionState( + error, + configuredDeploymentUrl, + 'config_mount', + ); + debugLogger.warn('Mount health check failed', disconnectedState); + + try { + await persistPluginParameters({ + lambdaConnection: disconnectedState, + connectionValidationMode: null, + }); + } catch { + // Ignore persistence failure on mount. + } + } finally { + if (!isCancelled()) { + setIsMountChecking(false); + debugLogger.log('Mount health check finished'); + } + } + }, + [ctx.environment, debugLogger, persistPluginParameters], + ); + + const runMigrateAndCheck = useCallback( + async (isCancelled: () => boolean) => { + const snapshot = initialMountRef.current; + if (!snapshot) { + return; + } + + if (snapshot.scheduleNormalization.requiresMigration) { + try { + await persistPluginParameters({ + backupSchedule: snapshot.scheduleNormalization.config, + }); + } catch { + // Best-effort schedule migration. + } + } + + const configuredDeploymentUrl = snapshot.url; + setIsMountChecking(true); + + if (!configuredDeploymentUrl.trim()) { + debugLogger.log( + 'Skipping mount health check because no deployment URL is configured', + ); + if (!isCancelled()) { + setConnectionTestError(null); + setIsMountChecking(false); + } + + try { + await persistPluginParameters({ + lambdaConnection: null, + connectionValidationMode: null, + }); + } catch { + // Ignore persistence errors on mount. + } + + return; + } + + await runMountHealthCheck({ configuredDeploymentUrl, isCancelled }); + }, + [debugLogger, persistPluginParameters, runMountHealthCheck], + ); + + useEffect(() => { + // A StrictMode remount re-enters this effect on the same fiber, so reset the + // unmount flag here; a genuine unmount sets it again via the cleanup below + // and is never followed by a re-entry. + isMountCheckUnmountedRef.current = false; + + if (!hasRunMountCheckRef.current) { + hasRunMountCheckRef.current = true; + debugLoggerRef.current.log('Config screen mounted'); + void runMigrateAndCheck(() => isMountCheckUnmountedRef.current); + } + + return () => { + isMountCheckUnmountedRef.current = true; + debugLoggerRef.current.log('Config screen unmounted'); + }; + // Must run exactly once per component instance (StrictMode double-invoke + // included). Its callbacks close over `ctx`, whose identity changes after + // every updatePluginParameters; listing them would re-fire the effect on + // every render and, because the effect persists parameters, recreate the + // infinite request loop this guard fixes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + void refreshLambdaBackupOverview(); + }, [refreshLambdaBackupOverview]); + + useEffect(() => { + let isCancelled = false; + + const loadAvailableEnvironments = async () => { + const environmentIds = await fetchAvailableEnvironmentIds(); + if (isCancelled) { + return; + } + setAvailableEnvironmentIds(environmentIds); + }; + + void loadAvailableEnvironments(); + + return () => { + isCancelled = true; + }; + }, [fetchAvailableEnvironmentIds]); + + const handleUrlChange = useCallback((value: string) => { + setUrlInput(value); + setConnectionTestError(null); + }, []); + + const regenerateSecret = useCallback(() => { + setSecretInput(generateAuthSecret()); + }, []); + + const copySecret = useCallback(async () => { + const value = secretInput.trim(); + if (!value) { + return; + } + try { + await navigator.clipboard.writeText(value); + ctx.notice('Auth secret copied to clipboard.'); + } catch { + await ctx.alert('Could not copy the secret. Copy it manually.'); + } + }, [ctx, secretInput]); + + const saveSecret = useCallback(async () => { + const nextSecret = secretInput.trim(); + if (!nextSecret) { + await ctx.alert('Enter or generate an auth secret before saving.'); + return; + } + + setIsSavingSecret(true); + try { + const previousSecret = readAuthSecret(params); + const secretChanged = nextSecret !== previousSecret; + const wasConnected = isConnectionHealthy(params); + + const updates: Record = { lambdaAuthSecret: nextSecret }; + // Changing a secret invalidates any prior healthy connection: re-gate the + // Connect step so the user re-tests against the new secret. + if (secretChanged && wasConnected) { + updates.lambdaConnection = null; + updates.connectionValidationMode = null; + } + + await persistPluginParameters(updates); + debugLogger.log('Auth secret saved', { secretChanged }); + ctx.notice('Auth secret saved.'); + } catch (error) { + debugLogger.error('Could not save auth secret', error); + await ctx.alert('Could not save the auth secret.'); + } finally { + setIsSavingSecret(false); + } + }, [ctx, debugLogger, params, persistPluginParameters, secretInput]); + + const saveAndTestConnection = useCallback(async () => { + const candidateUrl = urlInput.trim(); + if (!candidateUrl) { + setConnectionTestError({ + summary: 'Enter your lambda deployment URL.', + details: [], + }); + return; + } + + const secret = readAuthSecret(params); + if (!secret) { + setConnectionTestError({ + summary: MISSING_AUTH_SECRET_MESSAGE, + details: [ + 'Save an auth secret in step 1 first, and set the same value as DATOCMS_BACKUPS_SHARED_SECRET on your deployment.', + ], + }); + return; + } + + setIsConnecting(true); + setConnectionTestError(null); + + try { + const verificationResult = await verifyLambdaHealth({ + baseUrl: candidateUrl, + environment: ctx.environment, + phase: 'config_connect', + lambdaAuthSecret: secret, + }); + + const connectedState = buildConnectedLambdaConnectionState( + verificationResult.endpoint, + verificationResult.checkedAt, + 'config_connect', + ); + + // Persist the deployment URL triplet (legacy netlify/vercel keys kept in + // lockstep) together with the resulting connection state. + await persistPluginParameters({ + deploymentURL: verificationResult.normalizedBaseUrl, + netlifyURL: verificationResult.normalizedBaseUrl, + vercelURL: verificationResult.normalizedBaseUrl, + lambdaConnection: connectedState, + connectionValidationMode: 'health', + lambdaAuthSecret: secret, + }); + + setUrlInput(verificationResult.normalizedBaseUrl); + debugLogger.log('Lambda connected successfully', { + endpoint: verificationResult.endpoint, + }); + ctx.notice('Lambda function connected successfully.'); + } catch (error) { + if (error instanceof LambdaHealthCheckError) { + const disconnectedState = buildDisconnectedLambdaConnectionState( + error, + candidateUrl, + 'config_connect', + ); + debugLogger.warn('Lambda health check failed during connect', error); + try { + await persistPluginParameters({ + deploymentURL: candidateUrl, + netlifyURL: candidateUrl, + vercelURL: candidateUrl, + lambdaConnection: disconnectedState, + connectionValidationMode: null, + }); + } catch { + // Ignore persistence errors while surfacing the error in the UI. + } + } else { + debugLogger.error('Unexpected error while connecting lambda', error); + setConnectionTestError({ + summary: 'Unexpected error while connecting lambda.', + details: [`Failure details: ${getErrorMessage(error)}`], + }); + } + } finally { + setIsConnecting(false); + } + }, [ctx, debugLogger, params, persistPluginParameters, urlInput]); + + const disconnect = useCallback(async () => { + setIsDisconnecting(true); + setConnectionTestError(null); + + try { + await persistPluginParameters({ + deploymentURL: '', + netlifyURL: '', + vercelURL: '', + lambdaConnection: null, + connectionValidationMode: null, + }); + + setUrlInput(''); + setLambdaBackupStatus(undefined); + setOverviewError( + 'Backup status is unavailable until a Lambda URL is connected.', + ); + debugLogger.log('Lambda disconnected'); + ctx.notice('Current lambda function has been disconnected.'); + } catch (error) { + debugLogger.error('Could not disconnect current lambda', error); + await ctx.alert('Could not disconnect the current lambda function.'); + } finally { + setIsDisconnecting(false); + } + }, [ctx, debugLogger, persistPluginParameters]); + + const buildPersistedBackupSchedule = useCallback( + (normalizedEnabledCadences: BackupCadence[]): BackupScheduleConfig => { + const savedSchedule = normalizeBackupScheduleConfig({ + value: params?.backupSchedule, + timezoneFallback: projectTimezone, + }).config; + const savedCadences = savedSchedule.enabledCadences; + const didCadencesChange = + savedCadences.length !== normalizedEnabledCadences.length || + savedCadences.some( + (cadence, index) => cadence !== normalizedEnabledCadences[index], + ); + return { + version: BACKUP_SCHEDULE_VERSION, + enabledCadences: normalizedEnabledCadences, + timezone: projectTimezone, + anchorLocalDate: didCadencesChange + ? toLocalDateKey(new Date(), projectTimezone) + : savedSchedule.anchorLocalDate, + updatedAt: new Date().toISOString(), + }; + }, + [params?.backupSchedule, projectTimezone], + ); + + const setCadenceEnabled = useCallback( + (cadence: BackupCadence, enabled: boolean) => { + setCadenceSelection((current) => { + if (enabled) { + if (current.includes(cadence)) { + return current; + } + return BACKUP_CADENCES.filter( + (candidate) => candidate === cadence || current.includes(candidate), + ); + } + return current.filter((candidate) => candidate !== cadence); + }); + }, + [], + ); + + const saveSchedule = useCallback(async () => { + const normalized = BACKUP_CADENCES.filter((cadence) => + cadenceSelection.includes(cadence), + ); + if (normalized.length === 0) { + await ctx.alert('Select at least one backup cadence.'); + return; + } + + setIsSavingSchedule(true); + try { + const persistedSchedule = buildPersistedBackupSchedule(normalized); + await persistPluginParameters({ backupSchedule: persistedSchedule }); + debugLogger.log('Backup schedule saved', { + enabledCadences: normalized, + }); + ctx.notice('Backup schedule saved.'); + + const baseUrl = readDeploymentUrl(params); + const secret = readAuthSecret(params); + if (baseUrl && secret) { + await ensureBackupsExistForCadences({ + baseUrl, + lambdaAuthSecret: secret, + cadences: normalized, + }); + } + } catch (error) { + debugLogger.error('Could not save backup schedule', error); + await ctx.alert('Could not save the backup schedule.'); + } finally { + setIsSavingSchedule(false); + } + }, [ + buildPersistedBackupSchedule, + cadenceSelection, + ctx, + debugLogger, + ensureBackupsExistForCadences, + params, + persistPluginParameters, + ]); + + const saveDebug = useCallback( + async (enabled: boolean) => { + setDebugEnabled(enabled); + try { + await persistPluginParameters({ debug: enabled }); + } catch (error) { + debugLogger.error('Could not persist debug setting', error); + } + }, + [debugLogger, persistPluginParameters], + ); + + const backupNow = useCallback( + async (scope: BackupCadence) => { + if (backupNowInFlightCadence) { + return; + } + + const candidateUrl = readDeploymentUrl(params); + const secret = readAuthSecret(params); + + if (!candidateUrl) { + setOverviewError('Connect a Lambda URL before running backup now.'); + return; + } + if (!secret) { + setOverviewError( + 'Lambda auth secret is required before running backup now.', + ); + return; + } + + setBackupNowInFlightCadence(scope); + setOverviewError(''); + + try { + const result = await triggerLambdaBackupNow({ + baseUrl: candidateUrl, + environment: ctx.environment, + scope, + lambdaAuthSecret: secret, + }); + ctx.notice( + `${getCadenceLabel(scope)} backup created: ${result.createdEnvironmentId}.`, + ); + await refreshLambdaBackupOverview(candidateUrl); + await refreshAvailableEnvironments(); + } catch (error) { + setOverviewError( + error instanceof Error + ? error.message + : `Could not trigger ${getCadenceLabel(scope).toLowerCase()} backup.`, + ); + } finally { + setBackupNowInFlightCadence(null); + } + }, + [ + backupNowInFlightCadence, + ctx, + params, + refreshAvailableEnvironments, + refreshLambdaBackupOverview, + ], + ); + + const onOpenEnvironments = useCallback(async () => { + const environmentPrefix = ctx.isEnvironmentPrimary + ? '' + : `/environments/${ctx.environment}`; + await ctx.navigateTo(`${environmentPrefix}/project_settings/environments`); + }, [ctx]); + + const canBackupNow = + isConnected && + savedSecret.trim().length > 0 && + !isConnecting && + !isMountChecking && + !isDisconnecting; + + const connectionErrorDetails: string[] = + !isConnected && connection?.status === 'disconnected' + ? getLambdaConnectionErrorDetails(connection) + : []; + + return { + params, + projectTimezone, + // saved reads + savedSecret, + savedUrl, + connection, + isConnected, + connectionErrorDetails, + // edit state + secretInput, + setSecretInput, + urlInput, + setUrlInput: handleUrlChange, + cadenceSelection, + setCadenceEnabled, + debugEnabled, + // handlers + saveSecret, + regenerateSecret, + copySecret, + saveAndTestConnection, + disconnect, + saveSchedule, + saveDebug, + backupNow, + onOpenEnvironments, + // activity + isSavingSecret, + isConnecting, + isMountChecking, + isDisconnecting, + isSavingSchedule, + backupNowInFlightCadence, + progressMessage, + connectionTestError, + // overview + lambdaBackupStatus, + availableEnvironmentIds, + overviewError, + isLoadingOverview, + canBackupNow, + }; +}; + +export type BackupsConfig = ReturnType; diff --git a/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx b/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx index e7e595f3..be20c675 100644 --- a/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx +++ b/automatic-environment-backups/src/entrypoints/ConfigScreen.tsx @@ -1,1862 +1,119 @@ -import { buildClient } from '@datocms/cma-client-browser'; import type { RenderConfigScreenCtx } from 'datocms-plugin-sdk'; +import { Canvas } from 'datocms-react-ui'; +import { useEffect, useRef, useState } from 'react'; +import { AdvancedSettings } from '../config/AdvancedSettings'; import { - Button, - Canvas, - Form, - Section, - SwitchField, - TextField, -} from 'datocms-react-ui'; -import { - type CSSProperties, - type KeyboardEvent, - type MouseEvent as ReactMouseEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import type { - BackupCadence, - BackupOverviewRow, - BackupScheduleConfig, - ConnectionValidationMode, - LambdaBackupStatus, - LambdaConnectionState, -} from '../types/types'; -import { - BACKUP_CADENCES, - BACKUP_SCHEDULE_VERSION, - getCadenceLabel, - normalizeBackupScheduleConfig, - toLocalDateKey, -} from '../utils/backupSchedule'; -import { buildBackupOverviewRows } from '../utils/buildBackupOverviewRows'; -import { createDebugLogger, isDebugEnabled } from '../utils/debugLogger'; -import { - DEPLOY_PROVIDER_OPTIONS, - type DeployProvider, -} from '../utils/deployProviders'; -import { fetchLambdaBackupStatus } from '../utils/fetchLambdaBackupStatus'; -import { getDeploymentUrlFromParameters } from '../utils/getDeploymentUrlFromParameters'; -import { - mergePluginParameterUpdates, - toPluginParameterRecord, -} from '../utils/pluginParameterMerging'; -import { - LambdaBackupNowError, - triggerLambdaBackupNow, -} from '../utils/triggerLambdaBackupNow'; -import { - buildConnectedLambdaConnectionState, - buildDisconnectedLambdaConnectionState, - getLambdaConnectionErrorDetails, - LambdaHealthCheckError, - verifyLambdaHealth, -} from '../utils/verifyLambdaHealth'; - -const DEFAULT_CONNECTION_ERROR_SUMMARY = - 'Could not validate the Automatic Backups deployment.'; -const MISSING_AUTH_SECRET_MESSAGE = - 'Enter Lambda auth secret before calling lambda endpoints.'; -const DEFAULT_LAMBDA_AUTH_SECRET = 'superSecretToken'; -const BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS = 1200; - -const getErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : 'Unknown error'; - -const delay = (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -async function retryBackupNowAfterDelay({ - baseUrl, - environment, - scope, - lambdaAuthSecret, - cadence, -}: { - baseUrl: string; - environment: string; - scope: BackupCadence; - lambdaAuthSecret: string; - cadence: BackupCadence; -}): Promise<{ - success: boolean; - environmentId?: string; - errorMessage?: string; -}> { - try { - await delay(BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS); - const retryResult = await triggerLambdaBackupNow({ - baseUrl, - environment, - scope, - lambdaAuthSecret, - }); - return { success: true, environmentId: retryResult.createdEnvironmentId }; - } catch (retryError) { - const retryMessage = getErrorMessage(retryError); - return { - success: false, - errorMessage: `${getCadenceLabel(cadence)}: ${retryMessage}`, - }; - } -} - -type PluginParameters = Record | undefined; - -const toConnectionValidationMode = ( - value: unknown, -): ConnectionValidationMode | undefined => { - return value === 'health' ? value : undefined; -}; - -const toLambdaConnectionState = ( - value: unknown, -): LambdaConnectionState | undefined => { - if (!value || typeof value !== 'object') { - return undefined; - } - - const candidate = value as Partial; - if ( - (candidate.status === 'connected' || candidate.status === 'disconnected') && - typeof candidate.endpoint === 'string' && - typeof candidate.lastCheckedAt === 'string' && - (candidate.lastCheckPhase === 'finish_installation' || - candidate.lastCheckPhase === 'config_mount' || - candidate.lastCheckPhase === 'config_connect') - ) { - return candidate as LambdaConnectionState; - } - - return undefined; -}; - -const getProjectTimezone = (site: unknown): string => { - if ( - site && - typeof site === 'object' && - 'timezone' in site && - typeof (site as { timezone?: unknown }).timezone === 'string' && - (site as { timezone: string }).timezone.trim() - ) { - return (site as { timezone: string }).timezone.trim(); - } - - return 'UTC'; -}; - -const getConnectionErrorSummary = ( - connection?: LambdaConnectionState, -): string => { - if (!connection || connection.status !== 'disconnected') { - return ''; - } - - return connection.errorMessage || DEFAULT_CONNECTION_ERROR_SUMMARY; -}; - -const getPluginIdFromCtx = (ctx: RenderConfigScreenCtx): string | undefined => { - const candidate = (ctx.plugin as { id?: unknown } | undefined)?.id; - return typeof candidate === 'string' && candidate.trim() - ? candidate.trim() - : undefined; -}; - -const getInitialLambdaAuthSecret = ( - pluginParameters: PluginParameters, -): string => { - const hasSecret = - typeof pluginParameters?.lambdaAuthSecret === 'string' && - pluginParameters.lambdaAuthSecret.trim().length > 0; - return hasSecret - ? (pluginParameters?.lambdaAuthSecret as string) - : DEFAULT_LAMBDA_AUTH_SECRET; -}; - -const getPingIndicator = ({ - connectionActivityMessage, - isHealthChecking, - isConnecting, - connectionState, - activeDeploymentUrl, -}: { - connectionActivityMessage: string | null; - isHealthChecking: boolean; - isConnecting: boolean; - connectionState: LambdaConnectionState | undefined; - activeDeploymentUrl: string; -}): { label: string; color: string } => { - if (connectionActivityMessage) { - return { label: connectionActivityMessage, color: 'var(--color--warning-soft--ink)' }; - } - if (isHealthChecking || isConnecting) { - return { label: 'Checking ping...', color: 'var(--color--warning-soft--ink)' }; - } - if (connectionState?.status === 'connected') { - return { - label: 'Connected (ping successful)', - color: 'var(--color--success-soft--ink)', - }; - } - if (connectionState?.status === 'disconnected') { - return { label: 'Disconnected (ping failed)', color: 'var(--color--danger-soft--ink)' }; - } - if (activeDeploymentUrl) { - return { label: 'Connection pending', color: 'var(--color--ink-subtle)' }; - } - return { - label: 'Disconnected (no lambda URL configured)', - color: 'var(--color--ink-subtle)', - }; -}; - -const getConnectButtonLabel = ( - isConnecting: boolean, - hasActiveDeploymentUrl: boolean, -): string => { - if (isConnecting) { - return hasActiveDeploymentUrl ? 'Changing Lambda URL...' : 'Connecting...'; - } - return hasActiveDeploymentUrl ? 'Change Lambda URL' : 'Connect'; -}; - -const getInitialConnectionErrorSummary = ( - hasInitialConnectionErrorDetails: boolean, - initialConnectionState: LambdaConnectionState | undefined, -): string => { - if (!hasInitialConnectionErrorDetails) { - return ''; - } - return getConnectionErrorSummary(initialConnectionState); -}; - -const getInitialConnectionErrorDetails = ( - hasInitialConnectionErrorDetails: boolean, - initialConnectionState: LambdaConnectionState | undefined, -): string[] => { - if (!hasInitialConnectionErrorDetails || !initialConnectionState) { - return []; - } - return getLambdaConnectionErrorDetails(initialConnectionState); -}; - -const didCadenceSelectionChange = ( - current: BackupCadence[], - saved: BackupCadence[], -): boolean => { - if (current.length !== saved.length) { - return true; - } - return current.some((cadence, index) => cadence !== saved[index]); -}; - -const checkHasInitialConnectionErrorDetails = ( - initialDeploymentUrl: string, - initialConnectionState: LambdaConnectionState | undefined, -): boolean => { - if (initialDeploymentUrl.trim().length === 0) { - return false; - } - if (initialConnectionState?.status !== 'disconnected') { - return false; - } - return Boolean( - initialConnectionState.errorCode || - initialConnectionState.errorMessage || - initialConnectionState.httpStatus || - initialConnectionState.responseSnippet, - ); -}; - + deriveStepStatuses, + type SetupStepId, +} from '../config/deriveStepStatuses'; +import { readEnabledCadences } from '../config/pluginParams'; +import { StatusOverview } from '../config/StatusOverview'; +import { StepConnect } from '../config/StepConnect'; +import { StepSchedule } from '../config/StepSchedule'; +import { StepSection } from '../config/StepSection'; +import { StepSecret } from '../config/StepSecret'; +import { useBackupsConfig } from '../config/useBackupsConfig'; +import { getCadenceLabel } from '../utils/backupSchedule'; + +/** + * Thin orchestrator for the config wizard. Reads the saved plugin parameters as + * the single source of truth, derives per-step statuses, and renders the gated + * accordion (steps 1–3) plus the always-visible Status overview and Advanced + * settings. All state and side effects live in {@link useBackupsConfig}; there + * is no global Save button — each step commits its own change. + */ export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) { - const pluginParameters = ctx.plugin.attributes.parameters as PluginParameters; - const initialDeploymentUrl = getDeploymentUrlFromParameters(pluginParameters); - const initialLambdaAuthSecret = getInitialLambdaAuthSecret(pluginParameters); - const initialDebugEnabled = isDebugEnabled(pluginParameters); - const initialConnectionState = toLambdaConnectionState( - pluginParameters?.lambdaConnection, - ); - const initialValidationMode = toConnectionValidationMode( - pluginParameters?.connectionValidationMode, - ); - const projectTimezone = getProjectTimezone(ctx.site); - const initialScheduleNormalization = normalizeBackupScheduleConfig({ - value: pluginParameters?.backupSchedule, - timezoneFallback: projectTimezone, - }); - const initialBackupSchedule = initialScheduleNormalization.config; - - const hasInitialConnectionErrorDetails = - checkHasInitialConnectionErrorDetails( - initialDeploymentUrl, - initialConnectionState, - ); - - const [enabledCadencesSelection, setEnabledCadencesSelection] = useState< - BackupCadence[] - >(initialBackupSchedule.enabledCadences); - const [debugEnabled, setDebugEnabled] = useState(initialDebugEnabled); - const [savedFormValues, setSavedFormValues] = useState({ - debugEnabled: initialDebugEnabled, - lambdaAuthSecret: initialLambdaAuthSecret, - backupSchedule: initialBackupSchedule, - }); - - const [isLoading, setIsLoading] = useState(false); - const [isHealthChecking, setIsHealthChecking] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [isDisconnecting, setIsDisconnecting] = useState(false); - - const [deploymentUrlInput, setDeploymentUrlInput] = - useState(initialDeploymentUrl); - const [activeDeploymentUrl, setActiveDeploymentUrl] = - useState(initialDeploymentUrl); - const [lambdaAuthSecretInput, setLambdaAuthSecretInput] = useState( - initialLambdaAuthSecret, - ); - const [connectionState, setConnectionState] = useState< - LambdaConnectionState | undefined - >(initialConnectionState); - const [connectionValidationMode, setConnectionValidationMode] = useState< - ConnectionValidationMode | undefined - >(initialValidationMode); - const [connectionErrorSummary, setConnectionErrorSummary] = useState( - getInitialConnectionErrorSummary( - hasInitialConnectionErrorDetails, - initialConnectionState, - ), - ); - const [connectionErrorDetails, setConnectionErrorDetails] = useState< - string[] - >( - getInitialConnectionErrorDetails( - hasInitialConnectionErrorDetails, - initialConnectionState, - ), - ); - const [showConnectionDetails, setShowConnectionDetails] = useState(false); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const [lambdaBackupStatus, setLambdaBackupStatus] = useState< - LambdaBackupStatus | undefined - >(undefined); - const [isLoadingBackupOverview, setIsLoadingBackupOverview] = useState(false); - const [backupOverviewError, setBackupOverviewError] = useState(''); - const [availableEnvironmentIds, setAvailableEnvironmentIds] = useState< - string[] | undefined - >(undefined); - const [backupNowInFlightCadence, setBackupNowInFlightCadence] = - useState(null); - const [connectionActivityMessage, setConnectionActivityMessage] = useState< - string | null - >(null); - const [isDeployProviderMenuOpen, setIsDeployProviderMenuOpen] = - useState(false); - const debugLogger = createDebugLogger(debugEnabled, 'ConfigScreen'); - const persistQueueRef = useRef>(Promise.resolve()); - const deployProviderMenuRef = useRef(null); - - const persistPluginParameters = useCallback( - async (updates: Record) => { - const persistTask = async () => { - let latestParameters = toPluginParameterRecord( - ctx.plugin.attributes.parameters, - ); - const pluginId = getPluginIdFromCtx(ctx); - - if (pluginId && ctx.currentUserAccessToken) { - try { - const client = buildClient({ - apiToken: ctx.currentUserAccessToken, - environment: ctx.environment, - baseUrl: ctx.cmaBaseUrl, - }); - const plugin = await client.plugins.find(pluginId); - latestParameters = toPluginParameterRecord(plugin.parameters); - } catch (error) { - debugLogger.warn( - 'Falling back to local plugin parameters because authoritative read failed', - { - pluginId, - error: getErrorMessage(error), - }, - ); - } - } - - await ctx.updatePluginParameters( - mergePluginParameterUpdates(latestParameters, updates), - ); - }; - - const queuedPersist = persistQueueRef.current.then( - persistTask, - persistTask, - ); - persistQueueRef.current = queuedPersist.then( - () => undefined, - () => undefined, - ); - return queuedPersist; - }, - [ctx, debugLogger], - ); - - const clearConnectionErrorState = useCallback(() => { - setConnectionErrorSummary(''); - setConnectionErrorDetails([]); - setShowConnectionDetails(false); - }, []); - - const getNormalizedLambdaAuthSecret = () => lambdaAuthSecretInput.trim(); - - const applyDisconnectedState = useCallback((state: LambdaConnectionState) => { - setConnectionState(state); - setConnectionErrorSummary(getConnectionErrorSummary(state)); - setConnectionErrorDetails(getLambdaConnectionErrorDetails(state)); - setShowConnectionDetails(false); - }, []); - - const refreshLambdaBackupOverview = useCallback( - async (baseUrl?: string) => { - const candidateUrl = (baseUrl || activeDeploymentUrl).trim(); - const normalizedLambdaAuthSecret = - savedFormValues.lambdaAuthSecret.trim(); - const shouldFetch = - candidateUrl.length > 0 && normalizedLambdaAuthSecret.length > 0; - - if (!shouldFetch) { - setLambdaBackupStatus(undefined); - setBackupOverviewError( - candidateUrl.length === 0 - ? 'Lambda status is unavailable until the saved Lambda URL is connected with a healthy ping.' - : 'Lambda status is unavailable until Lambda auth secret is configured and saved.', - ); - setIsLoadingBackupOverview(false); - return; - } - - setIsLoadingBackupOverview(true); - setBackupOverviewError(''); - - try { - const status = await fetchLambdaBackupStatus({ - baseUrl: candidateUrl, - environment: ctx.environment, - lambdaAuthSecret: normalizedLambdaAuthSecret, - }); - setLambdaBackupStatus(status); - } catch (error) { - setLambdaBackupStatus(undefined); - setBackupOverviewError( - error instanceof Error - ? error.message - : 'Could not load backup overview from lambda status endpoint.', - ); - } finally { - setIsLoadingBackupOverview(false); - } - }, - [activeDeploymentUrl, ctx.environment, savedFormValues.lambdaAuthSecret], - ); - - const fetchAvailableEnvironmentIds = useCallback(async () => { - if (!ctx.currentUserAccessToken) { - return undefined; - } - - try { - const client = buildClient({ - apiToken: ctx.currentUserAccessToken, - environment: ctx.environment, - baseUrl: ctx.cmaBaseUrl, - }); - const environments = await client.environments.list(); - return environments - .map((environment) => environment.id) - .filter((id) => typeof id === 'string' && id.trim().length > 0); - } catch { - return undefined; - } - }, [ctx.currentUserAccessToken, ctx.environment, ctx.cmaBaseUrl]); - - const refreshAvailableEnvironments = useCallback(async () => { - const environmentIds = await fetchAvailableEnvironmentIds(); - setAvailableEnvironmentIds(environmentIds); - }, [fetchAvailableEnvironmentIds]); - - const triggerBackupForSingleCadence = useCallback( - async ({ - baseUrl, - lambdaAuthSecret, - cadence, - reason, - setBackupNowInFlightCadence: setInFlight, - }: { - baseUrl: string; - lambdaAuthSecret: string; - cadence: BackupCadence; - reason: 'connect' | 'schedule'; - setBackupNowInFlightCadence: (cadence: BackupCadence | null) => void; - }): Promise<{ - success: boolean; - environmentId?: string; - errorMessage?: string; - }> => { - setInFlight(cadence); - try { - const result = await triggerLambdaBackupNow({ - baseUrl, - environment: ctx.environment, - scope: cadence, - lambdaAuthSecret, - }); - return { success: true, environmentId: result.createdEnvironmentId }; - } catch (error) { - const isScheduleRaceCondition = - reason === 'schedule' && - error instanceof LambdaBackupNowError && - error.code === 'HTTP' && - error.httpStatus === 409; - - if (isScheduleRaceCondition) { - return retryBackupNowAfterDelay({ - baseUrl, - environment: ctx.environment, - scope: cadence, - lambdaAuthSecret, - cadence, - }); - } - - return { - success: false, - errorMessage: `${getCadenceLabel(cadence)}: ${getErrorMessage(error)}`, - }; - } - }, - [ctx.environment], - ); - - const createMissingBackupsSequentially = useCallback( - async ({ - baseUrl, - lambdaAuthSecret, - cadencesMissingEnvironment, - reason, - }: { - baseUrl: string; - lambdaAuthSecret: string; - cadencesMissingEnvironment: BackupCadence[]; - reason: 'connect' | 'schedule'; - }): Promise<{ - createdEnvironmentIds: string[]; - failedCadences: string[]; - }> => { - const processCadence = async ( - accumulated: { - createdEnvironmentIds: string[]; - failedCadences: string[]; - }, - cadence: BackupCadence, - ): Promise<{ - createdEnvironmentIds: string[]; - failedCadences: string[]; - }> => { - if (reason === 'connect') { - setConnectionActivityMessage( - `Connected. Creating ${getCadenceLabel(cadence).toLowerCase()} backup...`, - ); - } - - const outcome = await triggerBackupForSingleCadence({ - baseUrl, - lambdaAuthSecret, - cadence, - reason, - setBackupNowInFlightCadence, - }); - - if (outcome.success && outcome.environmentId) { - accumulated.createdEnvironmentIds.push(outcome.environmentId); - } else if (outcome.errorMessage) { - accumulated.failedCadences.push(outcome.errorMessage); - } - - return accumulated; - }; - - const initialAccumulator = { - createdEnvironmentIds: [] as string[], - failedCadences: [] as string[], - }; - - return cadencesMissingEnvironment.reduce( - (chainedPromise, cadence) => - chainedPromise.then((acc) => processCadence(acc, cadence)), - Promise.resolve(initialAccumulator), - ); - }, - [triggerBackupForSingleCadence], - ); - - const reportBackupCreationResults = useCallback( - ({ - createdEnvironmentIds, - failedCadences, - reason, - }: { - createdEnvironmentIds: string[]; - failedCadences: string[]; - reason: 'connect' | 'schedule'; - }) => { - if (createdEnvironmentIds.length > 0) { - const reasonLabel = - reason === 'connect' - ? 'after connecting Lambda' - : 'for newly added schedule cadences'; - const plural = createdEnvironmentIds.length > 1 ? 's' : ''; - ctx.notice( - `Created ${createdEnvironmentIds.length} backup environment${plural} ${reasonLabel}.`, - ); - } - - if (failedCadences.length > 0) { - setBackupOverviewError( - `Some automatic backup creations failed: ${failedCadences.join(' | ')}`, - ); - } - }, - [ctx], - ); - - const ensureBackupsExistForCadences = useCallback( - async ({ - baseUrl, - lambdaAuthSecret, - cadences, - reason, - }: { - baseUrl: string; - lambdaAuthSecret: string; - cadences: BackupCadence[]; - reason: 'connect' | 'schedule'; - }) => { - if (cadences.length === 0) { - return; - } - - try { - const status = await fetchLambdaBackupStatus({ - baseUrl, - environment: ctx.environment, - lambdaAuthSecret, - }); - - const cadencesMissingEnvironment = cadences.filter((cadence) => { - const slot = status.slots[cadence]; - return !slot?.lastBackupAt; - }); - - if (cadencesMissingEnvironment.length === 0) { - await refreshLambdaBackupOverview(baseUrl); - await refreshAvailableEnvironments(); - return; - } - - if (reason === 'connect') { - setConnectionActivityMessage( - 'Connected. Creating initial backups...', - ); - } - - const backupCreationResults = await createMissingBackupsSequentially({ - baseUrl, - lambdaAuthSecret, - cadencesMissingEnvironment, - reason, - }); - - reportBackupCreationResults({ ...backupCreationResults, reason }); - - await refreshLambdaBackupOverview(baseUrl); - await refreshAvailableEnvironments(); - } catch (error) { - setBackupOverviewError( - error instanceof Error - ? error.message - : 'Could not automatically create missing backup environments.', - ); - } finally { - if (reason === 'connect') { - setConnectionActivityMessage(null); - } - setBackupNowInFlightCadence(null); - } - }, - [ - ctx, - createMissingBackupsSequentially, - refreshAvailableEnvironments, - refreshLambdaBackupOverview, - reportBackupCreationResults, - ], - ); - - const runMountHealthCheck = useCallback( - async ({ - configuredDeploymentUrl, - isCancelled, - }: { - configuredDeploymentUrl: string; - isCancelled: () => boolean; - }) => { - const normalizedLambdaAuthSecret = initialLambdaAuthSecret.trim(); - - if (!normalizedLambdaAuthSecret) { - const mountHealthEndpoint = `${configuredDeploymentUrl.replace(/\/+$/, '')}/api/datocms/plugin-health`; - const disconnectedState = buildDisconnectedLambdaConnectionState( - new LambdaHealthCheckError({ - code: 'MISSING_AUTH_SECRET', - message: MISSING_AUTH_SECRET_MESSAGE, - phase: 'config_mount', - endpoint: mountHealthEndpoint, - }), - configuredDeploymentUrl, - 'config_mount', - ); - applyDisconnectedState(disconnectedState); - setIsHealthChecking(false); - return; - } - - debugLogger.log('Running mount health check', { - configuredDeploymentUrl, - phase: 'config_mount', - }); - - try { - const verificationResult = await verifyLambdaHealth({ - baseUrl: configuredDeploymentUrl, - environment: ctx.environment, - phase: 'config_mount', - lambdaAuthSecret: normalizedLambdaAuthSecret, - }); - - if (isCancelled()) { - return; - } - - const connectedState = buildConnectedLambdaConnectionState( - verificationResult.endpoint, - verificationResult.checkedAt, - 'config_mount', - ); - - setConnectionState(connectedState); - setDeploymentUrlInput(verificationResult.normalizedBaseUrl); - setActiveDeploymentUrl(verificationResult.normalizedBaseUrl); - setConnectionValidationMode('health'); - clearConnectionErrorState(); - - await persistPluginParameters({ - deploymentURL: verificationResult.normalizedBaseUrl, - netlifyURL: verificationResult.normalizedBaseUrl, - vercelURL: verificationResult.normalizedBaseUrl, - lambdaConnection: connectedState, - connectionValidationMode: 'health', - lambdaAuthSecret: normalizedLambdaAuthSecret, - }); - } catch (error) { - if (isCancelled()) { - return; - } - - const disconnectedState = buildDisconnectedLambdaConnectionState( - error, - configuredDeploymentUrl, - 'config_mount', - ); - debugLogger.warn('Mount health check failed', disconnectedState); - applyDisconnectedState(disconnectedState); - - try { - await persistPluginParameters({ - lambdaConnection: disconnectedState, - connectionValidationMode: null, - }); - } catch { - // Ignore persistence failure on mount. - } - } finally { - if (!isCancelled()) { - setIsHealthChecking(false); - debugLogger.log('Mount health check finished'); - } - } - }, - [ - applyDisconnectedState, - clearConnectionErrorState, - ctx.environment, - debugLogger, - initialLambdaAuthSecret, - persistPluginParameters, - ], - ); - - const runMigrateAndCheck = useCallback( - async (isCancelled: () => boolean) => { - if (initialScheduleNormalization.requiresMigration) { - try { - await persistPluginParameters({ - backupSchedule: initialBackupSchedule, - }); - } catch { - // Best-effort schedule migration. - } - } - - const configuredDeploymentUrl = initialDeploymentUrl; - setIsHealthChecking(true); - - if (!configuredDeploymentUrl.trim()) { - debugLogger.log( - 'Skipping mount health check because no deployment URL is configured', - ); - if (!isCancelled()) { - setConnectionState(undefined); - setConnectionValidationMode(undefined); - clearConnectionErrorState(); - setIsHealthChecking(false); - } - - try { - await persistPluginParameters({ - lambdaConnection: null, - connectionValidationMode: null, - }); - } catch { - // Ignore persistence errors on mount. - } - - return; - } - - await runMountHealthCheck({ configuredDeploymentUrl, isCancelled }); - }, - [ - clearConnectionErrorState, - debugLogger, - initialBackupSchedule, - initialDeploymentUrl, - initialScheduleNormalization.requiresMigration, - persistPluginParameters, - runMountHealthCheck, - ], - ); - + const config = useBackupsConfig(ctx); + const { params } = config; + const statuses = deriveStepStatuses(params); + const { currentStep } = statuses; + + // The current/error step auto-expands; users can [Edit] an `ok` step to + // override. Syncing to `currentStep` (tracked via a ref) follows the wizard + // as saves advance it, without clobbering manual edits. + const [expandedStep, setExpandedStep] = useState( + currentStep, + ); + const previousCurrentStepRef = useRef(currentStep); useEffect(() => { - let cancelled = false; - const isCancelled = () => cancelled; - - debugLogger.log('Config screen mounted', { - initialDeploymentUrl, - initialValidationMode, - hasInitialConnectionState: Boolean(initialConnectionState), - debugEnabled, - }); - - void runMigrateAndCheck(isCancelled); - - return () => { - cancelled = true; - debugLogger.log('Config screen unmounted'); - }; - }, [ - debugEnabled, - debugLogger, - initialConnectionState, - initialDeploymentUrl, - initialValidationMode, - runMigrateAndCheck, - ]); - - useEffect(() => { - void refreshLambdaBackupOverview(); - }, [refreshLambdaBackupOverview]); - - useEffect(() => { - let isCancelled = false; - - const loadAvailableEnvironments = async () => { - const environmentIds = await fetchAvailableEnvironmentIds(); - if (isCancelled) { - return; - } - setAvailableEnvironmentIds(environmentIds); - }; - - void loadAvailableEnvironments(); - - return () => { - isCancelled = true; - }; - }, [fetchAvailableEnvironmentIds]); - - useEffect(() => { - if (!isDeployProviderMenuOpen) { - return undefined; - } - - const handleOutsideInteraction = (event: MouseEvent | TouchEvent) => { - const target = event.target; - if (!(target instanceof Node)) { - return; - } - - if (!deployProviderMenuRef.current?.contains(target)) { - setIsDeployProviderMenuOpen(false); - } - }; - - document.addEventListener('mousedown', handleOutsideInteraction); - document.addEventListener('touchstart', handleOutsideInteraction); - - return () => { - document.removeEventListener('mousedown', handleOutsideInteraction); - document.removeEventListener('touchstart', handleOutsideInteraction); - }; - }, [isDeployProviderMenuOpen]); - - useEffect(() => { - if (isConnecting || isHealthChecking || isDisconnecting) { - setIsDeployProviderMenuOpen(false); - } - }, [isConnecting, isHealthChecking, isDisconnecting]); - - const handleConnectLambdaError = useCallback( - async (error: unknown, candidateUrl: string) => { - if (error instanceof LambdaHealthCheckError) { - debugLogger.warn('Lambda health check failed during connect', error); - const disconnectedState = buildDisconnectedLambdaConnectionState( - error, - candidateUrl, - 'config_connect', - ); - applyDisconnectedState(disconnectedState); - - try { - await persistPluginParameters({ - lambdaConnection: disconnectedState, - connectionValidationMode: null, - }); - } catch { - // Ignore persistence errors while showing errors in UI. - } - } else { - debugLogger.error('Unexpected error while connecting lambda', error); - setConnectionErrorSummary('Unexpected error while connecting lambda.'); - setConnectionErrorDetails([ - 'Unexpected error while connecting lambda.', - `Failure details: ${error instanceof Error ? error.message : 'Unknown error'}`, - ]); - setShowConnectionDetails(false); - } - }, - [applyDisconnectedState, debugLogger, persistPluginParameters], - ); - - const connectLambdaHandler = async () => { - const candidateUrl = deploymentUrlInput.trim(); - if (!candidateUrl) { - setConnectionErrorSummary('Enter your lambda deployment URL.'); - setConnectionErrorDetails([]); - setShowConnectionDetails(false); - return; - } - - const normalizedLambdaAuthSecret = getNormalizedLambdaAuthSecret(); - if (!normalizedLambdaAuthSecret) { - setConnectionErrorSummary(MISSING_AUTH_SECRET_MESSAGE); - setConnectionErrorDetails([ - MISSING_AUTH_SECRET_MESSAGE, - 'Set the same value as DATOCMS_BACKUPS_SHARED_SECRET configured in the lambda deployment.', - ]); - setShowConnectionDetails(false); - return; - } - - debugLogger.log('Connecting lambda deployment', { candidateUrl }); - setConnectionActivityMessage(null); - setIsConnecting(true); - clearConnectionErrorState(); - - try { - const verificationResult = await verifyLambdaHealth({ - baseUrl: candidateUrl, - environment: ctx.environment, - phase: 'config_connect', - lambdaAuthSecret: normalizedLambdaAuthSecret, - }); - - const connectedState = buildConnectedLambdaConnectionState( - verificationResult.endpoint, - verificationResult.checkedAt, - 'config_connect', - ); - - setConnectionState(connectedState); - setDeploymentUrlInput(verificationResult.normalizedBaseUrl); - setActiveDeploymentUrl(verificationResult.normalizedBaseUrl); - setConnectionValidationMode('health'); - clearConnectionErrorState(); - - await persistPluginParameters({ - deploymentURL: verificationResult.normalizedBaseUrl, - netlifyURL: verificationResult.normalizedBaseUrl, - vercelURL: verificationResult.normalizedBaseUrl, - lambdaConnection: connectedState, - connectionValidationMode: 'health', - lambdaAuthSecret: normalizedLambdaAuthSecret, - }); - - debugLogger.log('Lambda connected successfully', { - endpoint: verificationResult.endpoint, - normalizedBaseUrl: verificationResult.normalizedBaseUrl, - mode: 'health', - }); - ctx.notice('Lambda function connected successfully.'); - await ensureBackupsExistForCadences({ - baseUrl: verificationResult.normalizedBaseUrl, - lambdaAuthSecret: normalizedLambdaAuthSecret, - cadences: savedFormValues.backupSchedule.enabledCadences, - reason: 'connect', - }); - } catch (error) { - await handleConnectLambdaError(error, candidateUrl); - } finally { - setConnectionActivityMessage(null); - setIsConnecting(false); - } - }; - - const disconnectCurrentLambdaHandler = async () => { - debugLogger.log('Disconnecting lambda deployment', { - activeDeploymentUrl, - }); - setIsDisconnecting(true); - clearConnectionErrorState(); - - try { - await persistPluginParameters({ - deploymentURL: '', - netlifyURL: '', - vercelURL: '', - lambdaConnection: null, - connectionValidationMode: null, - }); - - setDeploymentUrlInput(''); - setActiveDeploymentUrl(''); - setConnectionState(undefined); - setConnectionValidationMode(undefined); - setLambdaBackupStatus(undefined); - setConnectionActivityMessage(null); - setBackupOverviewError( - 'Lambda status is unavailable until the saved Lambda URL is connected with a healthy ping.', - ); - debugLogger.log('Lambda disconnected'); - ctx.notice('Current lambda function has been disconnected.'); - } catch (error) { - debugLogger.error('Could not disconnect current lambda', error); - setConnectionErrorSummary('Could not disconnect the current lambda.'); - setConnectionErrorDetails([ - 'Could not disconnect the current lambda function.', - ]); - setShowConnectionDetails(false); - await ctx.alert('Could not disconnect the current lambda function.'); - } finally { - setIsDisconnecting(false); - } - }; - - const buildPersistedBackupSchedule = useCallback( - (normalizedEnabledCadences: BackupCadence[]): BackupScheduleConfig => { - const savedCadences = savedFormValues.backupSchedule.enabledCadences; - const didCadencesChange = - savedCadences.length !== normalizedEnabledCadences.length || - savedCadences.some( - (cadence, index) => cadence !== normalizedEnabledCadences[index], - ); - return { - version: BACKUP_SCHEDULE_VERSION, - enabledCadences: normalizedEnabledCadences, - timezone: projectTimezone, - anchorLocalDate: didCadencesChange - ? toLocalDateKey(new Date(), projectTimezone) - : savedFormValues.backupSchedule.anchorLocalDate, - updatedAt: new Date().toISOString(), - }; - }, - [projectTimezone, savedFormValues.backupSchedule], - ); - - const saveSettingsHandler = async () => { - const normalizedEnabledCadences = BACKUP_CADENCES.filter((cadence) => - enabledCadencesSelection.includes(cadence), - ); - const normalizedLambdaAuthSecret = getNormalizedLambdaAuthSecret(); - - if (normalizedEnabledCadences.length === 0) { - await ctx.alert('Select at least one backup cadence.'); - return; - } - - debugLogger.log('Saving plugin settings', { - debugEnabled, - activeDeploymentUrl, - connectionStatus: connectionState?.status, - connectionValidationMode, - enabledCadences: normalizedEnabledCadences, - timezone: projectTimezone, - }); - - const hasConnectedLambdaForSave = - activeDeploymentUrl.trim().length > 0 && - normalizedLambdaAuthSecret.length > 0 && - connectionState?.status === 'connected' && - connectionValidationMode === 'health' && - !isHealthChecking && - !isConnecting; - - if (!hasConnectedLambdaForSave) { - await ctx.alert( - 'Cannot save unless Lambda URL and Lambda auth secret are configured and ping status is Connected.', - ); - return; - } - - setIsLoading(true); - - try { - const persistedDeploymentUrl = activeDeploymentUrl.trim(); - const persistedConnectionState = connectionState ?? null; - const persistedValidationMode: ConnectionValidationMode | null = - connectionValidationMode ?? null; - const persistedBackupSchedule = buildPersistedBackupSchedule( - normalizedEnabledCadences, - ); - - await persistPluginParameters({ - debug: debugEnabled, - lambdaAuthSecret: normalizedLambdaAuthSecret, - deploymentURL: persistedDeploymentUrl, - netlifyURL: persistedDeploymentUrl, - vercelURL: persistedDeploymentUrl, - lambdaConnection: persistedConnectionState, - connectionValidationMode: persistedValidationMode, - backupSchedule: persistedBackupSchedule, - }); - - setSavedFormValues({ - debugEnabled, - lambdaAuthSecret: normalizedLambdaAuthSecret, - backupSchedule: persistedBackupSchedule, - }); - - if (normalizedEnabledCadences.length > 0) { - await ensureBackupsExistForCadences({ - baseUrl: persistedDeploymentUrl, - lambdaAuthSecret: normalizedLambdaAuthSecret, - cadences: normalizedEnabledCadences, - reason: 'schedule', - }); - } - - ctx.notice( - `Settings saved. Runtime mode: Lambda (cron). Debug logging is ${ - debugEnabled ? 'enabled' : 'disabled' - }.`, - ); - } catch (error) { - debugLogger.error('Could not save plugin settings', error); - await ctx.alert('Could not save plugin settings.'); - } finally { - setIsLoading(false); - } - }; - - const handleDeployProviderClick = (provider: DeployProvider) => { - const option = DEPLOY_PROVIDER_OPTIONS.find( - (candidate) => candidate.provider === provider, - ); - if (!option) { - return; - } - - setIsDeployProviderMenuOpen(false); - debugLogger.log('Opening deploy provider', { provider, url: option.url }); - window.open(option.url, '_blank', 'noopener,noreferrer'); - }; - - const handleDeployProviderMenuKeyDown = ( - event: KeyboardEvent, - ) => { - if (event.key === 'Escape') { - event.preventDefault(); - setIsDeployProviderMenuOpen(false); - } - }; - - const setCadenceEnabled = (cadence: BackupCadence, enabled: boolean) => { - setEnabledCadencesSelection((current) => { - if (enabled) { - if (current.includes(cadence)) { - return current; - } - return BACKUP_CADENCES.filter( - (candidate) => candidate === cadence || current.includes(candidate), - ); - } - - return current.filter((candidate) => candidate !== cadence); - }); - }; - - const openEnvironmentsSettings = async ( - event: ReactMouseEvent, - ) => { - event.preventDefault(); - const environmentPrefix = ctx.isEnvironmentPrimary - ? '' - : `/environments/${ctx.environment}`; - await ctx.navigateTo(`${environmentPrefix}/project_settings/environments`); - }; - - const triggerBackupNowForCadence = async (scope: BackupCadence) => { - if (backupNowInFlightCadence) { - return; - } - - const candidateUrl = activeDeploymentUrl.trim(); - const normalizedLambdaAuthSecret = savedFormValues.lambdaAuthSecret.trim(); - - if (!candidateUrl) { - setBackupOverviewError('Connect a Lambda URL before running backup now.'); + const previous = previousCurrentStepRef.current; + if (previous === currentStep) { return; } + previousCurrentStepRef.current = currentStep; - if (!normalizedLambdaAuthSecret) { - setBackupOverviewError( - 'Lambda auth secret is required before running backup now.', - ); + // Right after the secret is saved (secret → connect) with nothing deployed + // yet, keep step 1 expanded so its deploy menu + paste callout stay visible; + // the user advances to step 2 when they have a deployed URL in hand. + const justSavedSecretBeforeDeploy = + previous === 'secret' && currentStep === 'connect' && !config.savedUrl; + if (justSavedSecretBeforeDeploy) { return; } - setBackupNowInFlightCadence(scope); - setBackupOverviewError(''); - - try { - const result = await triggerLambdaBackupNow({ - baseUrl: candidateUrl, - environment: ctx.environment, - scope, - lambdaAuthSecret: normalizedLambdaAuthSecret, - }); - - ctx.notice( - `${getCadenceLabel(scope)} backup created: ${result.createdEnvironmentId}.`, - ); - await refreshLambdaBackupOverview(candidateUrl); - await refreshAvailableEnvironments(); - } catch (error) { - setBackupOverviewError( - error instanceof Error - ? error.message - : `Could not trigger ${getCadenceLabel(scope).toLowerCase()} backup.`, - ); - } finally { - setBackupNowInFlightCadence(null); - } - }; - - const hasActiveDeploymentUrl = activeDeploymentUrl.trim().length > 0; - - const pingIndicator = getPingIndicator({ - connectionActivityMessage, - isHealthChecking, - isConnecting, - connectionState, - activeDeploymentUrl, - }); - - const connectButtonLabel = getConnectButtonLabel( - isConnecting, - hasActiveDeploymentUrl, - ); - const disconnectButtonLabel = isDisconnecting - ? 'Disconnecting...' - : 'Disconnect'; - const isDeployProviderMenuDisabled = - isConnecting || isHealthChecking || isDisconnecting; - const deployProviderMenuId = 'deploy-provider-menu'; - - const lambdaActionButtonStyle: CSSProperties = { - width: '100%', - height: '40px', - fontSize: 'var(--font-size-m)', - fontWeight: 500, - lineHeight: '1', - padding: '0 var(--spacing-m)', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - boxSizing: 'border-box', - flex: '1 1 0', - whiteSpace: 'nowrap', - }; - - const deployProviderMenuContainerStyle: CSSProperties = { - position: 'relative', - flex: '1 1 0', - }; - - const deployProviderMenuStyle: CSSProperties = { - position: 'absolute', - top: 'calc(100% + var(--spacing-xs))', - left: 0, - zIndex: 1000, - minWidth: '180px', - width: '100%', - border: '1px solid var(--color--border)', - borderRadius: '6px', - background: 'var(--color--surface)', - boxShadow: '0 8px 24px rgb(0 0 0 / 12%)', - padding: 'var(--spacing-xs) 0', - }; - - const deployProviderOptionStyle: CSSProperties = { - display: 'block', - width: '100%', - border: 0, - background: 'transparent', - color: 'var(--color--ink)', - cursor: 'pointer', - font: 'inherit', - fontSize: 'var(--font-size-s)', - lineHeight: '1.3', - padding: 'var(--spacing-s) var(--spacing-m)', - textAlign: 'left', - }; - - const cardStyle = { - border: '1px solid var(--color--border)', - borderRadius: '6px', - background: 'var(--color--surface)', - padding: 'var(--spacing-l)', - marginBottom: 'var(--spacing-l)', - textAlign: 'left' as const, - }; - - const subtleTextStyle = { - margin: 0, - color: 'var(--color--ink-subtle)', - fontSize: 'var(--font-size-xs)', - }; - - const infoTextStyle = { - marginTop: 0, - marginBottom: 'var(--spacing-s)', - color: 'var(--color--ink)', - fontSize: 'var(--font-size-s)', - }; - - const backupScheduleCardStyle: CSSProperties = { - ...cardStyle, - paddingTop: 'var(--spacing-m)', - paddingBottom: 'var(--spacing-m)', - }; - - const backupScheduleTitleStyle: CSSProperties = { - marginTop: 0, - marginBottom: 'var(--spacing-m)', - fontSize: 'var(--font-size-l)', - }; - - const backupScheduleInfoTextStyle: CSSProperties = { - marginTop: 0, - marginBottom: 'var(--spacing-m)', - color: 'var(--color--ink)', - fontSize: '12px', - lineHeight: '1.35', - }; - - const backupScheduleCadenceGridStyle: CSSProperties = { - display: 'grid', - gap: 'var(--spacing-s)', - }; - - const advancedSettingsStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', - gap: 'var(--spacing-m)', - }; - - const overviewGridStyle: CSSProperties = { - display: 'grid', - gap: 'var(--spacing-s)', - }; - - const overviewRowStyle: CSSProperties = { - border: '1px solid var(--color--border)', - borderRadius: '6px', - padding: 'var(--spacing-m)', - background: 'var(--color--surface)', - display: 'grid', - gridTemplateColumns: 'minmax(0, 1fr) auto', - columnGap: 'var(--spacing-m)', - alignItems: 'center', - }; - - const overviewRowHeaderStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 'var(--spacing-s)', - marginBottom: 'var(--spacing-s)', - }; - - const overviewRowContentStyle: CSSProperties = { - minWidth: 0, - }; - - const overviewRowLabelStyle: CSSProperties = { - marginTop: 0, - marginBottom: 0, - fontSize: 'var(--font-size-m)', - }; - - const overviewRowInfoStyle: CSSProperties = { - margin: 0, - fontSize: 'var(--font-size-s)', - }; - - const overviewRowActionStyle: CSSProperties = { - alignSelf: 'center', - minWidth: '140px', - }; + setExpandedStep(currentStep); + }, [currentStep, config.savedUrl]); - const switchFieldNoHintGapStyle = { - '--spacing-s': '0', - } as CSSProperties; + const toggleStep = (step: SetupStepId) => + setExpandedStep((current) => (current === step ? null : step)); - const switchFieldNoHintGapStyleWithExtraSpacing = { - ...switchFieldNoHintGapStyle, - marginBottom: '0.25rem', - } as CSSProperties; - - const normalizedCadenceSelection = BACKUP_CADENCES.filter((cadence) => - enabledCadencesSelection.includes(cadence), - ); - const hasCadenceSelectionChanged = didCadenceSelectionChange( - normalizedCadenceSelection, - savedFormValues.backupSchedule.enabledCadences, - ); - const hasLambdaAuthSecret = lambdaAuthSecretInput.trim().length > 0; - const hasSavedLambdaAuthSecret = - savedFormValues.lambdaAuthSecret.trim().length > 0; - const hasLambdaAuthSecretChanged = - lambdaAuthSecretInput.trim() !== savedFormValues.lambdaAuthSecret.trim(); - const hasUnsavedChanges = - debugEnabled !== savedFormValues.debugEnabled || - hasLambdaAuthSecretChanged || - hasCadenceSelectionChanged; - - const canSaveWithLambdaMode = - hasActiveDeploymentUrl && - hasLambdaAuthSecret && - connectionState?.status === 'connected' && - connectionValidationMode === 'health' && - !isHealthChecking && - !isConnecting; - - const canTriggerBackupNow = - hasActiveDeploymentUrl && - hasSavedLambdaAuthSecret && - connectionState?.status === 'connected' && - connectionValidationMode === 'health' && - !isHealthChecking && - !isConnecting && - !isDisconnecting; - - const savedBackupSchedule = savedFormValues.backupSchedule; - const backupOverviewRows: BackupOverviewRow[] = buildBackupOverviewRows({ - scheduleConfig: savedBackupSchedule, - lambdaStatus: lambdaBackupStatus, - availableEnvironmentIds, - }); + const enabledCadences = readEnabledCadences(params); + const secretSummary = 'Secret saved.'; + const connectSummary = config.savedUrl + ? `Connected to ${config.savedUrl}` + : 'Connected.'; + const scheduleSummary = `Backups: ${enabledCadences + .map(getCadenceLabel) + .join(', ')}`; return ( -
-
-

- Lambda setup -

- -

- Current URL:{' '} - - {activeDeploymentUrl || 'No lambda function connected.'} - -

- -

-

- -

- Status is based on the `/api/datocms/plugin-health` ping. -

- - { - setDeploymentUrlInput(newValue); - clearConnectionErrorState(); - }} - /> - -
- { - setLambdaAuthSecretInput(newValue); - clearConnectionErrorState(); - }} - /> -
- -
-
- - - {isDeployProviderMenuOpen && ( - - )} -
- - - - -
-
- - {connectionErrorSummary && ( -
-

- {connectionErrorSummary} -

- {connectionErrorDetails.length > 0 && ( - - )} -
- )} - - {showConnectionDetails && connectionErrorDetails.length > 0 && ( -
- {connectionErrorDetails.map((detail) => ( -

{detail}

- ))} -
- )} - -
-

Backup schedule

- -

- The scheduler runs once a day. The number of backups depends on your - selected schedule options. -

- -
- {BACKUP_CADENCES.map((cadence) => ( -
- setCadenceEnabled(cadence, newValue)} - /> -
- ))} -
-
- -
-

- Backup overview -

- - {isLoadingBackupOverview && ( -

- Refreshing Lambda backup status... -

- )} - - {backupOverviewError && ( -

- {backupOverviewError} -

- )} - -
- {backupOverviewRows.map((row) => { - const isBackupNowLoading = backupNowInFlightCadence === row.scope; - const isBackupNowDisabled = - !canTriggerBackupNow || backupNowInFlightCadence !== null; - - return ( -
-
-
-

- {getCadenceLabel(row.scope)} -

-
-

- Last backup: {row.lastBackup} -

-

- Next backup: {row.nextBackup} -

-

- Environment:{' '} - {row.environmentLinked ? ( - - {row.environmentName} - - ) : ( - row.environmentName - )} -

- {row.environmentStatusNote && ( -

- Status: {row.environmentStatusNote} -

- )} -
-
- -
-
- ); - })} -
-
- -
-
setShowAdvancedSettings((current) => !current), - }} - > -
-
- setDebugEnabled(newValue)} - /> -
- -

- This plugin runs in Lambda cron mode only. -

-
-
- - -
+
+ toggleStep('secret')} + summary={secretSummary} + > + + + + toggleStep('connect')} + summary={connectSummary} + > + + + + toggleStep('schedule')} + summary={scheduleSummary} + > + + + + + +
); From 37633e69c8145b48d4954ebe2dfd1603e61f0817 Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Tue, 30 Jun 2026 20:07:42 -0700 Subject: [PATCH 3/5] fix(automatic-backups): address code-review findings in config wizard Fixes from the xhigh review of the wizard rewrite: - persistPluginParameters now merges against a running accumulator ref, so sequential mount-flow persists compose even without a CMA token (was clobbering the schedule migration on token-less roles). - Mount health-check success persists only the connection result, not the stale first-render secret/URL snapshot (was reverting a secret saved during the in-flight check). - Mount schedule migration only runs for an actually-stored legacy schedule, so a fresh install no longer auto-completes step 3 with unchosen cadences. - saveAndTestConnection re-creates missing backup environments on connect when a schedule is already saved (restores the old connect behavior for reconnects), surfaces failures via a transient error box independent of persistence, and no longer overwrites a previously-healthy deployment URL on a failed test. - saveSecret clears any recorded connection state on a secret change (not only a healthy one), so re-saving after a 401 doesn't show a stale error. - saveDebug reverts the optimistic toggle and alerts on persist failure. - deriveStepStatuses/buildStatusChecklist drop a dead always-true sub-condition. - StepSection gives a collapsed current/error step an explicit open affordance. - TSDoc added to the remaining internal helpers. tsc + vitest (60) + vite build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/config/StepSection.tsx | 10 ++ .../src/config/deriveStepStatuses.ts | 9 +- .../src/config/useBackupsConfig.ts | 115 +++++++++++++----- 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/automatic-environment-backups/src/config/StepSection.tsx b/automatic-environment-backups/src/config/StepSection.tsx index 7a3ce7ed..40ebee85 100644 --- a/automatic-environment-backups/src/config/StepSection.tsx +++ b/automatic-environment-backups/src/config/StepSection.tsx @@ -205,6 +205,16 @@ export const StepSection = ({
)} + + {!isDisabled && + !isExpanded && + (status === 'current' || status === 'error') && ( +
+ +
+ )} ); }; diff --git a/automatic-environment-backups/src/config/deriveStepStatuses.ts b/automatic-environment-backups/src/config/deriveStepStatuses.ts index bb98b8d2..e611e87d 100644 --- a/automatic-environment-backups/src/config/deriveStepStatuses.ts +++ b/automatic-environment-backups/src/config/deriveStepStatuses.ts @@ -35,8 +35,10 @@ export const deriveStepStatuses = (params: BackupsParameters): StepStatuses => { const urlSet = readDeploymentUrl(params) !== ''; const hasFailedPing = urlSet && !connected && readConnection(params)?.status === 'disconnected'; - const scheduleSet = - hasStoredBackupSchedule(params) && readEnabledCadences(params).length > 0; + // A stored schedule always carries at least one cadence (the save handler + // rejects an empty set and the normalizer defaults to daily+weekly), so + // presence alone is the completion signal. + const scheduleSet = hasStoredBackupSchedule(params); const secret: StepStatus = secretSet ? 'ok' : 'current'; @@ -95,8 +97,7 @@ export const buildStatusChecklist = ( const connected = isConnectionHealthy(params); const urlSet = readDeploymentUrl(params) !== ''; const enabledCadences = readEnabledCadences(params); - const scheduleSet = - hasStoredBackupSchedule(params) && enabledCadences.length > 0; + const scheduleSet = hasStoredBackupSchedule(params); const secretItem: ChecklistItem = secret === '' diff --git a/automatic-environment-backups/src/config/useBackupsConfig.ts b/automatic-environment-backups/src/config/useBackupsConfig.ts index 9c03f3d0..7725e236 100644 --- a/automatic-environment-backups/src/config/useBackupsConfig.ts +++ b/automatic-environment-backups/src/config/useBackupsConfig.ts @@ -34,6 +34,7 @@ import { generateAuthSecret } from './generateAuthSecret'; import { type BackupsParameters, getProjectTimezone, + hasStoredBackupSchedule, isConnectionHealthy, readAuthSecret, readConnection, @@ -46,14 +47,17 @@ const MISSING_AUTH_SECRET_MESSAGE = 'Enter Lambda auth secret before calling lambda endpoints.'; const BACKUP_NOW_AFTER_SAVE_RETRY_DELAY_MS = 1200; +/** Extract a human-readable message from an unknown thrown value. */ const getErrorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Unknown error'; +/** Resolve after `ms` milliseconds (used to space out backup-now retries). */ const delay = (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); }); +/** The plugin's id from ctx, or undefined when it is missing/blank. */ const getPluginIdFromCtx = (ctx: RenderConfigScreenCtx): string | undefined => { const candidate = (ctx.plugin as { id?: unknown } | undefined)?.id; return typeof candidate === 'string' && candidate.trim() @@ -129,18 +133,27 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { const persistQueueRef = useRef>(Promise.resolve()); const hasRunMountCheckRef = useRef(false); const isMountCheckUnmountedRef = useRef(false); + // Merge base for the persist queue. Accumulates every write so sequential + // persists compose, even without a CMA token to re-read authoritative params + // (the frozen mount closure would otherwise merge each write against the stale + // first-render params and silently drop the previous write). + const latestPersistedParamsRef = useRef>( + toPluginParameterRecord(params), + ); // Snapshot of the first-render params so the run-once mount effect always // validates against the values present at load, regardless of later persists. const initialMountRef = useRef<{ secret: string; url: string; + hasStoredSchedule: boolean; scheduleNormalization: ReturnType; } | null>(null); if (!initialMountRef.current) { initialMountRef.current = { secret: savedSecret, url: savedUrl, + hasStoredSchedule: hasStoredBackupSchedule(params), scheduleNormalization: normalizeBackupScheduleConfig({ value: params?.backupSchedule, timezoneFallback: projectTimezone, @@ -156,9 +169,9 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { const persistPluginParameters = useCallback( async (updates: Record) => { const persistTask = async () => { - let latestParameters = toPluginParameterRecord( - ctx.plugin.attributes.parameters, - ); + // Default merge base is the running accumulator so writes compose in + // order regardless of token availability. + let latestParameters = latestPersistedParamsRef.current; const pluginId = getPluginIdFromCtx(ctx); if (pluginId && ctx.currentUserAccessToken) { @@ -169,18 +182,19 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { baseUrl: ctx.cmaBaseUrl, }); const plugin = await client.plugins.find(pluginId); + // Authoritative read also picks up any external changes. latestParameters = toPluginParameterRecord(plugin.parameters); } catch (error) { debugLogger.warn( - 'Falling back to local plugin parameters because authoritative read failed', + 'Falling back to accumulated plugin parameters because authoritative read failed', { pluginId, error: getErrorMessage(error) }, ); } } - await ctx.updatePluginParameters( - mergePluginParameterUpdates(latestParameters, updates), - ); + const merged = mergePluginParameterUpdates(latestParameters, updates); + latestPersistedParamsRef.current = merged; + await ctx.updatePluginParameters(merged); }; const queuedPersist = persistQueueRef.current.then( @@ -469,13 +483,12 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { setUrlInput(verificationResult.normalizedBaseUrl); setConnectionTestError(null); + // Persist ONLY the connection result. The secret and URL are already + // saved; re-writing them from the frozen first-render snapshot would + // clobber a secret the user saves while this check is in flight. await persistPluginParameters({ - deploymentURL: verificationResult.normalizedBaseUrl, - netlifyURL: verificationResult.normalizedBaseUrl, - vercelURL: verificationResult.normalizedBaseUrl, lambdaConnection: connectedState, connectionValidationMode: 'health', - lambdaAuthSecret: secret, }); } catch (error) { if (isCancelled()) { @@ -514,7 +527,13 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { return; } - if (snapshot.scheduleNormalization.requiresMigration) { + // Only migrate a schedule that was actually stored in a legacy shape. + // A fresh install has no stored schedule, and persisting a default here + // would make step 3 auto-complete with cadences the user never chose. + if ( + snapshot.hasStoredSchedule && + snapshot.scheduleNormalization.requiresMigration + ) { try { await persistPluginParameters({ backupSchedule: snapshot.scheduleNormalization.config, @@ -632,12 +651,12 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { try { const previousSecret = readAuthSecret(params); const secretChanged = nextSecret !== previousSecret; - const wasConnected = isConnectionHealthy(params); const updates: Record = { lambdaAuthSecret: nextSecret }; - // Changing a secret invalidates any prior healthy connection: re-gate the - // Connect step so the user re-tests against the new secret. - if (secretChanged && wasConnected) { + // Any secret change invalidates the recorded connection state (healthy or + // failed): clear it so the Connect step re-gates and the user re-tests + // against the new secret, instead of showing a stale error/OK. + if (secretChanged) { updates.lambdaConnection = null; updates.connectionValidationMode = null; } @@ -674,6 +693,7 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { return; } + const hadHealthyConnection = isConnectionHealthy(params); setIsConnecting(true); setConnectionTestError(null); @@ -692,14 +712,16 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { ); // Persist the deployment URL triplet (legacy netlify/vercel keys kept in - // lockstep) together with the resulting connection state. + // lockstep) together with the resulting connection state. The secret is + // not re-written here — it is already saved, and re-persisting a value + // captured before this multi-second request risks clobbering a concurrent + // secret save. await persistPluginParameters({ deploymentURL: verificationResult.normalizedBaseUrl, netlifyURL: verificationResult.normalizedBaseUrl, vercelURL: verificationResult.normalizedBaseUrl, lambdaConnection: connectedState, connectionValidationMode: 'health', - lambdaAuthSecret: secret, }); setUrlInput(verificationResult.normalizedBaseUrl); @@ -707,6 +729,18 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { endpoint: verificationResult.endpoint, }); ctx.notice('Lambda function connected successfully.'); + + // If a schedule is already saved (e.g. reconnecting to a fresh + // deployment), create any missing backup environments now — matching the + // old connect behavior. Fresh installs have no stored schedule yet, so + // creation stays step 3's responsibility. + if (hasStoredBackupSchedule(params)) { + await ensureBackupsExistForCadences({ + baseUrl: verificationResult.normalizedBaseUrl, + lambdaAuthSecret: secret, + cadences: readEnabledCadences(params, projectTimezone), + }); + } } catch (error) { if (error instanceof LambdaHealthCheckError) { const disconnectedState = buildDisconnectedLambdaConnectionState( @@ -715,16 +749,26 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { 'config_connect', ); debugLogger.warn('Lambda health check failed during connect', error); - try { - await persistPluginParameters({ - deploymentURL: candidateUrl, - netlifyURL: candidateUrl, - vercelURL: candidateUrl, - lambdaConnection: disconnectedState, - connectionValidationMode: null, - }); - } catch { - // Ignore persistence errors while surfacing the error in the UI. + // Always surface the failure in the UI, independent of persistence. + setConnectionTestError({ + summary: error.message || 'Connection test failed.', + details: getLambdaConnectionErrorDetails(disconnectedState), + }); + // Never clobber a previously-healthy deployment on a failed test (e.g. + // a typo). Only persist the failing URL/state when there is no working + // connection to preserve, so the error stays sticky during setup. + if (!hadHealthyConnection) { + try { + await persistPluginParameters({ + deploymentURL: candidateUrl, + netlifyURL: candidateUrl, + vercelURL: candidateUrl, + lambdaConnection: disconnectedState, + connectionValidationMode: null, + }); + } catch { + // Error already surfaced via connectionTestError above. + } } } else { debugLogger.error('Unexpected error while connecting lambda', error); @@ -736,7 +780,15 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { } finally { setIsConnecting(false); } - }, [ctx, debugLogger, params, persistPluginParameters, urlInput]); + }, [ + ctx, + debugLogger, + ensureBackupsExistForCadences, + params, + persistPluginParameters, + projectTimezone, + urlInput, + ]); const disconnect = useCallback(async () => { setIsDisconnecting(true); @@ -857,10 +909,13 @@ export const useBackupsConfig = (ctx: RenderConfigScreenCtx) => { try { await persistPluginParameters({ debug: enabled }); } catch (error) { + // Revert the optimistic toggle and tell the user it did not stick. + setDebugEnabled(!enabled); debugLogger.error('Could not persist debug setting', error); + await ctx.alert('Could not save the debug setting.'); } }, - [debugLogger, persistPluginParameters], + [ctx, debugLogger, persistPluginParameters], ); const backupNow = useCallback( From 4835f9e41588acb0a644164454c98adb4e52b7e0 Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Tue, 30 Jun 2026 20:22:37 -0700 Subject: [PATCH 4/5] feat(automatic-backups): config wizard UI pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 (auth secret): - Regenerate is now an icon button beside the field (tooltip via native title + aria-label); Save and Copy are merged into one "Save and copy" primary button with an inline Spinner while saving. - The edit warning always offers a "Revert to saved?" link (new revertSecret), with the redeploy note shown when a healthy connection exists. - The deploy box no longer repeats the secret value and now reads: set DATOCMS_BACKUPS_SHARED_SECRET to the secret above, then continue with the deployed URL. - New hook handlers: saveAndCopySecret, revertSecret. Accordion / feedback: - Replaced the single expandedStep with a multi-open Set so [Edit] is additive and never collapses a step above the clicked one — fixes the scroll-jump CLS. Kept the custom StepSection (numbered card) over the SDK collapsible Section. - Added SDK Spinners with explicit "what we're waiting for" text to the Connect, Schedule, and Status-overview inline boxes (and per-cadence backup-now rows). New StepTimeline: horizontal progress stepper at the top with done/current/error /upcoming nodes, connectors, and an "All ok!" terminal state. Advanced settings: replaced the opaque "runs in Lambda cron mode only" note with a plain-language explanation of the scheduled-function backup flow (daily 02:05 UTC, clones the primary environment into sandbox backups). tsc + vitest (60) + vite build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/config/AdvancedSettings.tsx | 5 +- .../src/config/StatusOverview.tsx | 24 ++- .../src/config/StepConnect.tsx | 15 +- .../src/config/StepSchedule.tsx | 17 +- .../src/config/StepSecret.tsx | 144 ++++++++------ .../src/config/StepTimeline.tsx | 176 ++++++++++++++++++ .../src/config/useBackupsConfig.ts | 18 ++ .../src/entrypoints/ConfigScreen.tsx | 40 ++-- 8 files changed, 351 insertions(+), 88 deletions(-) create mode 100644 automatic-environment-backups/src/config/StepTimeline.tsx diff --git a/automatic-environment-backups/src/config/AdvancedSettings.tsx b/automatic-environment-backups/src/config/AdvancedSettings.tsx index 8d0c115e..b8e97782 100644 --- a/automatic-environment-backups/src/config/AdvancedSettings.tsx +++ b/automatic-environment-backups/src/config/AdvancedSettings.tsx @@ -45,7 +45,10 @@ export const AdvancedSettings = ({ fontSize: 'var(--font-size-xs)', }} > - This plugin runs in Lambda cron mode only. + Backups are created by the scheduled serverless function you deploy: + it runs once a day (02:05 UTC) and clones your primary environment + into sandbox backups. This plugin only configures and monitors that + function — nothing runs from your browser.

diff --git a/automatic-environment-backups/src/config/StatusOverview.tsx b/automatic-environment-backups/src/config/StatusOverview.tsx index e1158e28..1d2fbb5e 100644 --- a/automatic-environment-backups/src/config/StatusOverview.tsx +++ b/automatic-environment-backups/src/config/StatusOverview.tsx @@ -1,4 +1,4 @@ -import { Button } from 'datocms-react-ui'; +import { Button, Spinner } from 'datocms-react-ui'; import type { CSSProperties } from 'react'; import type { BackupOverviewRow } from '../types/types'; import { getCadenceLabel, normalizeBackupScheduleConfig } from '../utils/backupSchedule'; @@ -141,15 +141,18 @@ export const StatusOverview = ({ {isLoadingOverview && ( -

- Refreshing backup status… -

+ + + + Loading backup status… + + )} {overviewError && ( @@ -212,6 +215,7 @@ export const StatusOverview = ({ void backupNow(row.scope); }} disabled={isRowDisabled} + leftIcon={isRowLoading ? : undefined} > {isRowLoading ? 'Backing up…' : 'Backup now'} diff --git a/automatic-environment-backups/src/config/StepConnect.tsx b/automatic-environment-backups/src/config/StepConnect.tsx index 1acdf5d5..f8c8c130 100644 --- a/automatic-environment-backups/src/config/StepConnect.tsx +++ b/automatic-environment-backups/src/config/StepConnect.tsx @@ -1,8 +1,14 @@ -import { Button, TextField } from 'datocms-react-ui'; +import { Button, Spinner, TextField } from 'datocms-react-ui'; import { useState } from 'react'; import { StatusBox } from './StatusBox'; import type { BackupsConfig } from './useBackupsConfig'; +const spinnerRowStyle = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--spacing-s)', +} as const; + /** * Step 2 — point the plugin at the deployed function and verify it responds and * authenticates. The single action persists the URL triplet, runs the health @@ -72,7 +78,12 @@ export const StepConnect = ({ config }: { config: BackupsConfig }) => { {isTesting ? ( - Testing connection… + + + + Testing connection to {urlInput.trim() || 'your function'}… + + ) : connectionTestError ? ( {connectionTestError.details.length > 0 && ( diff --git a/automatic-environment-backups/src/config/StepSchedule.tsx b/automatic-environment-backups/src/config/StepSchedule.tsx index fd028b39..216f8dc9 100644 --- a/automatic-environment-backups/src/config/StepSchedule.tsx +++ b/automatic-environment-backups/src/config/StepSchedule.tsx @@ -1,4 +1,4 @@ -import { Button, SwitchField } from 'datocms-react-ui'; +import { Button, Spinner, SwitchField } from 'datocms-react-ui'; import type { CSSProperties } from 'react'; import { BACKUP_CADENCES, getCadenceLabel } from '../utils/backupSchedule'; import { StatusBox } from './StatusBox'; @@ -53,8 +53,19 @@ export const StepSchedule = ({ config }: { config: BackupsConfig }) => { - {progressMessage && ( - {progressMessage} + {isSavingSchedule && ( + + + + {progressMessage ?? 'Saving schedule and creating initial backups…'} + + )} ); diff --git a/automatic-environment-backups/src/config/StepSecret.tsx b/automatic-environment-backups/src/config/StepSecret.tsx index baf0b15c..646ac7d3 100644 --- a/automatic-environment-backups/src/config/StepSecret.tsx +++ b/automatic-environment-backups/src/config/StepSecret.tsx @@ -1,4 +1,4 @@ -import { Button, TextField } from 'datocms-react-ui'; +import { Button, Spinner, TextField } from 'datocms-react-ui'; import { type CSSProperties, type KeyboardEvent, @@ -15,6 +15,37 @@ import type { BackupsConfig } from './useBackupsConfig'; const DEPLOY_MENU_ID = 'deploy-provider-menu'; +/** Two-circular-arrows "regenerate" glyph (icons aren't shippable from the SDK). */ +const RegenerateIcon = () => ( + +); + +const linkButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + margin: 0, + color: 'inherit', + font: 'inherit', + fontWeight: 600, + textDecoration: 'underline', + cursor: 'pointer', +}; + const menuContainerStyle: CSSProperties = { position: 'relative', flex: '1 1 0', @@ -50,17 +81,18 @@ const menuOptionStyle: CSSProperties = { /** * Step 1 — create/rotate the shared auth secret and deploy the scheduler. The - * secret is generated (fresh install) or loaded as-is, saved explicitly, then a - * provider deploy menu + paste callout are revealed. Editing a saved secret - * while connected raises a redeploy warning. + * field carries an inline regenerate icon; one "Save and copy" action persists + * the secret and puts it on the clipboard for the deployment env var. Editing a + * saved secret raises a warning (with a "Revert to saved?" escape hatch), and a + * provider deploy menu + paste callout are revealed once a secret is saved. */ export const StepSecret = ({ config }: { config: BackupsConfig }) => { const { secretInput, setSecretInput, - saveSecret, + saveAndCopySecret, regenerateSecret, - copySecret, + revertSecret, isSavingSecret, savedSecret, isConnected, @@ -72,7 +104,6 @@ export const StepSecret = ({ config }: { config: BackupsConfig }) => { const hasSavedSecret = savedSecret.trim().length > 0; const trimmedInput = secretInput.trim(); const secretEdited = hasSavedSecret && trimmedInput !== savedSecret.trim(); - const showRedeployWarning = isConnected && secretEdited; useEffect(() => { if (!isMenuOpen) { @@ -113,76 +144,69 @@ export const StepSecret = ({ config }: { config: BackupsConfig }) => { return ( <> - - -
- - +
+
+ +
+ {/* Native title on the wrapping span carries the tooltip — the icon-only + Button has no text label. */} + + + +
+ +
- {showRedeployWarning && ( + {secretEdited && ( - Changing this means updating DATOCMS_BACKUPS_SHARED_SECRET{' '} - on your deployment and redeploying, or the connection will fail. + {isConnected ? ( + <> + Changing this means updating{' '} + DATOCMS_BACKUPS_SHARED_SECRET on your deployment and + redeploying, or the connection will fail.{' '} + + ) : ( + <>You’ve modified the saved secret. + )} + )} {hasSavedSecret && ( <> - Paste this value as DATOCMS_BACKUPS_SHARED_SECRET on your - provider, then come back with the deployed URL. - - {savedSecret} - + Deploy the scheduler to your provider, setting{' '} + DATOCMS_BACKUPS_SHARED_SECRET to the secret above. Once + you have the deployed URL, continue to the next step.
= { + ok: 'var(--color--success-soft--ink)', + current: 'var(--color--primary)', + error: 'var(--color--danger-soft--ink)', + disabled: 'var(--color--ink-subtle)', +}; + +/** Check glyph shown inside an `ok` node (icons aren't shippable from the SDK). */ +const CheckGlyph = () => ( + +); + +/** Cross glyph shown inside an `error` node. */ +const CrossGlyph = () => ( + +); + +type TimelineNode = { + key: string; + label: string; + status: StepStatus; + /** Number shown in a `disabled` setup node; the terminal node has none. */ + number?: number; +}; + +const CIRCLE_SIZE = 32; + +const NodeCircle = ({ status, number }: { status: StepStatus; number?: number }) => { + const color = NODE_COLOR[status]; + const isFilled = status === 'ok' || status === 'error'; + + const circleStyle: CSSProperties = { + boxSizing: 'border-box', + width: `${CIRCLE_SIZE}px`, + height: `${CIRCLE_SIZE}px`, + borderRadius: '999px', + border: `2px solid ${color}`, + background: isFilled ? color : 'transparent', + color: isFilled ? '#fff' : color, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 'var(--font-size-s)', + fontWeight: 600, + lineHeight: 1, + flex: '0 0 auto', + }; + + return ( +