diff --git a/.changeset/self-host-rebuild-on-publish.md b/.changeset/self-host-rebuild-on-publish.md new file mode 100644 index 00000000..1d836717 --- /dev/null +++ b/.changeset/self-host-rebuild-on-publish.md @@ -0,0 +1,15 @@ +--- +"@cloudhal/cli": patch +"@cloudhal/api": patch +"@cloudhal/core-ts": patch +"@cloudhal/mcp-server": patch +--- + +A self-hosted site rebuilds itself on publish (#150). With `CLOUDHAL_SITE_BUILD=on`, which is the Docker image's default, `cloudhal studio` runs the image's `site-build` in a child process after every publish: a publish in Studio or through MCP, an approval, a rollback, or a release that goes live (at its time or through "Publish now"). It also runs it once at start. + +- **One build per burst.** A burst of publishes builds once (3 seconds after the last one, at most a minute after the first). +- **One build at a time.** Studio's builds, a cron `site-build` and a hand-started one share one lock. +- **Failures keep the last good site.** A failed build keeps the previous site and is tried again on the next publish. +- **Bounded.** A build has a deadline (`CLOUDHAL_SITE_BUILD_TIMEOUT`, 30 minutes by default). Stopping Studio during a build ends it within 5 seconds, with everything it started, and records it as stopped. + +Every build is recorded in the site's Activity as "Build public site", with its duration, snapshot and output on failure. The site's overview shows a new "Public site build" card with the same details, and what visitors see now from `/data/site/current.json`, which `site-build` writes after each swap, so builds from cron or by hand show there too. The failure's output is shown only to roles with `audit:read`. `CLOUDHAL_SITE_BUILD=off` turns both off for sites built elsewhere, and a plain `cloudhal studio` keeps it off. For other integrations, `ScheduledTickOptions.onReleasePublished` (core-ts) reports releases that went live, `ReleaseServiceOptions.onReleasePublished` (and the API's `SchedulesDeps.onReleasePublished`) reports a release published now, `openLocalStore` takes `onPublished`, and `GET /api/sites/:id/site-build` reports the queue, the last build and what is served. diff --git a/apps/studio/src/components/Audit.tsx b/apps/studio/src/components/Audit.tsx index 081e01ba..cf94b4a0 100644 --- a/apps/studio/src/components/Audit.tsx +++ b/apps/studio/src/components/Audit.tsx @@ -11,16 +11,7 @@ import { import { Robot } from "@phosphor-icons/react"; import { useCallback } from "react"; import { useT } from "../i18n/index.tsx"; -import { - ASSET_EVENTS, - isAssetEvent, - isKeyEvent, - isKnownTool, - isMemberEvent, - KEY_EVENTS, - MEMBER_EVENTS, - requestedByAgent, -} from "../lib/activity.ts"; +import { eventLabelKey, isKnownTool, requestedByAgent } from "../lib/activity.ts"; import { isAutoApproved } from "../lib/agents.ts"; import type { AgentMode, @@ -35,21 +26,17 @@ import { RoleBadge } from "./Roles.tsx"; /** * Human-readable label for an MCP tool name ("update_page" → "Update page") or a person's action - * on an agent key ("agent-key.elevate" → "Elevate agent key"); unknown tools pass through. + * on an agent key ("agent-key.elevate" → "Elevate agent key"), or a system event ("site.build"); + * unknown tools pass through. */ export function useToolLabel() { const t = useT(); return useCallback( - (tool: string) => - isKnownTool(tool) - ? t(`tools.${tool}`) - : isKeyEvent(tool) - ? t(`tools.${KEY_EVENTS[tool]}`) - : isMemberEvent(tool) - ? t(`tools.${MEMBER_EVENTS[tool]}`) - : isAssetEvent(tool) - ? t(`tools.${ASSET_EVENTS[tool]}`) - : tool, + (tool: string) => { + if (isKnownTool(tool)) return t(`tools.${tool}`); + const key = eventLabelKey(tool); + return key ? t(`tools.${key}`) : tool; + }, [t], ); } diff --git a/apps/studio/src/components/site-overview/SiteBuildCard.tsx b/apps/studio/src/components/site-overview/SiteBuildCard.tsx new file mode 100644 index 00000000..eda4282e --- /dev/null +++ b/apps/studio/src/components/site-overview/SiteBuildCard.tsx @@ -0,0 +1,185 @@ +import { Badge, Banner, Code, InlineCopyText, SectionCard } from "@cloudhal/ui"; +import { Globe, WarningCircle } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useT } from "../../i18n/index.tsx"; +import { ApiRequestError, api, type SiteBuildStatus } from "../../lib/api.ts"; +import { RelativeTime } from "../RelativeTime.tsx"; +import { StatRow } from "./SiteStatusCard.tsx"; + +/** While a build is queued or running the card follows it; otherwise it looks now and then. */ +export const POLL_BUSY_MS = 3_000; +export const POLL_IDLE_MS = 30_000; + +type Enabled = Extract; + +/** An answer that will not change by asking again: a 4xx (403, 404, …), not a timeout or 429. */ +function isPermanent(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + error.status >= 400 && + error.status < 500 && + error.status !== 408 && + error.status !== 429 + ); +} + +/** + * How often to look again: never once the server said the feature is off or refused for good (a + * 4xx); after a network error or a 5xx, at the last pace (idle when nothing came yet). + */ +export function siteBuildPoll(data: SiteBuildStatus | undefined, error: unknown): number | false { + if (isPermanent(error) || data?.enabled === false) return false; + return data?.enabled && data.state !== "idle" ? POLL_BUSY_MS : POLL_IDLE_MS; +} + +/** + * Whether Studio's last build is what visitors see: by build id when both sides name one (a run + * that failed after its swap still went live), else by time (a build that went live within a + * minute of its end). + */ +function lastIsServed(s: Enabled): boolean | undefined { + const { last, served } = s; + if (!last || !served) return undefined; + if (last.build && served.build) return last.build === served.build; + if (!last.ok) return false; + const end = Date.parse(last.at) + (last.durationMs ?? 0); + return Date.parse(served.at) <= end + 60_000; +} + +/** What the card shows: what is online, and whether Studio's last build failed after it. */ +export function siteBuildView(s: Enabled) { + const { last, served } = s; + const live = lastIsServed(s); + const lastEnd = last ? Date.parse(last.at) + (last.durationMs ?? 0) : Number.NaN; + // Studio's failed build is current unless it went live after all (same build id), or another + // build went live after it (a cron or hand-started one: another id, or a later time) + const replaced = + !!served && + !!last && + (last.build && served.build ? served.build !== last.build : Date.parse(served.at) > lastEnd); + const failure = last && !last.ok && !live && !replaced ? last : undefined; + // the server says what is online (`served`); an older one only knows Studio's last build + const online = + served !== undefined ? served : last?.ok ? { at: last.at, ...pick(last) } : undefined; + // a failure: when it ran; otherwise when what is online went live + const when = failure ? failure.at : (served?.at ?? last?.at); + // the duration is Studio's build's, shown for the failure or while that build is online + const durationMs = failure || (last && (live ?? last.ok)) ? last?.durationMs : undefined; + return { + failure, + online, + when, + durationMs, + built: !failure && (!!last || !!served), + }; +} + +function pick(l: { snapshotId?: string; state?: "published" | "not-published" }) { + return { + ...(l.snapshotId ? { snapshotId: l.snapshotId } : {}), + ...(l.state ? { state: l.state } : {}), + }; +} + +/** + * The overview's "Public site build" card (#150): on a self-hosted server that rebuilds its static + * site after every publish, whether a build is running or queued, when the site was last built, + * what visitors see now (the published snapshot, or the coming soon page before the first + * publish) whoever built it, and why Studio's last build failed — saying whether a previous + * version stays online or nothing is online yet. Absent elsewhere. + */ +export function SiteBuildCard({ siteId }: { siteId: string }) { + const t = useT(); + const status = useQuery({ + queryKey: ["site-build", siteId], + queryFn: () => api.siteBuild.status(siteId), + retry: false, + refetchInterval: (q) => siteBuildPoll(q.state.data, q.state.error), + }); + const s = status.data; + if (!s?.enabled) return null; + const v = siteBuildView(s); + return ( + } + description={t("overview.siteBuild.description")} + actions={} + > +
+ {v.failure && ( + } + title={t("overview.siteBuild.failedTitle")} + description={ + v.online === null + ? t("overview.siteBuild.failedNothingOnline") + : t("overview.siteBuild.failedDescription") + } + /> + )} + {v.when && ( +
+ } + /> + {v.durationMs !== undefined && ( + + )} + {v.online?.state === "not-published" && ( + + )} + {v.online?.snapshotId && ( + {v.online.snapshotId}} + /> + )} +
+ )} + {v.failure?.error && } +
+
+ ); +} + +function StateBadge(p: { state: Enabled["state"]; failed: boolean; built: boolean }) { + const t = useT(); + if (p.state === "running") + return ( + + {t("overview.siteBuild.building")} + + ); + if (p.state === "queued") + return ( + + {t("overview.siteBuild.queued")} + + ); + if (p.failed) + return ( + + {t("overview.siteBuild.failed")} + + ); + return p.built ? ( + + {t("overview.siteBuild.built")} + + ) : ( + + {t("overview.siteBuild.never")} + + ); +} diff --git a/apps/studio/src/components/site-overview/SiteStatusCard.tsx b/apps/studio/src/components/site-overview/SiteStatusCard.tsx index a6760777..d55b6ef7 100644 --- a/apps/studio/src/components/site-overview/SiteStatusCard.tsx +++ b/apps/studio/src/components/site-overview/SiteStatusCard.tsx @@ -75,7 +75,8 @@ export function SiteStatusCard({ ); } -function StatRow({ label, value }: { label: string; value: ReactNode }) { +/** One label and value of an overview card's list (`
`). */ +export function StatRow({ label, value }: { label: string; value: ReactNode }) { return (
diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index aeaeb0be..d2e56ad7 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 44d1f7038905a304 +// hash: 4bc35bf389a3fc55 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -264,6 +264,24 @@ export const messages: Messages = { recentEmpty: "لم يُكتب شيء بعد", viewAll: "اعرض الكل", cli: "من الطرفية", + siteBuild: { + title: "بناء الموقع العام", + description: "يُعاد بناؤه بعد كل نشر وموافقة وتراجع وإصدار مجدول.", + building: "قيد البناء", + queued: "في انتظار البناء", + built: "تم البناء", + failed: "فشل", + never: "لم يُبنَ بعد", + lastBuild: "آخر بناء", + duration: "المدة", + seconds: "{seconds, number} ث", + output: "يرى الزوار", + statusPage: "صفحة «قريبًا»", + snapshot: "اللقطة", + failedTitle: "فشل آخر بناء", + failedDescription: "تبقى النسخة السابقة متاحة على الإنترنت. سيحاول النشر التالي مرة أخرى.", + failedNothingOnline: "لا يوجد شيء متاح على الإنترنت بعد. سيحاول النشر التالي مرة أخرى.", + }, }, pageStatus: { publishTitle: "هل تريد نشر «{title}»؟", @@ -1692,6 +1710,7 @@ export const messages: Messages = { assetDelete: "حذف ملف", assetPurge: "إزالة ملف نهائيًا", assetLiftPurge: "السماح بملف مجددًا", + siteBuild: "بناء الموقع العام", list_sites: "اسرد المواقع", get_site: "احصل على موقع", list_pages: "اسرد الصفحات", diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index 3c76b4b6..d1fe5be2 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 6137f81aef7c3570 +// hash: fee1e4da7ca5d8d9 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -269,6 +269,27 @@ export const messages: Messages = { recentEmpty: "Noch nichts geschrieben", viewAll: "Alle anzeigen", cli: "Im Terminal", + siteBuild: { + title: "Build der öffentlichen Website", + description: + "Wird nach jeder Veröffentlichung, Freigabe, Rücknahme und geplanten Release neu gebaut.", + building: "Wird gebaut", + queued: "Wartet auf den Build", + built: "Gebaut", + failed: "Fehlgeschlagen", + never: "Noch nicht gebaut", + lastBuild: "Letzter Build", + duration: "Dauer", + seconds: "{seconds, number} s", + output: "Besucher sehen", + statusPage: "Die Demnächst-Seite", + snapshot: "Snapshot", + failedTitle: "Der letzte Build ist fehlgeschlagen", + failedDescription: + "Die vorherige Version bleibt online. Die nächste Veröffentlichung versucht es erneut.", + failedNothingOnline: + "Noch ist nichts online. Die nächste Veröffentlichung versucht es erneut.", + }, }, pageStatus: { publishTitle: "„{title}“ veröffentlichen?", @@ -1775,6 +1796,7 @@ export const messages: Messages = { assetDelete: "Datei löschen", assetPurge: "Datei entfernen", assetLiftPurge: "Datei wieder zulassen", + siteBuild: "Öffentliche Website bauen", list_sites: "Websites auflisten", get_site: "Website abrufen", list_pages: "Seiten auflisten", diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index 7e38934a..0b9eac02 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -265,6 +265,25 @@ export const en = { recentEmpty: "Nothing written yet", viewAll: "View all", cli: "From the terminal", + /** The self-hosted server's static site, rebuilt after every publish (#150). */ + siteBuild: { + title: "Public site build", + description: "Rebuilt after every publish, approval, rollback and scheduled release.", + building: "Building", + queued: "Waiting to build", + built: "Built", + failed: "Failed", + never: "Not built yet", + lastBuild: "Last build", + duration: "Duration", + seconds: "{seconds, number} s", + output: "Visitors see", + statusPage: "The coming soon page", + snapshot: "Snapshot", + failedTitle: "The last build failed", + failedDescription: "The previous version stays online. The next publish tries again.", + failedNothingOnline: "Nothing is online yet. The next publish tries again.", + }, }, pageStatus: { publishTitle: "Publish “{title}”?", @@ -1658,6 +1677,7 @@ export const en = { assetDelete: "Delete file", assetPurge: "Take down file", assetLiftPurge: "Allow file again", + siteBuild: "Build public site", list_sites: "List sites", get_site: "Get site", list_pages: "List pages", diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index f2d36f25..c20c5cec 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a07985c5a97d4088 +// hash: 02de14595f0d2be5 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -269,6 +269,27 @@ export const messages: Messages = { recentEmpty: "Aún no hay nada escrito", viewAll: "Ver todo", cli: "Desde la terminal", + siteBuild: { + title: "Compilación del sitio público", + description: + "Se vuelve a compilar tras cada publicación, aprobación, reversión y release programado.", + building: "Compilando", + queued: "Esperando para compilar", + built: "Compilado", + failed: "Falló", + never: "Aún sin compilar", + lastBuild: "Última compilación", + duration: "Duración", + seconds: "{seconds, number} s", + output: "Los visitantes ven", + statusPage: "La página Próximamente", + snapshot: "Instantánea", + failedTitle: "La última compilación falló", + failedDescription: + "La versión anterior sigue en línea. La próxima publicación lo volverá a intentar.", + failedNothingOnline: + "Todavía no hay nada en línea. La próxima publicación lo volverá a intentar.", + }, }, pageStatus: { publishTitle: "¿Publicar «{title}»?", @@ -1757,6 +1778,7 @@ export const messages: Messages = { assetDelete: "Eliminar archivo", assetPurge: "Dar de baja archivo", assetLiftPurge: "Volver a permitir archivo", + siteBuild: "Compilar el sitio público", list_sites: "Listar sitios", get_site: "Obtener sitio", list_pages: "Listar páginas", diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index 7ee34941..0a642776 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 33720bb9f0e7fd0a +// hash: c28b24eec4031b20 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -267,6 +267,24 @@ export const messages: Messages = { recentEmpty: "هنوز چیزی نوشته نشده", viewAll: "مشاهدهٔ همه", cli: "از ترمینال", + siteBuild: { + title: "ساخت سایت عمومی", + description: "پس از هر انتشار، تأیید، بازگردانی و انتشار زمان‌بندی‌شده دوباره ساخته می‌شود.", + building: "در حال ساخت", + queued: "در انتظار ساخت", + built: "ساخته شد", + failed: "ناموفق", + never: "هنوز ساخته نشده", + lastBuild: "آخرین ساخت", + duration: "مدت", + seconds: "{seconds, number} ثانیه", + output: "بازدیدکنندگان می‌بینند", + statusPage: "صفحهٔ «به‌زودی»", + snapshot: "اسنپ‌شات", + failedTitle: "آخرین ساخت ناموفق بود", + failedDescription: "نسخهٔ قبلی آنلاین می‌ماند. انتشار بعدی دوباره تلاش می‌کند.", + failedNothingOnline: "هنوز چیزی آنلاین نیست. انتشار بعدی دوباره تلاش می‌کند.", + }, }, pageStatus: { publishTitle: "«{title}» منتشر شود؟", @@ -1731,6 +1749,7 @@ export const messages: Messages = { assetDelete: "حذف فایل", assetPurge: "برداشتن فایل", assetLiftPurge: "مجاز کردن دوبارهٔ فایل", + siteBuild: "ساخت سایت عمومی", list_sites: "فهرست سایت‌ها", get_site: "دریافت سایت", list_pages: "فهرست صفحه‌ها", diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index ddb99b34..74f7d47e 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 6f0fd7cd3765dd5e +// hash: 8943434737bb6f26 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -271,6 +271,26 @@ export const messages: Messages = { recentEmpty: "Rien d’écrit pour l’instant", viewAll: "Tout voir", cli: "Depuis le terminal", + siteBuild: { + title: "Build du site public", + description: + "Reconstruit après chaque publication, approbation, retour arrière et release planifiée.", + building: "Build en cours", + queued: "En attente du build", + built: "Construit", + failed: "Échec", + never: "Pas encore construit", + lastBuild: "Dernier build", + duration: "Durée", + seconds: "{seconds, number} s", + output: "Les visiteurs voient", + statusPage: "La page Bientôt disponible", + snapshot: "Instantané", + failedTitle: "Le dernier build a échoué", + failedDescription: + "La version précédente reste en ligne. La prochaine publication réessaiera.", + failedNothingOnline: "Rien n’est encore en ligne. La prochaine publication réessaiera.", + }, }, pageStatus: { publishTitle: "Publier « {title} » ?", @@ -1788,6 +1808,7 @@ export const messages: Messages = { assetDelete: "Supprimer un fichier", assetPurge: "Supprimer définitivement un fichier", assetLiftPurge: "Autoriser de nouveau un fichier", + siteBuild: "Construire le site public", list_sites: "Lister les sites", get_site: "Obtenir un site", list_pages: "Lister les pages", diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index e718e1a2..25358e20 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -562,6 +562,7 @@ export interface MessageParams { "oauthConsent.who.sendsBack": { host: string | number }; "oauthConsent.who.thisComputerApp": { scheme: string | number }; "overview.commentsWaiting": { count: number }; + "overview.siteBuild.seconds": { seconds: number }; "pageStatus.leftOut": { count: number }; "pageStatus.publishTitle": { title: string | number }; "passwords.advice.empty": { min: string | number }; diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index 4764ac2a..9f29b75e 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 66ad78b266ca3c48 +// hash: 5cbc390fb4a824fe import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -264,6 +264,24 @@ export const messages: Messages = { recentEmpty: "עדיין לא נכתב כלום", viewAll: "הצגת הכול", cli: "מהטרמינל", + siteBuild: { + title: "בניית האתר הציבורי", + description: "נבנה מחדש אחרי כל פרסום, אישור, החזרה לאחור ו־Release מתוזמן.", + building: "בבנייה", + queued: "ממתין לבנייה", + built: "נבנה", + failed: "נכשל", + never: "עדיין לא נבנה", + lastBuild: "הבנייה האחרונה", + duration: "משך", + seconds: "{seconds, number} שנ׳", + output: "המבקרים רואים", + statusPage: "דף ה־„בקרוב”", + snapshot: "תמונת מצב", + failedTitle: "הבנייה האחרונה נכשלה", + failedDescription: "הגרסה הקודמת נשארת באוויר. הפרסום הבא ינסה שוב.", + failedNothingOnline: "עדיין אין שום דבר באוויר. הפרסום הבא ינסה שוב.", + }, }, pageStatus: { publishTitle: "לפרסם את „{title}”?", @@ -1691,6 +1709,7 @@ export const messages: Messages = { assetDelete: "מחיקת קובץ", assetPurge: "הסרת קובץ לצמיתות", assetLiftPurge: "אישור קובץ מחדש", + siteBuild: "בניית האתר הציבורי", list_sites: "הצגת אתרים", get_site: "קבלת אתר", list_pages: "הצגת עמודים", diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index a68c8c4d..3646aca6 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4b54c6481ccc7a4f +// hash: 8604071d7fdc7c3f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -264,6 +264,24 @@ export const messages: Messages = { recentEmpty: "अभी कुछ नहीं लिखा गया", viewAll: "सभी देखें", cli: "टर्मिनल से", + siteBuild: { + title: "सार्वजनिक साइट बिल्ड", + description: "हर प्रकाशन, स्वीकृति, रोलबैक और शेड्यूल किए गए रिलीज़ के बाद फिर से बनती है।", + building: "बन रही है", + queued: "बनने की प्रतीक्षा में", + built: "बन गई", + failed: "विफल", + never: "अभी नहीं बनी", + lastBuild: "पिछला बिल्ड", + duration: "अवधि", + seconds: "{seconds, number} से.", + output: "आगंतुक देखते हैं", + statusPage: "“जल्द आ रहा है” पेज", + snapshot: "स्नैपशॉट", + failedTitle: "पिछला बिल्ड विफल रहा", + failedDescription: "पिछला संस्करण ऑनलाइन रहता है। अगला प्रकाशन फिर से कोशिश करेगा।", + failedNothingOnline: "अभी तक कुछ भी ऑनलाइन नहीं है। अगला प्रकाशन फिर से कोशिश करेगा।", + }, }, pageStatus: { publishTitle: "“{title}” प्रकाशित करें?", @@ -1686,6 +1704,7 @@ export const messages: Messages = { assetDelete: "फ़ाइल हटाएँ", assetPurge: "फ़ाइल स्थायी रूप से हटाएँ", assetLiftPurge: "फ़ाइल की फिर से अनुमति दें", + siteBuild: "सार्वजनिक साइट बनाएँ", list_sites: "साइटों की सूची", get_site: "साइट प्राप्त करें", list_pages: "पेजों की सूची", diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index aebc9a47..8a114b39 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 84c3e70911fa8af6 +// hash: 55b0c339c99af085 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -267,6 +267,25 @@ export const messages: Messages = { recentEmpty: "Belum ada yang ditulis", viewAll: "Lihat semua", cli: "Dari terminal", + siteBuild: { + title: "Build situs publik", + description: + "Dibangun ulang setelah setiap penerbitan, persetujuan, rollback, dan rilis terjadwal.", + building: "Sedang dibangun", + queued: "Menunggu dibangun", + built: "Selesai dibangun", + failed: "Gagal", + never: "Belum dibangun", + lastBuild: "Build terakhir", + duration: "Durasi", + seconds: "{seconds, number} dtk", + output: "Pengunjung melihat", + statusPage: "Halaman Segera hadir", + snapshot: "Snapshot", + failedTitle: "Build terakhir gagal", + failedDescription: "Versi sebelumnya tetap online. Penerbitan berikutnya akan mencoba lagi.", + failedNothingOnline: "Belum ada yang online. Penerbitan berikutnya akan mencoba lagi.", + }, }, pageStatus: { publishTitle: "Terbitkan “{title}”?", @@ -1755,6 +1774,7 @@ export const messages: Messages = { assetDelete: "Hapus file", assetPurge: "Turunkan file", assetLiftPurge: "Izinkan file lagi", + siteBuild: "Bangun situs publik", list_sites: "Daftar situs", get_site: "Ambil situs", list_pages: "Daftar halaman", diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index cbed3630..e3a6ced4 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 66f1cb9fa0ba33f5 +// hash: 4d5f1a6f6148b105 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -268,6 +268,26 @@ export const messages: Messages = { recentEmpty: "Ancora niente di scritto", viewAll: "Vedi tutti", cli: "Dal terminale", + siteBuild: { + title: "Build del sito pubblico", + description: + "Ricostruito dopo ogni pubblicazione, approvazione, rollback e release programmata.", + building: "In costruzione", + queued: "In attesa di build", + built: "Costruito", + failed: "Non riuscito", + never: "Non ancora costruito", + lastBuild: "Ultima build", + duration: "Durata", + seconds: "{seconds, number} s", + output: "I visitatori vedono", + statusPage: "La pagina Prossimamente", + snapshot: "Snapshot", + failedTitle: "L'ultima build non è riuscita", + failedDescription: + "La versione precedente resta online. La prossima pubblicazione riproverà.", + failedNothingOnline: "Non c’è ancora niente online. La prossima pubblicazione riproverà.", + }, }, pageStatus: { publishTitle: "Pubblicare «{title}»?", @@ -1752,6 +1772,7 @@ export const messages: Messages = { assetDelete: "Elimina file", assetPurge: "Rimuovi file", assetLiftPurge: "Consenti di nuovo il file", + siteBuild: "Costruisci il sito pubblico", list_sites: "Elenca siti", get_site: "Ottieni sito", list_pages: "Elenca pagine", diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index 92341534..175843e7 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: b9d24833e6a1c099 +// hash: ab88e7a796f77f4f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -266,6 +266,24 @@ export const messages: Messages = { recentEmpty: "まだ何も書かれていません", viewAll: "すべて表示", cli: "ターミナルから", + siteBuild: { + title: "公開サイトのビルド", + description: "公開、承認、ロールバック、予約リリースのたびに再ビルドされます。", + building: "ビルド中", + queued: "ビルド待ち", + built: "ビルド済み", + failed: "失敗", + never: "まだビルドされていません", + lastBuild: "前回のビルド", + duration: "所要時間", + seconds: "{seconds, number} 秒", + output: "訪問者に表示される内容", + statusPage: "「近日公開」ページ", + snapshot: "スナップショット", + failedTitle: "前回のビルドが失敗しました", + failedDescription: "以前のバージョンは公開されたままです。次回の公開時に再試行します。", + failedNothingOnline: "まだ何も公開されていません。次回の公開時に再試行します。", + }, }, pageStatus: { publishTitle: "「{title}」を公開しますか?", @@ -1739,6 +1757,7 @@ export const messages: Messages = { assetDelete: "ファイルを削除", assetPurge: "ファイルを取り下げ", assetLiftPurge: "ファイルを再び許可", + siteBuild: "公開サイトをビルド", list_sites: "サイトを一覧", get_site: "サイトを取得", list_pages: "ページを一覧", diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index 7c1358ab..9e6e4d16 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 018c86583b4fae9d +// hash: db81fb254381a9a4 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -264,6 +264,24 @@ export const messages: Messages = { recentEmpty: "아직 작성된 내용이 없어요", viewAll: "모두 보기", cli: "터미널에서", + siteBuild: { + title: "공개 사이트 빌드", + description: "게시, 승인, 롤백, 예약된 릴리스가 있을 때마다 다시 빌드돼요.", + building: "빌드 중", + queued: "빌드 대기 중", + built: "빌드됨", + failed: "실패", + never: "아직 빌드 안 됨", + lastBuild: "마지막 빌드", + duration: "소요 시간", + seconds: "{seconds, number}초", + output: "방문자에게 보이는 것", + statusPage: "‘곧 공개됩니다’ 페이지", + snapshot: "스냅샷", + failedTitle: "마지막 빌드가 실패했어요", + failedDescription: "이전 버전이 계속 온라인에 있어요. 다음 게시 때 다시 시도해요.", + failedNothingOnline: "아직 온라인에 있는 것이 없어요. 다음 게시 때 다시 시도해요.", + }, }, pageStatus: { publishTitle: "“{title}”을(를) 게시할까요?", @@ -1699,6 +1717,7 @@ export const messages: Messages = { assetDelete: "파일 삭제", assetPurge: "파일 게시 중단", assetLiftPurge: "파일 다시 허용", + siteBuild: "공개 사이트 빌드", list_sites: "사이트 목록", get_site: "사이트 가져오기", list_pages: "페이지 목록", diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index 6dbf0c5b..2f1f5466 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 6358f358e5e2e9bf +// hash: 7062660c894714b7 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -269,6 +269,27 @@ export const messages: Messages = { recentEmpty: "Nog niets geschreven", viewAll: "Alles bekijken", cli: "Vanuit de terminal", + siteBuild: { + title: "Build van de openbare site", + description: + "Opnieuw gebouwd na elke publicatie, goedkeuring, terugdraaiing en geplande release.", + building: "Bezig met bouwen", + queued: "Wacht op build", + built: "Gebouwd", + failed: "Mislukt", + never: "Nog niet gebouwd", + lastBuild: "Laatste build", + duration: "Duur", + seconds: "{seconds, number} s", + output: "Bezoekers zien", + statusPage: "De pagina Binnenkort", + snapshot: "Snapshot", + failedTitle: "De laatste build is mislukt", + failedDescription: + "De vorige versie blijft online. De volgende publicatie probeert het opnieuw.", + failedNothingOnline: + "Er staat nog niets online. De volgende publicatie probeert het opnieuw.", + }, }, pageStatus: { publishTitle: "‘{title}’ publiceren?", @@ -1755,6 +1776,7 @@ export const messages: Messages = { assetDelete: "Bestand verwijderen", assetPurge: "Bestand definitief verwijderen", assetLiftPurge: "Bestand weer toestaan", + siteBuild: "Openbare site bouwen", list_sites: "Sites weergeven", get_site: "Site ophalen", list_pages: "Pagina’s weergeven", diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index 61790531..e688841c 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 92b591cb8233cfb6 +// hash: beccb53cc7a749b5 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -266,6 +266,26 @@ export const messages: Messages = { recentEmpty: "Jeszcze nic nie napisano", viewAll: "Zobacz wszystkie", cli: "Z terminala", + siteBuild: { + title: "Budowanie strony publicznej", + description: + "Budowana od nowa po każdej publikacji, zatwierdzeniu, wycofaniu i zaplanowanym wydaniu.", + building: "Trwa budowanie", + queued: "Czeka na budowanie", + built: "Zbudowano", + failed: "Nie powiodło się", + never: "Jeszcze nie zbudowano", + lastBuild: "Ostatnie budowanie", + duration: "Czas trwania", + seconds: "{seconds, number} s", + output: "Odwiedzający widzą", + statusPage: "Stronę „Już wkrótce”", + snapshot: "Snapshot", + failedTitle: "Ostatnie budowanie nie powiodło się", + failedDescription: + "Poprzednia wersja pozostaje online. Następna publikacja spróbuje ponownie.", + failedNothingOnline: "Nic nie jest jeszcze online. Następna publikacja spróbuje ponownie.", + }, }, pageStatus: { publishTitle: "Opublikować „{title}”?", @@ -1736,6 +1756,7 @@ export const messages: Messages = { assetDelete: "Usunięcie pliku", assetPurge: "Usunięcie pliku na żądanie", assetLiftPurge: "Ponowne zezwolenie na plik", + siteBuild: "Zbuduj stronę publiczną", list_sites: "Lista witryn", get_site: "Odczyt witryny", list_pages: "Lista stron", diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index 0ef9f897..28b69d96 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: cc0bf81b7d135b50 +// hash: 95d49b5f53cc4a65 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -269,6 +269,25 @@ export const messages: Messages = { recentEmpty: "Nada escrito ainda", viewAll: "Ver tudo", cli: "Pelo terminal", + siteBuild: { + title: "Build do site público", + description: "Gerado de novo após cada publicação, aprovação, reversão e release agendado.", + building: "Gerando", + queued: "Aguardando o build", + built: "Gerado", + failed: "Falhou", + never: "Ainda não gerado", + lastBuild: "Último build", + duration: "Duração", + seconds: "{seconds, number} s", + output: "Os visitantes veem", + statusPage: "A página Em breve", + snapshot: "Snapshot", + failedTitle: "O último build falhou", + failedDescription: + "A versão anterior continua no ar. A próxima publicação vai tentar de novo.", + failedNothingOnline: "Ainda não há nada no ar. A próxima publicação vai tentar de novo.", + }, }, pageStatus: { publishTitle: "Publicar “{title}”?", @@ -1739,6 +1758,7 @@ export const messages: Messages = { assetDelete: "Excluir arquivo", assetPurge: "Retirar arquivo do ar", assetLiftPurge: "Permitir arquivo de novo", + siteBuild: "Gerar o site público", list_sites: "Listar sites", get_site: "Obter site", list_pages: "Listar páginas", diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index c77a297b..2691f4c4 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 14637c0642468649 +// hash: 0c0bafbd64bc3732 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -269,6 +269,26 @@ export const messages: Messages = { recentEmpty: "Пока ничего не написано", viewAll: "Показать все", cli: "Из терминала", + siteBuild: { + title: "Сборка публичного сайта", + description: + "Пересобирается после каждой публикации, одобрения, отката и запланированного релиза.", + building: "Идёт сборка", + queued: "Ожидает сборки", + built: "Собран", + failed: "Ошибка", + never: "Ещё не собран", + lastBuild: "Последняя сборка", + duration: "Длительность", + seconds: "{seconds, number} с", + output: "Посетители видят", + statusPage: "Страницу «Скоро»", + snapshot: "Снимок", + failedTitle: "Последняя сборка не удалась", + failedDescription: + "Предыдущая версия остаётся на сайте. Следующая публикация попробует снова.", + failedNothingOnline: "На сайте пока ничего нет. Следующая публикация попробует снова.", + }, }, pageStatus: { publishTitle: "Опубликовать «{title}»?", @@ -1763,6 +1783,7 @@ export const messages: Messages = { assetDelete: "Удаление файла", assetPurge: "Изъятие файла", assetLiftPurge: "Повторное разрешение файла", + siteBuild: "Собрать публичный сайт", list_sites: "Список сайтов", get_site: "Получить сайт", list_pages: "Список страниц", diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 55d87ec6..9a71dba7 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9463aa68810503b7 +// hash: f749f4c874aec237 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -266,6 +266,24 @@ export const messages: Messages = { recentEmpty: "Henüz bir şey yazılmadı", viewAll: "Tümünü gör", cli: "Terminalden", + siteBuild: { + title: "Herkese açık site derlemesi", + description: "Her yayın, onay, geri alma ve zamanlanmış release'ten sonra yeniden derlenir.", + building: "Derleniyor", + queued: "Derlenmeyi bekliyor", + built: "Derlendi", + failed: "Başarısız", + never: "Henüz derlenmedi", + lastBuild: "Son derleme", + duration: "Süre", + seconds: "{seconds, number} sn", + output: "Ziyaretçiler şunu görüyor", + statusPage: "Çok yakında sayfası", + snapshot: "Snapshot", + failedTitle: "Son derleme başarısız oldu", + failedDescription: "Önceki sürüm yayında kalır. Bir sonraki yayın yeniden dener.", + failedNothingOnline: "Henüz yayında hiçbir şey yok. Bir sonraki yayın yeniden dener.", + }, }, pageStatus: { publishTitle: "“{title}” yayınlansın mı?", @@ -1737,6 +1755,7 @@ export const messages: Messages = { assetDelete: "Dosyayı sil", assetPurge: "Dosyayı yayından kaldır", assetLiftPurge: "Dosyaya yeniden izin ver", + siteBuild: "Herkese açık siteyi derle", list_sites: "Siteleri listele", get_site: "Siteyi oku", list_pages: "Sayfaları listele", diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index b40d8f99..31bc6e08 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5e8a598de3725cb2 +// hash: 98d0c3037d5bd41f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -268,6 +268,25 @@ export const messages: Messages = { recentEmpty: "Поки нічого не написано", viewAll: "Переглянути всі", cli: "З термінала", + siteBuild: { + title: "Збирання публічного сайту", + description: + "Перезбирається після кожної публікації, схвалення, відкату й запланованого релізу.", + building: "Триває збирання", + queued: "Очікує збирання", + built: "Зібрано", + failed: "Помилка", + never: "Ще не зібрано", + lastBuild: "Останнє збирання", + duration: "Тривалість", + seconds: "{seconds, number} с", + output: "Відвідувачі бачать", + statusPage: "Сторінку «Незабаром»", + snapshot: "Знімок", + failedTitle: "Останнє збирання не вдалося", + failedDescription: "Попередня версія лишається на сайті. Наступна публікація спробує знову.", + failedNothingOnline: "На сайті поки нічого немає. Наступна публікація спробує знову.", + }, }, pageStatus: { publishTitle: "Опублікувати «{title}»?", @@ -1745,6 +1764,7 @@ export const messages: Messages = { assetDelete: "Видалити файл", assetPurge: "Вилучити файл", assetLiftPurge: "Знову дозволити файл", + siteBuild: "Зібрати публічний сайт", list_sites: "Список сайтів", get_site: "Отримати сайт", list_pages: "Список сторінок", diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index 96b6f36f..bbcc03b5 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 434bc2a6d650eed7 +// hash: 4149d141a4eae8ec import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -260,6 +260,24 @@ export const messages: Messages = { recentEmpty: "还没有写任何内容", viewAll: "查看全部", cli: "从终端", + siteBuild: { + title: "公开网站构建", + description: "每次发布、批准、回滚和定时发布后都会重新构建。", + building: "正在构建", + queued: "等待构建", + built: "已构建", + failed: "失败", + never: "尚未构建", + lastBuild: "上次构建", + duration: "耗时", + seconds: "{seconds, number} 秒", + output: "访客看到的是", + statusPage: "“即将上线”页面", + snapshot: "快照", + failedTitle: "上次构建失败", + failedDescription: "之前的版本仍保持在线。下次发布时会重试。", + failedNothingOnline: "目前还没有任何内容在线。下次发布时会重试。", + }, }, pageStatus: { publishTitle: "发布“{title}”?", @@ -1636,6 +1654,7 @@ export const messages: Messages = { assetDelete: "删除文件", assetPurge: "下架文件", assetLiftPurge: "重新允许文件", + siteBuild: "构建公开网站", list_sites: "列出站点", get_site: "获取站点", list_pages: "列出页面", diff --git a/apps/studio/src/lib/activity.ts b/apps/studio/src/lib/activity.ts index c7824a85..b17841c0 100644 --- a/apps/studio/src/lib/activity.ts +++ b/apps/studio/src/lib/activity.ts @@ -148,6 +148,27 @@ export function isAssetEvent(tool: string): tool is AssetEvent { return Object.hasOwn(ASSET_EVENTS, tool); } +/** + * What the deployment does by itself and records in the site's audit log, with their label key + * under `tools`: the self-hosted server's rebuild of the static site on publish (#150). + */ +export const SYSTEM_EVENTS = { + "site.build": "siteBuild", +} as const; +export type SystemEvent = keyof typeof SYSTEM_EVENTS; + +/** Every REST and system event with a label, by audit `tool`, and its key under `tools`. */ +const EVENT_LABELS = { ...KEY_EVENTS, ...MEMBER_EVENTS, ...ASSET_EVENTS, ...SYSTEM_EVENTS }; + +/** The label key under `tools` of a REST or system event, or undefined for an MCP tool. */ +export function eventLabelKey( + tool: string, +): (typeof EVENT_LABELS)[keyof typeof EVENT_LABELS] | undefined { + return Object.hasOwn(EVENT_LABELS, tool) + ? EVENT_LABELS[tool as keyof typeof EVENT_LABELS] + : undefined; +} + /** An elevation grant's audit reason ends with `requested by the agent (appr_…)` (ADR-0016). */ const REQUESTED_BY_AGENT = /requested by the agent \((appr_[A-Za-z0-9_-]+)\)\s*$/; diff --git a/apps/studio/src/lib/api.ts b/apps/studio/src/lib/api.ts index 16d96329..78b2c994 100644 --- a/apps/studio/src/lib/api.ts +++ b/apps/studio/src/lib/api.ts @@ -172,6 +172,7 @@ export type { } from "./api/schedules-types.ts"; export { SCHEDULE_NOTIFICATION_KINDS } from "./api/schedules-types.ts"; export type { SiteSearchHit, SiteSearchKind, SiteSearchResult } from "./api/search.ts"; +export type { SiteBuildLast, SiteBuildServed, SiteBuildStatus } from "./api/site-build.ts"; export type { ArchiveReason, EntrySummary, @@ -227,6 +228,7 @@ import { menusApi } from "./api/menus.ts"; import { notificationsApi } from "./api/notifications.ts"; import { projectsApi } from "./api/projects.ts"; import { searchApi } from "./api/search.ts"; +import { siteBuildApi } from "./api/site-build.ts"; import { taxonomyApi } from "./api/taxonomy.ts"; import { teamApi } from "./api/team.ts"; import { translationsApi } from "./api/translations.ts"; @@ -237,6 +239,7 @@ export const api = { meOnSite: teamApi.meOnSite, instance: teamApi.instance, scheduler: teamApi.scheduler, + siteBuild: siteBuildApi, members: teamApi.members, invites: teamApi.invites, passwordReset: teamApi.passwordReset, diff --git a/apps/studio/src/lib/api/site-build.ts b/apps/studio/src/lib/api/site-build.ts new file mode 100644 index 00000000..ccd78fcc --- /dev/null +++ b/apps/studio/src/lib/api/site-build.ts @@ -0,0 +1,43 @@ +/** The self-hosted static site's rebuild on publish (#150, `packages/api` `routes/site-build.ts`). */ +import { request } from "./http.ts"; + +/** The last build: when, whether it worked, what it rendered and why it failed. */ +export interface SiteBuildLast { + at: string; + ok: boolean; + durationMs?: number; + snapshotId?: string; + /** `published`: the site; `not-published`: the status page (nothing is published yet). */ + state?: "published" | "not-published"; + trigger?: string; + /** The build id it made live (a failure with one failed only after the swap). */ + build?: string; + /** The build's last output lines, when it failed. */ + error?: string; +} + +/** What visitors see now, from the last build that went live (Studio's, cron's or a hand-run). */ +export interface SiteBuildServed { + at: string; + /** Its build id: Studio's last build is what is online when `last.build` matches. */ + build?: string; + snapshotId?: string; + state?: "published" | "not-published"; + /** `studio`, `cron`, `manual`, `start`. */ + source?: string; +} + +export type SiteBuildStatus = + | { enabled: false } + | { + enabled: true; + state: "idle" | "queued" | "running"; + /** The last build Studio ran (its output only for `audit:read`). */ + last: SiteBuildLast | null; + /** What is online now; null: nothing; absent: the server cannot tell. */ + served?: SiteBuildServed | null; + }; + +export const siteBuildApi = { + status: (siteId: string) => request(`/api/sites/${siteId}/site-build`), +}; diff --git a/apps/studio/src/routes/SiteOverviewPage.tsx b/apps/studio/src/routes/SiteOverviewPage.tsx index b6a7ad76..566aa873 100644 --- a/apps/studio/src/routes/SiteOverviewPage.tsx +++ b/apps/studio/src/routes/SiteOverviewPage.tsx @@ -23,6 +23,7 @@ import { OverviewAnalyticsCard } from "../components/Readers.tsx"; import { RecentActivity } from "../components/RecentActivity.tsx"; import { Status } from "../components/Status.tsx"; import { RecentContentCard } from "../components/site-overview/RecentContentCard.tsx"; +import { SiteBuildCard } from "../components/site-overview/SiteBuildCard.tsx"; import { SiteStatusCard } from "../components/site-overview/SiteStatusCard.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; import { useT, withMessages } from "../i18n/index.tsx"; @@ -34,15 +35,21 @@ const SchedulerStalledBanner = lazy(() => withMessages(import("../components/SchedulerStalled.tsx"), ["schedules"]), ); +/** RFC-0008 §8.7: the scheduler's status when it is stalled with work due; otherwise undefined. */ +function useSchedulerStalled() { + const status = useQuery({ queryKey: ["scheduler"], queryFn: api.scheduler, retry: false }).data; + const stalled = + !!status && !status.healthy && (status.state !== "unknown" || status.dueWithin24h); + return stalled ? status : undefined; +} + export function SiteOverviewPage() { const t = useT(); const { siteId } = useParams({ from: "/sites/$siteId" }); const qc = useQueryClient(); const navigate = useNavigate(); const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) }); - const scheduler = useQuery({ queryKey: ["scheduler"], queryFn: api.scheduler, retry: false }); - const s = scheduler.data; - const stalled = !!s && !s.healthy && (s.state !== "unknown" || s.dueWithin24h); + const stalled = useSchedulerStalled(); const pages = useQuery({ queryKey: ["pages", siteId], queryFn: () => api.pages.list(siteId) }); const registry = useCollections(siteId); const posts = registry.data?.find((c) => c.key === BUILT_IN_COLLECTION); @@ -117,7 +124,7 @@ export function SiteOverviewPage() { {stalled ? ( - + ) : null} @@ -188,6 +195,7 @@ export function SiteOverviewPage() { articles={posts ? entryTotal(posts) : 0} snapshots={snapshots.data?.length ?? 0} /> +