418 lines
14 KiB
HTML
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>
|