Skip to content

FS: Handle unavailable backend #108544

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions devenv/frontend-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion devenv/frontend-service/backend.dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion devenv/frontend-service/build-grafana.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions devenv/frontend-service/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,41 @@ 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;
proxy_set_header X-Real-IP $remote_addr;
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;
Expand All @@ -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;
Expand Down
259 changes: 211 additions & 48 deletions pkg/services/frontend/index.html
Original file line number Diff line number Diff line change
@@ -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-->
Expand All @@ -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="fs-loader">
<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;
}

.fs-loader {
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]]">
Expand All @@ -56,46 +161,104 @@
[[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('.fs-loader').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;

async function loadBootData() {
while (true) {
const resp = await fetch("/bootdata");

const textResponse = await resp.text();
let rawBootData;
try {
rawBootData = JSON.parse(textResponse);
} catch {
// If we haven't gotten a JSON response, there must be an error
throw new Error("Unexpected response type: " + textResponse);
}

if (resp.status === 503 && rawBootData.code === 'Loading') {
// If the response is 503, poll until we get a valid response.
setLoading();
await new Promise(resolve => setTimeout(resolve, CHECK_INTERVAL));
continue;
} else if (!resp.ok) {
// Or if we got a non-200 response, throw an error.
const errorText = await resp.text();
throw new Error("Unexpected response body: " + errorText);
}

let theme = window.grafanaBootData.user.theme;
if (theme === "system") {
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
theme = darkQuery.matches ? 'dark' : 'light';
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;
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);
}

document.head.appendChild(cssLink);

resolve();
});
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]]
Expand Down
7 changes: 7 additions & 0 deletions public/app/AppWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
this.setState({ ready: true });
$('.preloader').remove();

if (config.featureToggles.multiTenantFrontend) {
const fsLoaderEl = document.querySelector('.fs-loader');
if (fsLoaderEl) {
fsLoaderEl.parentNode?.removeChild(fsLoaderEl);
}
}

// clear any old icon caches
const cacheKeys = (await window.caches?.keys()) ?? [];
for (const key of cacheKeys) {
Expand Down
Loading