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:
This commit is contained in:
@@ -16,3 +16,13 @@ On top of the main Grafana development dependencies, you will need installed:
|
||||
To start the stack, from the root of the Grafana project run `make frontend-service-up`. Tilt will orchestrate the webpack and docker builds, and then run the services with Docker compose. You can monitor it's progress and see logs with the URL to the Tilt console. Once done, you can access Grafana at `http://localhost:3000`.
|
||||
|
||||
Quitting the process will not stop the service from running. Run `make frontend-service-down` when done to shut down the docker containers.
|
||||
|
||||
### Bootdata unavailable
|
||||
|
||||
To simulate the `/bootdata` endpoint being available, there are special control URLs you can visit that use cookies to control behaviour:
|
||||
|
||||
- `/-/down` - Simulates the endpoint being unavailable for 60 seconds.
|
||||
- `/-/down/:seconds` - Simulates the endpoint being unavailable for a custom number of seconds.
|
||||
- `/-/up` - Restores the endpoint to being available.
|
||||
|
||||
When unavailable, the API will return `HTTP 503 Service Unavailable` with a JSON payload.
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG BASE_IMAGE=alpine:3.21
|
||||
ARG GO_IMAGE=golang:1.24.4-alpine
|
||||
ARG GO_IMAGE=golang:1.24.5-alpine
|
||||
|
||||
# ----- Go build stage
|
||||
FROM ${GO_IMAGE} AS go-dev-builder
|
||||
|
||||
@@ -6,7 +6,7 @@ echo "Go build cache: $(go env GOCACHE), $(ls -1 $(go env GOCACHE) | wc -l) item
|
||||
# Need to build version into the binary so plugin compatibility works correctly
|
||||
VERSION=$(jq -r .version package.json)
|
||||
|
||||
go build \
|
||||
go build -v \
|
||||
-ldflags "-X main.version=${VERSION}" \
|
||||
-gcflags "all=-N -l" \
|
||||
-o ./bin/grafana ./pkg/cmd/grafana
|
||||
|
||||
@@ -9,10 +9,29 @@ upstream frontend {
|
||||
server frontend-service:3000;
|
||||
}
|
||||
|
||||
map "$request_method:$cookie_fs_unavailable" $reject_login {
|
||||
default 0;
|
||||
"POST:1" 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location ~ ^/-/down/?$ {
|
||||
add_header Set-Cookie "fs_unavailable=true; Max-Age=60; Path=/; HttpOnly" always;
|
||||
return 302 $scheme://$http_host/;
|
||||
}
|
||||
|
||||
location ~ ^/-/down/(?<age>\d+)/?$ {
|
||||
add_header Set-Cookie "fs_unavailable=true; Max-Age=$age; Path=/; HttpOnly" always;
|
||||
return 302 $scheme://$http_host/;
|
||||
}
|
||||
|
||||
location ~ ^/-/up/?$ {
|
||||
return 302 $scheme://$http_host/-/down/0;
|
||||
}
|
||||
|
||||
# Special‐case POST /login to backend, GET to frontend
|
||||
location = /login {
|
||||
proxy_set_header Host $host;
|
||||
@@ -20,6 +39,11 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
if ($reject_login) {
|
||||
add_header Content-Type application/json always;
|
||||
return 503 '{"code":"Loading", "message": "Soon!"}';
|
||||
}
|
||||
|
||||
if ($request_method = POST) {
|
||||
proxy_pass http://backend;
|
||||
break;
|
||||
@@ -37,6 +61,11 @@ server {
|
||||
# Cheat with app plugin paths and route them to the backend. These should come from
|
||||
# the Plugin CDN
|
||||
location ~ ^/(api|apis|bootdata|logout|public\/plugins\/grafana\-\w+\-app) {
|
||||
if ($cookie_fs_unavailable) {
|
||||
add_header Content-Type application/json always;
|
||||
return 503 '{"code":"Loading", "message": "Soon!"}';
|
||||
}
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html class="fs-loading">
|
||||
<head>
|
||||
[[ if and .CSPEnabled .IsDevelopmentEnv ]]
|
||||
<!-- Cypress overwrites CSP headers in HTTP requests, so this is required for e2e tests-->
|
||||
@@ -24,28 +24,133 @@
|
||||
performance.mark('frontend_boot_css_time_seconds');
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/*
|
||||
Dev indicator that the page was loaded from the FEMT index.html.
|
||||
TODO: Will remove before deploying to staging.
|
||||
*/
|
||||
.femt-dev-frame {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99999;
|
||||
pointer-events: none;
|
||||
border: 2px solid white;
|
||||
border-image-source: linear-gradient(to left, #F55F3E, #FF8833);
|
||||
border-image-slice: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="femt-dev-frame"></div>
|
||||
<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]]">
|
||||
@@ -56,46 +161,130 @@
|
||||
[[if .Assets.ContentDeliveryURL]]
|
||||
window.public_cdn_path = '[[.Assets.ContentDeliveryURL]]public/build/';
|
||||
[[end]]
|
||||
</script>
|
||||
|
||||
window.__grafana_load_failed = function(...args) {
|
||||
console.error('Failed to load Grafana', ...args);
|
||||
};
|
||||
<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.__grafana_boot_data_promise = new Promise(async (resolve) => {
|
||||
const bootData = await fetch("/bootdata");
|
||||
window.onload = function() {
|
||||
if (window.__grafana_app_bundle_loaded) {
|
||||
return;
|
||||
}
|
||||
window.__grafana_load_failed();
|
||||
};
|
||||
|
||||
const rawBootData = await bootData.json();
|
||||
let hasSetLoading = false;
|
||||
function setLoading() {
|
||||
if (hasSetLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.grafanaBootData = {
|
||||
_femt: true,
|
||||
...rawBootData,
|
||||
document.querySelector('.preloader').classList.add('fs-loader-starting-up');
|
||||
hasSetLoading = true;
|
||||
}
|
||||
|
||||
// The per-theme CSS still contains some global styles needed
|
||||
// to render the page correctly.
|
||||
const cssLink = document.createElement("link");
|
||||
cssLink.rel = 'stylesheet';
|
||||
const CHECK_INTERVAL = 1 * 1000;
|
||||
|
||||
let theme = window.grafanaBootData.user.theme;
|
||||
if (theme === "system") {
|
||||
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
theme = darkQuery.matches ? 'dark' : 'light';
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
|
||||
document.head.appendChild(cssLink);
|
||||
async function initGrafana() {
|
||||
const rawBootData = await loadBootData();
|
||||
|
||||
resolve();
|
||||
});
|
||||
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]]
|
||||
|
||||
Reference in New Issue
Block a user