diff --git a/automatic-environment-backups/AGENTS.md b/automatic-environment-backups/AGENTS.md new file mode 100644 index 00000000..1735fc2d --- /dev/null +++ b/automatic-environment-backups/AGENTS.md @@ -0,0 +1,49 @@ +# Automatic Environment Backups — agent notes + +The configuration screen is a gated four-step wizard. All of its UI and state +live under `src/config/`; `src/entrypoints/ConfigScreen.tsx` is a thin +orchestrator. + +## Architecture +- **Single source of truth:** the persisted plugin parameters + (`ctx.plugin.attributes.parameters`), read via the pure getters in + `src/config/pluginParams.ts`. Never keep a parallel React snapshot of saved + values — that desync is what made the plugin send the wrong auth token. +- **Per-step saves:** every user change is committed by an explicit step action + (`useBackupsConfig` handlers: `saveSecret` / `saveAndCopySecret`, + `saveAndTestConnection`, `saveSchedule`, `saveDebug`). There is no global Save + button. A save+act handler acts on the value it just persisted, never on + unsaved input. +- **Pure, unit-tested modules** (vitest, no DOM harness): `generateAuthSecret.ts` + (128-bit hex secret), `pluginParams.ts` (typed param getters), and + `deriveStepStatuses.ts` (step gating + the Status-overview checklist). Put new + logic behind pure functions and test it here; there is intentionally no + component/render test harness. +- **Orchestration hook** `useBackupsConfig.ts`: edit-state, a queued + authoritative-merge persister, the run-once mount health ping, overview/env + loaders, and `ensureBackupsExistForCadences` (sequential creation with a 409 + retry). +- **Components:** `StepSection` (numbered-card chrome), `StepSecret` / + `StepConnect` / `StepSchedule`, `StatusOverview`, `StatusBox`, + `AdvancedSettings`, and `StepTimeline` (top progress stepper). + +## Gotchas +- DatoCMS `ctx` gets a **new identity after every `updatePluginParameters`**, and + the app wraps in `` (dev double-invoke). The mount health ping must + run **once** via the `hasRunMountCheckRef` / `isMountCheckUnmountedRef` guard + with an empty-deps effect. An effect that persists params *and* depends on + ctx-derived callbacks will re-fire forever — that was a real infinite-loop bug. +- The accordion is **multi-open** (an open-step `Set`) on purpose: single-open + collapsed a section above the clicked one and caused a scroll-jump (CLS). +- The **persisted-parameter schema must stay backward-compatible**: + `deploymentURL` / `netlifyURL` / `vercelURL`, `lambdaConnection`, + `connectionValidationMode`, `lambdaAuthSecret`, `backupSchedule`, `debug`. +- The actual backup work runs in an **external scheduled function** (repo: + `marcelofinamorvieira/datocms-backups-scheduled-function`), `executionMode: + 'lambda_cron'`, daily at 02:05 UTC. This plugin only configures and monitors + it — nothing runs from the browser. + +## Validate +- `npm run test` (tsc + vitest) and `npm run build`. No lint script is wired + locally; the repo-root `biome.json` is the formatting baseline. +- Design spec: `docs/superpowers/specs/2026-06-30-config-wizard-design.md`. diff --git a/automatic-environment-backups/CHANGELOG.md b/automatic-environment-backups/CHANGELOG.md index 4c1c4ab8..8f53998c 100644 --- a/automatic-environment-backups/CHANGELOG.md +++ b/automatic-environment-backups/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- 0.7.0: Redesigned the configuration screen as a guided four-step wizard (auth secret & deploy → connect & test → backup cadence → status overview) with a top progress bar and per-step status/loading feedback. Saved plugin parameters are now the single source of truth, fixing an auth-secret desync that could make the plugin send the wrong/default token (HTTP 401). Fixed an infinite request loop on the config screen. Fresh installs auto-generate a strong shared secret, and editing it offers a "Revert to saved?" undo. Existing setups keep working with no migration. - 0.6.13: Fixed a crash when opening the Deploy lambda menu. - 0.6.8: Added the README preview image, moved the changelog into this file, and updated the plugin metadata to use the JPEG preview. - 0.6.7: README update. diff --git a/automatic-environment-backups/README.md b/automatic-environment-backups/README.md index 94dbf5d4..cc0aa6ed 100644 --- a/automatic-environment-backups/README.md +++ b/automatic-environment-backups/README.md @@ -6,42 +6,42 @@ Because DatoCMS does not have a built-in job scheduler, the plugin has to create The lambda function is only used as a job scheduler, similar to a cronjob. The lambda calls the DatoCMS Content Management API (CMA) to actually manage the environments and perform the backups. +![The Automatic Environment Backups configuration wizard](docs/config-wizard.png) + ## How it works +- The plugin's configuration screen is a guided, four-step wizard with a progress bar showing the state of each step. - Backup cadence is configured in the plugin (`daily`, `weekly`, `bi-weekly`, `monthly`). -- The deployed scheduler calls the backup endpoints. -- The plugin validates connectivity between the serverless functions using health checks against `/api/datocms/plugin-health`. -- The **Backup overview** lists each enabled cadence with its last run, next scheduled run, and the linked sandbox environment, plus a per-slot **Backup now** button for on-demand execution. -- The created backups are just forked sandboxes inside your DatoCMS project (named with a `backup-plugin-` prefix), NOT separate files on an external provider. The external providers are only used to provide scheduled lambda executions that call our API to create a scheduled backup. -- Source code for the deployed lambda function is at https://github.com/marcelofinamorvieira/datocms-backups-scheduled-function#deploying-on-cloudflare-workers (this lambda was written by Marcelo Finamor, a DatoCMS employee). +- The deployed scheduler runs once a day (02:05 UTC) and calls the backup endpoints. +- The plugin validates connectivity to the serverless function with a health check against `/api/datocms/plugin-health`, authenticated with a shared secret. +- The **Status overview** lists each enabled cadence with its last run, next scheduled run, and the linked sandbox environment, plus a per-cadence **Backup now** button for on-demand execution. +- The created backups are just forked sandboxes inside your DatoCMS project (named with a `backup-plugin-` prefix), NOT separate files on an external provider. The external providers are only used to run the scheduled function that calls the CMA to create a backup. +- Source code for the deployed lambda function is at https://github.com/marcelofinamorvieira/datocms-backups-scheduled-function (this lambda was written by Marcelo Finamor, a DatoCMS employee). ## Before you begin + - You will need an account with Vercel, Netlify, or Cloudflare that is capable of creating projects and adding serverless functions (lambdas). Usually the free plan will suffice. - In your DatoCMS project, you will have to create a new API token with access to the CMA and an admin role. In older DatoCMS projects, this may have been automatically created as a "Full Access API Token", but newer projects will require manual creation of a similar token. ## Setup -1. Make sure you've read the "Before you begin" section, above. -2. Install the plugin. -3. Open your DatoCMS project Configuration and find the Automatic Environment Backups plugin settings. -4. In the **Lambda setup** section, leave the Lambda URL blank for now (it will be used later). -5. Change the default `superSecretToken` lambda auth secret to something safer, preferably a pseudorandom string. -6. Click the **Deploy lambda** button and choose one of the provided options (Vercel, Netlify, or Cloudflare). -7. A new browser tab will open where you must finish the lambda setup on that provider. You'll have to provide the project CMA API token (`DATOCMS_FULLACCESS_API_TOKEN`) and the lambda auth secret (`DATOCMS_BACKUPS_SHARED_SECRET`) that you configured earlier. -8. Once the lambda is deployed on the external provider, find and copy its deployment domain, e.g. `https://my-backup-app.vercel.app/` (just the domain, no path needed). Make sure it is publicly accessible and not hidden behind a preview login gate. -9. Back in the plugin settings, paste that deployed URL into the **Lambda URL** field. -10. Click **Connect**, wait a few seconds, and confirm that the health check status is **Connected (ping successful)**. -11. Toggle on the backup cadences you want under **Backup schedule** and click **Save**. After saving, the plugin automatically creates any missing backup environments for the enabled cadences. +The configuration screen walks you through four steps. Completed steps collapse to a summary you can re-open with **Edit**; the progress bar at the top shows overall state and reads **All ok!** once everything is configured. + +1. Read the "Before you begin" section above, then install the plugin and open your DatoCMS project Configuration → Plugins → Automatic Environment Backups. +2. **Step 1 — Auth secret & deploy.** A strong shared secret is generated for you (use the regenerate icon to roll a new one). Click **Save and copy** to store it and copy it to your clipboard. Then click **Deploy to…** and pick a provider. On the provider, set the `DATOCMS_BACKUPS_SHARED_SECRET` environment variable to the secret you just copied, and `DATOCMS_FULLACCESS_API_TOKEN` to your CMA/admin token. Deploy, then copy the deployment's public URL (e.g. `https://my-backups.netlify.app`). +3. **Step 2 — Connect & test.** Paste the deployed URL into **Deployed function URL** and click **Save & test connection**. The status box confirms the function responds and authenticates, or shows the exact error (for example, an auth mismatch means the plugin's secret and the provider's `DATOCMS_BACKUPS_SHARED_SECRET` differ — make them match and redeploy). +4. **Step 3 — Backup cadence.** Toggle the cadences you want and click **Save & continue**. The plugin creates any missing backup environments for the enabled cadences. +5. **Status overview.** Once all three steps are green, the overview shows "Configured and ready", plus the last/next backup and linked environment for each cadence and a per-cadence **Backup now** button. You can leave the screen — backups run on their own. ## Managing the connection -- Use **Change Lambda URL** to point at a different deployment; the plugin re-runs the health ping against the new URL before persisting it. -- Use **Disconnect** to clear the saved Lambda URL. The cron schedule on the external provider keeps running until you remove the deployment there, but the plugin will no longer surface its status. -- Re-opening the configuration screen runs a health check against the saved URL automatically, so a stale or expired deployment is caught before you make any other changes. +- Re-open any completed step with its **Edit** button to change the secret, URL, or cadence. Each step re-validates and re-gates the later steps when needed — for example, changing the shared secret clears the connection so you re-test it (remember to update `DATOCMS_BACKUPS_SHARED_SECRET` on your deployment and redeploy). +- Use **Disconnect** in step 2 to clear the saved deployment URL. The cron schedule on the external provider keeps running until you remove the deployment there, but the plugin will no longer surface its status. +- Re-opening the configuration screen automatically re-runs a health check against the saved URL, so a broken or expired deployment is caught immediately and surfaced on the affected step (and in the Status overview). ## Advanced settings -- **Enable debug logs** — When enabled, plugin events and outbound lambda requests are logged to the browser console for troubleshooting. +- **Enable debug logs** — When enabled, plugin events and outbound requests are logged to the browser console for troubleshooting. ## Changelog diff --git a/automatic-environment-backups/docs/config-wizard.png b/automatic-environment-backups/docs/config-wizard.png new file mode 100644 index 00000000..0120aa54 Binary files /dev/null and b/automatic-environment-backups/docs/config-wizard.png differ 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. diff --git a/automatic-environment-backups/package-lock.json b/automatic-environment-backups/package-lock.json index d515c17c..22f8636a 100644 --- a/automatic-environment-backups/package-lock.json +++ b/automatic-environment-backups/package-lock.json @@ -1,12 +1,12 @@ { "name": "datocms-plugin-automatic-environment-backups", - "version": "0.6.13", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "datocms-plugin-automatic-environment-backups", - "version": "0.6.13", + "version": "0.7.0", "dependencies": { "@datocms/cma-client-browser": "^5.3.0", "@types/node": "^25.5.0", @@ -56,6 +56,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1429,6 +1430,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1450,6 +1452,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1682,6 +1685,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2391,6 +2395,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2443,6 +2448,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2452,6 +2458,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2819,6 +2826,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/automatic-environment-backups/package.json b/automatic-environment-backups/package.json index 98b8faf2..6a6ebab0 100644 --- a/automatic-environment-backups/package.json +++ b/automatic-environment-backups/package.json @@ -1,7 +1,7 @@ { "name": "datocms-plugin-automatic-environment-backups", "homepage": "https://github.com/datocms/plugins/tree/master/automatic-environment-backups#readme", - "version": "0.6.13", + "version": "0.7.0", "author": "DatoCMS ", "description": "A plugin that creates daily and weekly backups of your main environment on DatoCMS automatically.", "keywords": [ diff --git a/automatic-environment-backups/src/config/AdvancedSettings.tsx b/automatic-environment-backups/src/config/AdvancedSettings.tsx new file mode 100644 index 00000000..b8e97782 --- /dev/null +++ b/automatic-environment-backups/src/config/AdvancedSettings.tsx @@ -0,0 +1,56 @@ +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), + }} + > +
+ +
+

