diff --git a/css/dashboard.css b/css/dashboard.css
index 3eca9f7..68bd835 100644
--- a/css/dashboard.css
+++ b/css/dashboard.css
@@ -86,55 +86,14 @@
align-items: start;
}
+/* Card surfaces, headers, titles and tools reuse the Status tab classes. */
.syslogDashboardCard {
- box-sizing: border-box;
position: relative;
- border: 1px solid var(--search-border, #cbd8d1);
- border-radius: 8px;
- background: var(--search-card, #fff);
- color: var(--search-text, #23362f);
- box-shadow: 0 1px 2px rgba(35, 54, 47, 0.08);
- overflow: hidden;
-}
-
-.syslogDashboardCardHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding: 8px 12px;
- border-bottom: 1px solid var(--search-tint, #e2ede6);
- background: var(--search-tint, #e2ede6);
-}
-
-.syslogDashboardCardTitle {
- margin: 0;
- font-size: 13px;
- font-weight: 600;
- line-height: 1.4;
- overflow-wrap: anywhere;
-}
-
-.syslogDashboardCardHeader .syslogDashboardCardButton {
- border: 0;
- background: transparent;
- color: var(--search-muted, #596d64);
- cursor: pointer;
- padding: 3px 4px;
- border-radius: 4px;
- font-size: 12px;
-}
-
-.syslogDashboardCardHeader .syslogDashboardCardButton:hover,
-.syslogDashboardCardHeader .syslogDashboardCardButton:focus {
- background: var(--search-accent, #276448);
- color: #fff;
- outline: none;
}
.syslogChart {
min-height: 260px;
- padding: 6px 6px 0 0;
+ padding: 6px 0 0;
}
/* Bottom-right drag handle; expands the pointer target to the corner. */
@@ -182,7 +141,7 @@
}
.syslogDashboardCardStatus {
- margin: 40px 12px;
+ margin: 8px 0;
font-size: 12px;
color: var(--search-muted, #596d64);
}
@@ -204,12 +163,8 @@
}
/* Drag-and-drop reorder affordances. */
-.syslogDashboardCardHeader {
- cursor: grab;
-}
-
.syslogDashboardCardDragging {
- opacity: 0.45;
+ opacity: 0.5;
}
.syslogDashboardCardDropBefore {
@@ -266,7 +221,12 @@
grid-template-columns: 1fr;
}
+ .syslogDashboardCard {
+ /* Fit saved multi-column panels into the mobile grid. */
+ grid-column: 1 / -1 !important;
+ }
+
.syslogDashboardPanelActions {
margin-left: 0;
}
-}
\ No newline at end of file
+}
diff --git a/js/dashboard.js b/js/dashboard.js
index bc17892..bf929eb 100644
--- a/js/dashboard.js
+++ b/js/dashboard.js
@@ -50,35 +50,47 @@ function syslogDashboardLoad(data) {
function syslogDashboardPanelCard(panel) {
var card = document.createElement('article');
- card.className = 'syslogDashboardCard';
+ card.className = 'syslogDashboardCard syslogStatusCard';
card.id = 'syslog_panel_' + panel.id;
var header = document.createElement('header');
- header.className = 'syslogDashboardCardHeader';
- if (syslogDashboard.canManage) header.setAttribute('draggable', 'true');
+ header.className = 'syslogDashboardCardHeader syslogStatusCardHeader syslogStatusCardHeader--none';
+ if (syslogDashboard.canManage) {
+ var handle = document.createElement('button');
+ handle.type = 'button';
+ handle.className = 'syslogStatusCardDrag';
+ handle.setAttribute('aria-label', syslogDashboard.text.dragPanel);
+ handle.innerHTML = '';
+ header.append(handle);
+ }
var title = document.createElement('h3');
- title.className = 'syslogDashboardCardTitle';
+ title.className = 'syslogDashboardCardTitle syslogStatusCardTitle';
title.textContent = panel.title;
header.append(title);
var actions = document.createElement('div');
+ actions.className = 'syslogStatusCardTools';
actionButtons().forEach(function(button) {
actions.append(button);
});
header.append(actions);
card.append(header);
+ var body = document.createElement('div');
+ body.className = 'syslogStatusCardBody';
+ card.append(body);
+
var chart = document.createElement('div');
chart.className = 'syslogChart';
chart.id = 'syslog_chart_' + panel.id;
- card.append(chart);
+ body.append(chart);
var status = document.createElement('p');
status.className = 'syslogDashboardCardStatus';
status.setAttribute('role', 'status');
status.textContent = '...';
- card.append(status);
+ body.append(status);
// Shared dashboards are view-only: no resize or drag affordances.
if (syslogDashboard.canManage) {
@@ -223,7 +235,7 @@ function actionButtons() {
actions.forEach(function(entry) {
var button = document.createElement('button');
button.type = 'button';
- button.className = 'syslogDashboardCardButton';
+ button.className = 'syslogDashboardCardButton syslogStatusCardTool';
button.dataset.action = entry[0];
button.innerHTML = '';
button.title = entry[2];
@@ -337,7 +349,7 @@ function syslogDashboardRenderGrid() {
/* ===================== Drag-and-drop reorder ===================== */
/**
- * Make a card reorderable: dragging its header shows a placeholder, and
+ * Make a card reorderable: dragging its handle shows a placeholder, and
* dropping posts the new 1-based position. Drop targets are the card
* midpoints, so moving a panel underneath another works in both
* directions without up/down buttons.
@@ -345,19 +357,45 @@ function syslogDashboardRenderGrid() {
function syslogDashboardInitDrag(card, panel) {
if (!syslogDashboard.canManage) return;
- var header = card.querySelector('.syslogDashboardCardHeader');
- if (!header) return;
+ var handle = card.querySelector('.syslogStatusCardDrag');
+ if (!handle) return;
+
+ 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});
+ handle.addEventListener('keydown', function(event) {
+ var direction = ['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1
+ : (['ArrowRight', 'ArrowDown'].includes(event.key) ? 1 : 0);
+ if (!direction) return;
+ event.preventDefault();
+ var order = syslogDashboard.panels.map(function(item) { return item.id; });
+ var from = order.indexOf(panel.id);
+ var to = from + direction;
+ if (from < 0 || to < 0 || to >= order.length) return;
+ order.splice(from, 1);
+ order.splice(to, 0, panel.id);
+ syslogDashboardApplyOrder(order, panel.id, to + 1);
+ handle.focus();
+ });
- header.addEventListener('dragstart', function(event) {
+ card.addEventListener('dragstart', function(event) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(panel.id));
card.classList.add('syslogDashboardCardDragging');
});
- header.addEventListener('dragend', function() {
+ card.addEventListener('dragend', function() {
+ card.draggable = false;
card.classList.remove('syslogDashboardCardDragging');
- document.querySelectorAll('.syslogDashboardCardDropBefore')
- .forEach(function(el) { el.classList.remove('syslogDashboardCardDropBefore'); });
+ document.querySelectorAll('.syslogDashboardCardDropBefore, .syslogDashboardCardDropAfter')
+ .forEach(function(el) { el.classList.remove('syslogDashboardCardDropBefore', 'syslogDashboardCardDropAfter'); });
});
card.addEventListener('dragover', function(event) {
@@ -366,7 +404,7 @@ function syslogDashboardInitDrag(card, panel) {
event.dataTransfer.dropEffect = 'move';
var rect = card.getBoundingClientRect();
- var before = event.clientY < rect.top + rect.height / 2;
+ var before = event.clientY < rect.top || (event.clientY <= rect.bottom && event.clientX < rect.left + rect.width / 2);
card.classList.toggle('syslogDashboardCardDropBefore', before);
card.classList.toggle('syslogDashboardCardDropAfter', !before);
});
@@ -390,7 +428,7 @@ function syslogDashboardInitDrag(card, panel) {
if (draggedId === panel.id) return;
var rect = card.getBoundingClientRect();
- var before = event.clientY < rect.top + rect.height / 2;
+ var before = event.clientY < rect.top || (event.clientY <= rect.bottom && event.clientX < rect.left + rect.width / 2);
var panels = syslogDashboard.panels || [];
var order = panels.map(function(p) { return p.id; });
var from = order.indexOf(draggedId);
diff --git a/lib/syslog_dashboard.php b/lib/syslog_dashboard.php
index 41d6532..35fdf9f 100644
--- a/lib/syslog_dashboard.php
+++ b/lib/syslog_dashboard.php
@@ -1363,6 +1363,7 @@ function syslog_dashboard(): void {
canShare: ,
shared: ,
text: {
+ dragPanel: ,
refreshPanel: ,
totalMessages: ,
libraryUnavailable: ,
diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot
index 58a080f..a28fca8 100644
--- a/locales/po/cacti.pot
+++ b/locales/po/cacti.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Cacti 1.2.32\n"
"Report-Msgid-Bugs-To: developers@cacti.net\n"
-"POT-Creation-Date: 2026-10-10 09:25-0400\n"
+"POT-Creation-Date: 2026-10-12 16:41-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Cacti Developers >\n"
"Language-Team: Cacti Developers \n"
@@ -1079,6 +1079,10 @@ msgstr ""
msgid "A title of up to 128 characters is required."
msgstr ""
+#: lib/syslog_dashboard.php syslog.php
+msgid "Drag to reorder card"
+msgstr ""
+
#: lib/syslog_dashboard.php
msgid "Refresh Panel"
msgstr ""
@@ -1808,10 +1812,6 @@ msgstr ""
msgid "Remove from page"
msgstr ""
-#: syslog.php
-msgid "Drag to reorder card"
-msgstr ""
-
#: syslog.php
msgid "Unknown card."
msgstr ""
diff --git a/tests/Unit/DashboardDefaultTest.php b/tests/Unit/DashboardDefaultTest.php
index 18e531c..0ce24b5 100644
--- a/tests/Unit/DashboardDefaultTest.php
+++ b/tests/Unit/DashboardDefaultTest.php
@@ -2,12 +2,10 @@
it('seeds valid starter panels atomically and does not reseed an existing default', function () {
syslog_load_plugin_source('setup.php');
+ syslog_load_plugin_source('includes/schema.php');
syslog_load_plugin_source('lib/syslog_dashboard.php');
- test_override('syslog_ensure_dashboard_tables', fn () => null);
- test_override('syslog_db_table_exists', fn () => true);
- test_override('syslog_db_column_exists', fn () => true);
+ test_override('db_update_table', fn () => true);
$GLOBALS['syslogdb_default'] = 'syslog';
- test_override('syslog_dashboard_username', fn () => 'admin');
test_override('syslog_db_fetch_insert_id', fn () => 42);
$existing = false;
$calls = [];
diff --git a/tests/Unit/DashboardRenderTest.php b/tests/Unit/DashboardRenderTest.php
new file mode 100644
index 0000000..81aa1e5
--- /dev/null
+++ b/tests/Unit/DashboardRenderTest.php
@@ -0,0 +1,26 @@
+ 'Never'];
+ $GLOBALS['request']['refresh'] = 0;
+ $GLOBALS['request']['dashboard_id'] = 0;
+ test_override('syslog_db_fetch_assoc_prepared', fn () => []);
+ test_override('syslog_db_fetch_assoc', fn () => []);
+ test_override('syslog_db_fetch_row_prepared', fn () => []);
+
+ ob_start();
+ try {
+ syslog_dashboard();
+ $html = ob_get_contents();
+ } finally {
+ ob_end_clean();
+ }
+
+ expect($html)->toContain('dragPanel: "Drag to reorder card"')
+ ->toContain('refreshPanel: "Refresh Panel"')
+ ->toContain("id='syslog_dashboard_grid'");
+});
diff --git a/tests/Unit/SetupModuleBoundaryTest.php b/tests/Unit/SetupModuleBoundaryTest.php
index abfb284..b4ef4d7 100644
--- a/tests/Unit/SetupModuleBoundaryTest.php
+++ b/tests/Unit/SetupModuleBoundaryTest.php
@@ -29,6 +29,7 @@
'syslog_upgrade_explicit_realm_grants',
'syslog_upgrade_create_permission_realms',
'syslog_check_upgrade',
+ 'syslog_dashboard_seed_default',
'plugin_syslog_version',
'syslog_refresh_plugin_version',
'syslog_upgrade_hook_module_paths',
diff --git a/tests/browser/README.md b/tests/browser/README.md
index 5aec3ea..2b962b5 100644
--- a/tests/browser/README.md
+++ b/tests/browser/README.md
@@ -9,3 +9,13 @@ CACTI_ROOT=/path/to/cacti node tests/browser/compact_workspace.cjs
Optional environment variables: `PLAYWRIGHT_MODULE` (installed module path), `CHROMIUM_PATH` (browser executable), and `TEST_ARTIFACT_DIR` (existing screenshot directory).
Covers builder date and negation submission, severity color preservation, opening the message details from any cell of a row, selected-message rule links and unavailable actions, escaped raw messages, inspector keyboard close, filter collapse, the results limit beside the search buttons, view options, refresh without detaching a saved search, and narrow-screen overflow.
+
+The Dashboard card fixture checks shared Status styling in light/dark themes,
+existing tools, drag/keyboard ordering requests, and narrow-screen panel sizing:
+
+```sh
+node tests/browser/dashboard_cards.cjs
+```
+
+It uses the actual Dashboard renderer and styles with fixture panels and records
+ordering requests; it does not verify a live database or chart data.
diff --git a/tests/browser/dashboard_cards.cjs b/tests/browser/dashboard_cards.cjs
new file mode 100644
index 0000000..cfa2058
--- /dev/null
+++ b/tests/browser/dashboard_cards.cjs
@@ -0,0 +1,68 @@
+const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const root = path.resolve(__dirname, '../..');
+const cacti = process.env.CACTI_ROOT || path.resolve(root, '../cacti');
+(async () => {
+ const browser = await chromium.launch({headless: true, executablePath: process.env.CHROMIUM_PATH, args: ['--no-sandbox']});
+ try {
+ const page = await browser.newPage({viewport: {width: 1200, height: 800}});
+ await page.route('http://fixture/webfonts/**', route => route.fulfill({path: path.join(cacti, 'include/fa/webfonts', path.basename(new URL(route.request().url()).pathname))}));
+ await page.setContent('');
+ await page.addStyleTag({path: path.join(cacti, 'include/fa/css/all.min.css')});
+ await page.addStyleTag({path: path.join(root, 'css/search.css')});
+ await page.addStyleTag({path: path.join(root, 'css/dashboard.css')});
+ await page.addScriptTag({path: path.join(root, 'js/dashboard.js')});
+ await page.evaluate(() => {
+ window.posts = [];
+ window.syslogDashboard = {canManage: true, dashboardId: 5, text: {dragPanel: 'Drag to reorder card', refreshPanel: 'Refresh Panel'}, panels: [1,2,3].map(id => ({id, title: 'Panel ' + id, width: 1, height: 0}))};
+ syslogDashboardPost = data => posts.push(data);
+ syslogDashboard.panels.forEach(panel => {
+ const card = syslogDashboardPanelCard(panel);
+ document.getElementById('syslog_dashboard_grid').append(card);
+ syslogDashboardInitDrag(card, panel);
+ });
+ });
+ const compare = () => page.evaluate(() => {
+ const pairs = [['.syslogStatusCard', '.syslogDashboardCard', ['backgroundColor','border','borderRadius','boxShadow']], ['.syslogStatusCardHeader', '.syslogDashboardCardHeader', ['padding','backgroundColor','backgroundImage','color','height']], ['.syslogStatusCardTitle', '.syslogDashboardCardTitle', ['fontSize','fontWeight','lineHeight']], ['.syslogStatusCardTool', '.syslogDashboardCardButton', ['padding','backgroundColor','color','border']]];
+ return pairs.map(([a,b,keys]) => keys.map(key => [key, getComputedStyle(document.querySelector('#status ' + a))[key], getComputedStyle(document.querySelector('#syslog_dashboard_grid ' + b))[key]]));
+ });
+ for (const mode of ['light', 'dark']) {
+ await page.evaluate(mode => {
+ for (const scope of document.querySelectorAll('#status, #syslog_dashboard_grid')) {
+ const tokens = mode === 'dark' ? {text:'#eee',card:'#222',border:'#555','header-bg':'#333','header-fg':'#eee'} : {text:'#23362f',card:'#fff',border:'#cbd8d1','header-bg':'#e5ece8','header-fg':'#23362f'};
+ Object.entries(tokens).forEach(([key,value]) => scope.style.setProperty('--search-' + key, value));
+ }
+ }, mode);
+ for (const pair of await compare()) for (const [key,status,dashboard] of pair) assert.equal(dashboard,status, mode + ' ' + key);
+ }
+ assert.equal(await page.locator('#syslog_panel_1 [data-action="refresh"]').count(), 1);
+ assert.equal(await page.locator('#syslog_panel_1 [data-action="edit"]').count(), 1);
+ assert.equal(await page.locator('#syslog_panel_1 [data-action="delete"]').count(), 1);
+ // Keyboard reordering persists the absolute position through the existing endpoint.
+ await page.locator('#syslog_panel_1 .syslogStatusCardDrag').focus();
+ await page.keyboard.press('ArrowRight');
+ assert.deepEqual(await page.evaluate(() => syslogDashboard.panels.map(p => p.id)), [2,1,3]);
+ assert.equal(await page.evaluate(() => posts.at(-1).position), 2);
+ // Pointer drag handles work across columns and clear both placeholder states.
+ const target = await page.locator('#syslog_panel_3').boundingBox();
+ const handle = await page.locator('#syslog_panel_2 .syslogStatusCardDrag').boundingBox();
+ await page.mouse.move(handle.x + handle.width/2, handle.y + handle.height/2);
+ await page.mouse.down();
+ await page.mouse.move(handle.x + handle.width/2 + 12, handle.y + handle.height/2, {steps: 3});
+ await page.mouse.move(target.x + target.width*0.75, target.y + 45, {steps: 10});
+ await page.mouse.up();
+ assert.deepEqual(await page.evaluate(() => syslogDashboard.panels.map(p => p.id)), [1,3,2]);
+ assert.equal(await page.evaluate(() => posts.at(-1).position), 3);
+ assert.equal(await page.locator('.syslogDashboardCardDropBefore, .syslogDashboardCardDropAfter').count(), 0);
+ if (process.env.TEST_ARTIFACT_DIR) await page.screenshot({path:path.join(process.env.TEST_ARTIFACT_DIR,'dashboard-status-style-desktop.png'),fullPage:true});
+ await page.evaluate(() => syslogDashboardApplySize({width:3,height:300}, document.getElementById('syslog_panel_1')));
+ await page.setViewportSize({width:390,height:844});
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ await page.evaluate(() => {syslogDashboard.canManage = false; const card = syslogDashboardPanelCard({id:4,title:'Shared',width:1,height:0}); document.getElementById('syslog_dashboard_grid').append(card);syslogDashboardInitDrag(card,{id:4});});
+ assert.equal(await page.locator('#syslog_panel_4 .syslogStatusCardDrag').count(), 0);
+ assert.equal(await page.locator('#syslog_panel_4 [data-action="refresh"]').count(), 1);
+ if (process.env.TEST_ARTIFACT_DIR) await page.screenshot({path:path.join(process.env.TEST_ARTIFACT_DIR,'dashboard-status-style.png'),fullPage:true});
+ console.log('Dashboard card styles and drag ordering passed (browser fixture)');
+ } finally { await browser.close(); }
+})().catch(error => {console.error(error);process.exit(1);});