@coywolf/emdash 0.0.0-stage → 0.35.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backups admin page and dashboard widget. Layout and components follow the
|
|
3
|
+
* EmDash admin's own list pages (Redirects, Bylines): Kumo controls, a bordered
|
|
4
|
+
* row list, and confirmation dialogs.
|
|
5
|
+
*/
|
|
6
|
+
import { Badge, Banner, Button, Dialog, DropdownMenu, Input, Loader, Switch } from "@cloudflare/kumo";
|
|
7
|
+
import {
|
|
8
|
+
ArrowCounterClockwise,
|
|
9
|
+
ClockCounterClockwise,
|
|
10
|
+
Database,
|
|
11
|
+
DotsThree,
|
|
12
|
+
DownloadSimple,
|
|
13
|
+
ImagesSquare,
|
|
14
|
+
Plus,
|
|
15
|
+
} from "@phosphor-icons/react";
|
|
16
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
|
|
19
|
+
import { SaveBar } from "./save-bar.js";
|
|
20
|
+
import { SettingsSection } from "./settings-ui.js";
|
|
21
|
+
|
|
22
|
+
const API = "/_emdash/api/plugins/coywolf-pack/backups";
|
|
23
|
+
|
|
24
|
+
interface Backup {
|
|
25
|
+
stamp: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
file: string;
|
|
28
|
+
bytes: number;
|
|
29
|
+
source?: string;
|
|
30
|
+
tables?: number;
|
|
31
|
+
rows?: number;
|
|
32
|
+
media?: { copied: number; preserved: number; total: number; pending?: number };
|
|
33
|
+
uploads?: { copied: number; preserved: number; total: number; pending?: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UndoPoint {
|
|
37
|
+
bookmark: string;
|
|
38
|
+
rewoundAt: string;
|
|
39
|
+
rewoundTo: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ListResponse {
|
|
43
|
+
items: Backup[];
|
|
44
|
+
scheduled: boolean;
|
|
45
|
+
retentionDays: number;
|
|
46
|
+
staleAfterHours: number;
|
|
47
|
+
restore: { enabled: true } | { enabled: false; reason: string };
|
|
48
|
+
undo: UndoPoint | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface NewDatabaseResult {
|
|
52
|
+
databaseId: string;
|
|
53
|
+
databaseName: string;
|
|
54
|
+
tables: number;
|
|
55
|
+
rows: number;
|
|
56
|
+
mismatches: Record<string, [number, number | null]>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
60
|
+
const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" });
|
|
61
|
+
const formatDate = (iso: string) => dateFormat.format(new Date(iso));
|
|
62
|
+
|
|
63
|
+
function formatBytes(bytes: number): string {
|
|
64
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
65
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
66
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ago(iso: string): string {
|
|
70
|
+
const hours = (Date.now() - new Date(iso).getTime()) / 3_600_000;
|
|
71
|
+
if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} min ago`;
|
|
72
|
+
if (hours < 48) return `${Math.round(hours)} h ago`;
|
|
73
|
+
return `${Math.round(hours / 24)} days ago`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sourceLabel(source?: string): string {
|
|
77
|
+
if (source === "admin") return "Manual";
|
|
78
|
+
if (source === "scheduled") return "Scheduled";
|
|
79
|
+
return "Nightly job";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function post<T>(path: string, body?: unknown): Promise<T> {
|
|
83
|
+
const response = await apiFetch(`${API}/${path}`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
|
86
|
+
});
|
|
87
|
+
return parseApiResponse<T>(response, "The request failed");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function useBackups() {
|
|
91
|
+
const [data, setData] = React.useState<ListResponse>();
|
|
92
|
+
const [error, setError] = React.useState<string>();
|
|
93
|
+
const load = React.useCallback(async () => {
|
|
94
|
+
setError(undefined);
|
|
95
|
+
try {
|
|
96
|
+
setData(await parseApiResponse<ListResponse>(await apiFetch(`${API}/list`), "Could not load backups"));
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
setError(errorText(cause, "Could not load backups"));
|
|
99
|
+
}
|
|
100
|
+
}, []);
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
void load();
|
|
103
|
+
}, [load]);
|
|
104
|
+
return { data, error, load };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isStale(data: ListResponse): boolean {
|
|
108
|
+
const latest = data.items[0];
|
|
109
|
+
return !latest || (Date.now() - new Date(latest.createdAt).getTime()) / 3_600_000 > data.staleAfterHours;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Download a backup's database dump. The server makes a short-lived link that
|
|
114
|
+
* streams the file from R2 (so any size works), served by the Coywolf Pack
|
|
115
|
+
* site middleware; the browser saves it like any other download.
|
|
116
|
+
*/
|
|
117
|
+
async function download(backup: Backup) {
|
|
118
|
+
const link = await post<{ url: string; filename: string }>("download-link", { stamp: backup.stamp, file: backup.file });
|
|
119
|
+
const check = await fetch(link.url, { method: "HEAD", credentials: "same-origin" }).catch(() => null);
|
|
120
|
+
if (!check?.ok) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"The download link didn't answer. Make sure coywolfPack() from @coywolf/emdash/middleware is in the site's src/middleware.ts (see Getting started in the Coywolf Pack README).",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const a = document.createElement("a");
|
|
126
|
+
a.href = link.url;
|
|
127
|
+
a.download = link.filename;
|
|
128
|
+
a.style.display = "none";
|
|
129
|
+
document.body.appendChild(a);
|
|
130
|
+
a.click();
|
|
131
|
+
a.remove();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Destructive confirmation that requires typing a word, styled like the admin's ConfirmDialog. */
|
|
135
|
+
function TypedConfirmDialog(props: {
|
|
136
|
+
open: boolean;
|
|
137
|
+
title: string;
|
|
138
|
+
word: string;
|
|
139
|
+
confirmLabel: string;
|
|
140
|
+
pendingLabel: string;
|
|
141
|
+
pending: boolean;
|
|
142
|
+
error?: string;
|
|
143
|
+
onClose: () => void;
|
|
144
|
+
onConfirm: () => void;
|
|
145
|
+
children: React.ReactNode;
|
|
146
|
+
}) {
|
|
147
|
+
const [typed, setTyped] = React.useState("");
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (props.open) setTyped("");
|
|
150
|
+
}, [props.open]);
|
|
151
|
+
return (
|
|
152
|
+
<Dialog.Root
|
|
153
|
+
role="alertdialog"
|
|
154
|
+
open={props.open}
|
|
155
|
+
onOpenChange={(next) => !next && !props.pending && props.onClose()}
|
|
156
|
+
>
|
|
157
|
+
<Dialog className="p-6" size="sm">
|
|
158
|
+
<Dialog.Title className="text-lg font-semibold">{props.title}</Dialog.Title>
|
|
159
|
+
<Dialog.Description className="mt-2 space-y-2 text-sm text-kumo-subtle">{props.children}</Dialog.Description>
|
|
160
|
+
<div className="mt-4">
|
|
161
|
+
<Input
|
|
162
|
+
label={`Type ${props.word} to confirm`}
|
|
163
|
+
value={typed}
|
|
164
|
+
autoComplete="off"
|
|
165
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTyped(e.target.value)}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
{props.error && (
|
|
169
|
+
<Banner variant="error" role="alert" className="mt-3" description={props.error} />
|
|
170
|
+
)}
|
|
171
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
172
|
+
<Button variant="secondary" disabled={props.pending} onClick={props.onClose}>
|
|
173
|
+
Cancel
|
|
174
|
+
</Button>
|
|
175
|
+
<Button variant="destructive" disabled={props.pending || typed !== props.word} onClick={props.onConfirm}>
|
|
176
|
+
{props.pending ? props.pendingLabel : props.confirmLabel}
|
|
177
|
+
</Button>
|
|
178
|
+
</div>
|
|
179
|
+
</Dialog>
|
|
180
|
+
</Dialog.Root>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Schedule and retention (stored as plugin settings; not on the generic Settings page). */
|
|
185
|
+
function ScheduleSettings(props: { data: ListResponse; onSaved: () => Promise<void> }) {
|
|
186
|
+
const initial = React.useCallback(
|
|
187
|
+
() => ({ scheduled: props.data.scheduled, retentionDays: String(props.data.retentionDays), staleAfterHours: String(props.data.staleAfterHours) }),
|
|
188
|
+
[props.data.scheduled, props.data.retentionDays, props.data.staleAfterHours],
|
|
189
|
+
);
|
|
190
|
+
const [draft, setDraft] = React.useState(initial);
|
|
191
|
+
React.useEffect(() => setDraft(initial()), [initial]);
|
|
192
|
+
const [pending, setPending] = React.useState(false);
|
|
193
|
+
const [status, setStatus] = React.useState<{ error: boolean; text: string }>();
|
|
194
|
+
const dirty =
|
|
195
|
+
draft.scheduled !== props.data.scheduled ||
|
|
196
|
+
draft.retentionDays !== String(props.data.retentionDays) ||
|
|
197
|
+
draft.staleAfterHours !== String(props.data.staleAfterHours);
|
|
198
|
+
|
|
199
|
+
const save = async () => {
|
|
200
|
+
setPending(true);
|
|
201
|
+
setStatus(undefined);
|
|
202
|
+
try {
|
|
203
|
+
await post("settings/save", {
|
|
204
|
+
scheduled: draft.scheduled,
|
|
205
|
+
retentionDays: Number(draft.retentionDays),
|
|
206
|
+
staleAfterHours: Number(draft.staleAfterHours),
|
|
207
|
+
});
|
|
208
|
+
await props.onSaved();
|
|
209
|
+
setStatus({ error: false, text: "Backup settings saved." });
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
setStatus({ error: true, text: errorText(cause, "Could not save the backup settings") });
|
|
212
|
+
} finally {
|
|
213
|
+
setPending(false);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
<form
|
|
220
|
+
id="cw-backups-settings-form"
|
|
221
|
+
onSubmit={(e) => {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
void save();
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<SettingsSection
|
|
227
|
+
id="backups-schedule"
|
|
228
|
+
title="Schedule and retention"
|
|
229
|
+
description="How often the site backs itself up, how long backups are kept, and when to warn that backups have stopped."
|
|
230
|
+
>
|
|
231
|
+
<Switch className="shrink-0"
|
|
232
|
+
label="Back up once a day from the site itself"
|
|
233
|
+
checked={draft.scheduled}
|
|
234
|
+
onCheckedChange={(checked: boolean) => setDraft((d) => ({ ...d, scheduled: checked }))}
|
|
235
|
+
/>
|
|
236
|
+
<p className="-mt-2 text-sm text-kumo-subtle">Leave this off if an external job (such as a GitHub Action) already backs up the site.</p>
|
|
237
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
238
|
+
<Input
|
|
239
|
+
type="number"
|
|
240
|
+
min={1}
|
|
241
|
+
max={365}
|
|
242
|
+
label="Keep backups for (days)"
|
|
243
|
+
description="Older database backups and replaced media and upload copies are deleted daily. The mirrors themselves are kept."
|
|
244
|
+
value={draft.retentionDays}
|
|
245
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft((d) => ({ ...d, retentionDays: e.target.value }))}
|
|
246
|
+
/>
|
|
247
|
+
<Input
|
|
248
|
+
type="number"
|
|
249
|
+
min={1}
|
|
250
|
+
max={720}
|
|
251
|
+
label="Warn when the newest backup is older than (hours)"
|
|
252
|
+
description="Shown on this page and the dashboard widget."
|
|
253
|
+
value={draft.staleAfterHours}
|
|
254
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDraft((d) => ({ ...d, staleAfterHours: e.target.value }))}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
<div aria-live="polite">
|
|
258
|
+
{status && (status.error ? <Banner variant="error" role="alert" description={status.text} /> : <p className="text-sm text-kumo-subtle">{status.text}</p>)}
|
|
259
|
+
</div>
|
|
260
|
+
</SettingsSection>
|
|
261
|
+
</form>
|
|
262
|
+
<SaveBar form="cw-backups-settings-form" dirty={dirty} saving={pending} onDiscard={() => setDraft(initial())} />
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type Action =
|
|
268
|
+
| { kind: "rewind"; backup: Backup }
|
|
269
|
+
| { kind: "restore-new"; backup: Backup }
|
|
270
|
+
| { kind: "undo"; undo: UndoPoint };
|
|
271
|
+
|
|
272
|
+
export function BackupsPage() {
|
|
273
|
+
const { data, error, load } = useBackups();
|
|
274
|
+
const [running, setRunning] = React.useState(false);
|
|
275
|
+
const [notice, setNotice] = React.useState<{ variant: "default" | "error"; title: string; description?: React.ReactNode }>();
|
|
276
|
+
const [action, setAction] = React.useState<Action>();
|
|
277
|
+
const [pending, setPending] = React.useState(false);
|
|
278
|
+
const [actionError, setActionError] = React.useState<string>();
|
|
279
|
+
const [mediaPending, setMediaPending] = React.useState(false);
|
|
280
|
+
|
|
281
|
+
const runBackup = async () => {
|
|
282
|
+
setRunning(true);
|
|
283
|
+
setNotice(undefined);
|
|
284
|
+
try {
|
|
285
|
+
const result = await post<Backup>("run");
|
|
286
|
+
setNotice({ variant: "default", title: `Backup saved (${formatBytes(result.bytes)}).` });
|
|
287
|
+
await load();
|
|
288
|
+
} catch (cause) {
|
|
289
|
+
setNotice({ variant: "error", title: "Backup failed", description: errorText(cause, "") });
|
|
290
|
+
} finally {
|
|
291
|
+
setRunning(false);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const restoreMedia = async () => {
|
|
296
|
+
setMediaPending(true);
|
|
297
|
+
setNotice(undefined);
|
|
298
|
+
try {
|
|
299
|
+
// The server copies a batch per request; keep asking until nothing is left.
|
|
300
|
+
let restored = 0;
|
|
301
|
+
for (;;) {
|
|
302
|
+
const result = await post<{ restored: number; checked: number; pending?: number }>("restore-media");
|
|
303
|
+
restored += result.restored;
|
|
304
|
+
if (!result.pending || !result.restored) break;
|
|
305
|
+
setNotice({ variant: "default", title: `Restoring files: ${restored} copied so far…` });
|
|
306
|
+
}
|
|
307
|
+
setNotice({
|
|
308
|
+
variant: "default",
|
|
309
|
+
title: restored ? `Restored ${restored} missing ${restored === 1 ? "file" : "files"}.` : "No files were missing.",
|
|
310
|
+
});
|
|
311
|
+
} catch (cause) {
|
|
312
|
+
setNotice({ variant: "error", title: "Media restore failed", description: errorText(cause, "") });
|
|
313
|
+
} finally {
|
|
314
|
+
setMediaPending(false);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const confirmAction = async () => {
|
|
319
|
+
if (!action) return;
|
|
320
|
+
setPending(true);
|
|
321
|
+
setActionError(undefined);
|
|
322
|
+
try {
|
|
323
|
+
if (action.kind === "rewind") {
|
|
324
|
+
await post("rewind", { stamp: action.backup.stamp, confirm: "REWIND" });
|
|
325
|
+
setNotice({
|
|
326
|
+
variant: "default",
|
|
327
|
+
title: `The database was rewound to ${formatDate(action.backup.createdAt)}.`,
|
|
328
|
+
description: "Reload any open admin tabs. Use Undo rewind below if this was a mistake.",
|
|
329
|
+
});
|
|
330
|
+
} else if (action.kind === "undo") {
|
|
331
|
+
await post("undo", { confirm: "UNDO" });
|
|
332
|
+
setNotice({ variant: "default", title: "The rewind was undone." });
|
|
333
|
+
} else {
|
|
334
|
+
const result = await post<NewDatabaseResult>("restore-new", { stamp: action.backup.stamp, confirm: "RESTORE" });
|
|
335
|
+
const mismatched = Object.keys(result.mismatches);
|
|
336
|
+
setNotice({
|
|
337
|
+
variant: mismatched.length ? "error" : "default",
|
|
338
|
+
title: mismatched.length
|
|
339
|
+
? `Restored to ${result.databaseName}, but ${mismatched.length} tables don't match the backup.`
|
|
340
|
+
: `Restored and verified ${result.rows.toLocaleString()} rows in ${result.tables} tables.`,
|
|
341
|
+
description: (
|
|
342
|
+
<div className="space-y-2">
|
|
343
|
+
<p>
|
|
344
|
+
The site still runs on its current database. To switch, set the <code>DB</code> binding in{" "}
|
|
345
|
+
<code>wrangler.jsonc</code> and deploy:
|
|
346
|
+
</p>
|
|
347
|
+
<pre className="overflow-x-auto rounded-md bg-kumo-tint p-3 text-xs">
|
|
348
|
+
{`"database_name": "${result.databaseName}",\n"database_id": "${result.databaseId}"`}
|
|
349
|
+
</pre>
|
|
350
|
+
<p>Keep the old database until you've checked the site.</p>
|
|
351
|
+
</div>
|
|
352
|
+
),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
setAction(undefined);
|
|
356
|
+
await load();
|
|
357
|
+
} catch (cause) {
|
|
358
|
+
setActionError(errorText(cause, "The restore failed"));
|
|
359
|
+
} finally {
|
|
360
|
+
setPending(false);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const latest = data?.items[0];
|
|
365
|
+
const restoreEnabled = data?.restore.enabled ?? false;
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<div className="space-y-6">
|
|
369
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
370
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
|
371
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Backups</h1>
|
|
372
|
+
<div className="flex shrink-0 justify-end gap-2">
|
|
373
|
+
<Button variant="primary" icon={<Plus />} onClick={() => void runBackup()} disabled={running}>
|
|
374
|
+
{running ? "Backing up…" : "Back up now"}
|
|
375
|
+
</Button>
|
|
376
|
+
</div>
|
|
377
|
+
<p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
|
|
378
|
+
Each backup is a full copy of the database (content, users, settings, menus, redirects) plus a mirror of the
|
|
379
|
+
media library and of private form uploads{data ? `, kept for ${data.retentionDays} days` : ""}. Theme code lives in your Git repository. Download
|
|
380
|
+
any backup to keep your own copy: it's a standard SQL file that restores into any D1 database.
|
|
381
|
+
</p>
|
|
382
|
+
</div>
|
|
383
|
+
</header>
|
|
384
|
+
|
|
385
|
+
{notice && (
|
|
386
|
+
<Banner
|
|
387
|
+
variant={notice.variant === "error" ? "error" : "default"}
|
|
388
|
+
role={notice.variant === "error" ? "alert" : "status"}
|
|
389
|
+
title={notice.title}
|
|
390
|
+
description={notice.description}
|
|
391
|
+
/>
|
|
392
|
+
)}
|
|
393
|
+
{error && <Banner variant="error" role="alert" title="Could not load backups" description={error} />}
|
|
394
|
+
{data && isStale(data) && (
|
|
395
|
+
<Banner
|
|
396
|
+
variant="alert"
|
|
397
|
+
role="alert"
|
|
398
|
+
title={latest ? `The newest backup is from ${ago(latest.createdAt)}.` : "There are no backups yet."}
|
|
399
|
+
description={
|
|
400
|
+
data.scheduled
|
|
401
|
+
? "Scheduled backups may not be running. Check the site's logs."
|
|
402
|
+
: "Turn on daily scheduled backups under Schedule and retention below, or check your external backup job."
|
|
403
|
+
}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
{data?.undo && (
|
|
407
|
+
<Banner
|
|
408
|
+
variant="default"
|
|
409
|
+
title={`The database was rewound to ${formatDate(new Date(`${data.undo.rewoundTo.slice(0, 13)}:${data.undo.rewoundTo.slice(13, 15)}:00Z`).toISOString())} on ${formatDate(data.undo.rewoundAt)}.`}
|
|
410
|
+
description={
|
|
411
|
+
<Button
|
|
412
|
+
variant="secondary"
|
|
413
|
+
size="sm"
|
|
414
|
+
className="mt-2"
|
|
415
|
+
icon={<ArrowCounterClockwise />}
|
|
416
|
+
disabled={!restoreEnabled}
|
|
417
|
+
onClick={() => setAction({ kind: "undo", undo: data.undo! })}
|
|
418
|
+
>
|
|
419
|
+
Undo rewind
|
|
420
|
+
</Button>
|
|
421
|
+
}
|
|
422
|
+
/>
|
|
423
|
+
)}
|
|
424
|
+
{data && !data.restore.enabled && (
|
|
425
|
+
<Banner variant="default" title="Restore isn't set up yet." description={`${data.restore.reason} See docs/backups.md in the Coywolf Pack repository.`} />
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{!data && !error ? (
|
|
429
|
+
<div className="py-12 text-center text-kumo-subtle">
|
|
430
|
+
<Loader />
|
|
431
|
+
</div>
|
|
432
|
+
) : data && data.items.length === 0 ? (
|
|
433
|
+
<div className="py-10 text-center text-kumo-subtle">
|
|
434
|
+
<Database size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
|
|
435
|
+
<p className="text-base font-medium">No backups yet</p>
|
|
436
|
+
<p className="mt-1 text-sm">Back up now, or turn on daily backups under Schedule and retention below.</p>
|
|
437
|
+
</div>
|
|
438
|
+
) : data ? (
|
|
439
|
+
<div className="rounded-lg border">
|
|
440
|
+
<div className="flex items-center gap-4 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
|
|
441
|
+
<div className="flex-1">Backup</div>
|
|
442
|
+
<div className="hidden w-32 sm:block">Type</div>
|
|
443
|
+
<div className="w-20 text-end">Size</div>
|
|
444
|
+
<div className="hidden flex-1 md:block">Contents</div>
|
|
445
|
+
<div className="w-10" />
|
|
446
|
+
</div>
|
|
447
|
+
{data.items.map((backup) => (
|
|
448
|
+
<div key={backup.stamp} className="flex items-center gap-4 border-b px-4 py-2 text-sm last:border-0">
|
|
449
|
+
<div className="min-w-0 flex-1">
|
|
450
|
+
<div className="truncate">{formatDate(backup.createdAt)}</div>
|
|
451
|
+
<div className="text-xs text-kumo-subtle">{ago(backup.createdAt)}</div>
|
|
452
|
+
</div>
|
|
453
|
+
<div className="hidden w-32 sm:block">
|
|
454
|
+
<Badge variant={backup.source === "admin" ? "secondary" : "outline"}>{sourceLabel(backup.source)}</Badge>
|
|
455
|
+
</div>
|
|
456
|
+
<div className="w-20 text-end tabular-nums">{formatBytes(backup.bytes)}</div>
|
|
457
|
+
<div className="hidden min-w-0 flex-1 truncate text-kumo-subtle md:block">
|
|
458
|
+
{backup.rows !== undefined ? `${backup.rows.toLocaleString()} rows, ${backup.tables} tables` : "Full database"}
|
|
459
|
+
{backup.media ? ` · ${backup.media.total} media files${backup.media.pending ? ` (${backup.media.pending} still copying)` : ""}` : ""}
|
|
460
|
+
{backup.uploads ? ` · ${backup.uploads.total} form uploads${backup.uploads.pending ? ` (${backup.uploads.pending} still copying)` : ""}` : ""}
|
|
461
|
+
</div>
|
|
462
|
+
<div className="flex w-10 justify-end">
|
|
463
|
+
<DropdownMenu>
|
|
464
|
+
<DropdownMenu.Trigger
|
|
465
|
+
render={
|
|
466
|
+
<Button
|
|
467
|
+
type="button"
|
|
468
|
+
variant="ghost"
|
|
469
|
+
shape="square"
|
|
470
|
+
icon={<DotsThree aria-hidden="true" />}
|
|
471
|
+
aria-label={`Actions for the ${formatDate(backup.createdAt)} backup`}
|
|
472
|
+
/>
|
|
473
|
+
}
|
|
474
|
+
/>
|
|
475
|
+
<DropdownMenu.Content className="p-1">
|
|
476
|
+
<DropdownMenu.Item
|
|
477
|
+
className="py-1 data-highlighted:bg-kumo-fill"
|
|
478
|
+
icon={<DownloadSimple className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
479
|
+
onClick={() =>
|
|
480
|
+
void download(backup).catch((cause) =>
|
|
481
|
+
setNotice({ variant: "error", title: "Download failed", description: errorText(cause, "") }),
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
>
|
|
485
|
+
Download (.sql.gz)
|
|
486
|
+
</DropdownMenu.Item>
|
|
487
|
+
<DropdownMenu.Separator className="my-0.5" />
|
|
488
|
+
<DropdownMenu.Item
|
|
489
|
+
className="py-1 data-highlighted:bg-kumo-fill"
|
|
490
|
+
disabled={!restoreEnabled}
|
|
491
|
+
icon={<ClockCounterClockwise className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
492
|
+
onClick={() => {
|
|
493
|
+
setActionError(undefined);
|
|
494
|
+
setAction({ kind: "rewind", backup });
|
|
495
|
+
}}
|
|
496
|
+
>
|
|
497
|
+
Rewind to this backup…
|
|
498
|
+
</DropdownMenu.Item>
|
|
499
|
+
<DropdownMenu.Item
|
|
500
|
+
className="py-1 data-highlighted:bg-kumo-fill"
|
|
501
|
+
disabled={!restoreEnabled}
|
|
502
|
+
icon={<Database className="me-1.5 size-3.5" aria-hidden="true" />}
|
|
503
|
+
onClick={() => {
|
|
504
|
+
setActionError(undefined);
|
|
505
|
+
setAction({ kind: "restore-new", backup });
|
|
506
|
+
}}
|
|
507
|
+
>
|
|
508
|
+
Restore to a new database…
|
|
509
|
+
</DropdownMenu.Item>
|
|
510
|
+
</DropdownMenu.Content>
|
|
511
|
+
</DropdownMenu>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
))}
|
|
515
|
+
</div>
|
|
516
|
+
) : null}
|
|
517
|
+
|
|
518
|
+
{data && data.items.length > 0 && (
|
|
519
|
+
<section className="flex flex-wrap items-center justify-between gap-4 rounded-lg border p-4">
|
|
520
|
+
<div>
|
|
521
|
+
<h2 className="text-base font-semibold">Media library</h2>
|
|
522
|
+
<p className="text-sm text-kumo-subtle">
|
|
523
|
+
Copy back any media files and private form uploads that are missing. Existing files are never overwritten.
|
|
524
|
+
</p>
|
|
525
|
+
</div>
|
|
526
|
+
<Button variant="secondary" icon={<ImagesSquare />} disabled={mediaPending} onClick={() => void restoreMedia()}>
|
|
527
|
+
{mediaPending ? "Restoring…" : "Restore missing media"}
|
|
528
|
+
</Button>
|
|
529
|
+
</section>
|
|
530
|
+
)}
|
|
531
|
+
|
|
532
|
+
{data && <ScheduleSettings data={data} onSaved={load} />}
|
|
533
|
+
|
|
534
|
+
<TypedConfirmDialog
|
|
535
|
+
open={action?.kind === "rewind"}
|
|
536
|
+
title="Rewind the database?"
|
|
537
|
+
word="REWIND"
|
|
538
|
+
confirmLabel="Rewind database"
|
|
539
|
+
pendingLabel="Rewinding…"
|
|
540
|
+
pending={pending}
|
|
541
|
+
error={actionError}
|
|
542
|
+
onClose={() => setAction(undefined)}
|
|
543
|
+
onConfirm={() => void confirmAction()}
|
|
544
|
+
>
|
|
545
|
+
{action?.kind === "rewind" && (
|
|
546
|
+
<>
|
|
547
|
+
<p>
|
|
548
|
+
Everything in the database returns to {formatDate(action.backup.createdAt)}: content, users, settings,
|
|
549
|
+
menus, and redirects. Changes made since then are undone.
|
|
550
|
+
</p>
|
|
551
|
+
<p>
|
|
552
|
+
An undo point is saved first, so you can reverse this. Media files aren't changed. Anyone who signed in
|
|
553
|
+
after that time may need to sign in again.
|
|
554
|
+
</p>
|
|
555
|
+
</>
|
|
556
|
+
)}
|
|
557
|
+
</TypedConfirmDialog>
|
|
558
|
+
|
|
559
|
+
<TypedConfirmDialog
|
|
560
|
+
open={action?.kind === "restore-new"}
|
|
561
|
+
title="Restore to a new database?"
|
|
562
|
+
word="RESTORE"
|
|
563
|
+
confirmLabel="Create and restore"
|
|
564
|
+
pendingLabel="Restoring…"
|
|
565
|
+
pending={pending}
|
|
566
|
+
error={actionError}
|
|
567
|
+
onClose={() => setAction(undefined)}
|
|
568
|
+
onConfirm={() => void confirmAction()}
|
|
569
|
+
>
|
|
570
|
+
{action?.kind === "restore-new" && (
|
|
571
|
+
<>
|
|
572
|
+
<p>
|
|
573
|
+
This creates a new D1 database from the {formatDate(action.backup.createdAt)} backup and checks it row by
|
|
574
|
+
row. The live site isn't touched.
|
|
575
|
+
</p>
|
|
576
|
+
<p>Use this when the database is gone or the backup is older than Time Travel's 30-day window.</p>
|
|
577
|
+
</>
|
|
578
|
+
)}
|
|
579
|
+
</TypedConfirmDialog>
|
|
580
|
+
|
|
581
|
+
<TypedConfirmDialog
|
|
582
|
+
open={action?.kind === "undo"}
|
|
583
|
+
title="Undo the rewind?"
|
|
584
|
+
word="UNDO"
|
|
585
|
+
confirmLabel="Undo rewind"
|
|
586
|
+
pendingLabel="Undoing…"
|
|
587
|
+
pending={pending}
|
|
588
|
+
error={actionError}
|
|
589
|
+
onClose={() => setAction(undefined)}
|
|
590
|
+
onConfirm={() => void confirmAction()}
|
|
591
|
+
>
|
|
592
|
+
<p>The database returns to the moment just before the rewind. Changes made since the rewind are undone.</p>
|
|
593
|
+
</TypedConfirmDialog>
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function BackupStatusWidget() {
|
|
599
|
+
const { data, error } = useBackups();
|
|
600
|
+
if (error) return <p className="text-sm text-kumo-danger">{error}</p>;
|
|
601
|
+
if (!data) return <Loader />;
|
|
602
|
+
const latest = data.items[0];
|
|
603
|
+
if (!latest) return <p className="text-sm text-kumo-danger">No backups yet.</p>;
|
|
604
|
+
return (
|
|
605
|
+
<div className="space-y-1 text-sm">
|
|
606
|
+
<p className={isStale(data) ? "font-medium text-kumo-danger" : "font-medium"}>Last backup {ago(latest.createdAt)}</p>
|
|
607
|
+
<p className="text-kumo-subtle">
|
|
608
|
+
{sourceLabel(latest.source)} · {data.items.length} kept
|
|
609
|
+
</p>
|
|
610
|
+
</div>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|