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 ''
- . ''
- . ''
- . '