chartboard/app/Controllers/Dashboard/DashboardController.php

692 lines
26 KiB
PHP

<?php
namespace App\Controllers\Dashboard;
use App\Controllers\BaseController;
use App\Libraries\AuditLogger;
use App\Models\ChartModel;
use App\Models\DashboardModel;
use App\Models\DashboardWidgetModel;
use App\Models\QueryVariableModel;
class DashboardController extends BaseController
{
public function index()
{
$workspaceId = (int) $this->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<array<string, mixed>> $widgets
* @return list<array<string, mixed>>
*/
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;
}
}