session->get('active_workspace_id'); $dashboards = (new DashboardModel())->forWorkspace($workspaceId); return view('dashboard/index', [ 'title' => 'Dashboards | Chart-Board', 'dashboards' => $dashboards, ]); } public function store() { $workspaceId = (int) $this->session->get('active_workspace_id'); $userId = (int) $this->session->get('user_id'); $rules = [ 'name' => 'required|min_length[2]|max_length[200]', 'description' => 'permit_empty|max_length[2000]', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $name = strip_tags((string) $this->request->getPost('name')); $desc = trim((string) $this->request->getPost('description')) ?: null; $model = new DashboardModel(); $slug = $model->generateUniqueSlug($workspaceId, $name); $id = (int) $model->insert([ 'workspace_id' => $workspaceId, 'name' => $name, 'description' => $desc, 'slug' => $slug, 'layout_config' => null, 'filters_config' => null, 'refresh_interval' => 0, 'theme' => 'system', 'is_public' => 0, 'public_token' => null, 'public_password' => null, 'public_expires_at'=> null, 'is_pinned' => 0, 'created_by' => $userId, ], true); AuditLogger::log( 'dashboard.created', 'dashboard', $id, null, ['name' => $name], $workspaceId, $userId ); return redirect()->to('/dashboard/view/' . $id)->with('success', 'Dashboard created. Add widgets and arrange your layout.'); } public function viewBoard(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); $dashModel = new DashboardModel(); $dashboard = $dashModel->findForWorkspace($id, $workspaceId); if (! $dashboard) { return redirect()->to('/dashboard')->with('error', 'Dashboard not found.'); } $widgets = (new DashboardWidgetModel())->forDashboard($id); $charts = (new ChartModel())->forWorkspace($workspaceId); return view('dashboard/view', [ 'title' => (string) $dashboard['name'] . ' | Chart-Board', 'dashboard' => $dashboard, 'widgets' => $widgets, 'chartsList' => $charts, 'dashBoot' => [ 'dashboardId' => $id, 'dashboardName' => (string) ($dashboard['name'] ?? 'dashboard'), 'workspaceTheme'=> (string) ($dashboard['theme'] ?? 'system'), 'refreshSec' => max(0, (int) ($dashboard['refresh_interval'] ?? 0)), 'widgets' => $this->widgetsBootPayload($widgets), 'charts' => array_map(static fn (array $c) => [ 'id' => (int) $c['id'], 'name' => (string) $c['name'], 'type' => (string) ($c['chart_type'] ?? ''), ], $charts), 'urls' => [ '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/', 'updateWidget' => rtrim(base_url(), '/') . '/dashboard/widget/', 'savedQueryVars' => rtrim(base_url(), '/') . '/chart/saved-query/', ], 'csrf' => [ 'name' => csrf_token(), 'hash' => csrf_hash(), ], ], ]); } /** * @param list> $widgets * @return list> */ private function widgetsBootPayload(array $widgets): array { $out = []; foreach ($widgets as $w) { $cfg = null; if (! empty($w['widget_config'])) { $d = json_decode((string) $w['widget_config'], true); $cfg = is_array($d) ? $d : null; } $out[] = [ 'id' => (int) $w['id'], 'chart_id' => isset($w['chart_id']) ? (int) $w['chart_id'] : null, 'widget_type' => (string) ($w['widget_type'] ?? 'chart'), 'title' => $w['title'] !== null && $w['title'] !== '' ? (string) $w['title'] : null, 'grid_x' => (int) ($w['grid_x'] ?? 0), 'grid_y' => (int) ($w['grid_y'] ?? 0), 'grid_w' => (int) ($w['grid_w'] ?? 4), 'grid_h' => (int) ($w['grid_h'] ?? 3), 'content' => $w['content'] !== null ? (string) $w['content'] : null, 'widget_config' => $cfg, 'chart_name' => isset($w['chart_name']) ? (string) $w['chart_name'] : null, 'chart_type' => isset($w['chart_type']) ? (string) $w['chart_type'] : null, 'saved_query_id' => isset($w['saved_query_id']) ? (int) $w['saved_query_id'] : null, ]; } return $out; } public function settings(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); $dashboard = (new DashboardModel())->findForWorkspace($id, $workspaceId); if (! $dashboard) { return redirect()->to('/dashboard')->with('error', 'Dashboard not found.'); } return view('dashboard/settings', [ 'title' => 'Dashboard settings | Chart-Board', 'dashboard' => $dashboard, ]); } /** * 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'); $model = new DashboardModel(); $dashboard = $model->findForWorkspace($id, $workspaceId); if (! $dashboard) { return redirect()->to('/dashboard')->with('error', 'Dashboard not found.'); } $rules = [ 'name' => 'required|min_length[2]|max_length[200]', 'description' => 'permit_empty|max_length[2000]', 'theme' => 'required|in_list[light,dark,system]', 'refresh_interval' => 'permit_empty|integer', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $oldSnap = [ 'name' => $dashboard['name'] ?? null, 'theme' => $dashboard['theme'] ?? null, 'refresh_interval' => $dashboard['refresh_interval'] ?? null, ]; $model->update($id, [ 'name' => strip_tags((string) $this->request->getPost('name')), 'description' => trim((string) $this->request->getPost('description')) ?: null, 'theme' => (string) $this->request->getPost('theme'), 'refresh_interval' => max(0, (int) $this->request->getPost('refresh_interval')), ]); AuditLogger::log( 'dashboard.updated', 'dashboard', $id, $oldSnap, [ 'name' => strip_tags((string) $this->request->getPost('name')), 'theme' => (string) $this->request->getPost('theme'), 'refresh_interval' => max(0, (int) $this->request->getPost('refresh_interval')), ], $workspaceId, (int) $this->session->get('user_id') ); return redirect()->back()->with('success', 'Settings saved.'); } public function delete(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); $model = new DashboardModel(); $dashboard = $model->findForWorkspace($id, $workspaceId); if (! $dashboard) { return redirect()->to('/dashboard')->with('error', 'Dashboard not found.'); } $confirm = trim((string) $this->request->getPost('confirm_name')); if ($confirm !== (string) $dashboard['name']) { return redirect()->back()->with('error', 'Type the dashboard name exactly to confirm deletion.'); } AuditLogger::log( 'dashboard.deleted', 'dashboard', $id, ['name' => $dashboard['name'] ?? null], null, $workspaceId, (int) $this->session->get('user_id') ); $model->delete($id); return redirect()->to('/dashboard')->with('success', 'Dashboard deleted.'); } public function pinToggle(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); $model = new DashboardModel(); $dashboard = $model->findForWorkspace($id, $workspaceId); if (! $dashboard) { return redirect()->to('/dashboard')->with('error', 'Dashboard not found.'); } $pinned = (int) ($dashboard['is_pinned'] ?? 0) === 1; $model->update($id, ['is_pinned' => $pinned ? 0 : 1]); return redirect()->back()->with('success', 'Pin updated.'); } /** * Batch-update widget grid positions (JSON body or form). */ public function saveLayout(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); $dashModel = new DashboardModel(); $dashboard = $dashModel->findForWorkspace($id, $workspaceId); if (! $dashboard) { return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404); } // Do not use IncomingRequest::getJSON() here: the front-end posts // application/x-www-form-urlencoded (csrf + items_json), and getJSON() // decodes the *entire* body as JSON, which throws HTTPException. $items = null; $ct = strtolower($this->request->getHeaderLine('Content-Type')); if (str_contains($ct, 'application/json')) { $body = $this->request->getBody(); if ($body !== null && $body !== '') { $decoded = json_decode($body, true); if (is_array($decoded)) { if (isset($decoded['items']) && is_array($decoded['items'])) { $items = $decoded['items']; } elseif (array_is_list($decoded)) { $items = $decoded; } } } } if (! is_array($items)) { $items = json_decode((string) $this->request->getPost('items_json'), true); } if (! is_array($items)) { return $this->response->setJSON(['success' => false, 'message' => 'Invalid layout payload.'])->setStatusCode(422); } $widgetModel = new DashboardWidgetModel(); $db = $widgetModel->db; $db->transStart(); foreach ($items as $row) { if (! is_array($row)) { continue; } $wid = (int) ($row['id'] ?? 0); if ($wid <= 0) { continue; } $w = $widgetModel->find($wid); if (! $w || (int) $w['dashboard_id'] !== $id) { continue; } $widgetModel->update($wid, [ 'grid_x' => max(0, min(255, (int) ($row['x'] ?? 0))), 'grid_y' => max(0, min(255, (int) ($row['y'] ?? 0))), 'grid_w' => max(1, min(12, (int) ($row['w'] ?? 4))), 'grid_h' => max(1, min(24, (int) ($row['h'] ?? 3))), ]); } // Keep DB row order aligned with visual layout (top-to-bottom, left-to-right) so PHP render matches the grid. $posById = []; foreach ($items as $row) { if (! is_array($row)) { continue; } $wid = (int) ($row['id'] ?? 0); if ($wid <= 0) { continue; } $posById[$wid] = [ 'y' => (int) ($row['y'] ?? 0), 'x' => (int) ($row['x'] ?? 0), ]; } $allWidgets = $widgetModel->where('dashboard_id', $id)->findAll(); usort($allWidgets, static function (array $a, array $b) use ($posById): int { $ida = (int) $a['id']; $idb = (int) $b['id']; $pa = $posById[$ida] ?? null; $pb = $posById[$idb] ?? null; if ($pa !== null && $pb !== null) { if ($pa['y'] !== $pb['y']) { return $pa['y'] <=> $pb['y']; } if ($pa['x'] !== $pb['x']) { return $pa['x'] <=> $pb['x']; } return $ida <=> $idb; } if ($pa !== null) { return -1; } if ($pb !== null) { return 1; } $ya = (int) ($a['grid_y'] ?? 0); $yb = (int) ($b['grid_y'] ?? 0); if ($ya !== $yb) { return $ya <=> $yb; } $xa = (int) ($a['grid_x'] ?? 0); $xb = (int) ($b['grid_x'] ?? 0); if ($xa !== $xb) { return $xa <=> $xb; } return $ida <=> $idb; }); $order = 1; foreach ($allWidgets as $wRow) { $widgetModel->update((int) $wRow['id'], ['sort_order' => $order]); $order++; } $db->transComplete(); if (! $db->transStatus()) { return $this->response->setJSON(['success' => false, 'message' => 'Could not save layout.'])->setStatusCode(500); } return $this->response->setJSON([ 'success' => true, 'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()], ]); } public function addWidget(int $id) { $workspaceId = (int) $this->session->get('active_workspace_id'); $dashModel = new DashboardModel(); $dashboard = $dashModel->findForWorkspace($id, $workspaceId); if (! $dashboard) { return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404); } $type = (string) $this->request->getPost('widget_type'); if (! in_array($type, ['chart', 'text', 'image', 'filter_date', 'filter_dropdown'], true)) { return $this->response->setJSON(['success' => false, 'message' => 'Invalid widget type.'])->setStatusCode(422); } $chartId = null; if ($type === 'chart') { $chartId = (int) $this->request->getPost('chart_id'); $chart = (new ChartModel())->where('workspace_id', $workspaceId)->find($chartId); if (! $chart) { return $this->response->setJSON(['success' => false, 'message' => 'Chart not found.'])->setStatusCode(404); } } $title = trim((string) $this->request->getPost('title')) ?: null; $content = trim((string) $this->request->getPost('content')) ?: null; $widgetConfig = $this->request->getPost('widget_config'); if ($type === 'text' && ($content === null || $content === '')) { return $this->response->setJSON(['success' => false, 'message' => 'Text widgets need content.'])->setStatusCode(422); } if ($type === 'image' && ($content === null || $content === '')) { return $this->response->setJSON(['success' => false, 'message' => 'Image widgets need a URL.'])->setStatusCode(422); } if ($type === 'image' && ! $this->isSafeImageUrl((string) $content)) { return $this->response->setJSON(['success' => false, 'message' => 'Only http(s) image URLs are allowed.'])->setStatusCode(422); } if ($type === 'image') { $fit = (string) $this->request->getPost('image_fit'); if (! in_array($fit, ['cover', 'contain', 'fill', 'scale-down'], true)) { $fit = 'cover'; } $widgetConfig = json_encode(['object_fit' => $fit], JSON_UNESCAPED_UNICODE); } if ($type === 'filter_date') { $sv = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->request->getPost('filter_start_var')) ?: 'date_from'; $ev = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->request->getPost('filter_end_var')) ?: 'date_to'; $widgetConfig = json_encode(['start_var' => $sv, 'end_var' => $ev], JSON_UNESCAPED_UNICODE); } if ($type === 'filter_dropdown') { $vn = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->request->getPost('filter_var_name')) ?: 'region'; $optRaw = (string) $this->request->getPost('filter_options'); $options = array_values(array_filter(array_map('trim', explode(',', $optRaw)), static fn ($s) => $s !== '')); $widgetConfig = json_encode(['var_name' => $vn, 'options' => $options], JSON_UNESCAPED_UNICODE); } $configJson = $this->normalizeWidgetConfig($widgetConfig, $type); $widgetModel = new DashboardWidgetModel(); $maxRow = $widgetModel->selectMax('sort_order')->where('dashboard_id', $id)->first(); $maxOrder = (int) ($maxRow['sort_order'] ?? 0); $newId = (int) $widgetModel->insert([ 'dashboard_id' => $id, 'chart_id' => $chartId, 'widget_type' => $type, 'title' => $title, 'grid_x' => 0, 'grid_y' => 0, 'grid_w' => $type === 'chart' ? 6 : 4, 'grid_h' => $type === 'chart' ? 4 : 2, 'content' => $type === 'chart' ? null : $content, 'widget_config' => $configJson, 'sort_order' => $maxOrder + 1, ], true); $row = $widgetModel->forDashboard($id); $created = null; foreach ($row as $r) { if ((int) $r['id'] === $newId) { $created = $r; break; } } return $this->response->setJSON([ 'success' => true, 'widget' => $created, 'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()], ]); } /** * Update markdown content for a text widget (AJAX). */ public function updateWidget(int $widgetId) { $workspaceId = (int) $this->session->get('active_workspace_id'); $widgetModel = new DashboardWidgetModel(); $w = $widgetModel->find($widgetId); if (! $w) { return $this->response->setJSON(['success' => false, 'message' => 'Widget not found.'])->setStatusCode(404); } $dashboard = (new DashboardModel())->findForWorkspace((int) $w['dashboard_id'], $workspaceId); if (! $dashboard) { return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404); } if (($w['widget_type'] ?? '') !== 'text') { return $this->response->setJSON(['success' => false, 'message' => 'Only text widgets can be updated here.'])->setStatusCode(422); } $content = trim((string) $this->request->getPost('content')); if ($content === '') { return $this->response->setJSON(['success' => false, 'message' => 'Content is required.'])->setStatusCode(422); } $widgetModel->update($widgetId, [ 'content' => $content, ]); $fresh = $widgetModel->find($widgetId); return $this->response->setJSON([ 'success' => true, 'widget' => [ 'id' => (int) ($fresh['id'] ?? $widgetId), 'content' => (string) ($fresh['content'] ?? ''), ], 'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()], ]); } public function removeWidget(int $widgetId) { $workspaceId = (int) $this->session->get('active_workspace_id'); $widgetModel = new DashboardWidgetModel(); $w = $widgetModel->find($widgetId); if (! $w) { return $this->response->setJSON(['success' => false, 'message' => 'Widget not found.'])->setStatusCode(404); } $dashboard = (new DashboardModel())->findForWorkspace((int) $w['dashboard_id'], $workspaceId); if (! $dashboard) { return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404); } $widgetModel->delete($widgetId); return $this->response->setJSON([ 'success' => true, 'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()], ]); } private function isSafeImageUrl(string $url): bool { $url = trim($url); if ($url === '') { return false; } if (preg_match('#^https?://#i', $url) !== 1) { return false; } return filter_var($url, FILTER_VALIDATE_URL) !== false; } private function normalizeWidgetConfig(mixed $raw, string $type): ?string { if ($raw === null || $raw === '') { if ($type === 'filter_dropdown') { return json_encode(['var_name' => 'filter', 'options' => []], JSON_UNESCAPED_UNICODE); } if ($type === 'filter_date') { return json_encode(['start_var' => 'date_from', 'end_var' => 'date_to'], JSON_UNESCAPED_UNICODE); } if ($type === 'image') { return json_encode(['object_fit' => 'cover'], JSON_UNESCAPED_UNICODE); } return null; } if (is_string($raw)) { $d = json_decode($raw, true); return is_array($d) ? json_encode($d, JSON_UNESCAPED_UNICODE) : null; } if (is_array($raw)) { return json_encode($raw, JSON_UNESCAPED_UNICODE); } return null; } }