diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e392cd3..ad1dc3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ --- develop --- +* feature: Converge the Syslog Status cards and the user chart Dashboards on the shared Cacti dashboarding Card API (js/cards.js + css/cards.css), so both render with the standardized .cc* card chrome (drag/keyboard reorder, show more/less, maximize, refresh, remove) and the bundle stays byte-identical across plugins for promotion into Cacti core; the Status-tab controller moves out of js/functions.js into a thin js/syslog_dashboard.js adapter over the shared controller, and the chart panels adopt the same chrome while keeping their continuous drag-resize +* dev: Load the bundled css/cards.css and js/cards.js only on cores that do not ship them globally, gated with file_exists so newer cores use the core copy + * dev: Run the CI poller with the shared runner group so RRD files and structured directories pass permission verification * security: Add the Cacti request nonce to plugin script tags so pages comply with the Content-Security-Policy nonce migration diff --git a/css/cards.css b/css/cards.css new file mode 100644 index 00000000..d68ef00e --- /dev/null +++ b/css/cards.css @@ -0,0 +1,298 @@ +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ +*/ + +/* + * cacti-cards vX.Y - shared "card chrome" (presentation layer). + * + * This file is bundled per-plugin (no cross-plugin or core dependency) and is + * kept byte-identical between the plugins that use it. It styles a grid of + * cards with a draggable header, per-card tools and optional expand/resize. + * All colours come from CSS custom properties the host sets, so it inherits + * the active Cacti theme without baking any palette in. + * + * HTML contract (emitted by the host's PHP): + * + *
+ *
+ *
+ * + *

Title

+ * + * + * + *
+ *
...
+ *
+ *
+ * + * Host theme variables (all optional, sensible fallbacks provided): + * --cc-cols grid column count (default 4) + * --cc-surface page surface behind the cards + * --cc-card card background + * --cc-text card foreground + * --cc-border card / divider border + * --cc-header-bg header background colour + * --cc-header-image header background image (gradient themes) + * --cc-header-fg header foreground + * --cc-sev-up/-warn/-down/-none severity hues (used as a translucent wash + * over the header + its bottom border) + */ + +.ccGrid { + display: grid; + grid-template-columns: repeat(var(--cc-cols, 4), minmax(0, 1fr)); + align-items: start; + gap: 14px; + margin: 10px 4px; + font-variant-numeric: tabular-nums; +} + +/* Column span (width). */ +.ccW1 { grid-column: span 1; } +.ccW2 { grid-column: span 2; } +.ccW3 { grid-column: span 3; } +.ccW4 { grid-column: span 4; } +.ccW5 { grid-column: span 5; } +.ccW6 { grid-column: span 6; } +.ccW7 { grid-column: span 7; } +.ccW8 { grid-column: span 8; } + +/* Row span (height) for the "steps" size model. */ +.ccH1 { grid-row: span 1; } +.ccH2 { grid-row: span 2; } +.ccH3 { grid-row: span 3; } + +.ccCard { + box-sizing: border-box; + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--cc-border, #cbd8d1); + /* lift the card off the page surface on both light and dark themes */ + background: color-mix(in srgb, var(--cc-text, #23362f) 4%, var(--cc-card, #fff)); + color: var(--cc-text, #23362f); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + overflow: hidden; +} + +.ccCardDragging { + opacity: 0.5; +} + +.ccCardHeader { + box-sizing: border-box; + /* fixed row height so a tool/handle hover highlight never reflows the header */ + min-height: 34px; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--cc-border, #cbd8d1); + /* paint like the active theme's native html_start_box title bar */ + background-color: var(--cc-header-bg, #e5ece8); + background-image: var(--cc-header-image, none); + background-repeat: repeat-x; + color: var(--cc-header-fg, var(--cc-text, #23362f)); +} + +/* Drag handle: lives on the LEFT of the header; grab it to reorder the card. */ +.ccDrag { + order: -1; + flex: 0 0 auto; + box-sizing: border-box; + padding: 2px 5px; + /* reserve the border so a hover/focus highlight recolours it instead of resizing the header */ + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, currentColor 50%, transparent); + font-size: 13px; + line-height: 1; + cursor: grab; +} + +.ccDrag:hover, +.ccDrag:focus-visible { + color: currentColor; + border-color: transparent; + background: color-mix(in srgb, currentColor 12%, transparent); + outline: none; +} + +.ccDrag:active { + cursor: grabbing; +} + +/* Host toggle to lock reordering (e.g. a "select text" mode): hide the handles. */ +.ccNoDrag .ccDrag { + display: none; +} + +.ccTitle { + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ccTools { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 1px; +} + +.ccTool { + box-sizing: border-box; + padding: 2px 5px; + /* reserve the border so a hover/focus highlight recolours it instead of resizing the header */ + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, currentColor 50%, transparent); + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.ccTool:hover, +.ccTool:focus-visible { + color: currentColor; + border-color: transparent; + background: color-mix(in srgb, currentColor 12%, transparent); + outline: none; +} + +/* Only the control matching the card's current state is shown. */ +.ccCard:not(.ccCardExpanded) .ccTool[data-tool="collapse"], +.ccCardExpanded .ccTool[data-tool="expand"] { + display: none; +} + +/* Expand/collapse is only meaningful on expandable cards. */ +.ccCard:not([data-expandable="1"]) .ccTool[data-tool="expand"], +.ccCard:not([data-expandable="1"]) .ccTool[data-tool="collapse"] { + display: none; +} + +/* Resize tools only apply to the "steps" size model, and clamp at the ends. */ +.ccCard:not([data-size-model="steps"]) .ccTool[data-tool="shrink"], +.ccCard:not([data-size-model="steps"]) .ccTool[data-tool="grow"] { + display: none; +} + +.ccCardBody { + padding: 4px 12px 10px; + overflow-x: auto; +} + +/* Expandable cards clip to a fixed height until "show more"; a fade hints at the + clipped content. */ +.ccCardExpandable:not(.ccCardExpanded) .ccCardBody { + position: relative; + max-height: 210px; + overflow: hidden; +} + +.ccCardExpandable:not(.ccCardExpanded) .ccCardBody::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 30px; + pointer-events: none; + background: linear-gradient(transparent, color-mix(in srgb, var(--cc-text, #23362f) 4%, var(--cc-card, #fff))); +} + +/* + * Severity wash. Every card carries exactly one data-severity. A single + * translucent colour layer is drawn over the sampled native theme header so the + * strength stays consistent on solid and gradient themes, light and dark. The + * hue comes from a host variable so it follows the theme's colour family. + */ +.ccCard[data-severity="none"] .ccCardHeader { + background-image: + linear-gradient(var(--cc-sev-none, rgba(140, 149, 159, 0.20)), var(--cc-sev-none, rgba(140, 149, 159, 0.20))), + var(--cc-header-image, none); + border-bottom-color: color-mix(in srgb, var(--cc-sev-none-border, #8c959f) 45%, transparent); +} + +.ccCard[data-severity="up"] .ccCardHeader { + background-image: + linear-gradient(var(--cc-sev-up, rgba(148, 179, 107, 0.32)), var(--cc-sev-up, rgba(148, 179, 107, 0.32))), + var(--cc-header-image, none); + border-bottom-color: color-mix(in srgb, var(--cc-sev-up-border, #94b36b) 60%, transparent); +} + +.ccCard[data-severity="warn"] .ccCardHeader { + background-image: + linear-gradient(var(--cc-sev-warn, rgba(255, 143, 30, 0.32)), var(--cc-sev-warn, rgba(255, 143, 30, 0.32))), + var(--cc-header-image, none); + border-bottom-color: color-mix(in srgb, var(--cc-sev-warn-border, #ff8f1e) 60%, transparent); +} + +.ccCard[data-severity="down"] .ccCardHeader { + background-image: + linear-gradient(var(--cc-sev-down, rgba(204, 0, 0, 0.34)), var(--cc-sev-down, rgba(204, 0, 0, 0.34))), + var(--cc-header-image, none); + border-bottom-color: color-mix(in srgb, var(--cc-sev-down-border, #cc0000) 60%, transparent); +} + +/* "Add card" toolbar above the grid. */ +.ccToolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 10px 4px 0; +} + +.ccToolbarLabel { + font-size: 12px; + font-weight: 600; + color: var(--cc-text, #23362f); +} + +.ccAdd, +.ccRefreshInterval { + box-sizing: border-box; + padding: 4px 8px; + border: 1px solid var(--cc-border, #cbd8d1); + border-radius: 7px; + background: var(--cc-card, #fff); + color: var(--cc-text, #23362f); + font-size: 12px; +} + +.ccAdd { + min-width: 220px; +} + +.ccRefresh { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +/* Maximize dialog: reuse the card body typography on the themed dialog surface. */ +.ccDialogBody { + color: var(--cc-text, #23362f); + font-variant-numeric: tabular-nums; +} diff --git a/css/dashboard.css b/css/dashboard.css index 68bd8350..245c7597 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -23,6 +23,24 @@ /* Scoped dashboard surfaces; follows the search.css conventions. */ +/* + * Bind the shared card chrome (core include/css/cards.css, bundled by the plugin + * on older cores) to syslog's theme for the user chart Dashboards. The panels + * carry the shared .cc* chrome classes and inherit these --cc-* custom + * properties from the grid and the panel-editor dialog (reparented under + * by jQuery UI when open). The palette derives from currentColor so the panels + * follow every Cacti theme. The grid itself keeps its own responsive auto-fill + * layout (below); the panels span columns via an inline grid-column so a saved + * width still reflows on narrow screens. + */ +#syslog_dashboard_grid, +.syslogPanelDialog { + --cc-card: transparent; + --cc-text: currentColor; + --cc-border: color-mix(in srgb, currentColor 18%, transparent); + --cc-header-bg: color-mix(in srgb, currentColor 9%, transparent); +} + /* Dashboard bar header: heading on the left, Time Range/Refresh on the right. */ #syslog_dashboard_form .syslogSearchHeader { display: flex; @@ -86,7 +104,9 @@ align-items: start; } -/* Card surfaces, headers, titles and tools reuse the Status tab classes. */ +/* Card surfaces, headers, titles and tools are painted by the shared card + chrome (css/cards.css); only the resize affordances and chart body remain + plugin-specific. */ .syslogDashboardCard { position: relative; } diff --git a/css/search.css b/css/search.css index 21243118..0ee7b18d 100644 --- a/css/search.css +++ b/css/search.css @@ -721,205 +721,22 @@ .syslogSearchBuilder .syslogSearchRow:not(.syslogSearchGroupRow) .syslogSearchRemove { grid-column: 4; } } -/* Syslog Status cards: a responsive flex grid of theme-aware cards (a - Bootstrap-style card layout without Bootstrap). Kept in the plugin for now; - intended to move into Cacti core later. Colours follow the sampled theme - tokens that js applies to #syslog_status, so the cards differentiate from the - page surface on both light and dark themes. */ -#syslog_status.syslogStatusGrid { - display: grid; - /* an 8-column grid; each card occupies a number of columns via a span class */ - grid-template-columns: repeat(8, minmax(0, 1fr)); - align-items: start; - gap: 14px; - margin: 10px 4px; - font-variant-numeric: tabular-nums; -} - -/* Column span out of 8 (Bootstrap-like, but 8 columns). Default cards span 2; - wide tables span 4. The card rendering API chooses the span per card. */ -.syslogStatusSpan1 { grid-column: span 1; } -.syslogStatusSpan2 { grid-column: span 2; } -.syslogStatusSpan3 { grid-column: span 3; } -.syslogStatusSpan4 { grid-column: span 4; } -.syslogStatusSpan5 { grid-column: span 5; } -.syslogStatusSpan6 { grid-column: span 6; } -.syslogStatusSpan7 { grid-column: span 7; } -.syslogStatusSpan8 { grid-column: span 8; } - -.syslogStatusCard { - box-sizing: border-box; - min-width: 0; - display: flex; - flex-direction: column; - border: 1px solid var(--search-border, #cbd8d1); - /* lift the card off the page surface on both light and dark themes */ - background: color-mix(in srgb, var(--search-text, #23362f) 4%, var(--search-card, #fff)); - color: var(--search-text, #23362f); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); - overflow: hidden; -} - -.syslogStatusCardHeader { - box-sizing: border-box; - /* fixed row height so a tool/handle hover highlight never reflows the header */ - min-height: 34px; - display: flex; - align-items: center; - gap: 8px; - padding: 7px 10px; - border-bottom: 1px solid var(--search-border, #cbd8d1); - /* paint like the active theme's native html_start_box title bar */ - background-color: var(--search-header-bg, #e5ece8); - background-image: var(--search-header-image, none); - background-repeat: repeat-x; - color: var(--search-header-fg, var(--search-text, #23362f)); -} - -/* Drag handle: grab the card by its header to reorder it. */ -.syslogStatusCardDrag { - flex: 0 0 auto; - box-sizing: border-box; - padding: 2px 5px; - /* reserve the border so a hover/focus highlight recolours it instead of resizing the header */ - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: color-mix(in srgb, currentColor 50%, transparent); - font-size: 13px; - line-height: 1; - cursor: grab; -} - -.syslogStatusCardDrag:hover, -.syslogStatusCardDrag:focus-visible { - color: currentColor; - border-color: transparent; - background: color-mix(in srgb, currentColor 12%, transparent); - outline: none; -} - -.syslogStatusCardDrag:active { - cursor: grabbing; -} - -.syslogStatusCardTitle { - flex: 1 1 auto; - min-width: 0; - margin: 0; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - /* keep the title to one line so a long title (or a width change mid-drag) never reflows the header */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.syslogStatusCardDragging { - opacity: 0.5; -} - -/* "Add card" toolbar above the grid. */ -.syslogStatusToolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin: 10px 4px 0; -} - -.syslogStatusToolbarLabel { - font-size: 12px; - font-weight: 600; - color: var(--search-text, #23362f); -} - -.syslogStatusAdd { - box-sizing: border-box; - min-width: 220px; - padding: 4px 8px; - border: 1px solid var(--search-border, #cbd8d1); - border-radius: 7px; - background: var(--search-card, #fff); - color: var(--search-text, #23362f); - font-size: 12px; -} - -/* Auto-refresh interval + manual refresh, pushed to the right of the toolbar. */ -.syslogStatusRefresh { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; -} - -.syslogStatusRefreshInterval { - box-sizing: border-box; - padding: 4px 8px; - border: 1px solid var(--search-border, #cbd8d1); - border-radius: 7px; - background: var(--search-card, #fff); - color: var(--search-text, #23362f); - font-size: 12px; -} - -.syslogStatusRefreshNow { - font-size: 14px; -} - -/* Per-card header tools: show more/less, maximize, refresh, remove. */ -.syslogStatusCardTools { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 1px; -} - -.syslogStatusCardTool { - box-sizing: border-box; - padding: 2px 5px; - /* reserve the border so a hover/focus highlight recolours it instead of resizing the header */ - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: color-mix(in srgb, currentColor 50%, transparent); - font-size: 12px; - line-height: 1; - cursor: pointer; -} - -.syslogStatusCardTool:hover, -.syslogStatusCardTool:focus-visible { - color: currentColor; - border-color: transparent; - background: color-mix(in srgb, currentColor 12%, transparent); - outline: none; -} - -/* Only the control matching the card's current state is shown. */ -.syslogStatusCard:not(.syslogStatusCardExpanded) .syslogStatusCardTool[data-tool="collapse"], -.syslogStatusCardExpanded .syslogStatusCardTool[data-tool="expand"] { - display: none; -} - -/* Expandable cards clip to a fixed height until "show more"; a fade hints at the - clipped content. */ -.syslogStatusCardExpandable:not(.syslogStatusCardExpanded) .syslogStatusCardBody { - position: relative; - max-height: 210px; - overflow: hidden; -} - -.syslogStatusCardExpandable:not(.syslogStatusCardExpanded) .syslogStatusCardBody::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 30px; - pointer-events: none; - background: linear-gradient(transparent, color-mix(in srgb, var(--search-text, #23362f) 4%, var(--search-card, #fff))); +/* Syslog card dashboards (Status tab + user chart Dashboards). The card chrome + itself is painted by the shared stylesheet (core include/css/cards.css, or the + plugin's bundled css/cards.css on older cores); these rules only bind the + shared --cc-* custom properties to syslog's theme and keep the plugin-specific + card *content* (status tables, notes, chart panels). The palette derives from + currentColor so the cards follow every Cacti theme; it is applied to the + toolbar, grids and the maximize dialog (which jQuery UI reparents under + ) so all three paint consistently. The grids are eight columns wide. */ +#syslog_status_panel, +#syslog_status_grid.ccGrid, +#syslog_status_dialog { + --cc-cols: 8; + --cc-card: transparent; + --cc-text: currentColor; + --cc-border: color-mix(in srgb, currentColor 18%, transparent); + --cc-header-bg: color-mix(in srgb, currentColor 9%, transparent); } /* Maximize dialog: reuse the card body typography on the themed dialog surface. */ @@ -959,39 +776,6 @@ border-top: 1px solid color-mix(in srgb, currentColor 9%, transparent); } -/* Header severity variants. Every card header carries exactly one: a neutral - grey "no severity" default, or good / warning / in-trouble. A single - translucent colour layer is drawn over the sampled native theme header so the - strength stays consistent on solid and gradient themes, light and dark. */ -.syslogStatusCardHeader--none { - background-color: var(--search-header-bg, #e5ece8); - background-image: linear-gradient(rgba(140, 149, 159, 0.20), rgba(140, 149, 159, 0.20)), var(--search-header-image, none); - border-bottom-color: color-mix(in srgb, #8c959f 45%, transparent); -} - -.syslogStatusCardHeader--up { - background-color: var(--search-header-bg, #e5ece8); - background-image: linear-gradient(rgba(148, 179, 107, 0.32), rgba(148, 179, 107, 0.32)), var(--search-header-image, none); - border-bottom-color: color-mix(in srgb, #94b36b 60%, transparent); -} - -.syslogStatusCardHeader--recovering { - background-color: var(--search-header-bg, #e5ece8); - background-image: linear-gradient(rgba(255, 143, 30, 0.32), rgba(255, 143, 30, 0.32)), var(--search-header-image, none); - border-bottom-color: color-mix(in srgb, #ff8f1e 60%, transparent); -} - -.syslogStatusCardHeader--down { - background-color: var(--search-header-bg, #e5ece8); - background-image: linear-gradient(rgba(204, 0, 0, 0.34), rgba(204, 0, 0, 0.34)), var(--search-header-image, none); - border-bottom-color: color-mix(in srgb, #cc0000 60%, transparent); -} - -.syslogStatusCardBody { - padding: 4px 12px 10px; - overflow-x: auto; -} - .syslogStatusTable { width: 100%; border-collapse: collapse; @@ -1054,36 +838,29 @@ font-weight: 600; } -/* Narrow the grid before cards get cramped: halve to 4 columns (wide span-4+ - cards fill the row), then collapse to a single column on small screens. */ +/* Narrow the grid before cards get cramped: halve to 4 columns, then collapse + to a single column on small screens. The shared .ccGrid derives its column + count from --cc-cols and the .ccW* spans clamp to the available tracks. */ @media (max-width: 1200px) { - #syslog_status.syslogStatusGrid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + #syslog_status_grid.ccGrid { + --cc-cols: 4; } - .syslogStatusSpan4, - .syslogStatusSpan5, - .syslogStatusSpan6, - .syslogStatusSpan7, - .syslogStatusSpan8 { + #syslog_status_grid.ccGrid .ccW5, + #syslog_status_grid.ccGrid .ccW6, + #syslog_status_grid.ccGrid .ccW7, + #syslog_status_grid.ccGrid .ccW8 { grid-column: 1 / -1; } } @media (max-width: 760px) { - #syslog_status.syslogStatusGrid { - grid-template-columns: 1fr; + #syslog_status_grid.ccGrid { + --cc-cols: 1; gap: 10px; } - .syslogStatusSpan1, - .syslogStatusSpan2, - .syslogStatusSpan3, - .syslogStatusSpan4, - .syslogStatusSpan5, - .syslogStatusSpan6, - .syslogStatusSpan7, - .syslogStatusSpan8 { + #syslog_status_grid.ccGrid > .ccCard { grid-column: 1 / -1; } } diff --git a/js/cards.js b/js/cards.js new file mode 100644 index 00000000..e8d4d1eb --- /dev/null +++ b/js/cards.js @@ -0,0 +1,469 @@ +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ +*/ + +/* + * cacti-cards vX.Y - shared "card chrome" controller (interaction layer). + * + * Bundled per-plugin with no cross-plugin or core dependency and kept + * byte-identical between the plugins that use it. It wires the DOM behaviour + * for a .ccGrid of .ccCard elements (see cards.css for the HTML contract): + * keyboard-accessible drag/drop reordering, the per-card tool buttons + * (expand/collapse, grow/shrink, maximize, refresh, remove), an optional + * "add card" control, and debounced layout persistence. Everything that is + * plugin-specific (fetching a card, persisting layout, the add catalogue, + * maximize, resize) is delegated to an adapter the host supplies. + * + * Usage (host, from an external nonce'd init or its own JS): + * CactiCards.init(document.getElementById('my_grid'), { + * fetchCard: function(id, state) { return Promise; }, + * onLayoutChange:function(state) { ... persist {order, cards} ... }, + * onAddCard: function(id) { return Promise; }, + * onRemoveCard: function(id) { return Promise|any; }, + * onResize: function(id, dir, cardEl) { return Promise|any; }, // 'grow'|'shrink' + * maximize: function(id, mode, cardEl) { ... }, // mode from data-maximize + * applyTheme: function(gridEl) { ... set --cc-* vars ... } + * }); + * + * The adapter is optional; every hook has a safe no-op default. init() is + * idempotent per grid element. + */ + +window.CactiCards = window.CactiCards || (function() { + 'use strict'; + + var dragging = null; + + function cardSel(adapter) { + return adapter.cardSelector || '.ccCard'; + } + + function cardId(card, adapter) { + return card.getAttribute(adapter.idAttr || 'data-card'); + } + + /* Locate the live card element for an id, used to re-resolve a reference that + * an overlapping replacement may have detached. */ + function findCard(grid, adapter, id) { + var match = null; + if (id == null) { + return null; + } + grid.querySelectorAll(cardSel(adapter)).forEach(function(card) { + if (match === null && cardId(card, adapter) === id) { + match = card; + } + }); + return match; + } + + /* Keep the documented data-* contract (data-expandable / data-expanded) in + * sync with the classes the CSS clipping and serialize() rely on, so a host + * may drive card state through either representation. Called whenever a card + * is (re)bound: initial, added, or refreshed. */ + function normalizeState(card) { + var expandable = card.getAttribute('data-expandable'); + if (expandable === '1' || expandable === 'true') { + card.classList.add('ccCardExpandable'); + } else if (expandable != null) { + card.classList.remove('ccCardExpandable'); + } + if (card.classList.contains('ccCardExpandable')) { + card.setAttribute('data-expandable', '1'); + } + + var expanded = card.getAttribute('data-expanded'); + if (expanded === '1' || expanded === 'true') { + card.classList.add('ccCardExpanded'); + } else if (expanded != null) { + card.classList.remove('ccCardExpanded'); + } + card.setAttribute('data-expanded', card.classList.contains('ccCardExpanded') ? '1' : '0'); + } + + /* Collect the current order and per-card state for persistence. */ + function serialize(grid, adapter) { + var order = []; + var cards = {}; + + grid.querySelectorAll(cardSel(adapter)).forEach(function(card) { + var id = cardId(card, adapter); + if (id == null) { + return; + } + order.push(id); + cards[id] = { + expanded: card.classList.contains('ccCardExpanded'), + height: Number.parseInt(card.getAttribute('data-height') || '1', 10) + }; + }); + + return { order: order, cards: cards }; + } + + /* Debounce layout saves so a burst of reorders coalesces into one call. */ + function saveLayout(grid, adapter) { + if (typeof adapter.onLayoutChange !== 'function') { + return; + } + if (grid.__ccSaveTimer) { + clearTimeout(grid.__ccSaveTimer); + } + grid.__ccSaveTimer = setTimeout(function() { + grid.__ccSaveTimer = null; + adapter.onLayoutChange(serialize(grid, adapter)); + }, 250); + } + + /* The insertion reference for a drop: the first card that follows the pointer + * in reading order. Resolve the pointer's row before comparing horizontal + * centres so dragging over a later card in a row no longer snaps the + * insertion point back to the start of that row. */ + function dragReference(grid, adapter, x, y) { + var cards = Array.prototype.slice.call(grid.querySelectorAll(cardSel(adapter) + ':not(.ccCardDragging)')); + var rowMatch = null; // first card in the pointer's row whose centre is past x + var belowMatch = null; // first card that begins in a row below the pointer + + cards.forEach(function(card) { + var box = card.getBoundingClientRect(); + if (y >= box.top && y <= box.bottom) { + // Pointer is within this card's row band: compare horizontally. + if (rowMatch === null && x < box.left + box.width / 2) { + rowMatch = card; + } + } else if (y < box.top && belowMatch === null) { + // This card starts in a row below the pointer; it ends the + // pointer's row when nothing in that row is past the pointer. + belowMatch = card; + } + }); + + return rowMatch || belowMatch; + } + + /* Swap a card's markup in place (after refresh/add) and re-bind it. */ + function replaceCard(grid, adapter, card, html) { + var tmp = document.createElement('div'); + tmp.innerHTML = (html || '').trim(); + var fresh = tmp.querySelector(cardSel(adapter)) || tmp.firstElementChild; + if (!fresh) { + return null; + } + // Overlapping refresh/resize responses may reference a card that an + // earlier response already swapped out, leaving the captured node + // detached. Re-resolve the live element by id so the latest response + // replaces what is currently in the grid instead of throwing on (and + // losing the update to) a detached node. + var target = card; + if (!grid.contains(target)) { + target = findCard(grid, adapter, cardId(card, adapter)); + } + if (!target || !target.parentNode) { + return null; + } + // The in-flight response carries the expansion state captured when the + // request started, but the user may have expanded/collapsed the live + // card since. Carry the live state onto the fresh markup so the swap + // (refresh, resize or refresh-all) does not undo that toggle; bindCard's + // normalizeState applies the class from this data-expanded value. + fresh.setAttribute('data-expanded', target.classList.contains('ccCardExpanded') ? '1' : '0'); + target.parentNode.replaceChild(fresh, target); + bindCard(grid, adapter, fresh); + if (typeof adapter.applyTheme === 'function') { + adapter.applyTheme(grid); + } + return fresh; + } + + /* A per-card-id request generation shared by refresh and resize so a slow + * response cannot overwrite the markup of a newer request for the same card + * (for example a refresh that lands after a later grow). The most recently + * started request for an id wins; earlier in-flight responses are discarded. */ + function startRequest(grid, id) { + if (!grid.__ccReq) { + grid.__ccReq = {}; + } + grid.__ccReq[id] = (grid.__ccReq[id] || 0) + 1; + return grid.__ccReq[id]; + } + + function requestSuperseded(grid, id, token) { + return !!grid.__ccReq && grid.__ccReq[id] !== token; + } + + function refreshCard(grid, adapter, card) { + var id = cardId(card, adapter); + // Hosts that inject into an inner body (rather than replacing the whole + // card) provide onRefresh and keep ownership of the swap. + if (typeof adapter.onRefresh === 'function') { + adapter.onRefresh(id, card); + return; + } + if (typeof adapter.fetchCard !== 'function') { + return; + } + var state = { + expanded: card.classList.contains('ccCardExpanded'), + height: Number.parseInt(card.getAttribute('data-height') || '1', 10) + }; + var token = startRequest(grid, id); + Promise.resolve(adapter.fetchCard(id, state)).then(function(html) { + // A newer refresh/resize for this card superseded us; discard the + // stale markup instead of reverting the card to an older state. + if (html != null && !requestSuperseded(grid, id, token)) { + replaceCard(grid, adapter, card, html); + } + }).catch(function() { /* fire-and-forget: the host surfaces adapter errors */ }); + } + + function removeCard(grid, adapter, card) { + var id = cardId(card, adapter); + // Only drop the live card and persist the new layout once the host has + // confirmed the deletion. Re-resolve by id so an overlapping refresh that + // swapped the node does not leave a detached reference behind. + var commit = function() { + var target = grid.contains(card) ? card : findCard(grid, adapter, id); + if (target && target.parentNode) { + target.parentNode.removeChild(target); + saveLayout(grid, adapter); + } + }; + if (typeof adapter.onRemoveCard === 'function') { + Promise.resolve(adapter.onRemoveCard(id, card)).then(commit).catch(function() { + /* rejected: leave the card and its saved layout untouched */ + }); + } else { + commit(); + } + } + + function resizeCard(grid, adapter, card, dir) { + var id = cardId(card, adapter); + if (typeof adapter.onResize === 'function') { + var token = startRequest(grid, id); + Promise.resolve(adapter.onResize(id, dir, card)).then(function(html) { + // A newer refresh/resize for this card has superseded us; leave + // its markup and layout in place rather than reverting them. + if (requestSuperseded(grid, id, token)) { + return; + } + if (typeof html === 'string') { + replaceCard(grid, adapter, card, html); + } + saveLayout(grid, adapter); + }).catch(function() { /* fire-and-forget: the host surfaces adapter errors */ }); + } else { + saveLayout(grid, adapter); + } + } + + /* After expand/collapse hides the button that was activated (see cards.css), + * move keyboard focus to the now-visible counterpart so the control is not + * lost when the card is toggled from the keyboard. */ + function refocusToolCounterpart(card, tool, counterpart) { + if (document.activeElement !== tool) { + return; + } + var next = card.querySelector('.ccTool[data-tool="' + counterpart + '"]'); + if (next) { + next.focus(); + } + } + + /* Wire a single card: its drag handle (mouse + touch + keyboard) and drag + * events. Handle interactions are DELEGATED from the persistent card element + * so they survive hosts that replace the header markup on refresh/reload. */ + function bindCard(grid, adapter, card) { + if (card.__ccBound) { + return; + } + card.__ccBound = true; + + normalizeState(card); + + var onHandle = function(target) { + return target && target.closest && target.closest('.ccDrag'); + }; + + // Arm HTML5 drag only from the handle, and disarm on release when no drag + // began so selecting content elsewhere can't drag the card. + var disarm = function() { card.draggable = false; }; + card.addEventListener('mousedown', function(event) { + if (!onHandle(event.target)) { + return; + } + card.draggable = true; + document.addEventListener('mouseup', disarm, { once: true }); + }); + card.addEventListener('touchstart', function(event) { + if (!onHandle(event.target)) { + return; + } + card.draggable = true; + document.addEventListener('touchend', disarm, { once: true }); + document.addEventListener('touchcancel', disarm, { once: true }); + }, { passive: true }); + + // Keyboard-accessible reordering with the arrow keys. + card.addEventListener('keydown', function(event) { + if (!onHandle(event.target)) { + return; + } + var back = event.key === 'ArrowLeft' || event.key === 'ArrowUp'; + var fwd = event.key === 'ArrowRight' || event.key === 'ArrowDown'; + if (!back && !fwd) { + return; + } + event.preventDefault(); + if (back && card.previousElementSibling) { + grid.insertBefore(card, card.previousElementSibling); + } else if (fwd && card.nextElementSibling) { + grid.insertBefore(card.nextElementSibling, card); + } else { + return; + } + var handle = card.querySelector('.ccDrag'); + if (handle) { + handle.focus(); + } + saveLayout(grid, adapter); + }); + + card.addEventListener('dragstart', function(event) { + // Only start a reorder when the card itself is the armed drag source. + // Native drags bubbling up from links or images in the body (or any + // drag while the handle is hidden via .ccNoDrag, which leaves the card + // unarmed) must not enter reorder mode. + if (!card.draggable || event.target !== card) { + return; + } + dragging = card; + card.classList.add('ccCardDragging'); + }); + card.addEventListener('dragend', function() { + // Native HTML drag-and-drop suppresses the mouseup that would + // otherwise disarm the card, so disarm here on every drag end. + disarm(); + if (dragging !== card) { + return; + } + card.classList.remove('ccCardDragging'); + dragging = null; + saveLayout(grid, adapter); + }); + } + + function init(grid, adapter) { + if (!grid || grid.__ccInit) { + return; + } + grid.__ccInit = true; + adapter = adapter || {}; + + if (typeof adapter.applyTheme === 'function') { + adapter.applyTheme(grid); + } + + grid.querySelectorAll(cardSel(adapter)).forEach(function(card) { + bindCard(grid, adapter, card); + }); + + grid.addEventListener('dragover', function(event) { + if (!dragging || dragging.parentNode !== grid) { + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + var before = dragReference(grid, adapter, event.clientX, event.clientY); + if (before == null) { + grid.appendChild(dragging); + } else if (before !== dragging) { + grid.insertBefore(dragging, before); + } + }); + + // Per-card tool buttons bubble up to the grid. + grid.addEventListener('click', function(event) { + var tool = event.target.closest ? event.target.closest('.ccTool') : null; + if (!tool || !grid.contains(tool)) { + return; + } + var card = tool.closest(cardSel(adapter)); + if (!card) { + return; + } + // Tools may be rendered as anchors (e.g. remove); never navigate. + event.preventDefault(); + switch (tool.getAttribute('data-tool')) { + case 'expand': + card.classList.add('ccCardExpanded'); + card.setAttribute('data-expanded', '1'); + refocusToolCounterpart(card, tool, 'collapse'); + saveLayout(grid, adapter); + break; + case 'collapse': + card.classList.remove('ccCardExpanded'); + card.setAttribute('data-expanded', '0'); + refocusToolCounterpart(card, tool, 'expand'); + saveLayout(grid, adapter); + break; + case 'grow': resizeCard(grid, adapter, card, 'grow'); break; + case 'shrink': resizeCard(grid, adapter, card, 'shrink'); break; + case 'maximize': + if (typeof adapter.maximize === 'function') { + adapter.maximize(cardId(card, adapter), card.getAttribute('data-maximize') || 'body', card); + } + break; + case 'refresh': refreshCard(grid, adapter, card); break; + case 'remove': removeCard(grid, adapter, card); break; + } + }); + + // Optional "add card" control referenced by adapter.addControl (an element or id). + var add = adapter.addControl; + if (typeof add === 'string') { + add = document.getElementById(add); + } + if (add) { + add.addEventListener('change', function() { + var id = this.value; + this.value = ''; + if (id && typeof adapter.onAddCard === 'function') { + Promise.resolve(adapter.onAddCard(id)).then(function(html) { + if (html == null) { + return; + } + var tmp = document.createElement('div'); + tmp.innerHTML = String(html).trim(); + var fresh = tmp.querySelector(cardSel(adapter)) || tmp.firstElementChild; + // A second add of the same card can resolve while the first + // is still pending (the option stays selectable until the + // debounced catalogue sync), so skip ids already on the grid + // to avoid appending a duplicate the server then dedupes. + if (fresh && findCard(grid, adapter, cardId(fresh, adapter)) === null) { + grid.appendChild(fresh); + bindCard(grid, adapter, fresh); + if (typeof adapter.applyTheme === 'function') { + adapter.applyTheme(grid); + } + saveLayout(grid, adapter); + } + }).catch(function() { /* fire-and-forget: the host surfaces adapter errors */ }); + } + }); + } + } + + return { + init: init, + serialize: serialize, + replaceCard: replaceCard, + bindCard: bindCard + }; +})(); diff --git a/js/dashboard.js b/js/dashboard.js index ebccfe60..3c1bc290 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -50,27 +50,28 @@ function syslogDashboardLoad(data) { function syslogDashboardPanelCard(panel) { var card = document.createElement('article'); - card.className = 'syslogDashboardCard syslogStatusCard'; + card.className = 'ccCard syslogDashboardCard'; card.id = 'syslog_panel_' + panel.id; + card.setAttribute('data-severity', 'none'); var header = document.createElement('header'); - header.className = 'syslogDashboardCardHeader syslogStatusCardHeader syslogStatusCardHeader--none'; + header.className = 'ccCardHeader'; if (syslogDashboard.canManage) { var handle = document.createElement('button'); handle.type = 'button'; - handle.className = 'syslogStatusCardDrag'; + handle.className = 'ccDrag'; handle.setAttribute('aria-label', syslogDashboard.text.dragPanel); handle.innerHTML = ''; header.append(handle); } var title = document.createElement('h3'); - title.className = 'syslogDashboardCardTitle syslogStatusCardTitle'; + title.className = 'ccTitle'; title.textContent = panel.title; header.append(title); var actions = document.createElement('div'); - actions.className = 'syslogStatusCardTools'; + actions.className = 'ccTools'; actionButtons().forEach(function(button) { actions.append(button); }); @@ -78,7 +79,7 @@ function syslogDashboardPanelCard(panel) { card.append(header); var body = document.createElement('div'); - body.className = 'syslogStatusCardBody'; + body.className = 'ccCardBody'; card.append(body); var chart = document.createElement('div'); @@ -235,7 +236,7 @@ function actionButtons() { actions.forEach(function(entry) { var button = document.createElement('button'); button.type = 'button'; - button.className = 'syslogDashboardCardButton syslogStatusCardTool'; + button.className = 'ccTool syslogDashboardCardButton'; button.dataset.action = entry[0]; button.innerHTML = ''; button.title = entry[2]; @@ -357,7 +358,7 @@ function syslogDashboardRenderGrid() { function syslogDashboardInitDrag(card, panel) { if (!syslogDashboard.canManage) return; - var handle = card.querySelector('.syslogStatusCardDrag'); + var handle = card.querySelector('.ccDrag'); if (!handle) return; var disarm = function() { card.draggable = false; }; diff --git a/js/functions.js b/js/functions.js index 45bd65c7..b58aff74 100644 --- a/js/functions.js +++ b/js/functions.js @@ -421,345 +421,6 @@ function initSyslogDashboards() { }); } -/** Sample the live theme onto the Status tab card grid, enable drag/keyboard - * reordering, the per-card tools (show more/less, maximize, refresh, remove) - * and the "Add card" catalogue. Layout changes persist server-side - * (settings_user) so they survive page revisits. */ -var syslogStatusDragging = null; - -function initSyslogStatus() { - $(function() { - var grid = document.getElementById('syslog_status'); - if (!grid) { - return; - } - - applySyslogTheme(document.getElementById('syslog_status_panel') || grid); - - grid.querySelectorAll('.syslogStatusCard').forEach(function(card) { - syslogStatusBindCard(grid, card); - }); - - grid.addEventListener('dragover', function(event) { - if (!syslogStatusDragging) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - var before = syslogStatusDragReference(grid, event.clientX, event.clientY); - if (before == null) { - grid.appendChild(syslogStatusDragging); - } else if (before !== syslogStatusDragging) { - grid.insertBefore(syslogStatusDragging, before); - } - }); - - // Per-card tool buttons bubble to the grid. - $(grid).off('click.syslogstatus').on('click.syslogstatus', '.syslogStatusCardTool', function() { - var card = this.closest('.syslogStatusCard'); - if (!card) { - return; - } - switch (this.dataset.tool) { - case 'expand': card.classList.add('syslogStatusCardExpanded'); syslogStatusSaveLayout(grid); break; - case 'collapse': card.classList.remove('syslogStatusCardExpanded'); syslogStatusSaveLayout(grid); break; - case 'maximize': syslogStatusMaximize(card); break; - case 'refresh': syslogStatusRefreshCard(grid, card); break; - case 'remove': syslogStatusRemoveCard(grid, card); break; - } - }); - - var add = document.getElementById('syslog_status_add'); - if (add) { - $(add).off('change.syslogstatus').on('change.syslogstatus', function() { - var key = this.value; - this.value = ''; - if (key) { - syslogStatusAddCard(grid, key); - } - }); - } - - // Changing the interval reloads the page so Cacti's page refresh picks up the - // new value; it persists in the session for subsequent auto-reloads. - var interval = document.getElementById('syslog_status_refresh'); - if (interval) { - $(interval).off('change.syslogstatus').on('change.syslogstatus', function() { - window.location = 'syslog.php?tab=status&refresh=' + encodeURIComponent(this.value); - }); - } - - var refreshNow = document.getElementById('syslog_status_refresh_now'); - if (refreshNow) { - $(refreshNow).off('click.syslogstatus').on('click.syslogstatus', function() { - syslogStatusRefreshAll(grid, this); - }); - } - }); -} - -/** Wire a single card's drag handle (mouse + keyboard) and drag events. */ -function syslogStatusBindCard(grid, card) { - var handle = card.querySelector('.syslogStatusCardDrag'); - if (!handle) { - return; - } - - // Arm HTML5 drag only from the handle, and disarm on release when no drag - // began (a plain click/tap) so selecting content elsewhere can't drag the card. - var disarm = function() { card.draggable = false; }; - handle.addEventListener('mousedown', function() { - card.draggable = true; - document.addEventListener('mouseup', disarm, {once: true}); - }); - handle.addEventListener('touchstart', function() { - card.draggable = true; - document.addEventListener('touchend', disarm, {once: true}); - document.addEventListener('touchcancel', disarm, {once: true}); - }, {passive: true}); - - // Keyboard-accessible reordering: move the card with the arrow keys. - handle.addEventListener('keydown', function(event) { - var back = event.key === 'ArrowLeft' || event.key === 'ArrowUp'; - var fwd = event.key === 'ArrowRight' || event.key === 'ArrowDown'; - if (!back && !fwd) { - return; - } - event.preventDefault(); - if (back && card.previousElementSibling) { - grid.insertBefore(card, card.previousElementSibling); - } else if (fwd && card.nextElementSibling) { - grid.insertBefore(card.nextElementSibling, card); - } else { - return; - } - handle.focus(); - syslogStatusSaveLayout(grid); - }); - - card.addEventListener('dragstart', function(event) { - syslogStatusDragging = card; - card.classList.add('syslogStatusCardDragging'); - event.dataTransfer.effectAllowed = 'move'; - try { event.dataTransfer.setData('text/plain', card.dataset.card || ''); } catch (error) { /* IE guard */ } - }); - - card.addEventListener('dragend', function() { - card.draggable = false; - card.classList.remove('syslogStatusCardDragging'); - if (syslogStatusDragging) { - syslogStatusDragging = null; - syslogStatusSaveLayout(grid); - } - }); -} - -/** The card the dragged card should be inserted before for the current pointer - * position, or null to append at the end. */ -function syslogStatusDragReference(grid, x, y) { - var cards = Array.prototype.slice.call(grid.querySelectorAll('.syslogStatusCard:not(.syslogStatusCardDragging)')); - for (var i = 0; i < cards.length; i++) { - var rect = cards[i].getBoundingClientRect(); - if (y < rect.top - 1) { - return cards[i]; - } - if (y <= rect.bottom && x < rect.left + rect.width / 2) { - return cards[i]; - } - } - return null; -} - -/** Add a card from the catalogue: fetch its HTML, append it, persist and refresh - * the "Add" options. */ -function syslogStatusAddCard(grid, key) { - if (syslogStatusHasCard(grid, key)) { - return; - } - $.post('syslog.php', {action: 'status_card', tab: 'status', card: key, __csrf_magic: csrfMagicToken}, null, 'json').done(function(data) { - if (!data || !data.html) { - return; - } - // A concurrent add for the same card may have landed first. - if (syslogStatusHasCard(grid, key)) { - return; - } - var card = syslogStatusParseCard(data.html); - if (!card) { - return; - } - grid.appendChild(card); - applySyslogTheme(grid); - syslogStatusBindCard(grid, card); - syslogStatusSaveLayout(grid); - syslogStatusSyncAddOptions(grid); - }); -} - -/** Whether a card with the given key is currently on the grid. */ -function syslogStatusHasCard(grid, key) { - var present = false; - grid.querySelectorAll('.syslogStatusCard').forEach(function(card) { - if (card.dataset.card === key) { - present = true; - } - }); - return present; -} - -/** Remove a card from the page and return it to the "Add" catalogue. */ -function syslogStatusRemoveCard(grid, card) { - card.parentNode.removeChild(card); - syslogStatusSaveLayout(grid); - syslogStatusSyncAddOptions(grid); -} - -/** Re-fetch a single card's HTML and swap it in place, keeping expanded state. */ -function syslogStatusRefreshCard(grid, card, done) { - var icon = card.querySelector('.syslogStatusCardTool[data-tool="refresh"] .fa'); - if (icon) { - icon.classList.add('fa-spin'); - } - var settle = function() { - if (icon) { - icon.classList.remove('fa-spin'); - } - if (done) { - done(); - } - }; - var expanded = card.classList.contains('syslogStatusCardExpanded') ? '1' : '0'; - $.post('syslog.php', {action: 'status_card', tab: 'status', card: card.dataset.card, expanded: expanded, __csrf_magic: csrfMagicToken}, null, 'json').done(function(data) { - // The card may have been removed or already replaced while we waited. - if (!data || !data.html || !card.parentNode) { - settle(); - return; - } - var fresh = syslogStatusParseCard(data.html); - if (!fresh) { - settle(); - return; - } - card.parentNode.replaceChild(fresh, card); - applySyslogTheme(grid); - syslogStatusBindCard(grid, fresh); - if (done) { - done(); - } - }).fail(settle); -} - -/** Refresh every card on the page, spinning the toolbar glyph until all land. */ -function syslogStatusRefreshAll(grid, button) { - var icon = button ? button.querySelector('.fa') : null; - var cards = Array.prototype.slice.call(grid.querySelectorAll('.syslogStatusCard')); - var pending = cards.length; - if (!pending) { - return; - } - if (icon) { - icon.classList.add('fa-spin'); - } - cards.forEach(function(card) { - syslogStatusRefreshCard(grid, card, function() { - pending--; - if (pending <= 0 && icon) { - icon.classList.remove('fa-spin'); - } - }); - }); -} - -/** Open a copy of a card's body in a large modal dialog. */ -function syslogStatusMaximize(card) { - var dialog = document.getElementById('syslog_status_dialog'); - if (!dialog) { - return; - } - var title = card.querySelector('.syslogStatusCardTitle'); - var body = card.querySelector('.syslogStatusCardBody'); - dialog.innerHTML = '
' + (body ? body.innerHTML : '') + '
'; - applySyslogTheme(dialog); - $(dialog).dialog({ - modal: true, - appendTo: 'body', - width: Math.min(900, $(window).width() - 40), - title: title ? title.textContent : '', - open: function() { applySyslogTheme(this); } - }); -} - -/** Parse a card HTML string into its
element. */ -function syslogStatusParseCard(html) { - var tmp = document.createElement('div'); - tmp.innerHTML = html; - return tmp.querySelector('.syslogStatusCard'); -} - -/** Rebuild the "Add" dropdown so it lists only cards not currently on the page. */ -function syslogStatusSyncAddOptions(grid) { - var add = document.getElementById('syslog_status_add'); - if (!add || typeof syslogStatusCatalog === 'undefined') { - return; - } - var present = {}; - grid.querySelectorAll('.syslogStatusCard').forEach(function(card) { - if (card.dataset.card) { - present[card.dataset.card] = true; - } - }); - while (add.options.length > 1) { - add.remove(1); - } - Object.keys(syslogStatusCatalog).forEach(function(key) { - if (!present[key]) { - var option = document.createElement('option'); - option.value = key; - option.textContent = syslogStatusCatalog[key]; - add.appendChild(option); - } - }); -} - -/** Serialize layout POSTs so rapid actions can't persist out of order: one - * request is in flight at a time, and a save requested meanwhile is coalesced - * into a single follow-up that reads the latest DOM, so the final state wins. */ -var syslogStatusSaveInFlight = false; -var syslogStatusSaveQueued = false; - -function syslogStatusSaveLayout(grid) { - if (syslogStatusSaveInFlight) { - syslogStatusSaveQueued = true; - return; - } - syslogStatusSaveInFlight = true; - - var order = []; - var expanded = {}; - grid.querySelectorAll('.syslogStatusCard').forEach(function(card) { - if (!card.dataset.card) { - return; - } - order.push(card.dataset.card); - if (card.classList.contains('syslogStatusCardExpanded')) { - expanded[card.dataset.card] = true; - } - }); - - $.post('syslog.php', { - action: 'status_layout', - tab: 'status', - layout: JSON.stringify({order: order, expanded: expanded}), - __csrf_magic: csrfMagicToken - }, null, 'json').always(function() { - syslogStatusSaveInFlight = false; - if (syslogStatusSaveQueued) { - syslogStatusSaveQueued = false; - syslogStatusSaveLayout(grid); - } - }); -} - function initSyslogSearchDates(container) { container.querySelectorAll('.syslogSearchDate').forEach(function(input) { $(input).datetimepicker({ diff --git a/js/syslog_dashboard.js b/js/syslog_dashboard.js new file mode 100644 index 00000000..b0c964ee --- /dev/null +++ b/js/syslog_dashboard.js @@ -0,0 +1,261 @@ +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group | + | | + | This program is free software; you can redistribute it and/or | + | modify it under the terms of the GNU General Public License | + | as published by the Free Software Foundation; either version 2 | + | of the License, or (at your option) any later version. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ +*/ + +/* Syslog Status adapter for the shared card chrome (js/cards.js). The + * CactiCards controller owns drag/keyboard reorder, the per-card tools and the + * debounced layout save; this adapter maps those hooks to syslog.php's status + * endpoints (status_card / status_layout) and wires syslog's extras: the + * "Add card" catalogue, an auto-refresh interval and a manual refresh-all. Card + * markup uses the shared .cc* chrome contract; the card bodies keep syslog's + * own prefixed content classes (tables, key/value rows, notes). */ + +function initSyslogStatus() { + $(function() { + var grid = document.getElementById('syslog_status_grid'); + + if (!grid || !window.CactiCards) { + return; + } + + function catalogue() { + return typeof syslogStatusCatalog !== 'undefined' ? syslogStatusCatalog : null; + } + + function syncAddOptions() { + var add = document.getElementById('syslog_status_add'); + var cat = catalogue(); + + if (!add || !cat) { + return; + } + + var present = {}; + + grid.querySelectorAll('.ccCard').forEach(function(card) { + if (card.dataset.card) { + present[card.dataset.card] = true; + } + }); + + while (add.options.length > 1) { + add.remove(1); + } + + Object.keys(cat).forEach(function(key) { + if (!present[key]) { + var option = document.createElement('option'); + option.value = key; + option.textContent = cat[key]; + add.appendChild(option); + } + }); + } + + // A per-card-id request generation shared by the per-card refresh (routed + // through the controller) and the toolbar's Refresh all (which calls + // replaceCard directly). Both refresh paths pass through this helper, so + // tracking the generation here lets an earlier response resolve to null + // instead of overwriting the markup of a newer refresh for the same card. + var fetchGen = {}; + + function fetchCard(id, expanded) { + var gen = (fetchGen[id] = (fetchGen[id] || 0) + 1); + + return $.post('syslog.php', { + action: 'status_card', + tab: 'status', + card: id, + expanded: expanded ? '1' : '0', + __csrf_magic: csrfMagicToken + }, null, 'json').then(function(d) { + if (fetchGen[id] !== gen) { + return null; + } + + return d && d.html ? d.html : null; + }); + } + + function maximize(card) { + var dialog = document.getElementById('syslog_status_dialog'); + + if (!dialog) { + return; + } + + var titleEl = card.querySelector('.ccTitle'); + var body = card.querySelector('.ccCardBody'); + + dialog.innerHTML = '
' + (body ? body.innerHTML : '') + '
'; + + if (typeof applySyslogTheme === 'function') { + applySyslogTheme(dialog); + } + + $(dialog).dialog({ + modal: true, + appendTo: 'body', + width: Math.min(900, $(window).width() - 40), + title: titleEl ? titleEl.textContent : '', + open: function() { + if (typeof applySyslogTheme === 'function') { + applySyslogTheme(this); + } + } + }); + } + + /* The live card currently in the grid for an id; an overlapping swap may + * have detached the node captured when a request was issued. */ + function liveCard(id) { + var match = null; + + grid.querySelectorAll('.ccCard').forEach(function(card) { + if (match === null && card.dataset.card === id) { + match = card; + } + }); + + return match; + } + + // Guard against appending duplicate cards when the same absent card is + // chosen twice before its fetch completes (the shared controller appends + // every successful add response). + var pendingAdds = {}; + + // Serialize layout saves so a newer snapshot cannot be overwritten by a + // slower, earlier POST that the shared debounce already released. + var layoutSaving = false; + var layoutQueued = null; + + function sendLayout(payload) { + layoutSaving = true; + + $.post('syslog.php', { + action: 'status_layout', + tab: 'status', + layout: JSON.stringify(payload), + __csrf_magic: csrfMagicToken + }, null, 'json').always(function() { + layoutSaving = false; + + if (layoutQueued) { + var next = layoutQueued; + layoutQueued = null; + sendLayout(next); + } + }); + } + + var adapter = { + addControl: 'syslog_status_add', + + // Keep syslog's content palette (the note accent) in sync with the + // live Cacti theme; the shared chrome paints from the --cc-* custom + // properties bound in css/search.css. + applyTheme: function(gridEl) { + if (typeof applySyslogTheme === 'function') { + applySyslogTheme(gridEl); + } + }, + + fetchCard: function(id, state) { + return fetchCard(id, state && state.expanded); + }, + + onAddCard: function(id) { + if (liveCard(id) || pendingAdds[id]) { + return null; + } + + pendingAdds[id] = true; + + return fetchCard(id, false).always(function() { + delete pendingAdds[id]; + }); + }, + + onLayoutChange: function(state) { + var expanded = {}; + + state.order.forEach(function(id) { + if (state.cards[id] && state.cards[id].expanded) { + expanded[id] = true; + } + }); + + var payload = {order: state.order, expanded: expanded}; + + if (layoutSaving) { + layoutQueued = payload; + } else { + sendLayout(payload); + } + + syncAddOptions(); + }, + + maximize: function(id, mode, card) { + maximize(card); + } + }; + + CactiCards.init(grid, adapter); + syncAddOptions(); + + // Changing the interval reloads the page so Cacti's page refresh picks up + // the new value; it persists in the session for subsequent auto-reloads. + var interval = document.getElementById('syslog_status_refresh'); + if (interval) { + $(interval).off('change.syslogstatus').on('change.syslogstatus', function() { + window.location = 'syslog.php?tab=status&refresh=' + encodeURIComponent(this.value); + }); + } + + var refreshNow = document.getElementById('syslog_status_refresh_now'); + if (refreshNow) { + $(refreshNow).off('click.syslogstatus').on('click.syslogstatus', function() { + var icon = this.querySelector('.fa, .fas'); + var cards = Array.prototype.slice.call(grid.querySelectorAll('.ccCard')); + var pending = cards.length; + + if (!pending) { + return; + } + + if (icon) { + icon.classList.add('fa-spin'); + } + + cards.forEach(function(card) { + fetchCard(card.dataset.card, card.classList.contains('ccCardExpanded')).then(function(html) { + if (html && card.parentNode && window.CactiCards) { + CactiCards.replaceCard(grid, adapter, card, html); + } + }).always(function() { + pending--; + if (pending <= 0 && icon) { + icon.classList.remove('fa-spin'); + } + }); + }); + }); + } + }); +} diff --git a/syslog.php b/syslog.php index 347baadb..82e12421 100644 --- a/syslog.php +++ b/syslog.php @@ -177,6 +177,14 @@ syslog_include_js(); + // Shared card chrome stylesheet: load the plugin's bundled copy only on + // cores that do not already ship include/css/cards.css globally (Cacti + // < 1.2.32). Both the Status tab and the chart Dashboards render with the + // .cc* chrome, so this loads for every tab alongside the other assets. + if (!file_exists($config['base_path'] . '/include/css/cards.css')) { + print "\n"; + } + syslog_display_tabs($current_tab); if ($current_tab == 'current') { @@ -1099,39 +1107,37 @@ function syslog_status_render_card(string $key, bool $expanded = false): string $tools = ''; if ($def['expandable']) { - $tools .= ''; - $tools .= ''; + $tools .= ''; + $tools .= ''; } - $tools .= ''; - $tools .= ''; - $tools .= ''; + $tools .= ''; + $tools .= ''; + $tools .= ''; - $classes = 'syslogStatusCard syslogStatusSpan' . (int) $def['span']; + $classes = 'ccCard ccW' . (int) $def['span']; if ($def['expandable']) { - $classes .= ' syslogStatusCardExpandable'; + $classes .= ' ccCardExpandable'; } if ($expanded) { - $classes .= ' syslogStatusCardExpanded'; + $classes .= ' ccCardExpanded'; } - // Header severity variant: up / recovering / down, or a neutral grey when the - // card carries no severity. + // Header severity accent: up / recovering->warn / down, or neutral (none). $state = isset($def['state']) ? preg_replace('/[^a-z]/', '', strtolower((string) $def['state'])) : ''; - if (!in_array($state, ['up', 'recovering', 'down'], true)) { - $state = 'none'; - } - $header_class = 'syslogStatusCardHeader syslogStatusCardHeader--' . $state; + $severity = ($state === 'recovering') ? 'warn' : (($state === 'up' || $state === 'down') ? $state : 'none'); - return '
' - . '
' - . '' - . '

' . html_escape($def['title']) . '

' - . '' . $tools . '' + return '
' + . '
' + . '' + . '

' . html_escape($def['title']) . '

' + . '' . $tools . '' . '
' - . '
' . syslog_status_card_body($key) . '
' + . '
' . syslog_status_card_body($key) . '
' . '
'; } @@ -1240,7 +1246,17 @@ function syslog_status_card_ajax(): string { * @return void */ function syslog_status(): void { - global $page_refresh_interval; + global $config, $page_refresh_interval; + + // The shared card chrome script loads first: when Cacti loads this page + // through ajax the scripts run in order, and the adapter below needs the + // CactiCards controller it defines. + // @codeCoverageIgnoreStart -- bundled copy loads only on cores lacking it. + if (!file_exists($config['base_path'] . '/include/js/cards.js')) { + print ''; + } + // @codeCoverageIgnoreEnd + print ''; $layout = syslog_status_layout(); $meta = syslog_status_card_meta(); @@ -1248,9 +1264,9 @@ function syslog_status(): void { print '
'; - print '
'; - print ''; - print ''; foreach ($absent as $key) { print ''; @@ -1260,19 +1276,19 @@ function syslog_status(): void { // Auto-refresh interval picker plus a manual refresh, mirroring the log view's // header refresh. The interval persists through the page's auto-reload. $current_refresh = get_request_var('refresh'); - print '
'; - print ''; - print ''; foreach ($page_refresh_interval as $seconds => $display_text) { print ''; } print ''; - print ''; + print ''; print '
'; print '
'; - print '
'; + print '
'; foreach ($layout['order'] as $key) { print syslog_status_render_card($key, !empty($layout['expanded'][$key])); diff --git a/tests/Unit/PipelineHealthTest.php b/tests/Unit/PipelineHealthTest.php index 192b7a7f..0238874d 100644 --- a/tests/Unit/PipelineHealthTest.php +++ b/tests/Unit/PipelineHealthTest.php @@ -283,7 +283,7 @@ function pipeline_health_fixture(): void { $html = syslog_status_render_card('collector_health'); expect(syslog_status_card_meta()['collector_health']['title'])->toBe('Pipeline health') ->and(syslog_status_card_meta()['collector_health'])->not->toHaveKey('state') - ->and($html)->toContain('syslogStatusCardHeader--down')->toContain('Attention required')->toContain('Partition maintenance') + ->and($html)->toContain('data-severity="down"')->toContain('Attention required')->toContain('Partition maintenance') ->toContain('Failed; retry pending')->toContain('Last health check') ->toContain('<script>')->not->toContain('