GWM : dashboard redesin

This commit is contained in:
Gowtham M 2026-04-09 14:34:50 +05:30
parent 912042e054
commit 5deb9ce809
7 changed files with 794 additions and 80 deletions

View File

@ -34,6 +34,7 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) {
$routes->post('delete/(:num)', 'Dashboard\DashboardController::delete/$1');
$routes->post('pin/(:num)', 'Dashboard\DashboardController::pinToggle/$1');
$routes->post('(:num)/layout', 'Dashboard\DashboardController::saveLayout/$1');
$routes->get('(:num)/variables', 'Dashboard\DashboardController::variables/$1');
$routes->post('(:num)/widget', 'Dashboard\DashboardController::addWidget/$1');
$routes->post('widget/(:num)/update', 'Dashboard\DashboardController::updateWidget/$1');
$routes->post('widget/(:num)/delete', 'Dashboard\DashboardController::removeWidget/$1');
@ -153,6 +154,8 @@ $routes->get('invite/(:segment)', 'Workspace\WorkspaceInvitationController::acce
$routes->get('share/(:segment)', 'Share\PublicController::show/$1');
$routes->post('share/(:segment)/unlock', 'Share\PublicController::unlock/$1');
$routes->post('share/(:segment)/chart/(:num)/data', 'Share\PublicController::chartData/$1/$2');
$routes->get('share/(:segment)/dashboard/variables', 'Share\PublicController::dashboardVariables/$1');
$routes->get('share/(:segment)/saved-query/(:num)/variables', 'Share\PublicController::savedQueryVariables/$1/$2');
// API v1 routes
$routes->group('api/v1', ['filter' => 'apiauth'], static function ($routes) {

View File

@ -7,6 +7,7 @@ use App\Libraries\AuditLogger;
use App\Models\ChartModel;
use App\Models\DashboardModel;
use App\Models\DashboardWidgetModel;
use App\Models\QueryVariableModel;
class DashboardController extends BaseController
{
@ -104,6 +105,7 @@ class DashboardController extends BaseController
'chartData' => rtrim(base_url(), '/') . '/chart/',
'chartEdit' => rtrim(base_url(), '/') . '/chart/edit/',
'chartExportBase' => rtrim(base_url(), '/') . '/chart/',
'dashboardVariables' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/variables',
'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout',
'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget',
'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',
@ -166,6 +168,94 @@ class DashboardController extends BaseController
]);
}
/**
* Return merged query variable definitions used by dashboard chart widgets.
*/
public function variables(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$dashboard = (new DashboardModel())->findForWorkspace($id, $workspaceId);
if (! $dashboard) {
return $this->response->setJSON(['success' => false, 'message' => 'Dashboard not found.'])->setStatusCode(404);
}
$widgets = (new DashboardWidgetModel())->forDashboard($id);
$queryIds = [];
foreach ($widgets as $w) {
if (($w['widget_type'] ?? '') !== 'chart') {
continue;
}
$qid = (int) ($w['saved_query_id'] ?? 0);
if ($qid > 0) {
$queryIds[$qid] = true;
}
}
$queryIds = array_keys($queryIds);
if ($queryIds === []) {
return $this->response->setJSON(['success' => true, 'variables' => []]);
}
$rows = (new QueryVariableModel())
->whereIn('saved_query_id', $queryIds)
->where('workspace_id', $workspaceId)
->orderBy('saved_query_id', 'ASC')
->orderBy('sort_order', 'ASC')
->findAll();
$merged = [];
foreach ($rows as $row) {
$name = (string) ($row['name'] ?? '');
if ($name === '') {
continue;
}
$options = [];
$optsRaw = $row['options_json'] ?? null;
if ($optsRaw !== null && $optsRaw !== '') {
$decoded = json_decode((string) $optsRaw, true);
if (is_array($decoded)) {
$options = $decoded;
}
}
if (! isset($merged[$name])) {
$merged[$name] = [
'name' => $name,
'label' => (string) ($row['label'] ?? $name),
'type' => (string) ($row['type'] ?? 'text'),
'default_value' => (string) ($row['default_value'] ?? ''),
'is_required' => ! empty($row['is_required']),
'options' => $options,
];
continue;
}
$merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']);
if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') {
$merged[$name]['default_value'] = (string) $row['default_value'];
}
if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') {
$merged[$name]['label'] = (string) $row['label'];
}
if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') {
$merged[$name]['type'] = (string) $row['type'];
}
if (is_array($options) && $options !== []) {
$existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : [];
foreach ($options as $opt) {
if (! in_array($opt, $existing, true)) {
$existing[] = $opt;
}
}
$merged[$name]['options'] = $existing;
}
}
return $this->response->setJSON([
'success' => true,
'variables' => array_values($merged),
]);
}
public function updateSettings(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');

View File

@ -9,6 +9,7 @@ use App\Models\ChartModel;
use App\Models\DashboardModel;
use App\Models\DashboardWidgetModel;
use App\Models\DataSourceModel;
use App\Models\QueryVariableModel;
use App\Models\SavedQueryModel;
use App\Models\SharedLinkModel;
use CodeIgniter\HTTP\ResponseInterface;
@ -180,6 +181,163 @@ class PublicController extends BaseController
}
}
public function dashboardVariables(string $token)
{
$this->applyEmbedHeaders();
$model = new SharedLinkModel();
$link = $model->findActiveByToken($token);
if (! $link || ($link['type'] ?? '') !== 'dashboard') {
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
}
$hash = $link['password_hash'] ?? null;
if ($hash && ! session()->get('share_unlocked_' . $token)) {
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
}
$dashId = (int) ($link['resource_id'] ?? 0);
$workspaceId = (int) ($link['workspace_id'] ?? 0);
if ($dashId <= 0 || $workspaceId <= 0) {
return $this->response->setJSON(['success' => false, 'message' => 'Invalid dashboard.'])->setStatusCode(422);
}
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
$queryIds = [];
foreach ($widgets as $w) {
if (($w['widget_type'] ?? '') !== 'chart') {
continue;
}
$qid = (int) ($w['saved_query_id'] ?? 0);
if ($qid > 0) {
$queryIds[$qid] = true;
}
}
$queryIds = array_keys($queryIds);
if ($queryIds === []) {
return $this->response->setJSON(['success' => true, 'variables' => []]);
}
$rows = (new QueryVariableModel())
->whereIn('saved_query_id', $queryIds)
->where('workspace_id', $workspaceId)
->orderBy('saved_query_id', 'ASC')
->orderBy('sort_order', 'ASC')
->findAll();
$merged = [];
foreach ($rows as $row) {
$name = (string) ($row['name'] ?? '');
if ($name === '') {
continue;
}
$options = [];
$optsRaw = $row['options_json'] ?? null;
if ($optsRaw !== null && $optsRaw !== '') {
$decoded = json_decode((string) $optsRaw, true);
if (is_array($decoded)) {
$options = $decoded;
}
}
if (! isset($merged[$name])) {
$merged[$name] = [
'name' => $name,
'label' => (string) ($row['label'] ?? $name),
'type' => (string) ($row['type'] ?? 'text'),
'default_value' => (string) ($row['default_value'] ?? ''),
'is_required' => ! empty($row['is_required']),
'options' => $options,
];
continue;
}
$merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']);
if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') {
$merged[$name]['default_value'] = (string) $row['default_value'];
}
if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') {
$merged[$name]['label'] = (string) $row['label'];
}
if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') {
$merged[$name]['type'] = (string) $row['type'];
}
if (is_array($options) && $options !== []) {
$existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : [];
foreach ($options as $opt) {
if (! in_array($opt, $existing, true)) {
$existing[] = $opt;
}
}
$merged[$name]['options'] = $existing;
}
}
return $this->response->setJSON([
'success' => true,
'variables' => array_values($merged),
]);
}
public function savedQueryVariables(string $token, int $savedQueryId)
{
$this->applyEmbedHeaders();
$model = new SharedLinkModel();
$link = $model->findActiveByToken($token);
if (! $link || ($link['type'] ?? '') !== 'dashboard') {
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
}
$hash = $link['password_hash'] ?? null;
if ($hash && ! session()->get('share_unlocked_' . $token)) {
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
}
$dashId = (int) ($link['resource_id'] ?? 0);
$workspaceId = (int) ($link['workspace_id'] ?? 0);
if ($dashId <= 0 || $workspaceId <= 0 || $savedQueryId <= 0) {
return $this->response->setJSON(['success' => false, 'message' => 'Invalid request.'])->setStatusCode(422);
}
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
$allowed = false;
foreach ($widgets as $w) {
if (($w['widget_type'] ?? '') !== 'chart') {
continue;
}
if ((int) ($w['saved_query_id'] ?? 0) === $savedQueryId) {
$allowed = true;
break;
}
}
if (! $allowed) {
return $this->response->setJSON(['success' => false, 'message' => 'Query not in shared dashboard.'])->setStatusCode(403);
}
$rows = (new QueryVariableModel())
->where('saved_query_id', $savedQueryId)
->where('workspace_id', $workspaceId)
->orderBy('sort_order', 'ASC')
->findAll();
$vars = [];
foreach ($rows as $row) {
$optsRaw = $row['options_json'] ?? null;
$options = [];
if ($optsRaw !== null && $optsRaw !== '') {
$decoded = json_decode((string) $optsRaw, true);
$options = is_array($decoded) ? $decoded : [];
}
$vars[] = [
'name' => (string) ($row['name'] ?? ''),
'label' => (string) ($row['label'] ?? ''),
'type' => (string) ($row['type'] ?? 'text'),
'default_value' => (string) ($row['default_value'] ?? ''),
'is_required' => ! empty($row['is_required']),
'options' => $options,
];
}
return $this->response->setJSON(['success' => true, 'variables' => $vars]);
}
/**
* @param array<string, mixed> $link
* @return string
@ -234,10 +392,11 @@ class PublicController extends BaseController
'charts' => [],
'urls' => [
'chartData' => rtrim(base_url(), '/') . '/share/' . $token . '/chart/',
'dashboardVariables' => rtrim(base_url(), '/') . '/share/' . $token . '/dashboard/variables',
'saveLayout' => '',
'addWidget' => '',
'delWidget' => '',
'savedQueryVars' => '',
'savedQueryVars' => rtrim(base_url(), '/') . '/share/' . $token . '/saved-query/',
],
'csrf' => [
'name' => '',

View File

@ -145,7 +145,7 @@
return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q)));
};
const render = () => {
const render = (focusState) => {
const filtered = buildFiltered();
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
@ -199,9 +199,11 @@
const searchEl = el.querySelector('[data-cb-table-search]');
if (searchEl) {
searchEl.addEventListener('input', (ev) => {
const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null;
const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart;
state.q = ev.target.value || '';
state.page = 1;
render();
render({ focusSearch: true, selStart, selEnd });
});
}
const perPageEl = el.querySelector('[data-cb-table-per-page]');
@ -222,6 +224,18 @@
if (csvBtn) csvBtn.addEventListener('click', () => exportRowsAsCsv('table-preview.csv', cols, filtered));
const excelBtn = el.querySelector('[data-cb-table-export="excel"]');
if (excelBtn) excelBtn.addEventListener('click', () => exportRowsAsCsv('table-preview-excel.csv', cols, filtered));
if (focusState && focusState.focusSearch) {
const nextSearch = el.querySelector('[data-cb-table-search]');
if (nextSearch) {
nextSearch.focus();
if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') {
const len = String(nextSearch.value || '').length;
const s = Math.max(0, Math.min(len, focusState.selStart));
const e = Math.max(s, Math.min(len, focusState.selEnd));
nextSearch.setSelectionRange(s, e);
}
}
}
};
render();

View File

@ -9,6 +9,7 @@
let CB_CSRF = boot.csrf || { name: '', hash: '' };
const globalVars = {};
let dashVarDefs = [];
let grid = null;
let editing = false;
let refreshTimer = null;
@ -23,11 +24,75 @@
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function prettyHeaderName(raw) {
const t = String(raw ?? '').trim();
if (!t) return '';
return t
.replace(/[_\-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, (m) => m.toUpperCase());
}
function exportRowsAsCsv(filename, cols, rows) {
const esc = (v) => {
const s = String(v ?? '');
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
};
const lines = [];
lines.push(cols.map((c) => esc(prettyHeaderName(c))).join(','));
rows.forEach((r) => {
lines.push(cols.map((c) => esc(r && r[c] != null ? r[c] : '')).join(','));
});
const blob = new Blob(["\uFEFF" + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
function patchCsrf(j) {
if (!j || !j.csrf) return;
CB_CSRF = j.csrf;
}
function readUrlVars() {
const out = {};
try {
const usp = new URLSearchParams(window.location.search || '');
usp.forEach((v, k) => {
if (!k.startsWith('var_')) return;
out[k.slice(4)] = v;
});
} catch (e) { /* ignore */ }
return out;
}
function writeUrlVars() {
if (!window.history || !window.history.replaceState) return;
try {
const url = new URL(window.location.href);
Array.from(url.searchParams.keys()).forEach((k) => {
if (k.startsWith('var_')) url.searchParams.delete(k);
});
Object.keys(globalVars).forEach((k) => {
const v = globalVars[k];
if (v === null || v === undefined) return;
if (Array.isArray(v)) {
if (!v.length) return;
url.searchParams.set('var_' + k, v.join(','));
return;
}
const s = String(v).trim();
if (s !== '') url.searchParams.set('var_' + k, s);
});
window.history.replaceState({}, '', url.toString());
} catch (e) { /* ignore */ }
}
function formatAxisNumber(val, fmt, dec) {
const n = Number(val);
if (Number.isNaN(n)) return val;
@ -116,16 +181,102 @@
if (payload.engine === 'table') {
const cols = payload.columns || [];
const rows = payload.rows || [];
let html = '<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr>';
cols.forEach((c) => { html += '<th>' + escapeHtml(c) + '</th>'; });
html += '</tr></thead><tbody>';
rows.forEach((r) => {
html += '<tr>';
cols.forEach((c) => { html += '<td>' + escapeHtml(r[c] != null ? String(r[c]) : '') + '</td>'; });
html += '</tr>';
});
html += '</tbody></table></div>';
el.innerHTML = html;
const state = { q: '', page: 1, perPage: 10 };
const lower = (s) => String(s ?? '').toLowerCase();
const buildFiltered = () => {
if (!state.q) return rows.slice();
const q = lower(state.q);
return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q)));
};
const renderTable = (focusState) => {
const filtered = buildFiltered();
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
if (state.page > totalPages) state.page = totalPages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * state.perPage;
const paged = filtered.slice(start, start + state.perPage);
let html = '<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-2">';
html += '<div class="d-flex gap-2 align-items-center">';
html += '<input type="search" class="form-control form-control-sm" style="min-width:220px;" placeholder="Search..." data-cb-table-search value="' + escapeHtml(state.q) + '">';
html += '<select class="form-select form-select-sm" style="width:86px;" data-cb-table-per-page>';
[10, 25, 50, 100].forEach((n) => {
html += '<option value="' + n + '"' + (state.perPage === n ? ' selected' : '') + '>' + n + '</option>';
});
html += '</select><span class="small text-muted">rows / page</span></div>';
html += '<div class="d-flex gap-2 align-items-center">';
html += '<button type="button" class="btn btn-outline-secondary btn-sm" data-cb-table-export="csv">Export CSV</button>';
html += '<button type="button" class="btn btn-outline-secondary btn-sm" data-cb-table-export="excel">Export Excel</button>';
html += '</div></div>';
html += '<div class="cb-dash-table-wrap table-responsive"><table class="table table-sm mb-0"><thead><tr>';
cols.forEach((c) => { html += '<th>' + escapeHtml(prettyHeaderName(c)) + '</th>'; });
html += '</tr></thead><tbody>';
if (!paged.length) {
html += '<tr><td class="text-muted small" colspan="' + Math.max(cols.length, 1) + '">No matching rows.</td></tr>';
} else {
paged.forEach((r) => {
html += '<tr>';
cols.forEach((c) => { html += '<td>' + escapeHtml(r && r[c] != null ? String(r[c]) : '') + '</td>'; });
html += '</tr>';
});
}
html += '</tbody></table></div>';
const from = total === 0 ? 0 : start + 1;
const to = Math.min(start + state.perPage, total);
html += '<div class="d-flex align-items-center justify-content-between mt-2">';
html += '<span class="small text-muted">Showing ' + from + ' to ' + to + ' of ' + total + ' entries</span>';
html += '<div class="btn-group btn-group-sm">';
html += '<button type="button" class="btn btn-outline-secondary" data-cb-table-page="prev"' + (state.page <= 1 ? ' disabled' : '') + '>Prev</button>';
html += '<button type="button" class="btn btn-outline-secondary disabled">Page ' + state.page + ' / ' + totalPages + '</button>';
html += '<button type="button" class="btn btn-outline-secondary" data-cb-table-page="next"' + (state.page >= totalPages ? ' disabled' : '') + '>Next</button>';
html += '</div></div>';
el.innerHTML = html;
el.classList.add('cb-dash-table-host');
el.querySelector('[data-cb-table-search]')?.addEventListener('input', (ev) => {
const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null;
const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart;
state.q = ev.target.value || '';
state.page = 1;
renderTable({ focusSearch: true, selStart, selEnd });
});
el.querySelector('[data-cb-table-per-page]')?.addEventListener('change', (ev) => {
const next = Number(ev.target.value || 10);
state.perPage = [10, 25, 50, 100].includes(next) ? next : 10;
state.page = 1;
renderTable();
});
el.querySelector('[data-cb-table-page="prev"]')?.addEventListener('click', () => {
state.page -= 1;
renderTable();
});
el.querySelector('[data-cb-table-page="next"]')?.addEventListener('click', () => {
state.page += 1;
renderTable();
});
el.querySelector('[data-cb-table-export="csv"]')?.addEventListener('click', () => exportRowsAsCsv('dashboard-table.csv', cols, filtered));
el.querySelector('[data-cb-table-export="excel"]')?.addEventListener('click', () => exportRowsAsCsv('dashboard-table-excel.csv', cols, filtered));
if (focusState && focusState.focusSearch) {
const nextSearch = el.querySelector('[data-cb-table-search]');
if (nextSearch) {
nextSearch.focus();
if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') {
const len = String(nextSearch.value || '').length;
const s = Math.max(0, Math.min(len, focusState.selStart));
const e = Math.max(s, Math.min(len, focusState.selEnd));
nextSearch.setSelectionRange(s, e);
}
}
}
};
renderTable();
return;
}
if (payload.engine === 'kpi') {
@ -156,11 +307,20 @@
}
function setGlobalVar(key, val) {
globalVars[String(key)] = val;
const k = String(key).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const normKey = String(key || '').trim();
if (!normKey) return;
globalVars[normKey] = val;
const k = normKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll('[data-cb-var-key="' + k + '"]').forEach((inp) => {
if (inp && inp.value !== undefined && document.activeElement !== inp) inp.value = val;
if (!inp || document.activeElement === inp) return;
if (inp.tagName === 'SELECT' && inp.multiple && Array.isArray(val)) {
const sv = new Set(val.map((x) => String(x)));
Array.from(inp.options).forEach((o) => { o.selected = sv.has(String(o.value)); });
return;
}
if (inp.value !== undefined) inp.value = Array.isArray(val) ? val.join(',') : val;
});
writeUrlVars();
scheduleRefreshCharts();
}
@ -209,16 +369,6 @@
}
}
async function loadVariableDefs(savedQueryId) {
if (!savedQueryId || !boot.urls || !boot.urls.savedQueryVars) return [];
try {
const r = await fetch(boot.urls.savedQueryVars + savedQueryId + '/variables', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const j = await r.json();
if (!j.success) return [];
return j.variables || [];
} catch (e) { return []; }
}
function renderMarkdown(el, md) {
if (!el || !md) { el.innerHTML = ''; return; }
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
@ -229,35 +379,121 @@
el.innerHTML = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
}
function buildVarInput(def) {
function renderVarInput(def) {
const k = def.name;
const lab = def.label || def.name;
const req = def.is_required ? ' required' : '';
if (globalVars[k] === undefined && def.default_value != null) globalVars[k] = String(def.default_value);
const current = globalVars[k] ?? '';
const labelHtml = '<label class="cb-dash-var-label">' + escapeHtml(lab) + '</label>';
if (def.type === 'number') {
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><input type="number" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(globalVars[k] ?? '') + '"' + req + '>';
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="number" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
}
if (def.type === 'date') {
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><input type="date" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(globalVars[k] ?? '') + '"' + req + '>';
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="date" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
}
if (def.type === 'date_range') {
const parts = String(globalVars[k] || '').split('|');
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><div class="d-flex gap-1"><input type="date" class="form-control form-control-sm" data-cb-var-range="start" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[0] || '') + '"><input type="date" class="form-control form-control-sm" data-cb-var-range="end" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[1] || '') + '"></div>';
const parts = String(current || '').split('|');
return '<div class="cb-dash-var-item">' + labelHtml + '<div class="cb-dash-var-range"><input type="date" class="form-control form-control-sm" data-cb-var-range="start" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[0] || '') + '"><input type="date" class="form-control form-control-sm" data-cb-var-range="end" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[1] || '') + '"></div></div>';
}
if (def.type === 'select' || def.type === 'multi_select') {
const opts = Array.isArray(def.options) ? def.options : [];
let h = '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><select class="form-select form-select-sm" data-cb-var-key="' + escapeHtml(k) + '"' + (def.type === 'multi_select' ? ' multiple' : '') + '>';
const currentSet = def.type === 'multi_select'
? new Set((Array.isArray(current) ? current : String(current).split(',')).map((x) => String(x).trim()).filter(Boolean))
: null;
let h = '<div class="cb-dash-var-item">' + labelHtml + '<select class="form-select form-select-sm" data-cb-var-key="' + escapeHtml(k) + '"' + (def.type === 'multi_select' ? ' multiple' : '') + '>';
if (def.type === 'select') h += '<option value="">—</option>';
opts.forEach((o) => {
const v = typeof o === 'object' ? (o.value ?? o.label) : o;
const t = typeof o === 'object' ? (o.label ?? o.value) : o;
h += '<option value="' + escapeHtml(String(v)) + '">' + escapeHtml(String(t)) + '</option>';
const vv = String(v);
const sel = def.type === 'multi_select'
? (currentSet && currentSet.has(vv) ? ' selected' : '')
: (String(current) === vv ? ' selected' : '');
h += '<option value="' + escapeHtml(vv) + '"' + sel + '>' + escapeHtml(String(t)) + '</option>';
});
h += '</select>';
h += '</select></div>';
return h;
}
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><input type="text" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(globalVars[k] ?? '') + '"' + req + '>';
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="text" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
}
async function loadDashboardVariables() {
if (!boot.urls || !boot.urls.dashboardVariables) return [];
try {
const r = await fetch(boot.urls.dashboardVariables, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const j = await r.json();
if (!j.success) return [];
return Array.isArray(j.variables) ? j.variables : [];
} catch (e) {
return [];
}
}
async function loadVariableDefs(savedQueryId) {
if (!savedQueryId || !boot.urls || !boot.urls.savedQueryVars) return [];
try {
const r = await fetch(boot.urls.savedQueryVars + savedQueryId + '/variables', {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const j = await r.json();
if (!j.success) return [];
return Array.isArray(j.variables) ? j.variables : [];
} catch (e) {
return [];
}
}
function bindVarInputs(root) {
if (!root) return;
root.querySelectorAll('[data-cb-var-key]').forEach((inp) => {
const key = inp.getAttribute('data-cb-var-key');
const apply = () => {
if (!inp.multiple) {
setGlobalVar(key, inp.value);
return;
}
const vals = Array.from(inp.selectedOptions || []).map((o) => o.value);
setGlobalVar(key, vals);
};
inp.addEventListener('change', apply);
inp.addEventListener('input', apply);
});
root.querySelectorAll('[data-cb-var-range]').forEach((inp) => {
inp.addEventListener('change', () => {
const parent = inp.getAttribute('data-cb-var-parent');
const start = root.querySelector('[data-cb-var-range="start"][data-cb-var-parent="' + parent + '"]')?.value || '';
const end = root.querySelector('[data-cb-var-range="end"][data-cb-var-parent="' + parent + '"]')?.value || '';
setGlobalVar(parent, start + '|' + end);
});
});
}
function renderDashboardVariablePanel() {
const host = document.getElementById('cbDashGlobalVars');
const btn = document.getElementById('cbBtnVarsModal');
if (!host) return;
if (READ_ONLY) {
if (btn) btn.classList.add('d-none');
host.classList.add('d-none');
host.innerHTML = '';
return;
}
if (!dashVarDefs.length) {
if (btn) btn.classList.add('d-none');
host.classList.add('d-none');
host.innerHTML = '';
return;
}
if (btn) btn.classList.remove('d-none');
host.classList.remove('d-none');
host.innerHTML = dashVarDefs.map((v) => renderVarInput(v)).join('');
bindVarInputs(host);
}
function sanitizeFileName(raw) {
@ -538,45 +774,23 @@
const exportExcel = canExcel
? '<a class="cb-dash-widget-export" href="' + escapeHtml(excelHref) + '" title="Export Excel" aria-label="Export Excel" target="_blank" rel="noopener noreferrer"><span class="material-symbols-outlined" style="font-size:18px;">table_view</span></a>'
: '';
const chartActions = exportPng + exportPdf + exportExcel + chartEditLink + rm;
const vars = await loadVariableDefs(w.saved_query_id);
let varsHtml = '';
if (vars.length) {
varsHtml = '<div class="cb-dash-vars px-2 pt-1">' + vars.map(renderVarInput).join('') + '</div>';
let varWarnHtml = '';
if (READ_ONLY && w.saved_query_id) {
const vars = await loadVariableDefs(w.saved_query_id);
if (Array.isArray(vars) && vars.length) {
varWarnHtml = '<span class="cb-dash-widget-var-hint" title="This widget uses URL variables (var_*)" aria-label="Widget uses URL variables"><span class="material-symbols-outlined" aria-hidden="true">warning</span></span>';
}
}
const chartActions = varWarnHtml + exportPng + exportPdf + exportExcel + chartEditLink + rm;
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + chartActions + '</div></div>' +
'<div class="cb-dash-widget-body">' +
varsHtml +
'<div class="cb-dash-chart-inner position-relative">' +
'<div id="cb-chart-' + w.id + '" class="w-100" style="min-height:220px;"></div>' +
'<div class="cb-dash-loading" id="cb-load-' + w.id + '">Loading…</div>' +
'</div></div></div>';
mount.querySelectorAll('[data-cb-var-key]').forEach((inp) => {
const key = inp.getAttribute('data-cb-var-key');
inp.addEventListener('change', () => setGlobalVar(key, inp.value));
inp.addEventListener('input', () => setGlobalVar(key, inp.value));
});
mount.querySelectorAll('[data-cb-var-range]').forEach((inp) => {
inp.addEventListener('change', () => {
const parent = inp.getAttribute('data-cb-var-parent');
const box = mount.querySelector('[data-cb-var-parent="' + parent + '"]')?.closest('.cb-dash-vars');
if (!box) return;
const start = box.querySelector('[data-cb-var-range="start"]')?.value || '';
const end = box.querySelector('[data-cb-var-range="end"]')?.value || '';
setGlobalVar(parent, start + '|' + end);
});
});
mount.querySelectorAll('select[multiple][data-cb-var-key]').forEach((sel) => {
const key = sel.getAttribute('data-cb-var-key');
sel.addEventListener('change', () => {
const vals = Array.from(sel.selectedOptions).map((o) => o.value);
setGlobalVar(key, vals);
});
});
await loadChartData(w);
mount.querySelector('.cb-dash-export-png')?.addEventListener('click', () => exportChartPng(w));
mount.querySelector('.cb-dash-export-pdf')?.addEventListener('click', () => exportChartPdf(w));
@ -611,16 +825,28 @@
const cfg = w.widget_config || {};
const sv = cfg.start_var || 'date_from';
const ev = cfg.end_var || 'date_to';
const overlayHtml = (!READ_ONLY)
? ('<div class="cb-dash-widget-filter-actions--overlay">' + actions + '</div>')
: '';
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + actions + '</div></div>' +
'<div class="cb-dash-widget-body">' +
'<div class="cb-dash-filter-label">Date range → queries</div>' +
'<div class="row g-2">' +
'<div class="col-6"><label class="small text-muted">Start <code>' + escapeHtml(sv) + '</code></label><input type="date" class="form-control form-control-sm cb-fdate" data-fk="' + escapeHtml(sv) + '"></div>' +
'<div class="col-6"><label class="small text-muted">End <code>' + escapeHtml(ev) + '</code></label><input type="date" class="form-control form-control-sm cb-fdate" data-fk="' + escapeHtml(ev) + '"></div>' +
'</div></div></div>';
'<div class="cb-dash-widget-card cb-dash-widget-card--filter-date h-100">' +
overlayHtml +
'<div class="cb-dash-widget-body cb-dash-widget-body--filter-date">' +
'<div class="cb-dash-filter-title">Date Range</div>' +
'<div class="cb-dash-filter-row cb-dash-filter-row--compact">' +
'<div class="cb-dash-filter-col cb-dash-filter-col--compact">' +
'<label class="small text-muted mb-1">Start</label>' +
'<input type="date" class="form-control form-control-sm cb-fdate cb-fdate--compact" data-fk="' + escapeHtml(sv) + '">' +
'</div>' +
'<div class="cb-dash-filter-col cb-dash-filter-col--compact">' +
'<label class="small text-muted mb-1">End</label>' +
'<input type="date" class="form-control form-control-sm cb-fdate cb-fdate--compact" data-fk="' + escapeHtml(ev) + '">' +
'</div>' +
'</div>' +
'</div></div>';
mount.querySelectorAll('.cb-fdate').forEach((inp) => {
const fk = inp.getAttribute('data-fk');
if (fk && globalVars[fk]) inp.value = String(globalVars[fk]);
inp.addEventListener('change', () => {
const fk = inp.getAttribute('data-fk');
globalVars[fk] = inp.value;
@ -635,12 +861,16 @@
const vn = cfg.var_name || 'filter';
const opts = Array.isArray(cfg.options) ? cfg.options : [];
let optsHtml = opts.map((o) => '<option value="' + escapeHtml(String(o)) + '">' + escapeHtml(String(o)) + '</option>').join('');
const overlayHtml = (!READ_ONLY)
? ('<div class="cb-dash-widget-filter-actions--overlay">' + actions + '</div>')
: '';
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + actions + '</div></div>' +
'<div class="cb-dash-widget-body">' +
'<div class="cb-dash-filter-label">Dropdown · <code>' + escapeHtml(vn) + '</code></div>' +
'<select class="form-select form-select-sm cb-fdrop" data-fk="' + escapeHtml(vn) + '"><option value="">All</option>' + optsHtml + '</select>' +
'<div class="cb-dash-widget-card cb-dash-widget-card--filter-dropdown h-100">' +
overlayHtml +
'<div class="cb-dash-widget-body cb-dash-widget-body--filter-dropdown">' +
'<div class="cb-dash-filter-title">Dropdown</div>' +
'<label class="small text-muted mb-1">Select</label>' +
'<select class="form-select form-select-sm cb-fdrop cb-fdrop--compact" data-fk="' + escapeHtml(vn) + '"><option value="">All</option>' + optsHtml + '</select>' +
'</div></div>';
const sel = mount.querySelector('.cb-fdrop');
if (sel) sel.addEventListener('change', () => {
@ -859,7 +1089,7 @@
if (gs) gs.classList.toggle('cb-dash-editing', editing);
document.querySelectorAll('.cb-dash-widget-remove').forEach((b) => b.classList.toggle('d-none', !editing));
document.querySelectorAll('.cb-dash-widget-edit').forEach((b) => b.classList.toggle('d-none', !editing));
document.querySelectorAll('.cb-dash-widget-text-actions--overlay').forEach((box) => {
document.querySelectorAll('.cb-dash-widget-text-actions--overlay, .cb-dash-widget-filter-actions--overlay').forEach((box) => {
const any = box.querySelector('.cb-dash-widget-edit:not(.d-none), .cb-dash-widget-remove:not(.d-none)');
box.classList.toggle('d-none', !any);
});
@ -1110,7 +1340,20 @@
initGrid();
layoutDirty = false;
initMounts().then(() => {
loadDashboardVariables().then((defs) => {
dashVarDefs = defs;
const qVars = readUrlVars();
dashVarDefs.forEach((v) => {
const name = String(v.name || '');
if (!name) return;
if (qVars[name] !== undefined) {
globalVars[name] = qVars[name];
} else if (globalVars[name] === undefined) {
globalVars[name] = v.default_value != null ? String(v.default_value) : '';
}
});
renderDashboardVariablePanel();
}).finally(() => initMounts().then(() => {
finalizeLayoutFromServer();
/* GridStack may run resizeToContentCheck on a ~300ms timer after first layout; re-lock layout once after that. */
setTimeout(() => finalizeLayoutFromServer(), 360);
@ -1121,6 +1364,6 @@
if (sec > 0) {
refreshTimer = setInterval(() => refreshAllCharts(), sec * 1000);
}
});
}));
})();
</script>

View File

@ -38,6 +38,9 @@ $themeClass = match ($theme) {
</button>
</div>
<div class="cb-dash-toolbar-secondary d-flex flex-wrap align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn d-none" id="cbBtnVarsModal" data-bs-toggle="modal" data-bs-target="#modalDashboardVariables" title="Dashboard variables" aria-label="Dashboard variables">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">tune</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn cb-dash-fs-btn" id="cbBtnFs" title="Fullscreen" aria-label="Fullscreen">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">open_in_full</span>
</button>
@ -98,6 +101,20 @@ $themeClass = match ($theme) {
</div>
</div>
<div class="modal fade" id="modalDashboardVariables" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0">
<h2 class="modal-title h5 fw-bold">Dashboard variables</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-0">
<div class="cb-dash-global-vars d-none" id="cbDashGlobalVars"></div>
</div>
</div>
</div>
</div>
<!-- Add widget -->
<div class="modal fade" id="modalAddWidget" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">

View File

@ -341,6 +341,46 @@
overflow: visible;
}
.cb-dash-global-vars {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px 12px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
border: 1px solid #e5e7eb;
border-radius: 14px;
margin-bottom: 14px;
}
.cb-dash-global-vars .cb-dash-var-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.cb-dash-global-vars .cb-dash-var-label {
font-size: 11px;
font-weight: 700;
color: #475569;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.cb-dash-global-vars .cb-dash-var-range {
display: flex;
gap: 6px;
}
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars {
background: rgba(30, 41, 59, 0.85);
border-color: rgba(148, 163, 184, 0.2);
}
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-label {
color: #cbd5e1;
}
.cb-dash-grid-wrap .grid-stack {
width: 100%;
min-width: 0;
@ -398,11 +438,34 @@
flex-shrink: 0;
}
.cb-dash-widget-var-hint {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
color: #d97706;
background: #fffbeb;
border: 1px solid #fde68a;
}
.cb-dash-widget-var-hint .material-symbols-outlined {
font-size: 16px;
line-height: 1;
}
.cb-dash-shell.cb-theme-dark .cb-dash-widget-head {
border-bottom-color: rgba(148, 163, 184, 0.15);
color: #f1f5f9;
}
.cb-dash-shell.cb-theme-dark .cb-dash-widget-var-hint {
color: #fbbf24;
background: rgba(120, 53, 15, 0.35);
border-color: rgba(251, 191, 36, 0.35);
}
.cb-dash-widget-body {
flex: 1 1 auto;
min-height: 0;
@ -426,6 +489,20 @@
width: 100%;
}
/* Table chart inside dashboard widgets: keep controls visible and scroll rows inside tile. */
.cb-dash-table-host {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.cb-dash-table-host .cb-dash-table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.cb-dash-widget-edit,
.cb-dash-widget-remove {
display: inline-flex;
@ -520,6 +597,117 @@
padding-top: 8px;
}
/* Compact date-range filter widget (headerless like text widget) */
.cb-dash-widget-card--filter-date {
position: relative;
overflow: hidden;
}
.cb-dash-widget-filter-actions--overlay {
position: absolute;
top: 4px;
right: 6px;
z-index: 4;
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 1px 6px rgba(15, 23, 42, 0.08);
pointer-events: auto;
}
.cb-dash-shell.cb-theme-dark .cb-dash-widget-filter-actions--overlay {
background: rgba(30, 41, 59, 0.94);
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
}
.cb-dash-widget-body--filter-date {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 7px 10px 8px;
gap: 6px;
}
.cb-dash-filter-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin: 0;
}
.cb-dash-filter-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.cb-dash-filter-row--compact {
gap: 6px;
}
.cb-dash-filter-col {
min-width: 0;
}
.cb-dash-filter-col--compact label {
font-size: 11px;
line-height: 1.1;
}
.cb-dash-widget-body--filter-date label {
display: block;
margin-bottom: 4px;
}
.cb-fdate--compact {
height: 30px;
padding-top: 3px;
padding-bottom: 3px;
font-size: 12px;
}
.grid-stack.cb-dash-editing .cb-dash-widget-card--filter-date .cb-dash-widget-body--filter-date {
padding-right: 52px;
}
@media (max-width: 768px) {
.cb-dash-filter-row {
grid-template-columns: 1fr;
}
}
/* Compact dropdown filter widget (headerless) */
.cb-dash-widget-card--filter-dropdown {
position: relative;
overflow: hidden;
}
.cb-dash-widget-body--filter-dropdown {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 7px 10px 8px;
gap: 6px;
}
.cb-fdrop--compact {
height: 30px;
padding-top: 3px;
padding-bottom: 3px;
font-size: 12px;
}
.grid-stack.cb-dash-editing .cb-dash-widget-card--filter-dropdown .cb-dash-widget-body--filter-dropdown {
padding-right: 52px;
}
.cb-dash-prose.cb-dash-prose--bare {
flex: 1 1 auto;
min-height: 0;