+ 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/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..1d2fbb5e --- /dev/null +++ b/automatic-environment-backups/src/config/StatusOverview.tsx @@ -0,0 +1,229 @@ +import { Button, Spinner } 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 && ( + + + + Loading 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..f8c8c130 --- /dev/null +++ b/automatic-environment-backups/src/config/StepConnect.tsx @@ -0,0 +1,126 @@ +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 + * 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 to {urlInput.trim() || 'your function'}… + + + ) : 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..216f8dc9 --- /dev/null +++ b/automatic-environment-backups/src/config/StepSchedule.tsx @@ -0,0 +1,72 @@ +import { Button, Spinner, 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)} + /> +
+ ))} +
+ +
+ +
+ + {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 new file mode 100644 index 00000000..646ac7d3 --- /dev/null +++ b/automatic-environment-backups/src/config/StepSecret.tsx @@ -0,0 +1,253 @@ +import { Button, Spinner, 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'; + +/** 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', +}; + +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 + * 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, + saveAndCopySecret, + regenerateSecret, + revertSecret, + 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(); + + 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 ( + <> +
+
+ +
+ {/* Native title on the wrapping span carries the tooltip — the icon-only + Button has no text label. */} + + + +
+ +
+ +
+ + {secretEdited && ( + + {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 && ( + <> + + 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. + + +
+ + + {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..40ebee85 --- /dev/null +++ b/automatic-environment-backups/src/config/StepSection.tsx @@ -0,0 +1,220 @@ +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} + + +
+ )} + + {!isDisabled && + !isExpanded && + (status === 'current' || status === 'error') && ( +
+ +
+ )} +
+ ); +}; diff --git a/automatic-environment-backups/src/config/StepTimeline.tsx b/automatic-environment-backups/src/config/StepTimeline.tsx new file mode 100644 index 00000000..850ad9ca --- /dev/null +++ b/automatic-environment-backups/src/config/StepTimeline.tsx @@ -0,0 +1,176 @@ +import { Fragment } from 'react'; +import type { CSSProperties } from 'react'; +import type { StepStatus, StepStatuses } from './deriveStepStatuses'; + +/** Node color per status, shared by the circle border/fill and its label ink. */ +const NODE_COLOR: Record = { + 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 ( +