Warning, file /swf-monitor/src/templates/base.html was not indexed
or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).
0001 {% load static tz %}
0002 <!DOCTYPE html>
0003 <html lang="en">
0004 <head>
0005 <meta charset="UTF-8">
0006 <meta name="viewport" content="width=device-width, initial-scale=1.0">
0007 <title>{% block title %}SWF Monitor{% endblock %}</title>
0008 <script>
0009 (function() {
0010 var routeMode = '{{ nav_mode|default:""|escapejs }}';
0011 var mode = 'production';
0012 try {
0013 if (routeMode === 'testbed' || routeMode === 'production') {
0014 mode = routeMode;
0015 localStorage.setItem('navMode', mode);
0016 } else {
0017 mode = localStorage.getItem('navMode') || 'production';
0018 }
0019 } catch (e) {}
0020 if (mode !== 'testbed') mode = 'production';
0021 document.documentElement.setAttribute('data-nav-mode', mode);
0022 /* The user view follows the URL alone: ?user_view=1 renders it,
0023 any other request renders the full view. Nothing is remembered
0024 in the browser (Torre, 2026-09-10: no flag in the URL, no user
0025 view). A value an earlier version stored is cleared. */
0026 var routeUserView = '{{ user_view_param|default:""|escapejs }}';
0027 try { localStorage.removeItem('userView'); } catch (e) {}
0028 if (routeUserView === '1') document.documentElement.setAttribute('data-user-view', '1');
0029 })();
0030 /* Poll gate: a page polls only while someone is with it. Visibility
0031 alone does not establish that — the frontmost tab of a background
0032 window reports itself visible, so an unattended window polls
0033 forever. A page is attended while it is visible and either its
0034 window holds focus or it was interacted with inside the idle
0035 window. Pollers ask attended() before every request and register
0036 onReturn() to catch up when attention comes back. Defined here,
0037 ahead of the content block, so page scripts can use it. */
0038 window.swfPollGate = (function() {
0039 var idleMs = 5 * 60 * 1000;
0040 var lastInteraction = Date.now();
0041 var returnHandlers = [];
0042 function attended() {
0043 if (document.hidden) return false;
0044 if (document.hasFocus && document.hasFocus()) return true;
0045 return (Date.now() - lastInteraction) < idleMs;
0046 }
0047 function noteAttention() {
0048 var wasAttended = attended();
0049 lastInteraction = Date.now();
0050 if (wasAttended) return;
0051 returnHandlers.forEach(function(fn) {
0052 try { fn(); } catch (e) {}
0053 });
0054 }
0055 ['focus', 'pointerdown', 'keydown', 'wheel', 'touchstart']
0056 .forEach(function(evt) {
0057 window.addEventListener(evt, noteAttention,
0058 {passive: true, capture: true});
0059 });
0060 document.addEventListener('visibilitychange', function() {
0061 if (!document.hidden) noteAttention();
0062 });
0063 return {
0064 attended: attended,
0065 onReturn: function(fn) { returnHandlers.push(fn); }
0066 };
0067 })();
0068 </script>
0069 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
0070 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
0071 <link rel="stylesheet" href="{% static 'css/style.css' %}">
0072 <link rel="stylesheet" href="{% static 'css/state-colors.css' %}">
0073 <link rel="stylesheet" href="https://cdn.datatables.net/1.13.8/css/dataTables.bootstrap5.min.css">
0074 <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
0075 <script src="https://cdn.datatables.net/1.13.8/js/jquery.dataTables.min.js"></script>
0076 <script src="https://cdn.datatables.net/1.13.8/js/dataTables.bootstrap5.min.js"></script>
0077 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css">
0078 <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
0079 <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
0080 <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
0081 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
0082 <style>
0083 body {
0084 margin: 0;
0085 padding: 0;
0086 }
0087 main {
0088 padding: 0.65rem 1rem 1rem;
0089 position: relative;
0090 }
0091 main > .container,
0092 main > .container-fluid {
0093 margin-top: 0 !important;
0094 }
0095 main > h1,
0096 main > h2,
0097 main > .container > h1,
0098 main > .container > h2,
0099 main > .container-fluid > h1,
0100 main > .container-fluid > h2,
0101 main > .container > div.mb-3 > h1,
0102 main > .container > div.mb-3 > h2,
0103 main > .container > div.mb-4 > div > h1,
0104 main > .container > div.mb-4 > div > h2,
0105 main > .container > .d-flex h1,
0106 main > .container > .d-flex h2,
0107 main > .container-fluid > div.mb-3 > h1,
0108 main > .container-fluid > div.mb-3 > h2,
0109 main > .container-fluid > div.mb-4 > div > h1,
0110 main > .container-fluid > div.mb-4 > div > h2,
0111 main > .container-fluid > .d-flex h1,
0112 main > .container-fluid > .d-flex h2 {
0113 font-size: 1.7rem;
0114 line-height: 1.2;
0115 margin-top: 0 !important;
0116 }
0117 main > h1,
0118 main > h2,
0119 main > .container > h1,
0120 main > .container > h2,
0121 main > .container-fluid > h1,
0122 main > .container-fluid > h2,
0123 main > .container > div.mb-3 > h1,
0124 main > .container > div.mb-3 > h2,
0125 main > .container > div.mb-4 > div > h1,
0126 main > .container > div.mb-4 > div > h2,
0127 main > .container-fluid > div.mb-3 > h1,
0128 main > .container-fluid > div.mb-3 > h2,
0129 main > .container-fluid > div.mb-4 > div > h1,
0130 main > .container-fluid > div.mb-4 > div > h2 {
0131 margin-bottom: 0.2rem !important;
0132 }
0133 main > .container > .d-flex h1,
0134 main > .container > .d-flex h2,
0135 main > .container-fluid > .d-flex h1,
0136 main > .container-fluid > .d-flex h2 {
0137 margin-bottom: 0 !important;
0138 }
0139 /* Button roles (see CLAUDE.md "Button roles" — the AI-facing table):
0140 constructive create/save/new/import = btn-primary; object verbs
0141 edit/copy on existing items = btn-dark-green; consequential commits
0142 submit/lock/publish/approve = btn-success; suggestion-bar Apply =
0143 btn-warning btn-apply; destructive delete = btn-danger; neutral
0144 cancel/utility = btn-secondary; btn-outline-* reserved for stateful
0145 toggles where fill signals on/off. All btn-sm. */
0146 .btn.btn-dark-green {
0147 background-color: #166534; border-color: #166534; color: #fff;
0148 }
0149 .btn.btn-dark-green:hover, .btn.btn-dark-green:focus {
0150 background-color: #14532d; border-color: #14532d; color: #fff;
0151 }
0152 .btn.btn-dark-green:disabled, .btn.btn-dark-green.disabled {
0153 background-color: #6c757d; border-color: #6c757d; color: #e9ecef;
0154 }
0155 #js-error-overlay {
0156 position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
0157 z-index: 100000; max-width: 720px; width: calc(100vw - 2rem);
0158 background: #c9302c; color: #fff; padding: 1.25rem 1.5rem;
0159 border: 3px solid #7a1a18; border-radius: .5rem;
0160 box-shadow: 0 10px 30px rgba(0,0,0,.4);
0161 font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
0162 display: none;
0163 }
0164 #js-error-overlay h3 { margin: 0 0 .5rem; font-size: 1.1rem; }
0165 #js-error-overlay pre {
0166 background: rgba(0,0,0,.25); color: #fff; padding: .5rem .75rem;
0167 border-radius: .25rem; margin: .5rem 0 0; font-size: .8rem;
0168 white-space: pre-wrap; word-break: break-word; max-height: 40vh; overflow: auto;
0169 }
0170 #js-error-overlay .close-btn {
0171 position: absolute; top: .5rem; right: .75rem;
0172 background: transparent; border: none; color: #fff; font-size: 1.5rem;
0173 cursor: pointer; line-height: 1;
0174 }
0175 nav {
0176 display: flex;
0177 align-items: center;
0178 gap: .8em;
0179 }
0180 .sticky-header {
0181 position: sticky;
0182 top: 0;
0183 z-index: 1030;
0184 }
0185 /* Latching table headers: tables that fit the page latch beneath the
0186 nav while the page scrolls. A table wider than its available width
0187 is moved into an adaptive two-axis scroll frame; there the header
0188 latches at the frame top and the first two columns latch at the
0189 left. swf-latch-head opts any other table into the house behavior.
0190 border-collapse borders do not travel with a stuck header, so the
0191 header's frame is drawn as an inset shadow. */
0192 table.dataTable thead th,
0193 table.swf-sortable thead th,
0194 table.swf-latch-head thead th {
0195 /* !important: DataTables' stylesheet pins sortable th to
0196 position:relative with a more specific selector. */
0197 position: sticky !important;
0198 top: var(--swf-nav-height, 0px);
0199 z-index: 100;
0200 background-color: var(--bs-body-bg, #fff);
0201 box-shadow: inset 1px 0 0 #dee2e6, inset 0 1px 0 #dee2e6,
0202 inset 0 -1px 0 #dee2e6;
0203 }
0204 .swf-table-frame {
0205 max-width: 100%;
0206 width: 100%;
0207 }
0208 .swf-table-frame.swf-wide-table-scroll {
0209 max-height: var(--swf-table-max-height, 70vh);
0210 overflow: auto;
0211 /* Scrolling chains to the page at the frame's ends, so the wheel
0212 brings the matter above back when the table is at its top. */
0213 overscroll-behavior: auto;
0214 scrollbar-gutter: stable;
0215 }
0216 .swf-wide-table-scroll > table {
0217 margin-bottom: 0 !important;
0218 }
0219 .swf-wide-table-scroll > table thead th {
0220 top: 0;
0221 }
0222 /* Optional two-tier latch: a one-line table title sits above the
0223 ordinary column headings. Both tiers use the established sticky-th
0224 behavior and remain bounded by the table. The fixed one-line title
0225 height keeps the second tier seated exactly beneath it without a
0226 script-time measurement or jump. */
0227 table.swf-latch-titled-head {
0228 --swf-latch-title-height: 2rem;
0229 --swf-latch-column-height: 2rem;
0230 --swf-latch-section-height: 2rem;
0231 }
0232 table.swf-latch-titled-head thead tr.swf-latch-title-row > th {
0233 top: var(--swf-nav-height, 0px);
0234 height: var(--swf-latch-title-height);
0235 line-height: 1.5rem;
0236 white-space: nowrap;
0237 z-index: 102;
0238 }
0239 table.swf-latch-titled-head thead tr.swf-latch-title-row + tr > th {
0240 top: calc(var(--swf-nav-height, 0px)
0241 + var(--swf-latch-title-height));
0242 height: var(--swf-latch-column-height);
0243 line-height: 1.5rem;
0244 white-space: nowrap;
0245 z-index: 101;
0246 }
0247 table.swf-latch-titled-head tbody tr.swf-latch-section-row > * {
0248 position: sticky;
0249 top: calc(var(--swf-nav-height, 0px)
0250 + var(--swf-latch-title-height)
0251 + var(--swf-latch-column-height));
0252 z-index: 100;
0253 height: var(--swf-latch-section-height);
0254 line-height: 1.5rem;
0255 white-space: nowrap;
0256 background-color: var(--bs-table-bg,
0257 var(--bs-secondary-bg, #e2e3e5));
0258 box-shadow: inset 1px 0 0 #dee2e6,
0259 inset 0 -1px 0 #dee2e6;
0260 }
0261 table.swf-latch-titled-head tbody tr.swf-pc-anchor {
0262 scroll-margin-top: calc(var(--swf-nav-height, 0px)
0263 + var(--swf-latch-title-height)
0264 + var(--swf-latch-column-height) + .5rem);
0265 }
0266 table.swf-latch-titled-head.swf-latch-section-heads
0267 tbody tr.swf-pc-anchor {
0268 scroll-margin-top: calc(var(--swf-nav-height, 0px)
0269 + var(--swf-latch-title-height)
0270 + var(--swf-latch-column-height)
0271 + var(--swf-latch-section-height) + .5rem);
0272 }
0273 .swf-wide-table-scroll > table.swf-latch-titled-head
0274 thead tr.swf-latch-title-row > th {
0275 top: 0;
0276 }
0277 .swf-wide-table-scroll > table.swf-latch-titled-head
0278 thead tr.swf-latch-title-row + tr > th {
0279 top: var(--swf-latch-title-height);
0280 }
0281 .swf-wide-table-scroll > table.swf-latch-titled-head
0282 tbody tr.swf-latch-section-row > * {
0283 top: calc(var(--swf-latch-title-height)
0284 + var(--swf-latch-column-height));
0285 }
0286 /* Wide tables pin two leading columns by default so a compact ID can
0287 retain the useful name beside it. data-swf-pin-columns can lower
0288 that number for an exceptional table. Solid cell backgrounds keep
0289 horizontally scrolling columns from showing through. */
0290 .swf-wide-table[data-swf-active-pin-columns="1"] > tbody > tr > :first-child,
0291 .swf-wide-table[data-swf-active-pin-columns="2"] > tbody > tr > :first-child {
0292 position: sticky;
0293 left: 0;
0294 z-index: 50;
0295 background-color: var(--bs-body-bg, #fff);
0296 }
0297 .swf-wide-table[data-swf-active-pin-columns="2"] > tbody > tr > :nth-child(2) {
0298 position: sticky;
0299 left: var(--swf-first-column-width, 0px);
0300 z-index: 50;
0301 background-color: var(--bs-body-bg, #fff);
0302 box-shadow: inset -1px 0 0 #dee2e6;
0303 }
0304 .swf-wide-table.table-striped[data-swf-active-pin-columns="1"] > tbody > tr:nth-of-type(odd) > :first-child,
0305 .swf-wide-table.table-striped[data-swf-active-pin-columns="2"] > tbody > tr:nth-of-type(odd) > :first-child,
0306 .swf-wide-table.table-striped[data-swf-active-pin-columns="2"] > tbody > tr:nth-of-type(odd) > :nth-child(2) {
0307 background-color: var(--bs-tertiary-bg, #f2f2f2);
0308 }
0309 /* Header cells at the two-axis intersection sit above both the body
0310 columns and the rest of the latching header. */
0311 .swf-wide-table[data-swf-active-pin-columns="1"] thead th:first-child,
0312 .swf-wide-table[data-swf-active-pin-columns="2"] thead th:first-child {
0313 left: 0;
0314 z-index: 101;
0315 }
0316 .swf-wide-table[data-swf-active-pin-columns="2"] thead th:nth-child(2) {
0317 left: var(--swf-first-column-width, 0px);
0318 z-index: 101;
0319 }
0320 .nav-spacer {
0321 flex: 1 1 auto;
0322 }
0323 .nav-auth {
0324 display: flex;
0325 gap: .8em;
0326 }
0327 /* Its own line, right-aligned. It was floated, so every page header
0328 that paints a full-width band wrapped around it: the band stopped
0329 at the float and the stamp sat in the notch, over the page's own
0330 chrome. */
0331 .main-time-info {
0332 color: #667085;
0333 display: block;
0334 text-align: right;
0335 font-size: .75rem;
0336 margin: -.25rem 0 .25rem 1rem;
0337 white-space: nowrap;
0338 }
0339 nav a {
0340 color: #ADD8E6;
0341 }
0342 nav a.nav-active {
0343 text-shadow: 0 0 .65px currentColor, 0 0 .65px currentColor;
0344 }
0345 /* Dropdown Button */
0346 .dropbtn {
0347 background-color: inherit;
0348 color: #8fc7e8;
0349 text-decoration: none;
0350 padding: 0;
0351 font-size: inherit;
0352 border: none;
0353 cursor: pointer;
0354 }
0355 .dropbtn.nav-active {
0356 text-shadow: 0 0 .65px currentColor, 0 0 .65px currentColor;
0357 }
0358
0359 /* The container <div> - needed to position the dropdown content */
0360 .dropdown {
0361 position: relative;
0362 display: inline-block;
0363 margin-right: .35em;
0364 }
0365
0366 /* Dropdown Content (Hidden by Default) */
0367 .dropdown-content {
0368 display: none;
0369 position: absolute;
0370 background-color: #f9f9f9;
0371 min-width: 160px;
0372 box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
0373 z-index: 1;
0374 }
0375
0376 /* Links inside the dropdown. The selector carries three classes on
0377 purpose: the menu-bar rule is `.sticky-header > nav a` (white on
0378 #333) and outranks a bare `.dropdown-content a`, which painted
0379 every menu item white on the panel's near-white background and
0380 left the menus unreadable. */
0381 .sticky-header .dropdown .dropdown-content a {
0382 color: black;
0383 padding: 12px 16px;
0384 text-decoration: none;
0385 display: block;
0386 }
0387 .dropdown-content a.nav-active {
0388 background-color: #e9ecef;
0389 }
0390
0391 /* Change color of dropdown links on hover */
0392 .dropdown-content a:hover {background-color: #f1f1f1}
0393
0394 /* Show the dropdown menu on hover */
0395 .dropdown:hover .dropdown-content {
0396 display: block;
0397 }
0398 /* Nav mode flipper */
0399 .nav-mode { display: none; }
0400 .nav-mode.active { display: contents; }
0401 html[data-nav-mode="production"] .nav-production,
0402 html[data-nav-mode="testbed"] .nav-testbed { display: contents; }
0403 #mode-label {
0404 border-right: 1px solid rgba(255,255,255,.2);
0405 color: #fff;
0406 font-weight: 700;
0407 padding-right: 12px;
0408 margin-right: 4px;
0409 }
0410 #mode-label .mode-title { display: none; }
0411 html[data-nav-mode="production"] #mode-label .mode-title-production,
0412 html[data-nav-mode="testbed"] #mode-label .mode-title-testbed { display: inline; }
0413 /* User view: the reduced nav replaces the mode nav, and the brand
0414 reads "epicprod User View" and links to the user view home. */
0415 html[data-user-view="1"][data-nav-mode] .nav-production,
0416 html[data-user-view="1"][data-nav-mode] .nav-testbed { display: none; }
0417 html[data-user-view="1"] .nav-user { display: contents; }
0418 #mode-user-brand { display: none; }
0419 html[data-user-view="1"] #mode-flipper { display: none; }
0420 html[data-user-view="1"] #mode-user-brand {
0421 display: inline-block;
0422 border-right: 1px solid rgba(255,255,255,.2);
0423 color: #fff;
0424 font-weight: 700;
0425 padding-right: 12px;
0426 margin-right: 4px;
0427 text-decoration: none;
0428 }
0429 /* Top-level user-view links take the menu-bar blue of the dropdown
0430 buttons; the selector outranks style.css's `.sticky-header > nav a`. */
0431 .sticky-header > nav a.nav-user-link,
0432 .sticky-header > nav a.nav-user-item { color: #8fc7e8; }
0433 .nav-user-link { font-weight: 700; }
0434 .nav-user-exit { margin-left: 2.5em; }
0435 /* In the user view the only white text is the brand: the System
0436 menu and the built-at line are not shown, and the account links
0437 take the menu-bar blue. */
0438 html[data-user-view="1"] #nav-system,
0439 html[data-user-view="1"] .main-time-info { display: none; }
0440 html[data-user-view="1"] .sticky-header > nav .nav-auth,
0441 html[data-user-view="1"] .sticky-header > nav .nav-auth a { color: #8fc7e8; }
0442 .nav-label {
0443 color: #fff;
0444 font-weight: 700;
0445 }
0446 .nav-panda-label {
0447 margin-left: 1.5em;
0448 }
0449 .nav-analysis {
0450 margin-left: 1.5em;
0451 }
0452 .nav-system-error { color: #ff8174 !important; font-weight: 700; }
0453 /* One-click copy icon for IDs — see swf_fmt.copy_btn template tag */
0454 .copy-btn {
0455 background: none; border: none; color: #6c757d; cursor: pointer;
0456 padding: 0 .25rem; font-size: .85em; line-height: 1; vertical-align: baseline;
0457 }
0458 .copy-btn:hover { color: #0d6efd; }
0459 .copy-btn.copied { color: #198754; }
0460 /* AI presence — the universal convention for material attributable to
0461 the AI (proposals, recommendation filters, AI-authored blocks):
0462 dark purple ink on light purple ground. Tune the system-wide AI
0463 look here and nowhere else. */
0464 :root {
0465 --ai-fg: #5b21b6;
0466 --ai-bg: #f8f4fe;
0467 /* Lavender ground always wears a 1px border in the ink color. */
0468 --ai-border: var(--ai-fg);
0469 }
0470 .ai-attr {
0471 color: var(--ai-fg);
0472 background-color: var(--ai-bg);
0473 border: 1px solid var(--ai-border);
0474 border-radius: .5em;
0475 padding: 0 .45em;
0476 font-weight: 600;
0477 }
0478 /* Purple ink always sits on lavender ground — the universal pairing. */
0479 .ai-attr-text {
0480 color: var(--ai-fg); background-color: var(--ai-bg);
0481 border: 1px solid var(--ai-border);
0482 border-radius: .35em; padding: 0 .3em; font-weight: 600;
0483 }
0484 /* Lavender fill for AI-owned containers (home-page boxes, panels). */
0485 .ai-fill {
0486 background-color: var(--ai-bg);
0487 border: 1px solid var(--ai-border) !important;
0488 }
0489 button.ai-attr:hover, a.ai-attr:hover {
0490 background-color: var(--ai-fg); color: #fff;
0491 border-color: var(--ai-fg);
0492 }
0493 /* Checkboxes app-wide: Bootstrap's default border is too light
0494 to see; an unticked box must read as present. */
0495 .form-check-input, input[type="checkbox"] {
0496 border: 1.5px solid #495057;
0497 }
0498 </style>
0499 </head>
0500 <body>
0501 <div id="js-error-overlay" role="alert">
0502 <button class="close-btn" onclick="document.getElementById('js-error-overlay').style.display='none';">×</button>
0503 <h3>JavaScript error</h3>
0504 <div id="js-error-msg"></div>
0505 <pre id="js-error-detail"></pre>
0506 </div>
0507 <script>
0508 (function() {
0509 function show(title, detail) {
0510 var box = document.getElementById('js-error-overlay');
0511 var msg = document.getElementById('js-error-msg');
0512 var det = document.getElementById('js-error-detail');
0513 if (!box || !msg || !det) return;
0514 msg.textContent = title || 'Error';
0515 det.textContent = detail || '';
0516 box.style.display = 'block';
0517 }
0518 window.addEventListener('error', function(ev) {
0519 var err = ev.error;
0520 var loc = (ev.filename || '') + (ev.lineno ? ':' + ev.lineno + ':' + (ev.colno || 0) : '');
0521 var stack = err && err.stack ? err.stack : '';
0522 show(ev.message || 'Script error', (loc ? loc + '\n\n' : '') + stack);
0523 });
0524 window.addEventListener('unhandledrejection', function(ev) {
0525 var r = ev.reason;
0526 var msg = (r && r.message) ? r.message : String(r);
0527 var stack = (r && r.stack) ? r.stack : '';
0528 show('Unhandled promise rejection: ' + msg, stack);
0529 });
0530 })();
0531 // One-click copy-to-clipboard handler for .copy-btn buttons rendered by
0532 // the copy_btn template tag (swf_fmt). Swaps the icon to a checkmark for
0533 // 1s on success so the user sees the click landed.
0534 document.addEventListener('click', function(ev) {
0535 var btn = ev.target.closest('.copy-btn');
0536 if (!btn) return;
0537 ev.preventDefault();
0538 var text = btn.getAttribute('data-copy') || '';
0539 if (!text || !navigator.clipboard) return;
0540 navigator.clipboard.writeText(text).then(function() {
0541 var icon = btn.querySelector('i');
0542 if (!icon) return;
0543 var prev = icon.className;
0544 icon.className = 'bi bi-clipboard-check';
0545 btn.classList.add('copied');
0546 setTimeout(function() {
0547 icon.className = prev;
0548 btn.classList.remove('copied');
0549 }, 1000);
0550 });
0551 });
0552 // Near-zero-latency tooltips via Bootstrap 5 for elements with title=.
0553 // Scoped to state-filled cells and the copy button so we don't spin up
0554 // a tooltip for every cell on every page. show delay 50ms, hide 0ms.
0555 document.addEventListener('DOMContentLoaded', function() {
0556 if (!window.bootstrap || !bootstrap.Tooltip) return;
0557 function init(root) {
0558 root.querySelectorAll(
0559 'td[title]:not([data-bs-tooltip-init]), .copy-btn[title]:not([data-bs-tooltip-init])'
0560 ).forEach(function(el) {
0561 if (!el.getAttribute('title')) return;
0562 el.setAttribute('data-bs-tooltip-init', '1');
0563 new bootstrap.Tooltip(el, {delay: {show: 50, hide: 0}});
0564 });
0565 }
0566 init(document);
0567 // DataTables redraws replace rows; re-init after each draw.
0568 if (window.jQuery) {
0569 jQuery(document).on('draw.dt', function(_, settings) {
0570 init(settings.nTable || document);
0571 });
0572 }
0573 });
0574 </script>
0575 <div class="sticky-header">
0576 <nav>
0577 <a id="mode-user-brand" href="{% url 'monitor_app:prod_hub' %}?user_view=1">epicprod User View</a>
0578 <div class="dropdown" id="mode-flipper">
0579 <button class="dropbtn" id="mode-label">
0580 <span class="mode-title mode-title-production">epicprod</span>
0581 <span class="mode-title mode-title-testbed">ePIC Testbed</span>
0582 </button>
0583 <div class="dropdown-content">
0584 <a href="{% url 'monitor_app:prod_hub' %}" id="flip-production" onclick="setNavMode('production');">epicprod - ePIC Production</a>
0585 <a href="{% if is_tunnel %}https://pandaserver02.sdcc.bnl.gov/swf-monitor/testbed/{% else %}{% url 'monitor_app:testbed_hub' %}{% endif %}" id="flip-testbed" onclick="setNavMode('testbed');">ePIC Testbed</a>
0586 {# The user view is a URL, not a nav mode: a plain link, no mode change. #}
0587 <a href="{% url 'monitor_app:prod_hub' %}?user_view=1" id="flip-user-view">epicprod User View</a>
0588 </div>
0589 </div>
0590
0591 {# ── Testbed mode ── #}
0592 <span class="nav-mode nav-testbed">
0593 <div class="dropdown" id="system-status-dropdown">
0594 <button class="dropbtn{% if active_nav.workflows %} nav-active{% endif %}">Workflows</button>
0595 <div class="dropdown-content">
0596 <a href="{% url 'monitor_app:workflows_home' %}" class="{% if active_nav.workflows %}nav-active{% endif %}">Views</a>
0597 <a href="{% url 'monitor_app:workflow_definitions_list' %}" class="{% if active_nav.workflows %}nav-active{% endif %}">Workflow definitions</a>
0598 <a href="{% url 'monitor_app:workflow_executions_list' %}" class="{% if active_nav.workflows %}nav-active{% endif %}">Workflow executions</a>
0599 <a href="{% url 'monitor_app:namespaces_list' %}" class="{% if active_nav.workflows %}nav-active{% endif %}">Namespaces</a>
0600 </div>
0601 </div>
0602 <div class="dropdown">
0603 <button class="dropbtn{% if active_nav.files %} nav-active{% endif %}">Files</button>
0604 <div class="dropdown-content">
0605 <a href="{% url 'monitor_app:stf_files_list' %}" class="{% if active_nav.files %}nav-active{% endif %}">STF Files</a>
0606 <a href="{% url 'monitor_app:fastmon_files_list' %}" class="{% if active_nav.files %}nav-active{% endif %}">STF Samples</a>
0607 <a href="{% url 'monitor_app:tf_slices_list' %}" class="{% if active_nav.files %}nav-active{% endif %}">TF Slices</a>
0608 </div>
0609 </div>
0610 <a href="{% url 'monitor_app:workflow_agents_list' %}" class="{% if active_nav.agents %}nav-active{% endif %}">Agents</a>
0611 <a href="{% url 'monitor_app:subscribers_list' %}" class="{% if active_nav.subscribers %}nav-active{% endif %}">Subscribers</a>
0612 <a href="{% url 'monitor_app:workflow_messages' %}" class="{% if active_nav.messages %}nav-active{% endif %}">Messages</a>
0613 <a href="{% url 'monitor_app:log_summary' %}" class="{% if active_nav.logs %}nav-active{% endif %}">Logs</a>
0614 <a href="{% url 'monitor_app:database_tables_list' %}" class="{% if active_nav.database %}nav-active{% endif %}">Database</a>
0615 <a href="{% url 'monitor_app:persistent_state' %}" class="{% if active_nav.state %}nav-active{% endif %}">State</a>
0616 <div class="dropdown">
0617 <button class="dropbtn{% if active_nav.panda_rucio %} nav-active{% endif %}">PanDA/Rucio</button>
0618 <div class="dropdown-content">
0619 <a href="{% url 'monitor_app:panda_hub' %}" class="{% if active_nav.panda_hub %}nav-active{% endif %}">PanDA/Rucio Hub</a>
0620 <a href="{% url 'monitor_app:panda_activity' %}" class="{% if active_nav.panda_activity %}nav-active{% endif %}">Activity</a>
0621 <a href="{% url 'monitor_app:panda_tasks_list' %}" class="{% if active_nav.panda_tasks %}nav-active{% endif %}">Tasks</a>
0622 <a href="{% url 'monitor_app:panda_jobs_list' %}" class="{% if active_nav.panda_jobs %}nav-active{% endif %}">Jobs</a>
0623 <a href="{% url 'monitor_app:panda_errors_list' %}" class="{% if active_nav.panda_errors %}nav-active{% endif %}">Errors</a>
0624 <a href="{% url 'monitor_app:panda_diagnostics_list' %}" class="{% if active_nav.panda_diagnostics %}nav-active{% endif %}">Job diagnostics</a>
0625 <a href="{% url 'monitor_app:epic_queues_list' %}" class="{% if active_nav.panda_queues %}nav-active{% endif %}">PanDA queues</a>
0626 <a href="{% url 'monitor_app:panda_database_tables_list' %}" class="{% if active_nav.panda_database %}nav-active{% endif %}">PanDA Database</a>
0627 <a href="{% url 'monitor_app:idds_database_tables_list' %}" class="{% if active_nav.idds_database %}nav-active{% endif %}">iDDS Database</a>
0628 <a href="{% url 'monitor_app:rucio_endpoints_list' %}" class="{% if active_nav.rucio_endpoints %}nav-active{% endif %}">Rucio Endpoints</a>
0629 </div>
0630 </div>
0631 </span>
0632
0633 {# ── User view: the reduced epicprod face, entered with ?user_view=1 ── #}
0634 <span class="nav-mode nav-user">
0635 <div class="dropdown">
0636 <button class="dropbtn{% if active_nav.requests %} nav-active{% endif %}">Requests</button>
0637 <div class="dropdown-content">
0638 <a href="{% url 'pcs:pcs_request_composer' %}?user_view=1">Request form</a>
0639 <a href="{% url 'pcs:questionnaires_list' %}?user_view=1">Request list</a>
0640 </div>
0641 </div>
0642 <div class="dropdown">
0643 <button class="dropbtn{% if active_nav.campaigns %} nav-active{% endif %}">Campaign</button>
0644 <div class="dropdown-content">
0645 <a href="{% url 'pcs:pcs_campaign_plan' %}?user_view=1">Current campaign</a>
0646 <a href="{% url 'pcs:pcs_catalog' %}?user_view=1">Campaign Catalog</a>
0647 </div>
0648 </div>
0649 <a href="{% url 'pcs:find_data' %}?user_view=1" class="nav-user-item{% if active_nav.find_data %} nav-active{% endif %}">Find data</a>
0650 <a href="{% url 'monitor_app:prod_hub' %}?user_view=0" class="nav-user-item nav-user-exit">Full epicprod view</a>
0651 </span>
0652
0653 {# ── Production mode ── #}
0654 {# This nav is the only copy: swf-remote fetches it from the hub for its own pages. #}
0655 <span class="nav-mode nav-production">
0656 <a href="{% url 'monitor_app:prod_hub' %}?user_view=1" class="nav-user-link">User view</a>
0657 <div class="dropdown">
0658 <button class="dropbtn{% if active_nav.requests %} nav-active{% endif %}">Requests</button>
0659 <div class="dropdown-content">
0660 <a href="{% url 'pcs:pcs_request_composer' %}">Request Production</a>
0661 {% comment %}The Google Form intake is retired (2026-09-10); the link stays in the code and is not shown.{% endcomment %}
0662 {% if False %}<a href="https://docs.google.com/forms/d/e/1FAIpQLScDqiEaHayAcwBDGAWa4W6k-6yUFzS-XiWuhLolpy64mLk5FA/viewform" target="_blank" rel="noopener">Google request form</a>{% endif %}
0663 <a href="{% url 'pcs:questionnaires_list' %}">Production Requests</a>
0664 </div>
0665 </div>
0666 <div class="dropdown">
0667 <button class="dropbtn{% if active_nav.pcs %} nav-active{% endif %}">PCS</button>
0668 <div class="dropdown-content">
0669 <a href="{% url 'pcs:pcs_hub' %}" class="{% if active_nav.pcs_hub %}nav-active{% endif %}">PCS Hub</a>
0670 <a href="{% url 'pcs:physics_categories_list' %}" class="{% if active_nav.pcs_categories %}nav-active{% endif %}">Physics Categories</a>
0671 <a href="{% url 'pcs:tag_compose' tag_type='p' %}" class="{% if active_nav.pcs_physics_tags %}nav-active{% endif %}">Physics Tags</a>
0672 <a href="{% url 'pcs:tag_compose' tag_type='e' %}" class="{% if active_nav.pcs_evgen_tags %}nav-active{% endif %}">EvGen Tags</a>
0673 <a href="{% url 'pcs:tag_compose' tag_type='s' %}" class="{% if active_nav.pcs_simu_tags %}nav-active{% endif %}">Simu Tags</a>
0674 <a href="{% url 'pcs:tag_compose' tag_type='r' %}" class="{% if active_nav.pcs_reco_tags %}nav-active{% endif %}">Reco Tags</a>
0675 <a href="{% url 'pcs:tag_compose' tag_type='k' %}" class="{% if active_nav.pcs_background_tags %}nav-active{% endif %}">Background Tags</a>
0676 <a href="{% url 'pcs:datasets_compose' %}" class="{% if active_nav.pcs_datasets %}nav-active{% endif %}">Datasets</a>
0677 <a href="{% url 'pcs:evgen_inputs' %}" class="{% if active_nav.pcs_evgen_inputs %}nav-active{% endif %}">EVGEN Inputs</a>
0678 <a href="{% url 'pcs:pc_ingest' %}" class="{% if active_nav.pcs_ingest %}nav-active{% endif %}">PC ingest</a>
0679 <a href="{% url 'pcs:prod_configs_compose' %}" class="{% if active_nav.pcs_configs %}nav-active{% endif %}">Prod Configs</a>
0680 <a href="{% url 'pcs:prod_task_compose' %}?tab=tasks" class="{% if active_nav.pcs_tasks %}nav-active{% endif %}">Prod Tasks</a>
0681 <a href="{% url 'pcs:trials_list' %}" class="{% if active_nav.pcs_trials %}nav-active{% endif %}">Prod trials</a>
0682 </div>
0683 </div>
0684 <div class="dropdown">
0685 <button class="dropbtn{% if active_nav.campaigns %} nav-active{% endif %}">Campaigns</button>
0686 <div class="dropdown-content">
0687 <a href="{% url 'pcs:pcs_catalog' %}" class="{% if active_nav.campaigns %}nav-active{% endif %}">Campaign Catalog</a>
0688 <a href="{% url 'pcs:pcs_catalog' %}?lifecycle=current&view=progress">Campaign Progress</a>
0689 <a href="{% url 'pcs:pcs_physics_configs' %}">Physics configs by campaign</a>
0690 <a href="{% url 'pcs:pcs_campaign_plan' %}">Campaign Plan</a>
0691 <a href="{% url 'ai:ai_narratives' %}">Campaign Narratives</a>
0692 <a href="{% url 'monitor_app:ai_content_list' %}?subject_type=campaign_task,campaign">Campaign Assessments</a>
0693 </div>
0694 </div>
0695 <div class="dropdown">
0696 <button class="dropbtn{% if active_nav.data %} nav-active{% endif %}">Data</button>
0697 <div class="dropdown-content">
0698 <a href="{% url 'pcs:find_data' %}" class="{% if active_nav.find_data %}nav-active{% endif %}">Find data</a>
0699 <a href="{% url 'pcs:evgen_inputs' %}" class="{% if active_nav.pcs_evgen_inputs %}nav-active{% endif %}">EVGEN Inputs</a>
0700 <a href="{% url 'snapper_ai:snapper_focus' scope='epicprod' focus_slug='storage' %}" class="{% if active_nav.snapper_storage %}nav-active{% endif %}">Snapper storage view</a>
0701 <a href="{% url 'pcs:storage_listings' listing='ghosts' %}" class="{% if active_nav.pcs_storage %}nav-active{% endif %}">Storage exceptions</a>
0702 <a href="{% url 'pcs:request_size_plot' %}" class="{% if active_nav.pcs_request_sizes %}nav-active{% endif %}">Request size plot</a>
0703 <a href="{% url 'pcs:stash_page' %}">Failover stash</a>
0704 <a href="{% url 'pcs:validation_v1_index' %}">Validation API</a>
0705 </div>
0706 </div>
0707 <div class="dropdown">
0708 <button class="dropbtn{% if active_nav.ai_content %} nav-active{% endif %}">AI</button>
0709 <div class="dropdown-content">
0710 <a href="{% url 'monitor_app:ai_content_list' %}" class="{% if active_nav.ai_content %}nav-active{% endif %}">Campaign Assessments</a>
0711 <a href="{% url 'ai:ai_proposals' %}">AI Proposals</a>
0712 <a href="{% url 'ai:ai_narratives' %}">Campaign Narratives</a>
0713 <a href="{{ request.META.SCRIPT_NAME }}/mcp/" title="Connect an AI assistant to this monitor's MCP tools">MCP</a>
0714 </div>
0715 </div>
0716 <strong class="nav-label nav-panda-label">PanDA</strong>
0717 <div class="dropdown">
0718 <button class="dropbtn{% if active_nav.panda_activity or active_nav.panda_tasks or active_nav.panda_jobs %} nav-active{% endif %}">Activity</button>
0719 <div class="dropdown-content">
0720 <a href="{% url 'monitor_app:panda_activity' %}" class="{% if active_nav.panda_activity %}nav-active{% endif %}">Activity summary</a>
0721 <a href="{% url 'monitor_app:panda_tasks_list' %}" class="{% if active_nav.panda_tasks %}nav-active{% endif %}">Tasks</a>
0722 <a href="{% url 'monitor_app:panda_jobs_list' %}" class="{% if active_nav.panda_jobs %}nav-active{% endif %}">Jobs</a>
0723 <a href="{% url 'monitor_app:panda_front' %}">Pressure front</a>
0724 </div>
0725 </div>
0726 <div class="dropdown">
0727 <button class="dropbtn{% if active_nav.panda_errors or active_nav.panda_segfaults or active_nav.alarms or active_nav.panda_diagnostics %} nav-active{% endif %}">Diagnostics</button>
0728 <div class="dropdown-content">
0729 <a href="{% url 'monitor_app:panda_errors_list' %}" class="{% if active_nav.panda_errors %}nav-active{% endif %}">Error summary</a>
0730 <a href="{% url 'monitor_app:panda_segfaults' %}" class="{% if active_nav.panda_segfaults %}nav-active{% endif %}">Segfault catalog</a>
0731 <a href="{% url 'monitor_app:panda_segfault_findings' %}">Segfault findings</a>
0732 <a href="{% url 'snapper_ai:snapper_focus' scope='epicprod' focus_slug='errors' %}">Snapper error view</a>
0733 <a href="{% url 'monitor_app:alarms_dashboard' %}" class="{% if active_nav.alarms %}nav-active{% endif %}">Alarms</a>
0734 <a href="{% url 'monitor_app:panda_diagnostics_list' %}" class="{% if active_nav.panda_diagnostics %}nav-active{% endif %}">Job diagnostics</a>
0735 </div>
0736 </div>
0737 <div class="dropdown">
0738 <button class="dropbtn{% if active_nav.sites %} nav-active{% endif %}">Sites</button>
0739 <div class="dropdown-content">
0740 <a href="{% url 'monitor_app:epic_queues_list' %}" class="{% if active_nav.panda_queues %}nav-active{% endif %}">PanDA queues</a>
0741 <a href="{% url 'monitor_app:compute_usage' %}" class="{% if active_nav.compute_usage %}nav-active{% endif %}">Site compute usage</a>
0742 <a href="{% url 'snapper_ai:snapper_focus' scope='epicprod' focus_slug='site' %}">Snapper site view</a>
0743 <a href="{% url 'canary:probes_page' %}" class="{% if active_nav.canary %}nav-active{% endif %}">Canary probes</a>
0744 <a href="{% url 'monitor_app:panda_node_guard' %}" class="{% if active_nav.panda_node_guard %}nav-active{% endif %}">Node guard</a>
0745 <a href="{% url 'monitor_app:panda_storage_doors' %}" class="{% if active_nav.panda_storage_doors %}nav-active{% endif %}">Storage doors</a>
0746 </div>
0747 </div>
0748 <a href="{% url 'monitor_app:analysis' %}" class="nav-analysis{% if active_nav.analysis %} nav-active{% endif %}">Analysis</a>
0749 <a href="{% url 'monitor_app:log_list' %}?app_name=epicprod" class="{% if active_nav.logs %}nav-active{% endif %}">Logs</a>
0750 </span>
0751 <span class="nav-spacer"></span>
0752 <div class="dropdown" id="nav-system">
0753 <button class="dropbtn{% if system_status_overall == 'error' %} nav-system-error{% endif %}{% if active_nav.system or active_nav.snapper or active_nav.about %} nav-active{% endif %}"
0754 id="system-status-nav-link"
0755 data-system-status="{{ system_status_overall|default:'unknown' }}"
0756 data-system-status-url="{% url 'monitor_app:system_status_json' %}"
0757 {% if system_status_latest_checked_at %}data-system-checked-at="{{ system_status_latest_checked_at|date:'c' }}"{% endif %}
0758 title="{{ system_status_reason|default:'' }}">System</button>
0759 <div class="dropdown-content">
0760 <a href="{% url 'monitor_app:system_status' %}"
0761 id="system-status-dropdown-link"
0762 class="{% if system_status_overall == 'error' %}nav-system-error{% endif %}{% if active_nav.system %} nav-active{% endif %}">System Status</a>
0763 <a href="{% url 'snapper_ai:snapper_report' scope='epicprod' %}"
0764 class="{% if active_nav.snapper %}nav-active{% endif %}">Snapper report</a>
0765 <a href="{% url 'snapper_ai:snapper_focus' scope='epicprod' focus_slug='platform' %}">Platform status</a>
0766 {% if teamcomms_url %}
0767 <a href="{{ teamcomms_url }}">TeamComms</a>
0768 {% endif %}
0769 {% if may_administer_users %}
0770 <a href="{% url 'monitor_app:user_admin' %}"
0771 class="{% if active_nav.user_admin %}nav-active{% endif %}">User admin</a>
0772 {% endif %}
0773 {% if user.is_authenticated and user.is_staff %}
0774 <a href="{% url 'admin:index' %}">Admin</a>
0775 {% endif %}
0776 <a href="{{ request.META.SCRIPT_NAME }}/mcp/" title="Connect an AI assistant to this monitor's MCP tools">MCP</a>
0777 <a href="{% url 'monitor_app:about' %}" class="{% if active_nav.about %}nav-active{% endif %}">About</a>
0778 </div>
0779 </div>
0780 <div class="nav-auth">
0781 {% if user.is_authenticated %}
0782 <a href="{% url 'monitor_app:account' %}{% if user_view %}?user_view=1{% endif %}" class="{% if active_nav.account %}nav-active{% endif %}">{{ user.username }}</a>
0783 <form method="post" action="{% url 'logout' %}" style="display:inline;margin:0;">{% csrf_token %}<button type="submit" style="background:none;border:none;color:inherit;cursor:pointer;padding:0;font:inherit;">Logout</button></form>
0784 {% else %}
0785 <a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Login</a>
0786 {% endif %}
0787 </div>
0788 </nav>
0789 </div>
0790 <main>
0791 {% timezone "America/New_York" %}
0792 <div class="main-time-info">
0793 {% if is_tunnel %}
0794 <a href="https://pandaserver02.sdcc.bnl.gov/swf-monitor{{ request.path_info }}{% if request.META.QUERY_STRING %}?{{ request.META.QUERY_STRING }}{% endif %}"
0795 target="_blank" rel="noopener">Go to page at pandaserver02</a> ·
0796 {% else %}
0797 <a href="{{ external_face_base_url }}/prod{{ request.path_info }}{% if request.META.QUERY_STRING %}?{{ request.META.QUERY_STRING }}{% endif %}"
0798 target="_blank" rel="noopener">Go to page at {{ external_face_base_url|cut:"https://" }}</a> ·
0799 {% endif %}
0800 Built at {% now "Ymd H:i:s" %} {% now "T" %}
0801 </div>
0802 {% endtimezone %}
0803 {% if messages %}
0804 {% for message in messages %}
0805 <div class="alert alert-{{ message.tags|default:'info' }} alert-dismissible fade show w-100 mb-0 rounded-0" role="alert"
0806 style="background-color: #e9ecef; border: none; border-bottom: 1px solid #dee2e6; padding: 20px;">
0807 {% if 'safe' in message.tags %}
0808 {{ message|safe }}
0809 {% else %}
0810 {{ message }}
0811 {% endif %}
0812 <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
0813 </div>
0814 {% endfor %}
0815 {% endif %}
0816 {% block content %}{% endblock %}
0817 </main>
0818 <script>
0819 function setNavMode(mode) {
0820 localStorage.setItem('navMode', mode);
0821 applyNavMode(mode);
0822 }
0823 function applyNavMode(mode) {
0824 if (mode !== 'testbed') mode = 'production';
0825 document.documentElement.setAttribute('data-nav-mode', mode);
0826 const ft = document.getElementById('flip-testbed');
0827 const fp = document.getElementById('flip-production');
0828 if (ft) ft.classList.toggle('nav-active', mode === 'testbed');
0829 if (fp) fp.classList.toggle('nav-active', mode === 'production');
0830 }
0831 (function() {
0832 var mode = document.documentElement.getAttribute('data-nav-mode')
0833 || 'production';
0834 applyNavMode(mode);
0835 })();
0836 (function() {
0837 const staleAfterSeconds = 15 * 60;
0838 const dropdown = document.getElementById('system-status-dropdown');
0839 const link = document.getElementById('system-status-nav-link');
0840 if (!link) return;
0841 const dropdownLink = document.getElementById('system-status-dropdown-link');
0842 const statusUrl = link.getAttribute('data-system-status-url');
0843 function refreshSystemNav() {
0844 const status = link.getAttribute('data-system-status') || 'unknown';
0845 const checkedAt = link.getAttribute('data-system-checked-at');
0846 let stale = false;
0847 if (checkedAt) {
0848 const ts = Date.parse(checkedAt);
0849 if (!Number.isNaN(ts)) {
0850 stale = (Date.now() - ts) / 1000 > staleAfterSeconds;
0851 }
0852 }
0853 const showError = status === 'error' || stale;
0854 link.classList.toggle('nav-system-error', showError);
0855 if (dropdownLink) dropdownLink.classList.toggle('nav-system-error', showError);
0856 if (stale) {
0857 link.title = 'System status is stale by more than 15 minutes.';
0858 }
0859 }
0860 function pollSystemStatus() {
0861 if (!statusUrl || !window.fetch) return;
0862 if (!window.swfPollGate.attended()) return;
0863 fetch(statusUrl, {
0864 cache: 'no-store',
0865 credentials: 'same-origin',
0866 headers: {'Accept': 'application/json'}
0867 }).then(function(resp) {
0868 if (!resp.ok) throw new Error('HTTP ' + resp.status);
0869 return resp.json();
0870 }).then(function(data) {
0871 link.setAttribute('data-system-status', data.overall_status || 'unknown');
0872 if (data.latest_checked_at) {
0873 link.setAttribute('data-system-checked-at', data.latest_checked_at);
0874 }
0875 link.title = data.overall_reason || '';
0876 refreshSystemNav();
0877 }).catch(function() {
0878 refreshSystemNav();
0879 });
0880 }
0881 refreshSystemNav();
0882 pollSystemStatus();
0883 if (dropdown) {
0884 dropdown.addEventListener('mouseenter', pollSystemStatus);
0885 dropdown.addEventListener('focusin', pollSystemStatus);
0886 }
0887 window.swfPollGate.onReturn(pollSystemStatus);
0888 setInterval(refreshSystemNav, 10000);
0889 setInterval(pollSystemStatus, 60000);
0890 })();
0891 </script>
0892 <script>
0893 /* Fit an in-page document editor to the visible window: if the
0894 document overflows its panel, grow the panel to use nearly the
0895 full viewport height. The app-wide utility for editing large
0896 documents — call swfFitEditor(el) when opening an editor, or give
0897 the textarea the swf-fit-editor class to fit on focus. */
0898 window.swfFitEditor = function (el) {
0899 if (!el) return;
0900 const margin = 24;
0901 const avail = window.innerHeight - el.getBoundingClientRect().top - margin;
0902 if (el.scrollHeight > el.clientHeight && avail > el.clientHeight) {
0903 el.style.height = Math.max(avail, 240) + 'px';
0904 }
0905 };
0906 document.addEventListener('focusin', function (e) {
0907 if (e.target.matches && e.target.matches('textarea.swf-fit-editor')) {
0908 window.swfFitEditor(e.target);
0909 }
0910 });
0911
0912 /* The app-wide relative-time ticker: give any element
0913 data-relative-time="<ISO timestamp>" and its text renders and
0914 ticks as 'just now' / 'Ns ago' / 'Nm ago' / 'Nh ago' / 'Nd ago'.
0915 Optional data-relative-time-interval="<seconds>" slows that
0916 element's redraw to every N seconds (default 1). */
0917 window.swfFormatElapsed = function (seconds) {
0918 if (seconds < 5) return 'just now';
0919 if (seconds < 60) return seconds + 's ago';
0920 const minutes = Math.floor(seconds / 60);
0921 if (minutes < 60) return minutes + 'm ago';
0922 const hours = Math.floor(minutes / 60);
0923 if (hours < 48) return hours + 'h ago';
0924 return Math.floor(hours / 24) + 'd ago';
0925 };
0926 (function () {
0927 let n = 0;
0928 function tick() {
0929 const now = Date.now();
0930 document.querySelectorAll('[data-relative-time]').forEach(function (el) {
0931 const every = parseInt(
0932 el.getAttribute('data-relative-time-interval'), 10) || 1;
0933 if (n % every !== 0) return;
0934 const ts = Date.parse(el.getAttribute('data-relative-time'));
0935 if (Number.isNaN(ts)) return;
0936 el.textContent = window.swfFormatElapsed(
0937 Math.max(0, Math.floor((now - ts) / 1000)));
0938 });
0939 n += 1;
0940 }
0941 tick();
0942 setInterval(tick, 1000);
0943 })();
0944 /* Update-button click feedback: an element with class
0945 swf-update-btn shows 'Updating...' while its navigation is in
0946 flight. The label restores on pageshow (a back/forward-cache
0947 return) and by a 20 s timeout (an aborted navigation), so it
0948 can never stick. */
0949 (function () {
0950 function restore() {
0951 document.querySelectorAll('.swf-update-btn[data-updating]').forEach(function (el) {
0952 el.textContent = el.getAttribute('data-updating');
0953 el.removeAttribute('data-updating');
0954 });
0955 }
0956 document.addEventListener('click', function (ev) {
0957 const el = ev.target.closest ? ev.target.closest('.swf-update-btn') : null;
0958 if (!el || el.hasAttribute('data-updating')) return;
0959 el.setAttribute('data-updating', el.textContent);
0960 el.textContent = 'Updating...';
0961 setTimeout(restore, 20000);
0962 });
0963 window.addEventListener('pageshow', restore);
0964 })();
0965 // Latching table headers latch beneath the sticky nav bar; expose
0966 // its measured height to the CSS above.
0967 (function () {
0968 function setNavHeightVar() {
0969 const nav = document.querySelector('.sticky-header');
0970 document.documentElement.style.setProperty(
0971 '--swf-nav-height', (nav ? nav.offsetHeight : 0) + 'px');
0972 }
0973 setNavHeightVar();
0974 window.addEventListener('resize', setNavHeightVar);
0975 })();
0976 /* Adaptive house tables. Narrow tables retain window scrolling and
0977 the nav-offset header latch. Only a table whose rendered width
0978 exceeds its available width gets a fitted two-axis scroll frame,
0979 a frame-top header latch, and two pinned leading columns. */
0980 (function () {
0981 const tableSelector =
0982 'table.dataTable, table.swf-sortable, table.swf-latch-head';
0983 let tableFrameUpdatePending = false;
0984 let tableFrameFitPending = false;
0985
0986 function ensureTableFrame(table) {
0987 if (table.parentElement &&
0988 table.parentElement.classList.contains('swf-table-frame')) {
0989 return table.parentElement;
0990 }
0991 const frame = document.createElement('div');
0992 frame.className = 'swf-table-frame';
0993 table.parentNode.insertBefore(frame, table);
0994 frame.appendChild(table);
0995 return frame;
0996 }
0997
0998 function requestedPinnedColumns(table) {
0999 const value = table.getAttribute('data-swf-pin-columns');
1000 if (value === null) return 2;
1001 const parsed = Number.parseInt(value, 10);
1002 return Number.isFinite(parsed) ? Math.max(0, Math.min(2, parsed)) : 2;
1003 }
1004
1005 function fitTableFrame(frame) {
1006 if (!frame.classList.contains('swf-wide-table-scroll')) return;
1007 const nav = document.querySelector('.sticky-header');
1008 const navHeight = nav ? nav.offsetHeight : 0;
1009 // The region is sized to the whole height under the nav, wherever
1010 // the frame sits, so the page keeps exactly (frame top - nav) to
1011 // scroll: the matter above scrolls away until the header seats
1012 // beneath the nav, where it latches. Until then the frame lets
1013 // the wheel through (no vertical scrolling of its own), so
1014 // scrolling anywhere moves the page first and the table after.
1015 const fullAvailable = Math.max(120, window.innerHeight - navHeight - 16);
1016 frame.style.setProperty('--swf-table-max-height', fullAvailable + 'px');
1017 const seated = frame.getBoundingClientRect().top <= navHeight + 1;
1018 frame.style.overflowY = seated ? 'auto' : 'hidden';
1019 }
1020
1021 function updateTableFrame(table) {
1022 if (!table.matches(tableSelector) || !table.parentNode) return;
1023 const frame = ensureTableFrame(table);
1024 const availableWidth = frame.getBoundingClientRect().width;
1025 const isWide = table.scrollWidth > availableWidth + 1;
1026
1027 frame.classList.toggle('swf-wide-table-scroll', isWide);
1028 table.classList.toggle('swf-wide-table', isWide);
1029 if (!isWide) {
1030 table.removeAttribute('data-swf-active-pin-columns');
1031 table.style.removeProperty('--swf-first-column-width');
1032 frame.style.removeProperty('--swf-table-max-height');
1033 return;
1034 }
1035
1036 const columnHeaderRow =
1037 table.querySelector('thead tr.swf-latch-column-row')
1038 || table.querySelector('thead tr:first-child');
1039 const headerCells = columnHeaderRow
1040 ? columnHeaderRow.querySelectorAll(':scope > th') : [];
1041 const pinned = Math.min(requestedPinnedColumns(table), headerCells.length);
1042 table.setAttribute('data-swf-active-pin-columns', String(pinned));
1043 if (pinned > 1) {
1044 table.style.setProperty(
1045 '--swf-first-column-width',
1046 headerCells[0].getBoundingClientRect().width + 'px');
1047 } else {
1048 table.style.removeProperty('--swf-first-column-width');
1049 }
1050 fitTableFrame(frame);
1051 }
1052
1053 function updateAllTableFrames() {
1054 tableFrameUpdatePending = false;
1055 document.querySelectorAll(tableSelector).forEach(updateTableFrame);
1056 }
1057
1058 function scheduleTableFrameUpdate() {
1059 if (tableFrameUpdatePending) return;
1060 tableFrameUpdatePending = true;
1061 window.requestAnimationFrame(updateAllTableFrames);
1062 }
1063
1064 function scheduleTableFrameFit() {
1065 if (tableFrameFitPending) return;
1066 tableFrameFitPending = true;
1067 window.requestAnimationFrame(function () {
1068 tableFrameFitPending = false;
1069 document.querySelectorAll(
1070 '.swf-table-frame.swf-wide-table-scroll'
1071 ).forEach(fitTableFrame);
1072 });
1073 }
1074
1075 window.swfUpdateTableFrames = scheduleTableFrameUpdate;
1076 $(function () {
1077 // House sortable tables get chrome-free DataTables sorting before
1078 // the adaptive frame measures their rendered column widths.
1079 // data-swf-order="N,desc" opts a table into a default sort.
1080 $('table.swf-sortable').each(function () {
1081 const spec = this.getAttribute('data-swf-order');
1082 let order = [];
1083 if (spec) {
1084 const parts = spec.split(',');
1085 order = [[parseInt(parts[0], 10) || 0,
1086 parts[1] === 'desc' ? 'desc' : 'asc']];
1087 }
1088 $(this).DataTable({
1089 paging: false, searching: false, info: false, order: order
1090 });
1091 });
1092 scheduleTableFrameUpdate();
1093 });
1094 $(document).on('draw.dt column-sizing.dt', scheduleTableFrameUpdate);
1095 window.addEventListener('resize', scheduleTableFrameUpdate);
1096 window.addEventListener('scroll', scheduleTableFrameFit, {passive: true});
1097 })();
1098 </script>
1099 </body>
1100 </html>