mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Closes #7866 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
640 lines
33 KiB
PHP
640 lines
33 KiB
PHP
<!DOCTYPE html>
|
|
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
<script data-navigate-once>
|
|
// Immediate theme application - runs before any rendering
|
|
(function () {
|
|
// The OS color picker only speaks hex. Convert it once so the whole theme
|
|
// cascade is authored in OKLCH (sRGB -> linear -> OKLab -> OKLCH).
|
|
const srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
// localStorage can hold values from older versions or manual edits, so accept
|
|
// only #rgb / #rrggbb and fall back to the default color for anything else.
|
|
window.normalizeThemeColor = (color) => {
|
|
const hex = String(color ?? '').trim().replace(/^#/, '').toLowerCase();
|
|
if (/^[a-f\d]{6}$/.test(hex)) return `#${hex}`;
|
|
if (/^[a-f\d]{3}$/.test(hex)) return `#${hex.replace(/./g, '$&$&')}`;
|
|
return '#6b16ed';
|
|
};
|
|
window.hexToOklch = (hex) => {
|
|
const [r, g, b] = window.normalizeThemeColor(hex).match(/[a-f\d]{2}/gi).map((channel) => srgbToLinear(parseInt(channel, 16) / 255));
|
|
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
const okL = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
|
|
const okA = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
|
const okB = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
|
const chroma = Math.sqrt(okA * okA + okB * okB);
|
|
let hue = Math.atan2(okB, okA) * 180 / Math.PI;
|
|
if (hue < 0) hue += 360;
|
|
return `oklch(${(okL * 100).toFixed(2)}% ${chroma.toFixed(4)} ${hue.toFixed(2)})`;
|
|
};
|
|
window.themeAccentForeground = (color) => {
|
|
const channels = window.normalizeThemeColor(color).match(/[a-f\d]{2}/gi).map(channel => parseInt(channel, 16) * 0.85 + 255 * 0.15);
|
|
const luminance = channels
|
|
.map(channel => channel / 255)
|
|
.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
|
|
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
|
|
|
return luminance > 0.179 ? 'oklch(0% 0 0)' : 'oklch(100% 0 0)';
|
|
};
|
|
// Custom theme: four colors. Its share code is "#sidebar,#selected,#accent,#background".
|
|
// Every text color is derived from the color under it, so text stays readable.
|
|
const strictHex = (color) => {
|
|
const hex = String(color ?? '').trim().replace(/^#/, '').toLowerCase();
|
|
if (/^[a-f\d]{6}$/.test(hex)) return `#${hex}`;
|
|
if (/^[a-f\d]{3}$/.test(hex)) return `#${hex.replace(/./g, '$&$&')}`;
|
|
return null;
|
|
};
|
|
const relativeLuminance = (hex) => hex.match(/[a-f\d]{2}/gi)
|
|
.map((channel) => srgbToLinear(parseInt(channel, 16) / 255))
|
|
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
|
const mixHex = (from, to, amount) => `#${[1, 3, 5].map((offset) => {
|
|
const start = parseInt(from.slice(offset, offset + 2), 16);
|
|
const end = parseInt(to.slice(offset, offset + 2), 16);
|
|
return Math.round(start + (end - start) * amount).toString(16).padStart(2, '0');
|
|
}).join('')}`;
|
|
// True when light text has more contrast on this color than dark text (WCAG ratios).
|
|
window.isDarkColor = (hex) => {
|
|
const luminance = relativeLuminance(window.normalizeThemeColor(hex));
|
|
return 1.05 / (luminance + 0.05) >= (luminance + 0.05) / (relativeLuminance('#18181b') + 0.05);
|
|
};
|
|
window.readableTextOn = (hex) => window.isDarkColor(hex) ? '#f4f4f5' : '#18181b';
|
|
window.customThemeKeys = ['sidebar', 'selected', 'accent', 'background'];
|
|
window.serializeCustomTheme = (theme) => window.customThemeKeys.map((key) => theme[key]).join(',');
|
|
// A theme from a share code, or null when the text is not four hex colors.
|
|
window.parseCustomTheme = (code) => {
|
|
const colors = String(code ?? '').split(/[\s,]+/).filter(Boolean).map(strictHex);
|
|
if (colors.length !== 4 || colors.includes(null)) return null;
|
|
return Object.fromEntries(window.customThemeKeys.map((key, index) => [key, colors[index]]));
|
|
};
|
|
// The saved theme. Older versions saved one color and a light/dark mode; build the four colors from them.
|
|
window.storedCustomTheme = () => {
|
|
const saved = window.parseCustomTheme(localStorage.customTheme);
|
|
if (saved) return saved;
|
|
const accent = window.normalizeThemeColor(localStorage.themeColor);
|
|
return localStorage.customMode === 'light'
|
|
? { sidebar: mixHex('#f4f4f5', accent, 0.06), selected: mixHex('#ffffff', accent, 0.16), accent, background: mixHex('#fafafa', accent, 0.03) }
|
|
: { sidebar: mixHex('#0c0c0e', accent, 0.1), selected: mixHex('#1c1c1f', accent, 0.3), accent, background: mixHex('#0f0f11', accent, 0.12) };
|
|
};
|
|
window.customThemePresets = [
|
|
// The built-in Dark theme, not a custom theme. Its colors start the fields when the user edits it. The sidebar is
|
|
// the color that the Dark sidebar shows: #120e1a with its 9% purple wash.
|
|
{ name: 'Coolify', theme: null, swatch: { sidebar: '#1a0f2d', selected: '#1c1c1c', accent: '#6b16ed', background: '#0a0a0a' } },
|
|
{ name: 'Midnight', theme: { sidebar: '#0f172a', selected: '#2e4a7d', accent: '#f2458f', background: '#1a2338' } },
|
|
{ name: 'Forest', theme: { sidebar: '#12342b', selected: '#2c6e57', accent: '#f4b860', background: '#1b2a25' } },
|
|
{ name: 'Ember', theme: { sidebar: '#3b1410', selected: '#8f2d1f', accent: '#ffb86b', background: '#241816' } },
|
|
{ name: 'Slate', theme: { sidebar: '#2b3038', selected: '#4a5361', accent: '#6cc4f5', background: '#353b45' } },
|
|
{ name: 'Nord', theme: { sidebar: '#2e3440', selected: '#4c566a', accent: '#88c0d0', background: '#3b4252' } },
|
|
{ name: 'Dracula', theme: { sidebar: '#21222c', selected: '#44475a', accent: '#bd93f9', background: '#282a36' } },
|
|
{ name: 'GitHub Dark', theme: { sidebar: '#010409', selected: '#21262d', accent: '#2f81f7', background: '#0d1117' } },
|
|
{ name: 'Aubergine', theme: { sidebar: '#3f0e40', selected: '#1164a3', accent: '#1164a3', background: '#ffffff' } },
|
|
{ name: 'GitHub Light', theme: { sidebar: '#f6f8fa', selected: '#dde4ec', accent: '#0969da', background: '#ffffff' } },
|
|
{ name: 'Paper', theme: { sidebar: '#ebe6da', selected: '#d9d1bf', accent: '#c2410c', background: '#f7f4ec' } },
|
|
{ name: 'Blossom', theme: { sidebar: '#fde8f1', selected: '#f9c4dc', accent: '#d61f69', background: '#fff7fa' } },
|
|
];
|
|
// Sets the variables that app.css derives the custom theme from. The sidebar gets its own
|
|
// light/dark scheme (data-sidebar-scheme) when it differs from the page.
|
|
window.applyCustomTheme = (theme) => {
|
|
const root = document.documentElement;
|
|
const isDark = window.isDarkColor(theme.background);
|
|
const sidebarIsDark = window.isDarkColor(theme.sidebar);
|
|
Object.entries({
|
|
'--theme-bg': theme.background,
|
|
'--theme-text': window.readableTextOn(theme.background),
|
|
'--theme-light': isDark ? '0' : '1',
|
|
'--theme-sidebar': theme.sidebar,
|
|
'--theme-sidebar-text': window.readableTextOn(theme.sidebar),
|
|
'--theme-sidebar-light': sidebarIsDark ? '0' : '1',
|
|
'--theme-selected': theme.selected,
|
|
'--theme-selected-text': window.readableTextOn(theme.selected),
|
|
'--theme-base-color': window.hexToOklch(theme.accent),
|
|
'--theme-accent-foreground': window.themeAccentForeground(theme.accent),
|
|
}).forEach(([name, value]) => root.style.setProperty(name, value));
|
|
root.dataset.theme = 'custom';
|
|
root.classList.toggle('dark', isDark);
|
|
if (sidebarIsDark === isDark) {
|
|
delete root.dataset.sidebarScheme;
|
|
} else {
|
|
root.dataset.sidebarScheme = sidebarIsDark ? 'dark' : 'light';
|
|
}
|
|
document.querySelector('meta[name=theme-color]')?.setAttribute('content', theme.background);
|
|
};
|
|
window.applyStoredTheme = () => {
|
|
const root = document.documentElement;
|
|
const theme = localStorage.theme === 'purple' ? 'custom' : (localStorage.theme || 'dark');
|
|
root.dataset.navStyle = localStorage.navStyle === 'floating' ? 'floating' : 'default';
|
|
root.dataset.navItems = localStorage.navItems === 'flat' ? 'flat' : '3d';
|
|
localStorage.theme = theme;
|
|
if (localStorage.themeColor) localStorage.themeColor = window.normalizeThemeColor(localStorage.themeColor);
|
|
if (theme === 'custom') {
|
|
window.applyCustomTheme(window.storedCustomTheme());
|
|
return;
|
|
}
|
|
|
|
const isDark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
root.classList.toggle('dark', isDark);
|
|
root.dataset.theme = isDark ? 'dark' : 'light';
|
|
delete root.dataset.sidebarScheme;
|
|
root.style.setProperty('--theme-base-color', window.hexToOklch(localStorage.themeColor));
|
|
root.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(localStorage.themeColor));
|
|
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
|
};
|
|
// Time lines for chart tooltips. By default only the browser time zone is shown;
|
|
// the "Chart times" option on the Appearance page adds the UTC time.
|
|
window.chartTooltipTime = (timestamp, { seconds = true } = {}) => {
|
|
const format = (timeZone) => new Date(timestamp).toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
...(seconds ? { second: '2-digit' } : {}),
|
|
hourCycle: 'h23',
|
|
...(timeZone ? { timeZone, timeZoneName: 'short' } : {}),
|
|
});
|
|
if (localStorage.getItem('chartTimes') !== 'utc') {
|
|
return `<div class="apexcharts-tooltip-custom-title">${format()}</div>`;
|
|
}
|
|
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
return `<div class="apexcharts-tooltip-custom-title">Your time: ${format(localTimeZone)}</div>
|
|
<div class="apexcharts-tooltip-custom-title">UTC: ${format('UTC')}</div>`;
|
|
};
|
|
// Shows the tooltip at the same time on every chart of a group while one of them is hovered.
|
|
// ApexCharts' own chart.group needs equal widths and equal x ranges, so it does not
|
|
// fit side-by-side cards. Instead, replay the hover as a mouse event at the matching x.
|
|
// Charts can join a group from separate scripts; charts removed from the page drop out.
|
|
window.chartSyncGroups ??= {};
|
|
window.syncChartTooltips = (group, charts) => {
|
|
const grid = chart => chart.el.querySelector('.apexcharts-grid')?.getBoundingClientRect();
|
|
const hoverArea = chart => chart.el.querySelector('.apexcharts-svg');
|
|
const hide = chart => hoverArea(chart)?.dispatchEvent(new MouseEvent('mouseout', { clientX: 0, clientY: -1 }));
|
|
const peers = source => window.chartSyncGroups[group].filter(chart => chart !== source && chart.el.isConnected);
|
|
|
|
window.chartSyncGroups[group] = [
|
|
...(window.chartSyncGroups[group] ?? []).filter(chart => chart.el.isConnected),
|
|
...charts,
|
|
];
|
|
|
|
charts.forEach(source => {
|
|
source.el.addEventListener('mousemove', event => {
|
|
// Replayed events are untrusted; skip them so charts do not echo each other.
|
|
const rect = grid(source);
|
|
const { minX, maxX } = source.w.globals;
|
|
if (!event.isTrusted || !rect?.width || !(maxX > minX)) {
|
|
return;
|
|
}
|
|
// ApexCharts hides the source tooltip outside the plot grid; hide the peers too.
|
|
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
|
|
peers(source).forEach(hide);
|
|
return;
|
|
}
|
|
const timestamp = minX + (event.clientX - rect.left) / rect.width * (maxX - minX);
|
|
|
|
peers(source).forEach(chart => {
|
|
const target = grid(chart);
|
|
const { minX: min, maxX: max } = chart.w.globals;
|
|
if (!target?.width || !(max > min) || timestamp < min || timestamp > max) {
|
|
hide(chart);
|
|
return;
|
|
}
|
|
hoverArea(chart)?.dispatchEvent(new MouseEvent('mousemove', {
|
|
clientX: target.left + (timestamp - min) / (max - min) * target.width,
|
|
clientY: target.top + target.height / 2,
|
|
}));
|
|
});
|
|
});
|
|
source.el.addEventListener('mouseleave', () => peers(source).forEach(hide));
|
|
});
|
|
};
|
|
// Alpine state for the theme controls on the Appearance page (x-data="themeControls()").
|
|
window.themeControls = () => ({
|
|
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
|
|
customTheme: window.storedCustomTheme(),
|
|
pageWidth: localStorage.getItem('pageWidth') || 'centered',
|
|
navStyle: localStorage.getItem('navStyle') === 'floating' ? 'floating' : 'default',
|
|
navItems: localStorage.getItem('navItems') === 'flat' ? 'flat' : '3d',
|
|
chartTimes: localStorage.getItem('chartTimes') === 'utc' ? 'utc' : 'local',
|
|
presets: window.customThemePresets,
|
|
// The custom theme dialog edits a draft; Save applies it. A null draft is the built-in Dark theme.
|
|
dialogOpen: false,
|
|
draft: null,
|
|
get draftColors() {
|
|
return this.draft ?? window.customThemePresets[0].swatch;
|
|
},
|
|
shareCode: '',
|
|
shareCodeInvalid: false,
|
|
shareCodeCopied: false,
|
|
setTheme(type) {
|
|
this.theme = type;
|
|
localStorage.setItem('theme', type);
|
|
window.applyStoredTheme();
|
|
},
|
|
openCustomDialog() {
|
|
this.setDraft(this.theme === 'custom' ? { ...this.customTheme } : null);
|
|
this.dialogOpen = true;
|
|
},
|
|
setDraft(theme) {
|
|
this.draft = theme;
|
|
this.shareCode = window.serializeCustomTheme(this.draftColors);
|
|
this.shareCodeInvalid = false;
|
|
},
|
|
setDraftColor(key, value) {
|
|
const theme = window.parseCustomTheme(window.serializeCustomTheme({ ...this.draftColors, [key]: value }));
|
|
if (theme) {
|
|
this.setDraft(theme);
|
|
}
|
|
},
|
|
readShareCode(code) {
|
|
const theme = window.parseCustomTheme(code);
|
|
if (theme) {
|
|
this.setDraft(theme);
|
|
return;
|
|
}
|
|
this.shareCodeInvalid = code.trim() !== '';
|
|
},
|
|
async copyShareCode() {
|
|
try {
|
|
await navigator.clipboard.writeText(this.shareCode);
|
|
this.shareCodeCopied = true;
|
|
setTimeout(() => this.shareCodeCopied = false, 1500);
|
|
} catch {
|
|
this.$refs.shareCode?.select();
|
|
}
|
|
},
|
|
isDraftPreset(preset) {
|
|
return preset.theme === null || this.draft === null
|
|
? preset.theme === this.draft
|
|
: window.serializeCustomTheme(preset.theme) === window.serializeCustomTheme(this.draft);
|
|
},
|
|
textOn(color) {
|
|
return window.readableTextOn(color);
|
|
},
|
|
accentTextOn(color) {
|
|
return window.themeAccentForeground(color);
|
|
},
|
|
saveCustomTheme() {
|
|
this.dialogOpen = false;
|
|
if (this.draft === null) {
|
|
localStorage.removeItem('customTheme');
|
|
this.setTheme('dark');
|
|
return;
|
|
}
|
|
this.customTheme = { ...this.draft };
|
|
localStorage.setItem('customTheme', window.serializeCustomTheme(this.customTheme));
|
|
localStorage.setItem('themeColor', this.customTheme.accent);
|
|
this.setTheme('custom');
|
|
},
|
|
setWidth(width) {
|
|
this.pageWidth = width;
|
|
localStorage.setItem('pageWidth', width);
|
|
window.dispatchEvent(new CustomEvent('page-width-changed', { detail: width }));
|
|
},
|
|
setNavStyle(style) {
|
|
this.navStyle = style;
|
|
localStorage.setItem('navStyle', style);
|
|
document.documentElement.dataset.navStyle = style;
|
|
},
|
|
setNavItems(style) {
|
|
this.navItems = style;
|
|
localStorage.setItem('navItems', style);
|
|
document.documentElement.dataset.navItems = style;
|
|
},
|
|
setChartTimes(value) {
|
|
this.chartTimes = value;
|
|
localStorage.setItem('chartTimes', value);
|
|
},
|
|
});
|
|
|
|
// wire:navigate copies the server <html> attributes over the live ones, which removes
|
|
// the theme class and colors. Apply them again in the same task as the swap, before
|
|
// the browser can paint the new page (livewire:navigated can run after a paint).
|
|
document.addEventListener('livewire:navigating', (event) => event.detail.onSwap(window.applyStoredTheme));
|
|
window.applyStoredTheme();
|
|
})();
|
|
</script>
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
/* The page background in the saved theme before app.css loads, so a full page load does not
|
|
paint the browser default (white with a light OS theme). :where() keeps app.css in charge after it loads. */
|
|
:where(html) { background-color: oklch(98.5% 0 0); }
|
|
:where(html.dark) { background-color: oklch(14.48% 0 0); }
|
|
:where(html[data-theme="custom"]) { background-color: var(--theme-bg); }
|
|
</style>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
<meta name="theme-color" content="#101010" id="theme-color-meta" />
|
|
<meta name="color-scheme" content="dark light" />
|
|
<meta name="Description" content="Coolify: An open-source & self-hostable Heroku / Netlify / Vercel alternative" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:site" content="@coolifyio" />
|
|
<meta name="twitter:title" content="Coolify" />
|
|
<meta name="twitter:description" content="An open-source & self-hostable Heroku / Netlify / Vercel alternative." />
|
|
<meta name="twitter:image" content="https://cdn.coollabs.io/og-images/coolify.png" />
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content="https://coolify.io" />
|
|
<meta property="og:title" content="Coolify" />
|
|
<meta property="og:description" content="An open-source & self-hostable Heroku / Netlify / Vercel alternative." />
|
|
<meta property="og:site_name" content="Coolify" />
|
|
<meta property="og:image" content="https://cdn.coollabs.io/og-images/coolify.png" />
|
|
@use('App\Models\InstanceSettings')
|
|
@php
|
|
|
|
$instanceSettings = instanceSettings();
|
|
$name = null;
|
|
|
|
if ($instanceSettings) {
|
|
$displayName = $instanceSettings->getTitleDisplayName();
|
|
|
|
if (strlen($displayName) > 0) {
|
|
$name = $displayName . ' ';
|
|
}
|
|
}
|
|
@endphp
|
|
<title>{{ $name }}{{ $title ?? 'Coolify' }}</title>
|
|
@env('local')
|
|
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/png" />
|
|
@else
|
|
<link rel="icon" href="{{ asset('favicon.ico') }}" sizes="32x32" />
|
|
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
|
|
@endenv
|
|
<link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}" />
|
|
<link rel="manifest" href="{{ asset('site.webmanifest') }}" />
|
|
<meta name="apple-mobile-web-app-title" content="Coolify" />
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
@vite(['resources/js/app.js', 'resources/css/app.css'])
|
|
<script>
|
|
// Update theme-color meta tag (non-critical, can run async)
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
document.getElementById('theme-color-meta')?.setAttribute('content', document.documentElement.dataset.theme === 'custom'
|
|
? window.storedCustomTheme().background
|
|
: (isDark ? '#101010' : '#ffffff'));
|
|
</script>
|
|
<style>
|
|
[x-cloak] {
|
|
display: none !important;
|
|
}
|
|
</style>
|
|
@if (config('app.name') == 'Coolify Cloud')
|
|
<script defer data-domain="app.coolify.io" src="https://analytics.coollabs.io/js/plausible.js"></script>
|
|
@endif
|
|
@auth
|
|
<script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script>
|
|
<script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script>
|
|
<script type="text/javascript" src="{{ URL::asset('js/apexcharts.js') }}"></script>
|
|
<script type="text/javascript" src="{{ URL::asset('js/purify.min.js') }}"></script>
|
|
@endauth
|
|
</head>
|
|
@section('body')
|
|
|
|
<body class="dark:text-inherit text-black">
|
|
<x-toast />
|
|
<x-icon-tooltip />
|
|
<script data-navigate-once>
|
|
// Global HTML sanitization function using DOMPurify
|
|
window.sanitizeHTML = function (html) {
|
|
if (!html) return '';
|
|
const URL_RE = /^(https?:|mailto:)/i;
|
|
const config = {
|
|
ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'mark', 'p', 'pre', 's', 'span', 'strong',
|
|
'u'
|
|
],
|
|
ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'],
|
|
ALLOW_DATA_ATTR: false,
|
|
FORBID_TAGS: ['script', 'object', 'embed', 'applet', 'iframe', 'form', 'input', 'button', 'select',
|
|
'textarea', 'details', 'summary', 'dialog', 'style'
|
|
],
|
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange',
|
|
'onsubmit', 'ontoggle', 'style'
|
|
],
|
|
KEEP_CONTENT: true,
|
|
RETURN_DOM: false,
|
|
RETURN_DOM_FRAGMENT: false,
|
|
SANITIZE_DOM: true,
|
|
SANITIZE_NAMED_PROPS: true,
|
|
SAFE_FOR_TEMPLATES: true,
|
|
ALLOWED_URI_REGEXP: URL_RE
|
|
};
|
|
|
|
// One-time hook registration (idempotent pattern)
|
|
if (!window.__dpLinkHook) {
|
|
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
|
// Remove Alpine.js directives to prevent XSS
|
|
if (node.hasAttributes && node.hasAttributes()) {
|
|
const attrs = Array.from(node.attributes);
|
|
attrs.forEach(attr => {
|
|
// Remove x-* attributes (Alpine directives)
|
|
if (attr.name.startsWith('x-')) {
|
|
node.removeAttribute(attr.name);
|
|
}
|
|
// Remove @* attributes (Alpine event shorthand)
|
|
if (attr.name.startsWith('@')) {
|
|
node.removeAttribute(attr.name);
|
|
}
|
|
// Remove :* attributes (Alpine binding shorthand)
|
|
if (attr.name.startsWith(':')) {
|
|
node.removeAttribute(attr.name);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Existing link sanitization
|
|
if (node.nodeName === 'A' && node.hasAttribute('href')) {
|
|
const href = node.getAttribute('href') || '';
|
|
if (!URL_RE.test(href)) node.removeAttribute('href');
|
|
if (node.getAttribute('target') === '_blank') {
|
|
node.setAttribute('rel', 'noopener noreferrer');
|
|
}
|
|
}
|
|
});
|
|
window.__dpLinkHook = true;
|
|
}
|
|
return DOMPurify.sanitize(html, config);
|
|
};
|
|
|
|
// Initialize theme if not set
|
|
if (!('theme' in localStorage)) {
|
|
localStorage.theme = 'dark';
|
|
}
|
|
|
|
let theme = localStorage.theme
|
|
let cpuColor = '#1e90ff'
|
|
let ramColor = '#00ced1'
|
|
// Chart text follows the CSS text color, so it stays readable after a theme change without a re-render
|
|
// (for example a page that Livewire restores from its cache on Back).
|
|
const textColor = 'currentColor'
|
|
let editorBackground = '#181818'
|
|
let editorTheme = 'blackboard'
|
|
|
|
function checkTheme() {
|
|
theme = localStorage.theme
|
|
if (theme == 'system') {
|
|
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
}
|
|
if (theme == 'dark' || (theme == 'custom' && document.documentElement.classList.contains('dark'))) {
|
|
cpuColor = '#1e90ff'
|
|
ramColor = '#00ced1'
|
|
editorBackground = '#181818'
|
|
editorTheme = 'blackboard'
|
|
} else {
|
|
cpuColor = '#1e90ff'
|
|
ramColor = '#00ced1'
|
|
editorBackground = '#ffffff'
|
|
editorTheme = null
|
|
}
|
|
}
|
|
// Chart legend in a card header: one entry per series with its last known value.
|
|
// items: [{ name, color, data: [[timestamp, value], ...], formatter }]
|
|
function renderChartLegend(element, items) {
|
|
if (!element) return;
|
|
element.replaceChildren(...items.map(({ name, color, data, formatter }) => {
|
|
const last = (data || []).findLast(point => point?.[1] !== null && point?.[1] !== undefined);
|
|
const item = document.createElement('span');
|
|
item.className = 'inline-flex items-center gap-1.5';
|
|
const dot = document.createElement('span');
|
|
dot.className = 'size-2 shrink-0 rounded-full';
|
|
dot.style.backgroundColor = color;
|
|
const label = document.createElement('span');
|
|
label.className = 'text-neutral-500 dark:text-fg-dim';
|
|
label.textContent = name;
|
|
item.append(dot, label);
|
|
if (last) {
|
|
const value = document.createElement('span');
|
|
value.className = 'font-medium tabular-nums text-black dark:text-fg';
|
|
value.textContent = formatter(last[1]);
|
|
item.append(value);
|
|
}
|
|
return item;
|
|
}));
|
|
}
|
|
@auth
|
|
window.Pusher = Pusher;
|
|
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
|
|
window.Echo = new EchoConstructor({
|
|
broadcaster: 'reverb',
|
|
key: "{{ config('constants.pusher.app_key') }}" || 'coolify',
|
|
wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname,
|
|
wsPort: "{{ getRealtime() }}",
|
|
wssPort: "{{ getRealtime() }}",
|
|
forceTLS: window.location.protocol === 'https:',
|
|
encrypted: true,
|
|
enableStats: false,
|
|
enableLogging: true,
|
|
enabledTransports: ['ws', 'wss'],
|
|
disableStats: true,
|
|
// Add auto reconnection settings
|
|
enabledTransports: ['ws', 'wss'],
|
|
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
|
|
// Attempt to reconnect on connection lost
|
|
autoReconnect: true,
|
|
// Wait 1 second before first reconnect attempt
|
|
reconnectionDelay: 1000,
|
|
// Maximum delay between reconnection attempts
|
|
maxReconnectionDelay: 1000,
|
|
// Multiply delay by this number for each reconnection attempt
|
|
reconnectionDelayGrowth: 1,
|
|
// Maximum number of reconnection attempts
|
|
maxAttempts: 15
|
|
});
|
|
@endauth
|
|
let checkHealthInterval = null;
|
|
let checkIfIamDeadInterval = null;
|
|
|
|
document.addEventListener('livewire:init', () => {
|
|
window.Livewire.on('reloadWindow', (timeout) => {
|
|
if (timeout) {
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, timeout);
|
|
return;
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
})
|
|
window.Livewire.on('info', (message) => {
|
|
if (typeof message === 'string') {
|
|
window.toast('Info', {
|
|
type: 'info',
|
|
description: message,
|
|
})
|
|
return;
|
|
}
|
|
if (message.length == 1) {
|
|
window.toast('Info', {
|
|
type: 'info',
|
|
description: message[0],
|
|
})
|
|
} else if (message.length == 2) {
|
|
window.toast(message[0], {
|
|
type: 'info',
|
|
description: message[1],
|
|
})
|
|
}
|
|
})
|
|
window.Livewire.on('error', (message) => {
|
|
if (typeof message === 'string') {
|
|
window.toast('Error', {
|
|
type: 'danger',
|
|
description: message,
|
|
})
|
|
return;
|
|
}
|
|
if (message.length == 1) {
|
|
window.toast('Error', {
|
|
type: 'danger',
|
|
description: message[0],
|
|
})
|
|
} else if (message.length == 2) {
|
|
window.toast(message[0], {
|
|
type: 'danger',
|
|
description: message[1],
|
|
})
|
|
}
|
|
})
|
|
window.Livewire.on('warning', (message) => {
|
|
if (typeof message === 'string') {
|
|
window.toast('Warning', {
|
|
type: 'warning',
|
|
description: message,
|
|
})
|
|
return;
|
|
}
|
|
if (message.length == 1) {
|
|
window.toast('Warning', {
|
|
type: 'warning',
|
|
description: message[0],
|
|
})
|
|
} else if (message.length == 2) {
|
|
window.toast(message[0], {
|
|
type: 'warning',
|
|
description: message[1],
|
|
})
|
|
}
|
|
})
|
|
window.Livewire.on('success', (message) => {
|
|
if (typeof message === 'string') {
|
|
window.toast('Success', {
|
|
type: 'success',
|
|
description: message,
|
|
})
|
|
return;
|
|
}
|
|
if (message.length == 1) {
|
|
window.toast('Success', {
|
|
type: 'success',
|
|
description: message[0],
|
|
})
|
|
} else if (message.length == 2) {
|
|
window.toast(message[0], {
|
|
type: 'success',
|
|
description: message[1],
|
|
})
|
|
}
|
|
})
|
|
});
|
|
</script>
|
|
</body>
|
|
@show
|
|
|
|
</html>
|