templates/index.html
Rama main · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="{{ html_lang }}" dir="{{ html_dir }}" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Traefik Manager</title>
<meta name="csrf-token" content="{{ csrf_token }}">
<meta name="tm-base-path" content="{{ base_path }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ base_path }}/static/icons/icon.png">
<link rel="apple-touch-icon" href="{{ base_path }}/static/icons/icon-192x192.png">
<link rel="manifest" href="{{ base_path }}/static/manifest.json">
<meta name="theme-color" content="#0d1117">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Traefik Manager">
<script type="application/json" id="i18n-catalog">{{ i18n_catalog | tojson }}</script>
<script src="{{ base_path }}/static/js/i18n.js?v={{ asset_version }}"></script>
<script>
window.TM_DEFAULT_THEME = '{{ default_theme }}' || 'dark';
window.TM_UI_PREFS = {{ ui_prefs | tojson }};
window.TM_LANGUAGE = {{ language_setting | tojson }};
window.TM_LANGUAGE_TAGS = {{ language_options | map(attribute='tag') | list | tojson }};
(function() {
var server = window.TM_UI_PREFS || {};
var t = localStorage.getItem('tm-theme') || window.TM_DEFAULT_THEME;
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = t === 'dark' || (t === 'system' && prefersDark);
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-tm-pref', t);
function pref(key, dflt) {
if (Object.prototype.hasOwnProperty.call(server, key)) return server[key];
var v = localStorage.getItem(key);
return v === null ? dflt : v !== 'false';
}
var d = document.documentElement.classList;
if (!pref('showStatCards', true)) d.add('tm-hide-stats');
if (pref('compactStatCards', false)) d.add('tm-compact-stats');
if (!pref('showEntrypoints', true)) d.add('tm-hide-entrypoints');
if (!pref('showDocsLink', true)) d.add('tm-hide-docs');
if (!pref('showApiLink', false)) d.add('tm-hide-api');
if (!pref('showShortcutsBtn', true)) d.add('tm-hide-shortcuts');
if (!pref('showIpDiagBtn', true)) d.add('tm-hide-ipdiag');
if (!pref('showLangPicker', true)) d.add('tm-hide-lang');
var _lm = pref('layoutMode', 'fluid');
if (_lm === 'classic') _lm = 'fixed';
if (_lm === 'fixed') d.add('tm-fixed');
if (localStorage.getItem('tm-nav-collapsed') === '1') d.add('tm-nav-collapsed');
var sbs = pref('statBarScope', 'all');
var sbSet = (sbs === 'all' || sbs === true || sbs === undefined || sbs === null || sbs === '')
? ['dashboard', 'services', 'middlewares', 'live']
: (sbs === 'none' ? [] : String(sbs).split(','));
if (sbSet.indexOf('services') !== -1) d.add('tm-stats-here');
})();
</script>
<style>
html.tm-hide-stats #statsPanel { display: none !important; }
html.tm-hide-stats.tm-hide-entrypoints #overviewSection { display: none !important; }
html.tm-hide-entrypoints #entrypointsBar { display: none !important; }
html:not(.tm-stats-here) #overviewSection { display: none !important; }
html:not(.tm-stats-here) #entrypointsBar { display: none !important; }
html.tm-hide-docs .nav-docs-link { display: none !important; }
html.tm-hide-api .nav-api-link { display: none !important; }
html.tm-hide-shortcuts .shortcuts-btn-wrap { display: none !important; }
html.tm-hide-ipdiag .nav-ipdiag-btn { display: none !important; }
html.tm-hide-lang .nav-lang-picker { display: none !important; }
.tm-theme-icon { display: none; }
html[data-tm-pref="dark"] .tm-theme-icon[data-pref="dark"],
html[data-tm-pref="light"] .tm-theme-icon[data-pref="light"],
html[data-tm-pref="system"] .tm-theme-icon[data-pref="system"],
html:not([data-tm-pref]) .tm-theme-icon[data-pref="dark"] { display: inline; }
.tab-optional { display: none; }
.tm-geo-map { width: 100%; min-width: 0; overflow: hidden; }
.tm-geo-map svg { width: 100%; height: auto; display: block; max-width: 100%; }
.tm-geo-grid { display: grid; gap: 16px; grid-template-columns: minmax(0,2fr) minmax(0,1fr); align-items: start; }
@media (max-width: 640px) { .tm-geo-grid { grid-template-columns: 1fr; } }
.tm-worldmap { width: 100%; height: auto; display: block; }
.tm-worldmap path { fill: var(--input-bg); stroke: var(--card); stroke-width: 0.3; }
[data-theme="light"] .tm-worldmap path { fill: #d8dee4; }
.tm-worldmap path.tm-geo-active { cursor: pointer; }
.tm-worldmap path.tm-geo-active:hover { stroke: var(--text); stroke-width: 0.6; }
.tm-worldmap path.tm-geo-selected { stroke: var(--text); stroke-width: 0.8; }
.tm-geo-tooltip { position: fixed; z-index: 400; pointer-events: none; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 12px; color: var(--text); box-shadow: 0 2px 10px rgba(0,0,0,0.3); white-space: nowrap; }
</style>
<link rel="preload" href="{{ base_path }}/static/vendor/phosphor/Phosphor-Bold.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ base_path }}/static/vendor/phosphor/Phosphor-Fill.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ base_path }}/static/vendor/phosphor/Phosphor-Light.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="{{ base_path }}/static/vendor/fonts/inter.css">
<link rel="stylesheet" href="{{ base_path }}/static/vendor/fonts/jetbrains-mono.css">
<link rel="stylesheet" href="{{ base_path }}/static/vendor/phosphor.css">
<link rel="stylesheet" href="{{ base_path }}/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="{{ base_path }}/static/css/tailwind.css">
<script src="{{ base_path }}/static/vendor/qrcode.min.js"></script>
</head>
<body class="min-h-screen font-sans">
{% if no_auth %}
<div id="noAuthBanner" style="background:var(--red);color:#fff;text-align:center;padding:8px 16px;font-size:13px;font-weight:600;position:sticky;top:0;z-index:99997;">
<i class="ph-bold ph-warning" style="margin-right:6px"></i>
{{ _('No authentication is active - this interface is publicly accessible. Enable a password or OIDC in Settings - Authentication.') }}
</div>
{% endif %}
<div id="customConfirmOverlay" style="display:none;position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.5);backdrop-filter:blur(3px);align-items:center;justify-content:center;">
<div style="max-width:440px;width:90%;background:var(--card);border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,0.4);">
<div style="display:flex;align-items:center;gap:10px;padding:18px 22px 0">
<i id="customConfirmIcon" class="ph-bold ph-warning-circle" style="display:none;font-size:18px;color:var(--red);flex-shrink:0"></i>
<p class="font-semibold text-sm" style="color:var(--text);margin:0" id="customConfirmTitle"></p>
</div>
<div style="padding:10px 22px 0">
<p class="text-sm" style="color:var(--muted);line-height:1.6;margin:0" id="customConfirmMsg"></p>
</div>
<div id="customConfirmNotes" style="display:none;flex-direction:column;gap:8px;padding:14px 22px 0"></div>
<div id="customConfirmCheckWrap" style="display:none;padding:14px 22px 0">
<label class="confirm-opt">
<input type="checkbox" id="customConfirmCheck" style="accent-color:var(--red);margin-top:1px;flex-shrink:0;width:15px;height:15px;cursor:pointer">
<span id="customConfirmCheckLabel"></span>
</label>
</div>
<div id="customConfirmTypeWrap" style="display:none;padding:16px 22px 0">
<label class="text-xs block mb-1.5" style="color:var(--muted)">{% set confirm_word %}<button type="button" id="customConfirmWord" title="{{ _('Click to copy') }}" style="color:var(--red);font-weight:700;background:none;border:none;padding:0 2px;cursor:copy;font-size:inherit;font-family:inherit;word-break:break-all;text-align:left" translate="no"></button>{% endset %}{{ _('Type %(confirm_word)s to confirm', confirm_word=confirm_word) }}</label>
<input type="text" id="customConfirmType" class="input-field" autocomplete="off" spellcheck="false" placeholder="">
</div>
<div class="flex gap-2 justify-end" style="padding:18px 22px;margin-top:4px">
<button id="customConfirmCancel" class="btn-secondary text-xs">{{ pgettext('button', 'Cancel') }}</button>
<button id="customConfirmOk" class="btn-primary text-xs">{{ pgettext('button', 'Confirm') }}</button>
</div>
</div>
</div>
<div id="staticYamlPopout" style="display:none;position:fixed;inset:0;z-index:9998;background:var(--bg);flex-direction:column;">
<div class="flex items-center justify-between px-6 py-3.5" style="border-bottom:1px solid var(--border);flex-shrink:0;">
<div class="flex items-center gap-2">
<i class="ph-bold ph-code" style="color:var(--purple)"></i>
<span class="font-semibold text-sm">{{ _('Raw YAML - traefik.yml') }}</span>
</div>
<div class="flex gap-2">
<button onclick="saveStaticPopout()" class="btn-primary text-xs"><i class="ph-bold ph-floppy-disk mr-1"></i>{{ pgettext('button', 'Save') }}</button>
<button onclick="closeStaticYamlPopout()" class="btn-secondary text-xs"><i class="ph-bold ph-arrows-in mr-1"></i>{{ pgettext('button', 'Close') }}</button>
</div>
</div>
<div id="staticYamlPopoutEditor" style="flex:1;min-height:0;"></div>
</div>
<div id="routeYamlPopout" style="display:none;position:fixed;inset:0;z-index:9998;background:var(--bg);flex-direction:column;">
<div class="flex items-center justify-between px-6 py-3.5" style="border-bottom:1px solid var(--border);flex-shrink:0;">
<div class="flex items-center gap-2">
<i class="ph-bold ph-code" style="color:var(--blue)"></i>
<span class="font-semibold text-sm" id="routeYamlPopoutTitle">{{ _('Raw YAML') }}</span>
</div>
<div class="flex gap-2">
<button onclick="saveRouteYaml()" class="btn-primary text-xs"><i class="ph-bold ph-floppy-disk mr-1"></i>{{ pgettext('button', 'Save') }}</button>
<button onclick="closeRouteYamlEditor()" class="btn-secondary text-xs"><i class="ph-bold ph-arrows-in mr-1"></i>{{ pgettext('button', 'Close') }}</button>
</div>
</div>
<div id="routeYamlOrigins" class="px-6 py-2 text-xs" style="display:none;flex-shrink:0;border-bottom:1px solid var(--border);color:var(--muted);"></div>
<div id="routeYamlPopoutEditor" style="flex:1;min-height:0;"></div>
</div>
<div id="gitDiffPopout" style="display:none;position:fixed;inset:0;z-index:9999;background:var(--bg);flex-direction:column;">
<div class="flex items-center justify-between px-6 py-3.5" style="border-bottom:1px solid var(--border);flex-shrink:0;">
<div class="flex items-center gap-3" style="min-width:0;flex:1;">
<div class="flex items-center gap-2 flex-shrink-0">
<i class="ph-bold ph-git-diff" style="color:var(--blue)"></i>
<span class="font-semibold text-sm" id="gitDiffPopoutTitle">{{ pgettext('label', 'Diff') }}</span>
</div>
<div id="gitDiffFileTabs" class="flex gap-1 flex-wrap"></div>
</div>
<button onclick="closeGitDiffPopout()" class="btn-secondary text-xs flex-shrink-0"><i class="ph-bold ph-arrows-in mr-1"></i>{{ pgettext('button', 'Close') }}</button>
</div>
<div id="gitDiffPopoutEditor" style="flex:1;min-height:0;"></div>
</div>
<div id="traefikRestartOverlay" style="display:none;position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.75);backdrop-filter:blur(4px);flex-direction:column;align-items:center;justify-content:center;gap:16px">
<i class="ph-bold ph-arrows-clockwise text-5xl animate-spin" style="color:var(--blue);opacity:0.9"></i>
<div class="text-center">
<p class="text-lg font-semibold" style="color:var(--text)">{{ _('Restarting Traefik…') }}</p>
<p class="text-sm mt-1" style="color:var(--muted)">{{ _('This page will reconnect automatically.') }}</p>
</div>
<button id="traefikRestartManualReload" onclick="location.reload()" style="display:none;margin-top:8px;padding:8px 20px;border-radius:8px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);color:#fff;font-size:13px;cursor:pointer">{{ _('Reload now') }}</button>
</div>
<div id="tmUpdatePopup" class="hidden" style="position:fixed;bottom:24px;right:24px;z-index:9999;background:var(--card);border:1px solid var(--orange);border-radius:10px;padding:14px 16px;min-width:240px;max-width:300px;box-shadow:0 4px 24px rgba(0,0,0,0.25);">
<div class="flex items-start gap-3">
<i class="ph-bold ph-arrow-circle-up" style="color:var(--orange);font-size:20px;flex-shrink:0;margin-top:1px"></i>
<div style="flex:1;min-width:0;">
<div class="text-sm font-medium" style="color:var(--text)">{{ _('Traefik Manager update available') }}</div>
<div class="text-xs mt-0.5" style="color:var(--muted)"><span id="tmUpdatePopupVersion"></span></div>
<a id="tmUpdatePopupLink" href="#" target="_blank" rel="noopener" class="text-xs mt-2 inline-block" style="color:var(--blue)">{{ _('View release notes') }}</a>
</div>
<button onclick="dismissTmUpdatePopup()" style="color:var(--muted);background:none;border:none;cursor:pointer;padding:0;line-height:1;flex-shrink:0">
<i class="ph-bold ph-x text-sm"></i>
</button>
</div>
</div>
<div id="securityAdvisoryPopup" class="hidden" style="position:fixed;bottom:24px;right:24px;z-index:10000;background:var(--card);border:1px solid var(--red);border-radius:10px;padding:14px 16px;min-width:260px;max-width:340px;box-shadow:0 4px 24px rgba(0,0,0,0.25);">
<div class="flex items-start gap-3">
<i class="ph-bold ph-shield-warning" style="color:var(--red);font-size:20px;flex-shrink:0;margin-top:1px"></i>
<div style="flex:1;min-width:0;">
<div class="text-sm font-medium" style="color:var(--text)">{{ _('Traefik security advisory') }}</div>
<div class="text-xs mt-0.5" style="color:var(--muted)"><span id="securityAdvisoryText"></span></div>
<a id="securityAdvisoryLink" href="#" target="_blank" rel="noopener" class="text-xs mt-2 inline-block" style="color:var(--blue)">{{ _('View advisory') }}</a>
</div>
<button onclick="dismissSecurityAdvisory()" style="color:var(--muted);background:none;border:none;cursor:pointer;padding:0;line-height:1;flex-shrink:0">
<i class="ph-bold ph-x text-sm"></i>
</button>
</div>
</div>
<div class="toast" id="toastContainer">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast-item {{ category }}" onclick="this.remove()">
{% if category == 'success' %}<i class="ph-fill ph-check-circle text-green-400 text-lg"></i>{% else %}<i class="ph-fill ph-warning-circle text-red-400 text-lg"></i>{% endif %}
<span>{{ message }}</span>
<button onclick="this.parentElement.remove()" class="ml-auto text-traefik-muted hover:text-white"><i class="ph-bold ph-x text-sm"></i></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% include 'sections/navbar.html' %}
<div id="staticSetupBanner" style="display:none">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-2">
<div class="flex items-center gap-3 rounded-lg px-4 py-2.5" style="background:rgba(9,105,218,0.08);border:1px solid rgba(9,105,218,0.25)">
<i class="ph-bold ph-info text-sm shrink-0" style="color:var(--blue)"></i>
<p class="text-xs flex-1" style="color:var(--text)">
{{ _('%(new_in_v1)s Static Config Editor lets you manage your Traefik static config from the UI. %(set_it_up)s', new_in_v1=tag('strong', _('New in v1:')), set_it_up=tag('a', _('Set it up →'), href='https://traefik-manager.xyzlab.dev/static', target='_blank', style_='color:var(--blue);text-decoration:underline;margin-left:4px')) }}
</p>
<button onclick="document.getElementById('staticSetupBanner').style.display='none';localStorage.setItem('tm-static-setup-v1','1')" class="shrink-0" style="color:var(--muted);line-height:1" aria-label="{{ pgettext('label', 'Dismiss') }}">
<i class="ph-bold ph-x text-sm"></i>
</button>
</div>
</div>
</div>
<div class="side-nav-backdrop" id="sideNavBackdrop" onclick="closeSideNavDrawer()"></div>
<aside id="sideNav">
<div class="side-nav-app" id="sideNavApp">
<div class="side-nav-brand-row">
<img src="{{ base_path }}/static/icons/icon.png" alt="{{ pgettext('label', 'logo') }}">
<span>Traefik <span style="color:var(--blue)">Manager</span></span>
</div>
<div id="drawerServerWrap" style="display:none">
<button class="drawer-srv-head" id="drawerServerBtn" onclick="toggleDrawerServerList()" title="{{ _('Switch server') }}">
<span class="drawer-srv-mark" id="drawerServerMark">
<svg viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="206" y="206" width="100" height="100" rx="20" stroke="currentColor" stroke-width="34"/>
<line x1="256" y1="206" x2="256" y2="80" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<line x1="256" y1="306" x2="256" y2="432" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<line x1="206" y1="256" x2="80" y2="256" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<line x1="306" y1="256" x2="432" y2="256" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<circle cx="256" cy="60" r="26" fill="currentColor"/>
<circle cx="256" cy="452" r="26" fill="currentColor"/>
<circle cx="60" cy="256" r="26" fill="currentColor"/>
<circle cx="452" cy="256" r="26" fill="currentColor"/>
</svg>
<i class="ph-bold ph-hard-drives" id="drawerServerAgentIcon"></i>
<span class="drawer-srv-badge" id="drawerServerBadge" style="background:var(--green)"></span>
</span>
<span class="drawer-srv-dot" id="drawerServerDot" style="background:var(--green)"></span>
<span class="drawer-srv-t">
<span class="drawer-srv-n" id="drawerServerName">{{ pgettext('button', 'Host') }}</span>
<span class="drawer-srv-s" id="drawerServerSub">Traefik Manager</span>
</span>
<i class="ph-bold ph-caret-down" id="drawerServerCaret"></i>
</button>
<div class="drawer-srv-list" id="drawerServerList" style="display:none"></div>
</div>
</div>
<nav id="sideNavItems"></nav>
<div class="side-nav-foot">
<button onclick="toggleSideNavCollapse()" id="sideNavCollapseBtn" title="{{ _('Collapse navigation') }}">
<i class="ph-bold ph-caret-double-left" id="sideNavCollapseIcon"></i><span class="side-nav-toggle-label">{{ pgettext('button', 'Collapse') }}</span>
</button>
{% if auth_enabled %}
<form method="POST" action="{{ url_for('logout') }}" onsubmit="tabCacheClear()">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" id="sideNavLogoutBtn" title="{{ _('Sign out') }}">
<i class="ph-bold ph-sign-out"></i><span class="side-nav-toggle-label">{{ _('Sign out') }}</span>
</button>
</form>
{% endif %}
<button onclick="closeSideNavDrawer(); openSettingsModal()" id="sideNavSettingsBtn" title="{{ pgettext('tooltip', 'Settings') }}">
<i class="ph-bold ph-gear"></i><span class="side-nav-toggle-label">{{ pgettext('button', 'Settings') }}</span>
</button>
</div>
</aside>
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<div id="storageBanner" style="display:none;background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.35);border-radius:10px;padding:12px 16px;margin-bottom:16px"></div>
{% include 'sections/stats.html' %}
{% include 'tabs/tab_services.html' %}
{% include 'tabs/tab_middlewares.html' %}
{% include 'tabs/tab_routemap.html' %}
{% include 'tabs/tab_dashboard.html' %}
{% include 'tabs/tab_live.html' %}
{% include 'tabs/tab_docker.html' %}
{% include 'tabs/tab_kubernetes.html' %}
{% include 'tabs/tab_swarm.html' %}
{% include 'tabs/tab_nomad.html' %}
{% include 'tabs/tab_internal.html' %}
{% include 'tabs/tab_ecs.html' %}
{% include 'tabs/tab_consulcatalog.html' %}
{% include 'tabs/tab_redis.html' %}
{% include 'tabs/tab_etcd.html' %}
{% include 'tabs/tab_consul.html' %}
{% include 'tabs/tab_zookeeper.html' %}
{% include 'tabs/tab_http_provider.html' %}
{% include 'tabs/tab_file_external.html' %}
{% include 'tabs/tab_certs.html' %}
{% include 'tabs/tab_tls_options.html' %}
{% include 'tabs/tab_crowdsec.html' %}
{% include 'tabs/tab_plugins.html' %}
{% include 'tabs/tab_static.html' %}
{% include 'tabs/tab_logs.html' %}
</main>
{% include 'modals/route_modal.html' %}
{% include 'modals/mw_modal.html' %}
{% include 'modals/service_modal.html' %}
{% include 'modals/tls_options_modal.html' %}
{% include 'modals/ip_diagnostic_modal.html' %}
{% include 'modals/trusted_ips_modal.html' %}
{% include 'modals/detail_panels.html' %}
<script>
const APP_DATA = JSON.parse({{ apps | tojson | tojson }});
const availableDomains = {{ domains | tojson | safe }};
const availableCertResolvers = {{ cert_resolvers | tojson | safe }};
const MULTI_CONFIG = {{ multi_config | tojson }};
const CONFIG_DIR_SET = {{ config_dir_set | tojson }};
const CONFIG_PATHS_LIST = {{ config_paths_list | tojson | safe }};
let _allMiddlewares = {{ middlewares | tojson | safe }};
function _csrfHeaders() {
const token = document.querySelector('meta[name="csrf-token"]')?.content || '';
return { 'X-CSRF-Token': token };
}
window._tmAssetVersion = '{{ asset_version }}';
let _activeAgent = null;
const _agentRegistry = {};
let _agentList = [];
const _agentHealthCache = {};
function agentFetch(path, opts) {
const merged = { ...opts, headers: { ...(opts && opts.headers), ..._csrfHeaders() } };
if (!_activeAgent) return fetch(path, merged);
const proxyPath = '/api/agents/proxy/' + _activeAgent.id + '/' + path.replace(/^\/api\//, '');
return fetch(proxyPath, merged);
}
async function refreshStaticTabAvailability() {
const row = document.getElementById('staticTabToggleRow');
let available = false;
try {
if (_activeAgent) {
available = (await agentFetch('/api/static')).ok;
} else {
available = !!(await (await fetch('/api/static/available')).json()).available;
}
} catch(e) { available = false; }
if (row) row.style.display = available ? '' : 'none';
if (!available && _activeTab === 'static') switchTab('services');
if (typeof buildSideNav === 'function') buildSideNav();
_staticAvailable = available;
applyStaticPlacement(getStaticPlacement());
}
let _staticAvailable = false;
function getStaticPlacement() {
const v = tmPref('staticPlacement');
if (v === 'off' || v === 'settings' || v === 'tab') return v;
const tabs = _activeAgent ? (_activeAgent.visible_tabs || {}) : _localTabsCache;
return (tabs || {}).static ? 'tab' : 'off';
}
function applyStaticPlacement(mode) {
const el = document.getElementById('tab-static');
const sidebarBtn = document.getElementById('msb-static');
const mount = document.getElementById('staticSettingsMount');
const home = document.querySelector('main');
if (sidebarBtn) sidebarBtn.style.display = (mode === 'settings' && _staticAvailable) ? '' : 'none';
const mobileGroup = document.getElementById('staticMobileGroup');
if (mobileGroup) mobileGroup.style.display = (mode === 'settings' && _staticAvailable) ? '' : 'none';
document.documentElement.classList.toggle('tm-static-in-settings', mode === 'settings');
['off', 'settings', 'tab'].forEach(m => {
document.getElementById('scPlace-' + m)?.classList.toggle('active-http', m === mode);
});
if (el) {
if (mode === 'settings' && mount && el.parentElement !== mount) {
el.classList.remove('tab-content');
el.classList.remove('active');
el.dataset.scMounted = '1';
mount.appendChild(el);
} else if (mode !== 'settings' && el.dataset.scMounted === '1') {
delete el.dataset.scMounted;
el.classList.add('tab-content');
if (home) home.appendChild(el);
}
}
if (typeof applyTabVisibility === 'function') {
const tabs = _activeAgent ? (_activeAgent.visible_tabs || {}) : (_localTabsCache || {});
tabs.static = mode === 'tab';
applyTabVisibility(tabs);
}
if (typeof buildSideNav === 'function') buildSideNav();
if (mode === 'settings' && _staticAvailable && typeof refreshStaticTab === 'function') {
if (typeof _staticLoadedFor !== 'undefined') _staticLoadedFor = null;
refreshStaticTab();
}
}
function setStaticPlacement(mode) {
tmSetPref('staticPlacement', mode);
const tabs = _activeAgent ? (_activeAgent.visible_tabs || {}) : (_localTabsCache || {});
tabs.static = mode === 'tab';
if (!_activeAgent) {
_localTabsCache = tabs;
localStorage.setItem('tm_tabs_local', JSON.stringify(tabs));
fetch('/api/settings/tabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ..._csrfHeaders() },
body: JSON.stringify({ visible_tabs: tabs }),
}).catch(() => {});
}
applyStaticPlacement(mode);
}
function _renderServerSwitcherList(runHealthChecks) {
const agents = _agentList;
const wrap = document.getElementById('serverSwitcherWrap');
const list = document.getElementById('serverSwitcherList');
if (!wrap || !list) return;
wrap.style.display = agents.length ? '' : 'none';
const localActive = !_activeAgent;
const desktopItem = (id, label, dotColor, active, dotId) =>
`<div class="notif-item${active ? ' active' : ''}" onclick="${id ? `switchServer(${_jsArg(id)})` : 'switchServer(null)'}" style="cursor:pointer;${active?'background:var(--input-bg);':''}">
<div class="flex items-center gap-2 py-1">
<span class="w-2 h-2 rounded-full flex-shrink-0" style="background:${dotColor}"${dotId ? ` id="${dotId}"` : ''}></span>
<div style="min-width:0;flex:1;"><div class="text-xs font-medium truncate" style="color:var(--text)">${label}</div></div>
${active ? '<i class="ph-bold ph-check text-xs ml-auto flex-shrink-0" style="color:var(--blue)"></i>' : ''}
</div>
</div>`;
const hostDot = 'var(--green)';
list.innerHTML = desktopItem(null, thc('button', 'Host'), hostDot, localActive, null) +
agents.map(a => desktopItem(a.id, _esc(a.name), _agentHealthCache[a.id] || 'var(--muted)', _activeAgent && _activeAgent.id === a.id, 'ss-dot-' + a.id)).join('');
if (runHealthChecks) {
agents.forEach(a => fetch('/api/agents/' + a.id + '/health').then(r => r.json()).then(d => {
_agentHealthCache[a.id] = d.ok ? 'var(--green)' : 'var(--red)';
_agentHealthInfo[a.id] = d;
const dot = document.getElementById('ss-dot-' + a.id);
if (dot) dot.style.background = _agentHealthCache[a.id];
_renderDrawerServerList();
}).catch(() => { _agentHealthCache[a.id] = 'var(--red)'; _renderDrawerServerList(); }));
}
_renderDrawerServerList();
}
const _agentHealthInfo = {};
function _agentSubLine(a) {
const d = _agentHealthInfo[a.id];
if (!d) return '';
if (!d.ok) return tc('label', 'offline');
const v = d.version ? 'v' + d.version : '';
const ms = (typeof d.latency_ms === 'number' && d.latency_ms >= 0) ? d.latency_ms + 'ms' : '';
return [v, ms].filter(Boolean).join(' · ');
}
function _renderDrawerServerList() {
const wrap = document.getElementById('drawerServerWrap');
const list = document.getElementById('drawerServerList');
if (!wrap || !list) return;
wrap.style.display = _agentList.length ? '' : 'none';
if (!_agentList.length) return;
const name = document.getElementById('drawerServerName');
const sub = document.getElementById('drawerServerSub');
const dot = document.getElementById('drawerServerDot');
if (name) name.textContent = _activeAgent ? _activeAgent.name : tc('button', 'Host');
if (sub) sub.textContent = _activeAgent ? (_agentSubLine(_activeAgent) || tc('label', 'Agent')) : 'Traefik Manager';
if (dot) dot.style.background = _activeAgent ? (_agentHealthCache[_activeAgent.id] || 'var(--muted)') : 'var(--green)';
const mark = document.getElementById('drawerServerMark');
const badge = document.getElementById('drawerServerBadge');
if (mark) mark.classList.toggle('is-agent', !!_activeAgent);
if (badge) badge.style.background = _activeAgent ? (_agentHealthCache[_activeAgent.id] || 'var(--muted)') : 'var(--green)';
const TM_MARK = `<span class="drawer-srv-glyph"><svg viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="206" y="206" width="100" height="100" rx="20" stroke="currentColor" stroke-width="34"/>
<line x1="256" y1="206" x2="256" y2="80" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<line x1="256" y1="306" x2="256" y2="432" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<line x1="206" y1="256" x2="80" y2="256" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<line x1="306" y1="256" x2="432" y2="256" stroke="currentColor" stroke-width="34" stroke-linecap="round"/>
<circle cx="256" cy="60" r="26" fill="currentColor"/>
<circle cx="256" cy="452" r="26" fill="currentColor"/>
<circle cx="60" cy="256" r="26" fill="currentColor"/>
<circle cx="452" cy="256" r="26" fill="currentColor"/>
</svg></span>`;
const item = (id, icon, title, subText, active, dotColor) =>
`<button class="drawer-srv-item${active ? ' active' : ''}" onclick="_drawerPickServer(${id ? `${_jsArg(id)}` : 'null'})">
${icon ? `<i class="ph-bold ${icon}"></i>` : TM_MARK}
<span class="drawer-srv-t">
<span class="drawer-srv-n">${title}</span>
${subText ? `<span class="drawer-srv-s">${subText}</span>` : ''}
</span>
${active ? '<i class="ph-bold ph-check drawer-srv-check"></i>' : `<span class="drawer-srv-dot" style="background:${dotColor}"></span>`}
</button>`;
list.innerHTML =
`<div class="drawer-srv-title">${th('Switch server')}</div>` +
item(null, null, thc('button', 'Host'), 'Traefik Manager', !_activeAgent, 'var(--green)') +
_agentList.map(a => item(a.id, 'ph-hard-drives', _esc(a.name), _esc(_agentSubLine(a)), _activeAgent && _activeAgent.id === a.id, _agentHealthCache[a.id] || 'var(--muted)')).join('');
}
function _drawerPickServer(id) {
const list = document.getElementById('drawerServerList');
const caret = document.getElementById('drawerServerCaret');
if (list) list.style.display = 'none';
if (caret) caret.classList.remove('open');
switchServer(id);
}
function toggleDrawerServerList() {
const list = document.getElementById('drawerServerList');
const caret = document.getElementById('drawerServerCaret');
const btn = document.getElementById('drawerServerBtn');
if (!list) return;
const opening = list.style.display === 'none';
list.style.display = opening ? '' : 'none';
if (caret) caret.classList.toggle('open', opening);
const railed = document.documentElement.classList.contains('tm-nav-collapsed') && window.innerWidth >= 768;
if (opening && btn && railed) {
const r = btn.getBoundingClientRect();
list.style.left = (r.right + 8) + 'px';
list.style.top = Math.min(r.top, window.innerHeight - list.offsetHeight - 12) + 'px';
} else {
list.style.left = '';
list.style.top = '';
}
}
function _updateServerSwitcherList(agents) {
agents.forEach(a => _agentRegistry[a.id] = a);
_agentList = agents;
if (_activeAgent && _agentRegistry[_activeAgent.id]) _activeAgent = _agentRegistry[_activeAgent.id];
_renderServerSwitcherList(true);
}
function _warnIfAgentUnreachable(agent) {
const d = _agentHealthInfo[agent.id];
if (!d || d.ok) return;
if (typeof showToast === 'function') {
showToast(d.error
? t('{name} is offline - {error}. Its tabs will stay empty until it responds.', { name: agent.name, error: d.error })
: t('{name} is offline - it is not responding. Its tabs will stay empty until it responds.', { name: agent.name }),
'error', false);
}
}
function switchServer(agentId) {
const agent = agentId ? (_agentRegistry[agentId] || null) : null;
if (agent) _warnIfAgentUnreachable(agent);
_activeAgent = agent;
_currentVersion = null;
if (typeof _clearEpVersion === 'function') _clearEpVersion();
if (agent) localStorage.setItem('tm_active_agent', agent.id);
else localStorage.removeItem('tm_active_agent');
_cachedEntrypoints = null;
_cachedMiddlewares = null;
_cachedCertResolvers = null;
window._tmServices = null;
try { if (typeof renderRouteGrid === 'function') renderRouteGrid([]); } catch (e) {}
try { if (typeof renderMwGrid === 'function') renderMwGrid([]); } catch (e) {}
_inflightEntrypoints = null;
_inflightMiddlewares = null;
_inflightCertResolvers = null;
_inflightServicesList = null;
if (typeof _dropConfigFilesCache === 'function') _dropConfigFilesCache();
_customDomains = [];
if (agent) document.getElementById('selfRouteEpWarning')?.remove();
if (typeof tabCacheForgetAll === 'function') tabCacheForgetAll();
if (typeof window.rmServerChanged === 'function') window.rmServerChanged();
if (typeof window._sdServerChanged === 'function') window._sdServerChanged();
if (typeof window._certServerChanged === 'function') window._certServerChanged();
if (agent) {
const stored = JSON.parse(localStorage.getItem('tm_tabs_agent_' + agent.id) || '{}');
const hub = agent.visible_tabs || {};
if (Object.keys(hub).length) {
applyTabVisibility(hub);
localStorage.setItem('tm_tabs_agent_' + agent.id, JSON.stringify(hub));
} else {
applyTabVisibility(stored);
if (Object.keys(stored).length) {
agent.visible_tabs = stored;
fetch('/api/agents/' + agent.id, { method: 'PUT', headers: { 'Content-Type': 'application/json', ..._csrfHeaders() }, body: JSON.stringify({ visible_tabs: stored }) }).catch(() => {});
}
}
} else {
applyTabVisibility(_localTabsCache);
}
const label = document.getElementById('serverSwitcherLabel');
if (label) label.textContent = agent ? agent.name : tc('button', 'Host');
document.getElementById('serverSwitcherMenu')?.classList.remove('open');
const chip = document.getElementById('agentServerLabel');
if (chip) { chip.textContent = agent ? agent.name : ''; chip.style.display = agent ? '' : 'none'; }
_renderServerSwitcherList(false);
if (typeof _staticLoadedFor !== 'undefined') _staticLoadedFor = null;
refreshStaticTabAvailability();
const _cur = _activeTab;
if (_cur !== 'services' && _cur !== 'middlewares') refreshRoutes();
refreshCurrentTab();
loadOverviewStats();
if (typeof updateTmVersionBadge === 'function') updateTmVersionBadge();
const sm = document.getElementById('settingsModal');
if (sm && sm.style.display !== 'none' && typeof _loadAboutAgentInfo === 'function') _loadAboutAgentInfo();
}
function _setAgentEditControls(_agentActive) {}
function refreshCurrentTab() {
switchTab(_activeTab);
}
function toggleServerMenu() {
const menu = document.getElementById('serverSwitcherMenu');
if (!menu) return;
const opening = !menu.classList.contains('open');
document.querySelectorAll('.notif-panel.open').forEach(p => p.classList.remove('open'));
if (opening) menu.classList.add('open');
}
document.addEventListener('click', e => {
const wrap = document.getElementById('serverSwitcherWrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('serverSwitcherMenu')?.classList.remove('open');
}
});
</script>
<script src="{{ base_path }}/static/js/core.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/routes.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/middlewares.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/services.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/settings.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/logs.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/crowdsec.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/dashboard.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/providers.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/certs.js?v={{ asset_version }}"></script>
<script src="{{ base_path }}/static/js/init.js?v={{ asset_version }}"></script>
{% include 'modals/settings_modal.html' %}
<footer style="text-align:center;padding:20px 0 24px;font-size:12px;color:var(--muted)">
<a href="https://github.com/traefik/traefik" target="_blank"
style="color:var(--muted);text-decoration:none" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted)'">Traefik</a>
{{ pgettext('label', '·') }}
<a href="https://github.com/chr0nzz/traefik-manager" target="_blank"
style="color:var(--muted);text-decoration:none" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted)'">Traefik Manager</a>
{{ pgettext('label', '·') }}
<span id="footerManagerVer" style="cursor:pointer" title="{{ pgettext('tooltip', 'About') }}" onclick="openSettingsModal('about')">-</span>
</footer>
<div id="_copyToast" style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:6px 16px;border-radius:8px;font-size:13px;font-weight:500;pointer-events:none;opacity:0;transition:opacity 0.2s;z-index:9999"></div>
<script src="{{ base_path }}/static/vendor/dagre.min.js"></script>
<script src="{{ base_path }}/static/vendor/monaco/vs/loader.js"></script>
<script src="{{ base_path }}/static/js/static-config.js?v={{ asset_version }}"></script>
<div id="routeActionsMenu" class="route-ctx-menu" style="display:none;background:var(--card);border:1px solid var(--border)"></div>
</body>
</html>