Files
Josh Hunt c1c5c2db8b FS: Handle unavailable backend (#108544)
* add dev mechanism for making backend unavailable

* handle unavailable backend in html

* fix ordering of /-/ routes

* Add new loader to index.html

* tweak light colours

* fix readme

* add error handling and error state

* use setTimeout for the retry loop

* easier on the comments:
2025-07-25 17:21:48 +01:00

295 lines
8.7 KiB
HTML

<!DOCTYPE html>
<html class="fs-loading">
<head>
[[ if and .CSPEnabled .IsDevelopmentEnv ]]
<!-- Cypress overwrites CSP headers in HTTP requests, so this is required for e2e tests-->
<meta http-equiv="Content-Security-Policy" content="[[.CSPContent]]"/>
[[ end ]]
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#000" />
<title>[[.AppTitle]]</title>
<base href="[[.AppSubUrl]]/" />
<link rel="mask-icon" href="[[.Assets.ContentDeliveryURL]]public/img/grafana_mask_icon.svg" color="#F05A28" />
[[range $asset := .Assets.CSSFiles]]
<link rel="stylesheet" href="[[$asset.FilePath]]" />
[[end]]
<script nonce="[[.Nonce]]">
performance.mark('frontend_boot_css_time_seconds');
</script>
</head>
<body>
<div class="preloader">
<style>
/**
* This style tag is purposefully inside the fs-loader div so
* when AppWrapper mounts and removes the div
* the styles are taken away with it as well.
*/
/* Light theme */
:root {
--fs-loader-bg: #f4f5f5;
--fs-loader-text-color: rgb(36, 41, 46);
--fs-spinner-arc-color: #F55F3E;
--fs-spinner-track-color: rgba(36, 41, 46, 0.12);
--fs-color-error: #e0226e;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--fs-loader-bg: #111217;
--fs-loader-text-color: rgb(204, 204, 220);
--fs-spinner-arc-color: #F55F3E;
--fs-spinner-track-color: rgba(204, 204, 220, 0.12);
--fs-color-error: #d10e5c;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
body {
background-color: var(--fs-loader-bg);
color: var(--fs-loader-text-color);
margin: 0;
}
.preloader {
display: flex;
flex-direction: column;
align-items: center;
height: 100dvh;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
.fs-variant-loader, .fs-variant-error {
display: contents;
}
.fs-hidden {
display: none;
}
.fs-spinner {
animation: spin 1500ms linear infinite;
width: 32px;
height: 32px;
}
.fs-spinner-track {
stroke: rgba(255,255,255,.15);
}
.fs-spinner-arc {
stroke: #F55F3E;
}
.fs-loader-text {
opacity: 0;
font-size: 16px;
margin-bottom: 0;
transition: opacity 300ms ease-in-out;
}
.fs-loader-starting-up .fs-loader-text {
opacity: 1;
}
.fs-variant-error .fs-loader-text {
opacity: 1;
}
.fs-error-icon {
fill: var(--fs-color-error);
}
</style>
<div class="fs-variant-loader">
<svg
width="32"
height="32"
class="fs-spinner"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<circle class="fs-spinner-track" cx="50" cy="50" r="45" fill="none" stroke-width="10" />
<circle class="fs-spinner-arc" cx="50" cy="50" r="45" fill="none" stroke-width="10" stroke-linecap="round" stroke-dasharray="70.7 212.3" stroke-dashoffset="0" />
</svg>
<p class="fs-loader-text">Grafana is starting up...</p>
</div>
<div class="fs-variant-error fs-hidden">
<svg
width="32"
height="32"
class="fs-error-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12,14a1.25,1.25,0,1,0,1.25,1.25A1.25,1.25,0,0,0,12,14Zm0-1.5a1,1,0,0,0,1-1v-3a1,1,0,0,0-2,0v3A1,1,0,0,0,12,12.5ZM12,2A10,10,0,1,0,22,12,10.01114,10.01114,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8.00917,8.00917,0,0,1,12,20Z"/>
</svg>
<p class="fs-loader-text">Error loading Grafana</p>
</div>
</div>
<div id="reactRoot"></div>
<script nonce="[[.Nonce]]">
[[if .Nonce]]
window.nonce = '[[.Nonce]]';
[[end]]
[[if .Assets.ContentDeliveryURL]]
window.public_cdn_path = '[[.Assets.ContentDeliveryURL]]public/build/';
[[end]]
</script>
<script nonce="[[.Nonce]]">
// Wrap in an IIFE to avoid polluting the global scope. Intentionally global-scope properties
// are explicitly assigned to the `window` object.
(() => {
window.__grafana_load_failed = function(...args) {
console.error('Failed to load Grafana', ...args);
document.querySelector('.fs-variant-loader').classList.add('fs-hidden');
document.querySelector('.fs-variant-error').classList.remove('fs-hidden');
};
window.onload = function() {
if (window.__grafana_app_bundle_loaded) {
return;
}
window.__grafana_load_failed();
};
let hasSetLoading = false;
function setLoading() {
if (hasSetLoading) {
return;
}
document.querySelector('.preloader').classList.add('fs-loader-starting-up');
hasSetLoading = true;
}
const CHECK_INTERVAL = 1 * 1000;
/**
* Fetches boot data from the server. If it returns undefined, it should be retried later.
* Will return a rejected promise on unrecoverable errors.
**/
async function fetchBootData() {
const resp = await fetch("/bootdata");
const textResponse = await resp.text();
let rawBootData;
try {
rawBootData = JSON.parse(textResponse);
} catch {
throw new Error("Unexpected response type: " + textResponse);
}
// If the response is 503, instruct the caller to retry again later.
if (resp.status === 503 && rawBootData.code === 'Loading') {
return;
}
if (!resp.ok) {
throw new Error("Unexpected response body: " + textResponse);
}
return rawBootData;
}
/**
* Loads the boot data from the server, retrying if it's unavailable.
**/
function loadBootData() {
return new Promise((resolve, reject) => {
const attemptFetch = async () => {
try {
const bootData = await fetchBootData();
// If the boot data is undefined, retry after a delay
if (!bootData) {
setLoading();
setTimeout(attemptFetch, CHECK_INTERVAL);
return;
}
resolve(bootData);
} catch (error) {
reject(error);
}
};
// Start the first attempt immediately
attemptFetch();
});
}
async function initGrafana() {
const rawBootData = await loadBootData();
window.grafanaBootData = {
_femt: true,
...rawBootData,
}
// The per-theme CSS still contains some global styles needed
// to render the page correctly.
const cssLink = document.createElement("link");
cssLink.rel = 'stylesheet';
let theme = window.grafanaBootData.user.theme;
if (theme === "system") {
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
theme = darkQuery.matches ? 'dark' : 'light';
}
if (theme === "light") {
document.body.classList.add("theme-light");
cssLink.href = window.grafanaBootData.assets.light;
window.grafanaBootData.user.lightTheme = true;
} else if (theme === "dark") {
document.body.classList.add("theme-dark");
cssLink.href = window.grafanaBootData.assets.dark;
window.grafanaBootData.user.lightTheme = false;
}
document.head.appendChild(cssLink);
}
window.__grafana_boot_data_promise = initGrafana()
window.__grafana_boot_data_promise.catch((err) => {
console.error("__grafana_boot_data_promise rejected", err);
window.__grafana_load_failed(err);
});
})();
</script>
[[range $asset := .Assets.JSFiles]]
<script nonce="[[$.Nonce]]" src="[[$asset.FilePath]]" type="text/javascript" defer></script>
[[end]]
</body>
</html>