Files
grafana/pkg/services/frontend/index.html
2025-11-28 12:53:38 +00:00

418 lines
14 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="icon" type="image/png" href="[[.Assets.ContentDeliveryURL]]public/build/img/fav32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="[[.Assets.ContentDeliveryURL]]public/build/img/apple-touch-icon.png" />
<link rel="mask-icon" href="[[.Assets.ContentDeliveryURL]]public/build/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";
line-height: 1; /* prevent shift when css loads in that changes the body line-height */
}
.fs-variant-loader, .fs-variant-error, .fs-custom-domain-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-custom-domain-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 class="fs-custom-domain-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>
<p class="fs-loader-text">Please, use your URL custom domain directly</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.
(() => {
// Grafana can only fail to load once
// However, it can fail to load in multiple different places
// To avoid double reporting the error, we use this boolean to check if we've already failed
let hasFailedToBoot = false;
window.__grafana_load_failed = function(err) {
if (hasFailedToBoot) {
return;
}
hasFailedToBoot = true;
console.error('Failed to load Grafana', err);
document.querySelector('.fs-variant-loader').classList.add('fs-hidden');
document.querySelector('.fs-variant-error').classList.remove('fs-hidden');
// not a secure random value, but collisions are highly unlikely and 1/1000_000_000 lost requests
// doesn't make a difference.
fetch(`/-/fe-boot-error?ts=${Date.now()}${Math.random()}`, {
// This "should" be a POST request, but we must use GET to interact with the correct service.
// no-store and ?ts=_ are used to ensure the request isn't cached.
method: 'GET',
cache: "no-store",
}).catch(err => {
console.error('Failed to report boot error to backend: ', err);
});
};
// Needed to stop retrying boot data when custom domain error occurs
// TODO remove when https://github.com/grafana/grafana/pull/113717 is released in backend
const CUSTOM_DOMAIN_ERROR = "Custom domain error";
window.__grafana_custom_domain_failed = function(err) {
console.error('Failed to load Grafana due to custom domain issue', err);
document.querySelector('.fs-variant-loader').classList.add('fs-hidden');
document.querySelector('.fs-custom-domain-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;
function getCookie(name) {
const cookies = document.cookie.split(";").map(c => c.trim());
for (const cookie of cookies) {
if (cookie.startsWith(name + "=")) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function getSessionExpiration() {
const value = getCookie("grafana_session_expiry") || "0";
const realExpiresSeconds = parseInt(value, 10);
const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration
const expiration = new Date(expiresSeconds * 1000);
return expiration;
}
async function rotateSession() {
await fetch('/api/user/auth-tokens/rotate', { method: 'POST' });
}
/**
* 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 queryParams = new URLSearchParams(window.location.search);
// pass the search params through to the bootdata request
// this allows for overriding the theme/language etc
const bootDataUrl = new URL('/bootdata', window.location.origin);
for (const [key, value] of queryParams.entries()) {
bootDataUrl.searchParams.append(key, value);
}
const resp = await fetch(bootDataUrl, {redirect: 'manual'});
// TODO remove when https://github.com/grafana/grafana/pull/113717 is released in backend
if (resp.type === 'opaqueredirect') {
window.__grafana_custom_domain_failed();
return CUSTOM_DOMAIN_ERROR;
}
// manual redirect for custom domains
// see pkg/middleware/validate_host.go
if (resp.status === 204) {
const redirectDomain = resp.headers.get('Redirect-Domain');
if (redirectDomain) {
window.location.hostname = redirectDomain;
return;
}
}
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 sessionExpiration = getSessionExpiration();
const now = new Date();
// If the session has expired, don't continue trying to fetch boot data
if (now >= sessionExpiration) {
await rotateSession();
}
} catch (error) {
// Just ignore any errors in session rotation. The user can just log in again.
console.warn("Failed to rotate session", error);
}
try {
const bootData = await fetchBootData();
// If the boot data is undefined, retry after a delay
if (!bootData) {
setLoading();
setTimeout(attemptFetch, CHECK_INTERVAL);
return;
// TODO remove when https://github.com/grafana/grafana/pull/113717 is released in backend
} else if (bootData === CUSTOM_DOMAIN_ERROR) {
return;
}
resolve(bootData);
} catch (error) {
reject(error);
}
};
// Start the first attempt immediately
attemptFetch();
});
}
async function initGrafana() {
// global config
window.grafanaBootData = {
_femt: true, // isFrontendService() needs this
assets: [[.Assets]],
navTree: [],
settings: [[.Settings]],
user: [[.DefaultUser]],
}
// Preserve a mix of values from the initial boot data, and from the backend
// - nav tree and user info come from the backend
// - merge settings from both. FS settings contains less values
// - build info edition comes from the backend
const { navTree, settings, user } = await loadBootData();
window.grafanaBootData.settings = {
...settings,
...window.grafanaBootData.settings,
}
window.grafanaBootData.navTree = navTree;
window.grafanaBootData.user = user;
if (settings?.buildInfo?.edition) {
window.grafanaBootData.settings.buildInfo.edition = settings.buildInfo.edition;
}
// The per-theme CSS still contains some global styles needed
// to render the page correctly.
const cssLink = document.createElement("link");
cssLink.rel = 'stylesheet';
const theme = window.grafanaBootData.user.theme;
if (theme === "system") {
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
window.grafanaBootData.user.lightTheme = !darkQuery.matches;
}
const isLightTheme = window.grafanaBootData.user.lightTheme;
document.body.classList.add(isLightTheme ? "theme-light" : "theme-dark");
cssLink.href = window.grafanaBootData.assets[isLightTheme ? 'light' : 'dark'];
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>