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

Status reference

Status
'); + 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);});