mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(landing): add a live system status indicator to the footer (#641)
Adds /api/status to the landing Pages worker, HEAD-probing demo.snapotter.com and docs.snapotter.com with a 2 second per-attempt deadline and one retry. snapotter.com is not probed; the worker answering the request is the proof it is up. The footer badge ships grey in the static HTML and only upgrades once the route answers. A rejected fetch, a non-ok response, an unparseable body, and an unrecognized verdict all leave it grey, so it never claims green on its own. Color lives in the dot, never the label: `--color-success` scores 4.498:1 against the footer's `--color-background-alt`, just under AA. Four labels across 21 locales.
This commit is contained in:
@@ -1,3 +1,56 @@
|
||||
// snapotter.com needs no probe: this worker answering the request is the proof
|
||||
// it is up. Only the two sibling properties are checked.
|
||||
const STATUS_PROBES = ["https://demo.snapotter.com/", "https://docs.snapotter.com/"];
|
||||
|
||||
// Probed at the edge rather than read from the Sentry uptime API: no standing
|
||||
// credential on a public worker, and no 15-minute lag from Sentry's 5-minute
|
||||
// interval times its 3-strike rule.
|
||||
//
|
||||
// One immediate retry absorbs a transient blip. `redirect: "manual"` stops the
|
||||
// follow, so a 3xx arrives as-is and still counts as up.
|
||||
async function probe(url) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
// Built outside the try, so a throw here surfaces as the runtime bug it is
|
||||
// rather than being read as a down leg. Built inside the loop, so each
|
||||
// attempt carries its own deadline: hoisting it above the loop would leave
|
||||
// attempt 2 holding an already-fired signal, silently deleting the retry
|
||||
// for the exact transient-blip case the retry exists to absorb.
|
||||
const signal = AbortSignal.timeout(2000);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { method: "HEAD", redirect: "manual", signal });
|
||||
} catch {
|
||||
// A throw is a timeout or a network error, which is a legitimate "down"
|
||||
// signal rather than a swallowed bug. Retry once, then report the leg
|
||||
// down. Only the fetch is guarded, so nothing below can be swallowed.
|
||||
continue;
|
||||
}
|
||||
if (res.status < 400) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function statusResponse() {
|
||||
const legs = await Promise.all(STATUS_PROBES.map((url) => probe(url)));
|
||||
const downCount = legs.filter((up) => !up).length;
|
||||
const status = downCount === 0 ? "operational" : downCount === legs.length ? "down" : "partial";
|
||||
|
||||
// A false green is cheap to sit on for a minute; a false red is not, and it
|
||||
// would otherwise pin in the browser across every navigation until it aged
|
||||
// out. Recheck a bad verdict sooner.
|
||||
const maxAge = status === "operational" ? 60 : 15;
|
||||
|
||||
// `_headers` only decorates env.ASSETS responses in advanced mode, so a
|
||||
// synthesized response has to carry its own.
|
||||
return new Response(JSON.stringify({ status }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": `public, max-age=${maxAge}`,
|
||||
"X-Robots-Tag": "noindex",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
@@ -7,6 +60,10 @@ export default {
|
||||
return Response.redirect(url.toString(), 301);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/status") {
|
||||
return statusResponse();
|
||||
}
|
||||
|
||||
const response = await env.ASSETS.fetch(request);
|
||||
|
||||
if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/_next/data/")) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher.astro";
|
||||
import StatusIndicator from "@/components/StatusIndicator.astro";
|
||||
import { t } from "@/i18n";
|
||||
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
|
||||
|
||||
@@ -156,6 +157,8 @@ const columns = [
|
||||
{t(locale, "footer.copyright", { year })}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<StatusIndicator locale={locale} />
|
||||
<span class="hidden h-3 w-px bg-border sm:block" aria-hidden="true"></span>
|
||||
<p class="text-xs text-muted">
|
||||
contact@snapotter.com
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Required rather than optional-with-an-"en"-default: an omitted prop would
|
||||
// typecheck, lint, render, and quietly serve English on all 20 localized page
|
||||
// trees. LanguageSwitcher, three lines away in the same footer cluster,
|
||||
// declares locale the same way.
|
||||
interface Props {
|
||||
locale: string;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
// Every label is resolved server-side and handed to the script, so the client
|
||||
// never needs a second request or a translation table of its own.
|
||||
const labels = {
|
||||
checking: t(locale, "footer.status.checking"),
|
||||
operational: t(locale, "footer.status.operational"),
|
||||
partial: t(locale, "footer.status.partial"),
|
||||
down: t(locale, "footer.status.down"),
|
||||
};
|
||||
---
|
||||
|
||||
<span
|
||||
class="flex items-center gap-2 text-xs text-muted"
|
||||
data-status-indicator
|
||||
data-status="checking"
|
||||
title="snapotter.com, demo.snapotter.com, docs.snapotter.com"
|
||||
>
|
||||
<span class="status-dot" data-status-dot aria-hidden="true"></span>
|
||||
<span data-status-label>{labels.checking}</span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
/* The dot carries every state color and the label never does. --color-success
|
||||
is 4.498:1 on --color-background-alt, the footer's background, just under
|
||||
AA's 4.5. palette-contrast.test.ts does not cover that pair: it checks
|
||||
success against --color-background, where it passes at 4.967. So the guard
|
||||
here is the toHaveCSS assertion in tests/e2e-landing/status-indicator.spec.ts,
|
||||
not the unit test. Adding the pair to the unit test would fail by design,
|
||||
since success-as-text-on-alt is not a supported combination.
|
||||
|
||||
Every state clears WCAG 1.4.11's 3:1 floor for non-text against
|
||||
--color-background-alt: muted 4.97, success 4.50, primary-ink 4.57,
|
||||
danger 4.73. */
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
[data-status="operational"] .status-dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
[data-status="partial"] .status-dot {
|
||||
background: var(--color-primary-ink);
|
||||
}
|
||||
[data-status="down"] .status-dot {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline define:vars={{ labels }}>
|
||||
(() => {
|
||||
const root = document.querySelector("[data-status-indicator]");
|
||||
if (!root) return;
|
||||
fetch("/api/status")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
// Route unreachable or body unparseable: stay grey rather than guess.
|
||||
// Scoped to the fetch alone. A trailing catch would also swallow a DOM
|
||||
// bug in the handler below and pin the badge on "Checking status" with
|
||||
// nothing in the console.
|
||||
.catch(() => null)
|
||||
.then((data) => {
|
||||
const status = data?.status ?? "";
|
||||
// Own properties only. A payload of {"status":"toString"} would
|
||||
// otherwise resolve up the prototype chain and render the function.
|
||||
const label = Object.hasOwn(labels, status) ? labels[status] : null;
|
||||
// An unknown or absent verdict leaves the grey default alone. The badge
|
||||
// never claims a state nothing told it.
|
||||
if (!label) return;
|
||||
root.dataset.status = status;
|
||||
root.querySelector("[data-status-label]").textContent = label;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "الرعاة",
|
||||
"footer.link.terms": "الشروط",
|
||||
"footer.link.videoTools": "أدوات الفيديو",
|
||||
"footer.status.checking": "جارٍ التحقق من الحالة",
|
||||
"footer.status.down": "انقطاع في الخدمة",
|
||||
"footer.status.operational": "جميع الأنظمة تعمل",
|
||||
"footer.status.partial": "انقطاع جزئي",
|
||||
"footer.tagline": "معالجة ملفات ذاتية الاستضافة. ملفاتك، بنيتك التحتية.",
|
||||
"home.categoryCards.audio.blurb": "حوّل وقُص وانسخ نصيًا",
|
||||
"home.categoryCards.audio.label": "أدوات الصوت",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "256d93f3139d",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "b017d78ed0a9",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "e5e2ee0d501d",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "d20bd747d369",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "9d7b2ae83bbb",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsoren",
|
||||
"footer.link.terms": "AGB",
|
||||
"footer.link.videoTools": "Video-Tools",
|
||||
"footer.status.checking": "Status wird geprüft",
|
||||
"footer.status.down": "Betriebsstörung",
|
||||
"footer.status.operational": "Alle Systeme betriebsbereit",
|
||||
"footer.status.partial": "Teilweise Störung",
|
||||
"footer.tagline": "Selbst gehostete Dateiverarbeitung. Deine Dateien, deine Infrastruktur.",
|
||||
"home.categoryCards.audio.blurb": "Konvertieren, schneiden und transkribieren",
|
||||
"home.categoryCards.audio.label": "Audio-Tools",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "a502215d5613",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "88b330c6150a",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "04328d48c0a3",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "e40909e11e26",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "0113e86fcbec",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
"terms.contact.title": "Contact",
|
||||
"terms.contact.pre": "If you have questions about these terms, contact us at",
|
||||
"footer.tagline": "Self-hosted file processing. Your files, your infrastructure.",
|
||||
"footer.status.checking": "Checking status",
|
||||
"footer.status.operational": "All systems operational",
|
||||
"footer.status.partial": "Partial outage",
|
||||
"footer.status.down": "Service disruption",
|
||||
"footer.col.product": "Product",
|
||||
"footer.col.solutions": "Solutions",
|
||||
"footer.col.resources": "Resources",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Patrocinadores",
|
||||
"footer.link.terms": "Términos",
|
||||
"footer.link.videoTools": "Herramientas de vídeo",
|
||||
"footer.status.checking": "Comprobando el estado",
|
||||
"footer.status.down": "Interrupción del servicio",
|
||||
"footer.status.operational": "Todos los sistemas operativos",
|
||||
"footer.status.partial": "Interrupción parcial",
|
||||
"footer.tagline": "Procesamiento de archivos autoalojado. Tus archivos, tu infraestructura.",
|
||||
"home.categoryCards.audio.blurb": "Convierte, recorta y transcribe",
|
||||
"home.categoryCards.audio.label": "Herramientas de audio",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "2d072a23e553",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "d32b32586b62",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "c4a2ba37ea1c",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "f27b36cd7197",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "a4f51d3ff14a",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsors",
|
||||
"footer.link.terms": "Conditions",
|
||||
"footer.link.videoTools": "Outils vidéo",
|
||||
"footer.status.checking": "Vérification de l'état",
|
||||
"footer.status.down": "Interruption de service",
|
||||
"footer.status.operational": "Tous les systèmes sont opérationnels",
|
||||
"footer.status.partial": "Panne partielle",
|
||||
"footer.tagline": "Traitement de fichiers auto-hébergé. Vos fichiers, votre infrastructure.",
|
||||
"home.categoryCards.audio.blurb": "Convertir, découper et transcrire",
|
||||
"home.categoryCards.audio.label": "Outils audio",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "79b73471a4c0",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "301797ab5747",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "31fe4322d704",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "d85e9b29c782",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "e3cadcd85e3f",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "स्पॉन्सर",
|
||||
"footer.link.terms": "नियम",
|
||||
"footer.link.videoTools": "वीडियो टूल्स",
|
||||
"footer.status.checking": "स्थिति जाँची जा रही है",
|
||||
"footer.status.down": "सेवा में व्यवधान",
|
||||
"footer.status.operational": "सभी सिस्टम चालू हैं",
|
||||
"footer.status.partial": "आंशिक व्यवधान",
|
||||
"footer.tagline": "सेल्फ-होस्टेड फ़ाइल प्रोसेसिंग। आपकी फ़ाइलें, आपका इंफ़्रास्ट्रक्चर।",
|
||||
"home.categoryCards.audio.blurb": "कन्वर्ट करें, ट्रिम करें और ट्रांसक्राइब करें",
|
||||
"home.categoryCards.audio.label": "ऑडियो टूल्स",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "872b9cdef397",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "cd4ab3cacf37",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "c02bb8cefe76",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "f453462e3c7c",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "7c3d9cf3fd6f",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsor",
|
||||
"footer.link.terms": "Ketentuan",
|
||||
"footer.link.videoTools": "Alat Video",
|
||||
"footer.status.checking": "Memeriksa status",
|
||||
"footer.status.down": "Gangguan layanan",
|
||||
"footer.status.operational": "Semua sistem beroperasi",
|
||||
"footer.status.partial": "Gangguan sebagian",
|
||||
"footer.tagline": "Pemrosesan file yang di-host sendiri. File Anda, infrastruktur Anda.",
|
||||
"home.categoryCards.audio.blurb": "Konversi, pangkas, dan transkrip",
|
||||
"home.categoryCards.audio.label": "Alat Audio",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "dec57d480cf0",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "b73b3dd1f0d2",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "5c5fd19fba20",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "cf6583627ce3",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "31de9dede8be",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsor",
|
||||
"footer.link.terms": "Termini",
|
||||
"footer.link.videoTools": "Strumenti per video",
|
||||
"footer.status.checking": "Verifica dello stato",
|
||||
"footer.status.down": "Interruzione del servizio",
|
||||
"footer.status.operational": "Tutti i sistemi operativi",
|
||||
"footer.status.partial": "Interruzione parziale",
|
||||
"footer.tagline": "Elaborazione dei file self-hosted. I tuoi file, la tua infrastruttura.",
|
||||
"home.categoryCards.audio.blurb": "Converti, taglia e trascrivi",
|
||||
"home.categoryCards.audio.label": "Strumenti per audio",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "33815b0d0dbc",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "2e624449a048",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "1709c7763a02",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "c621b714e7ca",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "3bbdc1feee81",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "スポンサー",
|
||||
"footer.link.terms": "利用規約",
|
||||
"footer.link.videoTools": "動画ツール",
|
||||
"footer.status.checking": "ステータスを確認中",
|
||||
"footer.status.down": "サービス障害",
|
||||
"footer.status.operational": "全システム正常稼働中",
|
||||
"footer.status.partial": "一部システムで障害",
|
||||
"footer.tagline": "セルフホスト型のファイル処理。あなたのファイルは、あなたのインフラで。",
|
||||
"home.categoryCards.audio.blurb": "変換、トリミング、文字起こし",
|
||||
"home.categoryCards.audio.label": "音声ツール",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "4754b9af9b4e",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "993f0f834f79",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "413c8ab1c0d7",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "4be5062794d7",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "2d30a3f815fa",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "후원자",
|
||||
"footer.link.terms": "약관",
|
||||
"footer.link.videoTools": "동영상 도구",
|
||||
"footer.status.checking": "상태 확인 중",
|
||||
"footer.status.down": "서비스 장애",
|
||||
"footer.status.operational": "모든 시스템 정상",
|
||||
"footer.status.partial": "일부 서비스 장애",
|
||||
"footer.tagline": "셀프 호스팅 파일 처리. 여러분의 파일, 여러분의 인프라.",
|
||||
"home.categoryCards.audio.blurb": "변환, 자르기, 전사",
|
||||
"home.categoryCards.audio.label": "오디오 도구",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "d53b28e2d833",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "a8bb5f84b6b3",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "16604bec3746",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "d521ab3858fe",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "8e5fe4c37a54",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsors",
|
||||
"footer.link.terms": "Voorwaarden",
|
||||
"footer.link.videoTools": "Videotools",
|
||||
"footer.status.checking": "Status wordt gecontroleerd",
|
||||
"footer.status.down": "Servicestoring",
|
||||
"footer.status.operational": "Alle systemen operationeel",
|
||||
"footer.status.partial": "Gedeeltelijke storing",
|
||||
"footer.tagline": "Zelf-gehoste bestandsverwerking. Jouw bestanden, jouw infrastructuur.",
|
||||
"home.categoryCards.audio.blurb": "Converteren, inkorten en transcriberen",
|
||||
"home.categoryCards.audio.label": "Audiotools",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "e30f0ffd40ec",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "52cfcddd2d4e",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "3fbde2c6f5e6",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "e3841d8bf881",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "a9a167bcbe19",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsorzy",
|
||||
"footer.link.terms": "Regulamin",
|
||||
"footer.link.videoTools": "Narzędzia do wideo",
|
||||
"footer.status.checking": "Sprawdzanie statusu",
|
||||
"footer.status.down": "Awaria usługi",
|
||||
"footer.status.operational": "Wszystkie systemy działają",
|
||||
"footer.status.partial": "Częściowa awaria",
|
||||
"footer.tagline": "Samodzielnie hostowane przetwarzanie plików. Twoje pliki, Twoja infrastruktura.",
|
||||
"home.categoryCards.audio.blurb": "Konwertuj, przycinaj i transkrybuj",
|
||||
"home.categoryCards.audio.label": "Narzędzia do audio",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "5ccc15b451b7",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "0e518680e1b8",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "7d5f725ae927",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "43f93b3c9ace",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "437c2ddf09be",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Patrocinadores",
|
||||
"footer.link.terms": "Termos",
|
||||
"footer.link.videoTools": "Ferramentas de vídeo",
|
||||
"footer.status.checking": "Verificando o status",
|
||||
"footer.status.down": "Interrupção do serviço",
|
||||
"footer.status.operational": "Todos os sistemas operacionais",
|
||||
"footer.status.partial": "Interrupção parcial",
|
||||
"footer.tagline": "Processamento de arquivos auto-hospedado. Seus arquivos, sua infraestrutura.",
|
||||
"home.categoryCards.audio.blurb": "Converta, corte e transcreva",
|
||||
"home.categoryCards.audio.label": "Ferramentas de áudio",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "fb5f986641d1",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "2f7ec907dc67",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "97b5fe59cd19",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "e7798a36be1c",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "78106a539f55",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Спонсоры",
|
||||
"footer.link.terms": "Условия",
|
||||
"footer.link.videoTools": "Инструменты для видео",
|
||||
"footer.status.checking": "Проверка статуса",
|
||||
"footer.status.down": "Сбой в работе сервиса",
|
||||
"footer.status.operational": "Все системы работают",
|
||||
"footer.status.partial": "Частичный сбой",
|
||||
"footer.tagline": "Самостоятельно размещаемая обработка файлов. Ваши файлы, ваша инфраструктура.",
|
||||
"home.categoryCards.audio.blurb": "Конвертируйте, обрезайте и транскрибируйте",
|
||||
"home.categoryCards.audio.label": "Инструменты для аудио",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "d897071989ce",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "39016fb23fb8",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "92c73257e0d4",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "450b2f632fd3",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "4a6fb635f59f",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsorer",
|
||||
"footer.link.terms": "Villkor",
|
||||
"footer.link.videoTools": "Videoverktyg",
|
||||
"footer.status.checking": "Kontrollerar status",
|
||||
"footer.status.down": "Driftstörning",
|
||||
"footer.status.operational": "Alla system fungerar",
|
||||
"footer.status.partial": "Delvis avbrott",
|
||||
"footer.tagline": "Självhostad filbearbetning. Dina filer, din infrastruktur.",
|
||||
"home.categoryCards.audio.blurb": "Konvertera, klipp och transkribera",
|
||||
"home.categoryCards.audio.label": "Ljudverktyg",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "b970845d98cb",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "39a833f3c2b4",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "2679f3423e08",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "3652b9d5fc4e",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "9ace411742a2",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "ผู้สนับสนุน",
|
||||
"footer.link.terms": "ข้อกำหนด",
|
||||
"footer.link.videoTools": "เครื่องมือวิดีโอ",
|
||||
"footer.status.checking": "กำลังตรวจสอบสถานะ",
|
||||
"footer.status.down": "บริการขัดข้อง",
|
||||
"footer.status.operational": "ทุกระบบทำงานปกติ",
|
||||
"footer.status.partial": "ขัดข้องบางส่วน",
|
||||
"footer.tagline": "ประมวลผลไฟล์แบบโฮสต์เอง ไฟล์ของคุณ โครงสร้างพื้นฐานของคุณ",
|
||||
"home.categoryCards.audio.blurb": "แปลง ตัด และถอดเสียง",
|
||||
"home.categoryCards.audio.label": "เครื่องมือเสียง",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "63b3362278c6",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "69776a79aca8",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "a6eedaa8ff73",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "919c1026fc4b",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "dd6351678012",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Sponsorlar",
|
||||
"footer.link.terms": "Şartlar",
|
||||
"footer.link.videoTools": "Video Araçları",
|
||||
"footer.status.checking": "Durum kontrol ediliyor",
|
||||
"footer.status.down": "Hizmet kesintisi",
|
||||
"footer.status.operational": "Tüm sistemler çalışıyor",
|
||||
"footer.status.partial": "Kısmi kesinti",
|
||||
"footer.tagline": "Kendi sunucunuzda barındırılan dosya işleme. Sizin dosyalarınız, sizin altyapınız.",
|
||||
"home.categoryCards.audio.blurb": "Dönüştürün, kırpın ve metne dökün",
|
||||
"home.categoryCards.audio.label": "Ses Araçları",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "8f4869afbb23",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "08554b73d2d1",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "aa7e604f9977",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "888a893e465b",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "4b7471af08d9",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Спонсори",
|
||||
"footer.link.terms": "Умови",
|
||||
"footer.link.videoTools": "Інструменти для відео",
|
||||
"footer.status.checking": "Перевірка статусу",
|
||||
"footer.status.down": "Збій у роботі сервісу",
|
||||
"footer.status.operational": "Усі системи працюють",
|
||||
"footer.status.partial": "Частковий збій",
|
||||
"footer.tagline": "Самостійно розгортувана обробка файлів. Ваші файли, ваша інфраструктура.",
|
||||
"home.categoryCards.audio.blurb": "Конвертуйте, обрізайте та транскрибуйте",
|
||||
"home.categoryCards.audio.label": "Інструменти для аудіо",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "b92ec9f967d6",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "8ff7d1c7337c",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "fc37b5fabd85",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "d157982d7af2",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "1a89631e7f3b",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "Nhà tài trợ",
|
||||
"footer.link.terms": "Điều khoản",
|
||||
"footer.link.videoTools": "Công cụ video",
|
||||
"footer.status.checking": "Đang kiểm tra trạng thái",
|
||||
"footer.status.down": "Gián đoạn dịch vụ",
|
||||
"footer.status.operational": "Tất cả hệ thống bình thường",
|
||||
"footer.status.partial": "Gián đoạn một phần",
|
||||
"footer.tagline": "Xử lý tệp tự lưu trữ. Tệp của bạn, hạ tầng của bạn.",
|
||||
"home.categoryCards.audio.blurb": "Chuyển đổi, cắt và chuyển thành văn bản",
|
||||
"home.categoryCards.audio.label": "Công cụ âm thanh",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "c9fe04b0df2d",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "49115ffaf18a",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "cae0666ae97d",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "6a09095a95ea",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "bfa234c711e2",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "赞助者",
|
||||
"footer.link.terms": "条款",
|
||||
"footer.link.videoTools": "视频工具",
|
||||
"footer.status.checking": "正在检查状态",
|
||||
"footer.status.down": "服务中断",
|
||||
"footer.status.operational": "所有系统运行正常",
|
||||
"footer.status.partial": "部分服务中断",
|
||||
"footer.tagline": "自托管的文件处理。你的文件,你的基础设施。",
|
||||
"home.categoryCards.audio.blurb": "转换、裁剪与转录",
|
||||
"home.categoryCards.audio.label": "音频工具",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "d2442cb204fa",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "6473ac29f52a",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "45ac29e8d2dd",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "8a35458ed0e1",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "ace2633874b3",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"footer.link.sponsors": "贊助者",
|
||||
"footer.link.terms": "條款",
|
||||
"footer.link.videoTools": "影片工具",
|
||||
"footer.status.checking": "正在檢查狀態",
|
||||
"footer.status.down": "服務中斷",
|
||||
"footer.status.operational": "所有系統運作正常",
|
||||
"footer.status.partial": "部分服務中斷",
|
||||
"footer.tagline": "自架式檔案處理。你的檔案,你的基礎設施。",
|
||||
"home.categoryCards.audio.blurb": "轉檔、剪裁與轉錄",
|
||||
"home.categoryCards.audio.label": "音訊工具",
|
||||
|
||||
@@ -1367,6 +1367,30 @@
|
||||
"outputHash": "562594ac098f",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.checking": {
|
||||
"sourceHash": "a4a534f1f446",
|
||||
"provenance": "machine",
|
||||
"outputHash": "7cad251fa973",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.down": {
|
||||
"sourceHash": "653599e07324",
|
||||
"provenance": "machine",
|
||||
"outputHash": "20f1c4908911",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.operational": {
|
||||
"sourceHash": "39bd8d494d8d",
|
||||
"provenance": "machine",
|
||||
"outputHash": "00e82d2be922",
|
||||
"stale": false
|
||||
},
|
||||
"footer.status.partial": {
|
||||
"sourceHash": "5b1fb8ceb794",
|
||||
"provenance": "machine",
|
||||
"outputHash": "f580bff60ef9",
|
||||
"stale": false
|
||||
},
|
||||
"footer.tagline": {
|
||||
"sourceHash": "3b1e77c7b8b6",
|
||||
"provenance": "machine",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import de from "../../apps/landing/src/i18n/de.json";
|
||||
|
||||
/**
|
||||
* `astro dev` does not run _worker.js, so /api/status really is absent here.
|
||||
* Tests that need a verdict mock it; the ones that do not are exercising the
|
||||
* degraded path on purpose.
|
||||
*/
|
||||
|
||||
const INDICATOR = "[data-status-indicator]";
|
||||
const DOT = "[data-status-dot]";
|
||||
const LABEL = "[data-status-label]";
|
||||
|
||||
/** --color-muted #6B6560. The label must never take a state color (AA: success is 4.498:1). */
|
||||
const MUTED = "rgb(107, 101, 96)";
|
||||
|
||||
function mockStatus(page: import("@playwright/test").Page, status: string) {
|
||||
return page.route("**/api/status", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `waitForResponse` resolves when Playwright sees the response over CDP, which
|
||||
* is ahead of the page's own `.then` chain. Two frames give the badge its
|
||||
* chance to change, so a "stays grey" assertion proves it did not rather than
|
||||
* just winning a race.
|
||||
*/
|
||||
function flushFrames(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Footer status indicator", () => {
|
||||
test("ships the grey state in the served HTML, before any script runs", async ({ page }) => {
|
||||
// Fetched as raw markup rather than a rendered page, so this covers the
|
||||
// no-JavaScript reader too. The badge must never be baked green.
|
||||
const res = await page.request.get("/");
|
||||
const html = await res.text();
|
||||
expect(html).toContain('data-status="checking"');
|
||||
expect(html).toMatch(/<span data-status-label[^>]*>Checking status<\/span>/);
|
||||
// Scoped to the badge's own rendered text rather than the whole document.
|
||||
// The three verdict strings do appear further down, inside the script's
|
||||
// label table, because the client has to localize whatever /api/status
|
||||
// reports. What must never happen is the markup claiming a verdict.
|
||||
for (const verdict of ["All systems operational", "Partial outage", "Service disruption"]) {
|
||||
expect(html).not.toMatch(new RegExp(`<span data-status-label[^>]*>${verdict}</span>`));
|
||||
}
|
||||
});
|
||||
|
||||
test("stays grey when the status route is unavailable", async ({ page }) => {
|
||||
const settled = page.waitForResponse("**/api/status");
|
||||
await page.goto("/");
|
||||
await settled;
|
||||
await flushFrames(page);
|
||||
await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking");
|
||||
await expect(page.locator(INDICATOR)).toContainText("Checking status");
|
||||
// The state most visitors see first, so it needs pinning too. The dot was
|
||||
// once #A8A29A: 2.19:1 on the footer, under WCAG 1.4.11's 3:1 for non-text.
|
||||
// --color-muted is 4.97:1 and is a palette token rather than a magic hex.
|
||||
await expect(page.locator(DOT)).toHaveCSS("background-color", MUTED);
|
||||
});
|
||||
|
||||
// The body is parseable and claims green on purpose. With an empty body the
|
||||
// badge stays grey because res.json() rejects, so `res.ok` is never exercised
|
||||
// and deleting it passes. A 5xx carrying a verdict is the realistic case: a
|
||||
// Cloudflare error envelope, or a stale cached body served on a 502.
|
||||
test("stays grey when the status route errors, even with a parseable body", async ({ page }) => {
|
||||
await page.route("**/api/status", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "operational" }),
|
||||
}),
|
||||
);
|
||||
const settled = page.waitForResponse("**/api/status");
|
||||
await page.goto("/");
|
||||
await settled;
|
||||
await flushFrames(page);
|
||||
await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking");
|
||||
await expect(page.locator(INDICATOR)).toContainText("Checking status");
|
||||
});
|
||||
|
||||
// Reaches the .catch through a rejected fetch rather than a bad response.
|
||||
test("stays grey when the request itself fails", async ({ page }) => {
|
||||
await page.route("**/api/status", (route) => route.abort("failed"));
|
||||
await page.goto("/");
|
||||
await flushFrames(page);
|
||||
await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking");
|
||||
await expect(page.locator(INDICATOR)).toContainText("Checking status");
|
||||
});
|
||||
|
||||
// Reaches the .catch through res.json() instead. This is the broken-deploy
|
||||
// signature: _worker.js fails to load, or a _redirects rule shadows the
|
||||
// route, and the client gets a 200 carrying HTML.
|
||||
test("stays grey when a 200 carries HTML instead of JSON", async ({ page }) => {
|
||||
await page.route("**/api/status", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: "<!doctype html><title>404</title>",
|
||||
}),
|
||||
);
|
||||
const settled = page.waitForResponse("**/api/status");
|
||||
await page.goto("/");
|
||||
await settled;
|
||||
await flushFrames(page);
|
||||
await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking");
|
||||
await expect(page.locator(INDICATOR)).toContainText("Checking status");
|
||||
});
|
||||
|
||||
for (const [status, label, dotColor] of [
|
||||
["operational", "All systems operational", "rgb(30, 123, 92)"],
|
||||
["partial", "Partial outage", "rgb(168, 85, 24)"],
|
||||
["down", "Service disruption", "rgb(190, 58, 53)"],
|
||||
] as const) {
|
||||
test(`renders the ${status} state`, async ({ page }) => {
|
||||
await mockStatus(page, status);
|
||||
await page.goto("/");
|
||||
// Scoped to the footer, which is where the badge belongs. This subsumes
|
||||
// the standalone placement test that used to sit at the bottom of the
|
||||
// file and never observed its own mock.
|
||||
const badge = page.locator(`footer ${INDICATOR}`);
|
||||
await expect(badge).toHaveAttribute("data-status", status);
|
||||
await expect(badge).toContainText(label);
|
||||
// The dot is the only thing that carries state. Each color clears WCAG
|
||||
// 1.4.11's 3:1 non-text floor against the footer's --color-background-alt,
|
||||
// pinned as a ratio in tests/unit/palette-contrast.test.ts.
|
||||
await expect(badge.locator(DOT)).toHaveCSS("background-color", dotColor);
|
||||
// And the label never carries it, in any state. --color-success is
|
||||
// 4.498:1 on the footer's --color-background-alt, just under AA.
|
||||
// palette-contrast.test.ts pins the dot ratios but cannot answer a
|
||||
// cascade question like "does the label take a state color", so this
|
||||
// assertion is the only guard. "down" is the tempting one to redden.
|
||||
await expect(badge.locator(LABEL)).toHaveCSS("color", MUTED);
|
||||
});
|
||||
}
|
||||
|
||||
// "banana" is absent for the easy reason. "toString" is the one that matters:
|
||||
// it resolves on Object.prototype, so an unguarded lookup renders
|
||||
// "function toString() { [native code] }" in the footer.
|
||||
for (const status of ["banana", "toString"] as const) {
|
||||
test(`ignores the unrecognized status "${status}"`, async ({ page }) => {
|
||||
await mockStatus(page, status);
|
||||
const settled = page.waitForResponse("**/api/status");
|
||||
await page.goto("/");
|
||||
await settled;
|
||||
await flushFrames(page);
|
||||
await expect(page.locator(INDICATOR)).toHaveAttribute("data-status", "checking");
|
||||
await expect(page.locator(INDICATOR)).toContainText("Checking status");
|
||||
});
|
||||
}
|
||||
|
||||
test("names the three services it speaks for", async ({ page }) => {
|
||||
// The badge reports on snapotter.com's own properties, never on a visitor's
|
||||
// self-hosted instance. The title is the only thing that says which.
|
||||
await page.goto("/");
|
||||
const title = await page.locator(INDICATOR).getAttribute("title");
|
||||
// Compared as whole entries, not substrings: "snapotter.com" is a suffix of
|
||||
// the other two, so toContain would pass on a title missing the apex.
|
||||
const hosts = new Set(title?.split(",").map((host) => host.trim()));
|
||||
expect(hosts).toEqual(new Set(["snapotter.com", "demo.snapotter.com", "docs.snapotter.com"]));
|
||||
});
|
||||
|
||||
test("keeps the decorative dot out of the accessible name", async ({ page }) => {
|
||||
// Without aria-hidden the empty span joins the badge's accessible name.
|
||||
await page.goto("/");
|
||||
await expect(page.locator(DOT)).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("localizes the badge on a locale-prefixed page", async ({ page }) => {
|
||||
// Raw markup, no browser: this is about what the server renders for a
|
||||
// localized tree. Without it, dropping the locale prop from <StatusIndicator />
|
||||
// in Footer.astro serves English on all 20 and every other test still passes.
|
||||
const html = await (await page.request.get("/de/")).text();
|
||||
const checking = de["footer.status.checking"];
|
||||
// Makes the assertion below mean something: it fails loudly if de.json ever
|
||||
// falls back to the English string rather than silently comparing "" to "".
|
||||
expect(checking).not.toBe("Checking status");
|
||||
expect(html).toContain(`>${checking}</span>`);
|
||||
// The inline script's label table has to be German too, not just the
|
||||
// server-rendered default the reader sees first.
|
||||
expect(html).toContain(JSON.stringify(de["footer.status.operational"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { SUPPORTED_LOCALES } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The footer status badge resolves all four labels server-side and hands them
|
||||
* to its inline script, so a catalog gap is invisible on the English page and
|
||||
* only shows on the localized one.
|
||||
*
|
||||
* `t()` falls back with `??`, which does not fall through on `""`. An empty
|
||||
* translation therefore resolves to an empty string rather than English, and
|
||||
* the badge would sit blank on that locale forever. Scoped to these four keys
|
||||
* on purpose: a whole-catalog parity guard is worth having but is a separate
|
||||
* change, and would trip on pre-existing debt.
|
||||
*/
|
||||
|
||||
const I18N_DIR = path.resolve(__dirname, "../../apps/landing/src/i18n");
|
||||
|
||||
const STATUS_KEYS = [
|
||||
"footer.status.checking",
|
||||
"footer.status.operational",
|
||||
"footer.status.partial",
|
||||
"footer.status.down",
|
||||
];
|
||||
|
||||
const locales = SUPPORTED_LOCALES.map((l) => l.code);
|
||||
|
||||
function catalog(locale: string): Record<string, string> {
|
||||
return JSON.parse(fs.readFileSync(path.join(I18N_DIR, `${locale}.json`), "utf-8"));
|
||||
}
|
||||
|
||||
describe("landing footer status labels", () => {
|
||||
it("covers every supported locale", () => {
|
||||
// Guards the loop below: if the locale list were ever empty or unresolvable
|
||||
// the per-locale assertions would vacuously pass.
|
||||
expect(locales.length).toBe(21);
|
||||
});
|
||||
|
||||
it.each(locales)("%s defines all four status labels as non-empty strings", (locale) => {
|
||||
const strings = catalog(locale);
|
||||
for (const key of STATUS_KEYS) {
|
||||
expect(typeof strings[key], `${locale}.json is missing ${key}`).toBe("string");
|
||||
expect(strings[key].trim(), `${locale}.json has an empty ${key}`).not.toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import worker from "../../apps/landing/public/_worker.js";
|
||||
|
||||
/**
|
||||
* The landing site's Cloudflare Pages worker. `_worker.js` is a dependency-free
|
||||
* ES module (Pages advanced mode forbids imports), so it can be exercised
|
||||
* directly with a stubbed global fetch and a fake ASSETS binding.
|
||||
*/
|
||||
|
||||
const ENV = { ASSETS: { fetch: async () => new Response("asset", { status: 200 }) } };
|
||||
|
||||
const DEMO = "https://demo.snapotter.com/";
|
||||
const DOCS = "https://docs.snapotter.com/";
|
||||
|
||||
type ProbeCall = { url: string; init: RequestInit | undefined };
|
||||
|
||||
/**
|
||||
* Stub global fetch with a per-URL responder, returning the call log. The log
|
||||
* records the init argument alongside the URL so tests can assert on how a
|
||||
* probe was issued, not only where it was sent.
|
||||
*/
|
||||
function stubProbes(responder: (url: string) => Promise<Response>): ProbeCall[] {
|
||||
const calls: ProbeCall[] = [];
|
||||
vi.stubGlobal("fetch", async (input: string | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
calls.push({ url, init });
|
||||
return responder(url);
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const res = await worker.fetch(new Request("https://snapotter.com/api/status"), ENV);
|
||||
return { res, body: (await res.json()) as { status: string } };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
// `unstubAllGlobals` does not undo a `spyOn`, so restore separately.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("landing worker /api/status", () => {
|
||||
it("reports operational when both probed legs answer", async () => {
|
||||
stubProbes(async () => new Response(null, { status: 200 }));
|
||||
const { body } = await getStatus();
|
||||
expect(body.status).toBe("operational");
|
||||
});
|
||||
|
||||
// Named for what it pins. The stub ignores `redirect: "manual"`, so this
|
||||
// cannot observe whether redirects are followed, only that a 301 reads as up.
|
||||
it("counts a 301 as up", async () => {
|
||||
stubProbes(async () => new Response(null, { status: 301 }));
|
||||
const { body } = await getStatus();
|
||||
expect(body.status).toBe("operational");
|
||||
});
|
||||
|
||||
// Pins the up/down boundary at 400, not 500. A 404 on a sibling property is a
|
||||
// realistic bad-deploy signature, so it has to read as down rather than up.
|
||||
it("counts a 404 as down, not just 5xx", async () => {
|
||||
stubProbes(async (url) =>
|
||||
url === DEMO ? new Response(null, { status: 404 }) : new Response(null, { status: 200 }),
|
||||
);
|
||||
const { body } = await getStatus();
|
||||
expect(body.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("reports partial when exactly one leg is down", async () => {
|
||||
stubProbes(async (url) =>
|
||||
url === DEMO ? new Response(null, { status: 503 }) : new Response(null, { status: 200 }),
|
||||
);
|
||||
const { body } = await getStatus();
|
||||
expect(body.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("reports down when both probed legs are down", async () => {
|
||||
stubProbes(async () => new Response(null, { status: 503 }));
|
||||
const { res, body } = await getStatus();
|
||||
expect(body.status).toBe("down");
|
||||
// The badge reads the verdict from the body, so the transport stays 200
|
||||
// even when everything probed is down. Answering 503 here would break it.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("treats a thrown request (timeout, DNS) as down", async () => {
|
||||
stubProbes(async () => {
|
||||
throw new Error("timed out");
|
||||
});
|
||||
const { body } = await getStatus();
|
||||
expect(body.status).toBe("down");
|
||||
});
|
||||
|
||||
it("retries a failed leg once before declaring it down", async () => {
|
||||
let demoAttempts = 0;
|
||||
const calls = stubProbes(async (url) => {
|
||||
if (url !== DEMO) return new Response(null, { status: 200 });
|
||||
demoAttempts += 1;
|
||||
if (demoAttempts === 1) throw new Error("transient blip");
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
const { body } = await getStatus();
|
||||
expect(body.status).toBe("operational");
|
||||
expect(calls.filter((c) => c.url === DEMO)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("gives up after the single retry", async () => {
|
||||
const calls = stubProbes(async () => new Response(null, { status: 500 }));
|
||||
await getStatus();
|
||||
expect(calls.filter((c) => c.url === DOCS)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sets the caching and noindex headers on its own response", async () => {
|
||||
stubProbes(async () => new Response(null, { status: 200 }));
|
||||
const { res } = await getStatus();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toContain("application/json");
|
||||
expect(res.headers.get("Cache-Control")).toBe("public, max-age=60");
|
||||
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||
});
|
||||
|
||||
// A false green is cheap to sit on; a false red would pin in the browser
|
||||
// across every navigation for the full minute. Bad verdicts recheck sooner.
|
||||
it.each([
|
||||
{ verdict: "partial", demo: 503, docs: 200 },
|
||||
{ verdict: "down", demo: 503, docs: 503 },
|
||||
])("caches a $verdict verdict for 15 seconds, not 60", async ({ verdict, demo, docs }) => {
|
||||
stubProbes(async (url) =>
|
||||
url === DEMO ? new Response(null, { status: demo }) : new Response(null, { status: docs }),
|
||||
);
|
||||
const { res, body } = await getStatus();
|
||||
expect(body.status).toBe(verdict);
|
||||
expect(res.headers.get("Cache-Control")).toBe("public, max-age=15");
|
||||
});
|
||||
|
||||
// Without a bound signal a hung leg would stall the whole route, and the
|
||||
// footer request behind it, for as long as the edge allows. Asserting merely
|
||||
// that some signal arrived is not enough: a much longer timeout, or an
|
||||
// AbortController signal that never fires, would both slip through. The stub
|
||||
// makes the real deadline unobservable, so pin the budget at the constructor.
|
||||
it("bounds every probe with a 2 second abort signal", async () => {
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
|
||||
const calls = stubProbes(async () => new Response(null, { status: 200 }));
|
||||
await getStatus();
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const { init } of calls) {
|
||||
expect(init).toBeDefined();
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(2000);
|
||||
expect(timeoutSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// The test above cannot catch a signal hoisted above the retry loop, because
|
||||
// both legs succeed on attempt 1 and the counts match either way. Force all
|
||||
// four attempts: a hoisted signal would build 2 signals for 4 fetches and
|
||||
// hand attempt 2 an already-fired one, deleting the retry.
|
||||
it("gives each attempt its own deadline, not the first attempt's leftovers", async () => {
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
|
||||
const calls = stubProbes(async () => new Response(null, { status: 500 }));
|
||||
await getStatus();
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(timeoutSpy).toHaveBeenCalledTimes(4);
|
||||
expect(new Set(calls.map((c) => c.init?.signal)).size).toBe(4);
|
||||
});
|
||||
|
||||
it("probes only the two sibling properties, never snapotter.com itself", async () => {
|
||||
const calls = stubProbes(async () => new Response(null, { status: 200 }));
|
||||
await getStatus();
|
||||
expect(new Set(calls.map((c) => c.url))).toEqual(new Set([DEMO, DOCS]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("landing worker existing behavior", () => {
|
||||
it("still redirects www to the apex", async () => {
|
||||
const res = await worker.fetch(new Request("https://www.snapotter.com/faq"), ENV);
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.headers.get("location")).toBe("https://snapotter.com/faq");
|
||||
});
|
||||
|
||||
it("still serves assets for every other path", async () => {
|
||||
const res = await worker.fetch(new Request("https://snapotter.com/faq"), ENV);
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.text()).resolves.toBe("asset");
|
||||
});
|
||||
});
|
||||
@@ -159,4 +159,13 @@ describe("landing", () => {
|
||||
expectPair(landing, "muted", "background");
|
||||
expectPair(landing, "dark-fg", "dark-bg");
|
||||
});
|
||||
|
||||
// The footer status dot is a graphic, so WCAG 1.4.11's 3:1 applies rather
|
||||
// than AA's 4.5. Note "success" clears 3:1 here but not 4.5, which is why the
|
||||
// badge's label stays muted while only the dot takes a state color.
|
||||
it("footer status dot clears 3:1 non-text on the footer surface", () => {
|
||||
for (const dot of ["muted", "success", "primary-ink", "danger"]) {
|
||||
expectPair(landing, dot, "background-alt", 3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user