commit fdccda93e15c2f0147dff16ea4d5007c794979d7 Author: Gowtham M Date: Mon Mar 30 09:51:33 2026 +0530 Initial commit diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..6444af4 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,51 @@ +## CodeIgniter 4 Project Rules + +### Project Context +You are an expert PHP developer specializing in CodeIgniter 4(latest version). This is an Chart board. + +### Coding Standards +- **Framework:** CodeIgniter 4.x +- **Architecture:** MVC (Models in `app/Models`, Controllers in `app/Controllers`, Views in `app/Views`). +- **Database:** Use CI4 Query Builder or Entities for database interactions. No raw SQL unless specified. +- **Naming:** + - Controllers: PascalCase (e.g., `UserController.php`) + - Models: PascalCase (e.g., `UserModel.php`) + - Views: snake_case (e.g., `add_user.php`) + +### Best Practices +- **Routes:** Always check `app/Config/Routes.php` before suggesting new URLs. Use named routes where possible. +- **Validation:** Use CI4's built-in validation service in Controllers or Model `$validationRules`. +- **Security:** Always use `csrf_field()` in forms and ensure data is escaped using the Query Builder. +- **Entities:** Prefer using Entities (`app/Entities`) for mapping database rows to objects to keep business logic out of Models. + +### Common Commands +- When asked to create a file, suggest using PHP Spark: `php spark make:controller Name`, `php spark make:model Name`, `php spark make:migration Name`. + +### envirnment file +- keep base_url , database credentials , jwt secret , other secret ... inside .env file + +### view +- dont't rewrite header , footer code in every view file keep that seperatly . +- user base_url from .env file +- Refer the UI wire frame files inside (/public/ui-sample-html-asset/) folder + +### SECURITY focus +1 SQL Injection +2 HTML XSS script iframe injuction +3 csrf +4 Stored Cross Site Scripting (XSS) +5 Role based Access Control +6 Improper Input Validation +7 Handle OPTIONS Method +8 Basic CI4 validation for all form +9 Input sanitizer (get,post,put) + + +### DB connectivity details +database.default.hostname = localhost +database.default.database = chartboard +database.default.username = root +database.default.password = +database.default.DBDriver = MySQLi +# database.sql file attached here in same location + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e86b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,126 @@ +#------------------------- +# Operating Specific Junk Files +#------------------------- + +# OS X +.DS_Store +.AppleDouble +.LSOverride + +# OS X Thumbnails +._* + +# Windows image file caches +Thumbs.db +ehthumbs.db +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Linux +*~ + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +#------------------------- +# Environment Files +#------------------------- +# These should never be under version control, +# as it poses a security risk. +.env +.vagrant +Vagrantfile + +#------------------------- +# Temporary Files +#------------------------- +writable/cache/* +!writable/cache/index.html + +writable/logs/* +!writable/logs/index.html + +writable/session/* +!writable/session/index.html + +writable/uploads/* +!writable/uploads/index.html + +writable/debugbar/* +!writable/debugbar/index.html + +php_errors.log + +#------------------------- +# User Guide Temp Files +#------------------------- +user_guide_src/build/* +user_guide_src/cilexer/build/* +user_guide_src/cilexer/dist/* +user_guide_src/cilexer/pycilexer.egg-info/* + +#------------------------- +# Test Files +#------------------------- +tests/coverage* + +# Don't save phpunit under version control. +phpunit + +#------------------------- +# Composer +#------------------------- +vendor/ + +#------------------------- +# IDE / Development Files +#------------------------- + +# Modules Testing +_modules/* + +# phpenv local config +.php-version + +# Jetbrains editors (PHPStorm, etc) +.idea/ +*.iml + +# NetBeans +/nbproject/ +/build/ +/nbbuild/ +/dist/ +/nbdist/ +/nbactions.xml +/nb-configuration.xml +/.nb-gradle/ + +# Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project +.phpintel +/api/ + +# Visual Studio Code +.vscode/ + +/results/ +/phpunit*.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24728f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 British Columbia Institute of Technology +Copyright (c) 2019-present CodeIgniter Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..13c6acf --- /dev/null +++ b/README.md @@ -0,0 +1,541 @@ +# πŸ“Š Chart-Board + +> A self-hosted, open-source data visualization and dashboard platform built with CodeIgniter 4 (CI4). Connect your databases and APIs, build stunning dashboards, and share insights with your team β€” all from a clean, modern interface. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Features](#features) +3. [Tech Stack](#tech-stack) +4. [System Requirements](#system-requirements) +5. [Installation](#installation) +6. [Configuration](#configuration) +7. [Project Structure](#project-structure) +8. [UI typography (shell alignment)](#ui-typography-shell-alignment) +9. [Modules & Functionality](#modules--functionality) + - [Authentication & User Management](#1-authentication--user-management) + - [Workspace Management](#2-workspace-management) + - [Data Source Connections](#3-data-source-connections) + - [Query Builder](#4-query-builder) + - [Chart Builder](#5-chart-builder) + - [Dashboard Builder](#6-dashboard-builder) + - [API Data Source Support](#7-api-data-source-support) + - [Alerts & Notifications](#8-alerts--notifications) + - [Sharing & Embedding](#9-sharing--embedding) + - [Audit Logs](#10-audit-logs) +10. [Supported Chart Types](#supported-chart-types) +11. [Supported Data Sources](#supported-data-sources) +12. [REST API Reference](#rest-api-reference) +13. [Roles & Permissions](#roles--permissions) +14. [Security Considerations](#security-considerations) +15. [Roadmap](#roadmap) +16. [Contributing](#contributing) +17. [License](#license) + +--- + +## Overview + +**Chart-Board** is a lightweight, powerful, and extensible analytics dashboard platform. It is designed for developers, analysts, and teams who need to visualize data from multiple sources β€” databases, REST APIs, CSV files β€” without writing front-end code from scratch. + +Inspired by tools like Metabase and Chartbrew, Chart-Board is tailored for teams who prefer a self-hosted solution with full control over their data and infrastructure, built on the familiar and lightweight **CodeIgniter 4** PHP framework. + +--- + +## Features + +- πŸ”Œ **Multi-source Connections** β€” MySQL, PostgreSQL, MongoDB, REST APIs, CSV uploads +- 🧱 **Drag-and-Drop Dashboard Builder** β€” Arrange, resize, and compose charts freely +- πŸ“ˆ **Rich Chart Types** β€” Bar, Line, Pie, Donut, Area, Scatter, Table, KPI Cards, Funnel, Gauge +- πŸ” **Visual Query Builder** β€” Build queries without SQL knowledge; raw SQL mode also supported +- πŸ‘₯ **Team Workspaces** β€” Organize dashboards by team or project +- πŸ” **Role-Based Access Control** β€” Admin, Editor, Viewer roles with fine-grained permissions +- πŸ”” **Alerts & Notifications** β€” Threshold-based alerts via Email and Slack Webhook +- πŸ”— **Public Sharing & Embedding** β€” Share dashboards via public URL or embed via iframe +- πŸ• **Scheduled Refresh** β€” Auto-refresh dashboards at set intervals +- πŸ“‹ **Audit Logs** β€” Track all user actions across workspaces +- πŸŒ™ **Dark Mode** β€” Built-in dark/light theme toggle +- πŸ“€ **Export** β€” Export charts as PNG/SVG; export data as CSV/Excel + +--- + +## Tech Stack + +| Layer | Technology | +|--------------|--------------------------------------| +| Backend | PHP 8.1+, CodeIgniter 4 | +| Frontend | HTML5, CSS3, Bootstrap 5, Alpine.js | +| Charts | ApexCharts.js / Chart.js | +| Database | MySQL 8.0+ | +| Query Engine | Custom CI4 Query Builder + Raw SQL | +| Auth | CI4 Session-based + API Token Auth | +| Cache | CI4 File/Redis Cache | +| Scheduler | CI4 Task Scheduler (Cron) | +| Storage | Local / AWS S3 (for exports/uploads) | + +--- + +## System Requirements + +- PHP >= 8.1 with extensions: `intl`, `mbstring`, `curl`, `pdo`, `pdo_mysql`, `json`, `xml` +- MySQL >= 8.0 +- Composer >= 2.x +- Node.js >= 18.x (for front-end asset compilation, optional) +- Web Server: Apache 2.4+ or Nginx 1.18+ +- Minimum 512MB RAM (1GB+ recommended for production) +- Disk Space: 500MB minimum + +--- + +## Installation + +```bash +# 1. Clone the repository +git clone https://github.com/yourorg/chart-board.git +cd chart-board + +# 2. Install PHP dependencies +composer install + +# 3. Copy and configure environment file +cp env .env +# Edit .env with your database credentials and app settings + +# 4. Run database migrations +php spark migrate + +# 5. Seed initial data (admin user, default roles) +php spark db:seed InitialSeeder + +# 6. (Optional) Install front-end dependencies +npm install && npm run build + +# 7. Set folder permissions +chmod -R 777 writable/ + +# 8. Start the development server +php spark serve +``` + +Visit `http://localhost:8080` to access Chart-Board. + +Default admin credentials (change after first login): +- **Email:** `admin@chartboard.local` +- **Password:** `Admin@1234` + +--- + +## Configuration + +Key settings in `.env`: + +```ini +# Application +app.baseURL = 'http://localhost:8080' +app.name = 'Chart-Board' + +# Database +database.default.hostname = localhost +database.default.database = chartboard +database.default.username = root +database.default.password = secret +database.default.DBDriver = MySQLi + +# Cache (file or redis) +cache.handler = file + +# Mail (for alerts) +email.fromEmail = noreply@chartboard.local +email.fromName = Chart-Board +email.SMTPHost = smtp.mailtrap.io +email.SMTPPort = 587 + +# Encryption key (generate with: php spark key:generate) +encryption.key = hex2bin:your_generated_key_here +``` + +--- + +## Project Structure + +``` +chart-board/ +β”œβ”€β”€ app/ +β”‚ β”œβ”€β”€ Config/ # CI4 configuration files +β”‚ β”œβ”€β”€ Controllers/ +β”‚ β”‚ β”œβ”€β”€ Auth/ # Login, Register, Password Reset +β”‚ β”‚ β”œβ”€β”€ Api/ # REST API controllers +β”‚ β”‚ β”œβ”€β”€ Dashboard/ # Dashboard CRUD +β”‚ β”‚ β”œβ”€β”€ Chart/ # Chart CRUD +β”‚ β”‚ β”œβ”€β”€ DataSource/ # Connection management +β”‚ β”‚ β”œβ”€β”€ Query/ # Query builder & executor +β”‚ β”‚ β”œβ”€β”€ Workspace/ # Workspace management +β”‚ β”‚ └── Admin/ # Admin panel +β”‚ β”œβ”€β”€ Models/ # CI4 Model classes +β”‚ β”œβ”€β”€ Views/ +β”‚ β”‚ β”œβ”€β”€ auth/ +β”‚ β”‚ β”œβ”€β”€ dashboard/ +β”‚ β”‚ β”œβ”€β”€ chart/ +β”‚ β”‚ β”œβ”€β”€ datasource/ +β”‚ β”‚ β”œβ”€β”€ workspace/ +β”‚ β”‚ └── layouts/ +β”‚ β”œβ”€β”€ Libraries/ +β”‚ β”‚ β”œβ”€β”€ QueryEngine.php # Runs queries against data sources +β”‚ β”‚ β”œβ”€β”€ ChartRenderer.php # Formats data for chart types +β”‚ β”‚ β”œβ”€β”€ ApiConnector.php # REST API data source handler +β”‚ β”‚ └── AlertEngine.php # Threshold checking & notifications +β”‚ β”œβ”€β”€ Filters/ # Auth, RBAC, API token filters +β”‚ └── Database/ +β”‚ β”œβ”€β”€ Migrations/ # All migration files +β”‚ └── Seeds/ # Seeders +β”œβ”€β”€ public/ # Web root +β”‚ β”œβ”€β”€ assets/ +β”‚ β”‚ β”œβ”€β”€ css/ +β”‚ β”‚ β”œβ”€β”€ js/ +β”‚ β”‚ └── images/ +β”œβ”€β”€ writable/ # Logs, cache, uploads +β”œβ”€β”€ tests/ # PHPUnit tests +β”œβ”€β”€ .env +β”œβ”€β”€ composer.json +└── README.md +``` + +--- + +## UI typography (shell alignment) + +The main chrome uses a compact type scale so navigation stays scannable: + +| Token | Variable | Size | Where it appears | +|-------|-----------|------|------------------| +| Top navigation links | `--cb-font-topnav` | **14px** | Header main menu (`.cb-top-main-link`) | +| Sidebar & dense UI | `--cb-font-sidebar` | **13px** | Left menu (`.cb-nav-link`), default body text in main content | + +These variables live in `public/assets/css/variables.css`. **All authenticated screens** wrapped in `
` (workspaces, data sources, queries list/view, dashboard, profile, admin users, etc.) inherit this scale: page body at **13px**, full-size inputs/buttons and subsection titles (`h2.h5`, `.h6`) at **14px**, matching the shell. Login/register/forgot-password use `.cb-auth-body` on the auth card for the same rules. + +The query builder adds `public/assets/css/query-builder.css` under `.qb-root` (max-width + preview/SQL-specific styles only; base type comes from `.cb-page`). + +Page titles use Bootstrap’s `h1` + `.h4` pattern with **font-weight 700** (not `display-*` sizes). + +--- + +## Modules & Functionality + +### 1. Authentication & User Management + +- Registration with email verification +- Login with session-based authentication +- Password reset via email token +- API Token generation for programmatic access +- Profile management (name, avatar, password change) +- Super Admin panel to manage all users + +### 2. Workspace Management + +A **Workspace** is the top-level organizational unit (similar to a team or project). + +- Create and manage multiple workspaces +- Invite users to a workspace with a specific role (Admin / Editor / Viewer) +- Each workspace has its own data sources, charts, and dashboards +- Workspace-level settings: name, logo, timezone, default refresh interval + +### 3. Data Source Connections + +Connect to external data sources. All credentials are encrypted at rest. + +**Supported types:** + +| Type | Details | +|--------------|--------------------------------------| +| MySQL | Host, Port, DB name, User, Password | +| PostgreSQL | Host, Port, DB name, User, Password | +| MongoDB | Connection URI | +| REST API | URL, Method, Headers, Auth (Bearer / Basic / API Key) | +| CSV Upload | Upload CSV files as static datasets | + +- Test connection before saving +- Multiple data sources per workspace +- Connection health status indicator + +### 4. Query Builder + +Two modes for fetching data from a data source: + +**Visual Builder (No-Code):** +- Select table β†’ choose columns β†’ apply filters β†’ set grouping & sorting +- Filter conditions: equals, not equals, contains, greater than, less than, between, is null +- Aggregate functions: COUNT, SUM, AVG, MIN, MAX +- Preview results in a table before saving + +**Raw SQL Mode:** +- Write custom SQL with syntax highlighting (CodeMirror) +- Schema browser sidebar (tables & columns) +- Query result preview (limited to 100 rows) +- Save queries for reuse + +**For API Sources:** +- Define endpoint URL (supports dynamic date variables like `{{today}}`, `{{last_30_days}}`) +- Map JSON response path to extract data array +- Define field aliases for chart use + +### Query Variables + +Query Variables allow chart creators to define named placeholders inside SQL or API queries that end users can fill in at dashboard view time β€” without touching the query itself. + +**Syntax:** Use double curly braces `{{ variable_name }}` anywhere in a SQL query. +```sql +SELECT DATE(created_at) AS date, SUM(amount) AS revenue +FROM orders +WHERE + created_at BETWEEN '{{ start_date }}' AND '{{ end_date }}' + AND status = '{{ order_status }}' + AND region = '{{ region }}' +GROUP BY DATE(created_at) +``` + +**Variable Types:** + +| Type | Input Widget | Example | +|------|-------------|---------| +| `text` | Free text input | customer name, SKU | +| `number` | Number input | threshold, limit | +| `date` | Date picker | start date, end date | +| `date_range` | Dual date picker | from β†’ to | +| `select` | Dropdown (options defined by creator) | status, region, category | +| `multi_select` | Multi-select dropdown | multiple statuses | + +**How it works:** + +1. Creator writes query with `{{ variable_name }}` placeholders +2. Chart-Board auto-detects all variables and prompts the creator to configure each one (type, label, default value, options for dropdowns) +3. Variable config is saved in `charts.display_config` as JSON +4. On dashboard view, users see a filter widget per variable above the chart +5. On change, Chart-Board re-runs the query with substituted safe-parameterized values (never raw string injection) + +**Built-in System Variables (no config needed):** + +| Variable | Resolves To | +|----------|------------| +| `{{ today }}` | Current date `YYYY-MM-DD` | +| `{{ now }}` | Current datetime `YYYY-MM-DD HH:MM:SS` | +| `{{ yesterday }}` | Yesterday's date | +| `{{ last_7_days_start }}` | 7 days ago | +| `{{ last_30_days_start }}` | 30 days ago | +| `{{ last_90_days_start }}` | 90 days ago | +| `{{ this_month_start }}` | First day of current month | +| `{{ this_year_start }}` | First day of current year | +| `{{ current_user_id }}` | Logged-in user's ID | +| `{{ current_workspace_id }}` | Active workspace ID | + +**Security:** All user-supplied variable values are passed as PDO prepared statement bindings β€” never interpolated directly into SQL strings. + +### 5. Chart Builder + +Build charts from a saved query or direct query. + +**Steps:** +1. Select Data Source + enter/select query +2. Choose Chart Type +3. Map X-axis, Y-axis, group-by fields +4. Configure labels, colors, legend, tooltips +5. Set refresh interval (manual / 1 min / 5 min / 15 min / 1 hour / 1 day) +6. Save chart with a name and description + +**Chart Settings:** +- Title & subtitle +- Color palette (preset or custom) +- Show/hide legend +- Show/hide data labels +- Custom Y-axis min/max +- Number formatting (currency, percentage, decimal places) +- Date format for time-series X-axis + +### 6. Dashboard Builder + +A Dashboard is a collection of charts arranged in a grid layout. + +- Drag-and-drop chart placement +- Resize charts (1Γ—1 to 4Γ—2 grid units) +- Add text/markdown widgets (for annotations and section titles) +- Add image widgets (logo, banner) +- Add filter widgets (date range picker, dropdown) that apply across all charts +- Dashboard-level refresh interval +- Fullscreen mode + +### 7. API Data Source Support + +A key differentiator β€” pull data directly from REST APIs: + +- GET / POST support +- Auth modes: None, Bearer Token, Basic Auth, API Key (header or query param) +- Static or dynamic headers +- JSON path extraction (`data.results[*].value`) +- Pagination support (offset/cursor-based) +- Schedule automatic sync (cache API response for N minutes) +- Supports webhook-push mode (Chart-Board provides a unique URL to receive data) + +### 8. Alerts & Notifications + +Define threshold-based alerts on any chart metric. + +- UI: **Alerts** in the top nav (`/alert`) β€” create, edit, mute (1h / 4h / 24h), history, delete +- Conditions: `gt`, `lt`, `eq`, `gte`, `lte` on the first row of the chart’s query (numeric column) +- Check interval: per-alert minutes (`check_interval`); cron runs `php spark alert:check` (intended every minute) +- Channels: Email (comma-separated addresses) and Slack incoming webhook +- Duplicate notifications suppressed if the same alert fired within the last 5 minutes +- History stored in `alert_history`; recent triggers shown on the alerts index; badge on **Alerts** for sends in the last hour + +### 9. Sharing & Embedding + +- **Public link** β€” `GET /share/{token}` (no auth). Optional password (`POST /share/{token}/unlock`) and expiry. Records in `shared_links`; view count incremented once per session per link. +- **Create / revoke** β€” Authenticated: `POST /sharing/generate`, `POST /sharing/revoke/{id}`, list: `GET /sharing/links?type=dashboard|chart&resource_id=…`. Share modal on dashboard view, chart list, and chart editor (QR via external API in modal). +- **Embed** β€” `?embed=1` on the same URL; response drops `X-Frame-Options` and sets CSP `frame-ancestors *`. Embed tab in the share modal with size presets and iframe HTML. +- **Chart data for public dashboards** β€” `POST /share/{token}/chart/{id}/data` (same variable JSON as authenticated charts; no CSRF when token is valid). +- **Export** β€” Chart editor: PNG (Apex `dataURI` + FileSaver.js, client-side). Server: `GET /chart/{id}/export?format=csv|excel` (PhpSpreadsheet for `.xlsx`); logged in `chart_exports` for CSV/Excel. + +### 10. Audit Logs + +Track all significant user actions: + +- User login / logout +- Data source created / updated / deleted +- Chart created / updated / deleted +- Dashboard shared / unshared +- User role changes +- Alert triggered + +Logs are viewable by workspace admins and filterable by user, action type, and date range. + +--- + +## Supported Chart Types + +| Chart Type | Description | +|---------------|--------------------------------------------------| +| Bar Chart | Vertical or horizontal bar comparison | +| Line Chart | Trend over time | +| Area Chart | Filled line chart for volume trends | +| Pie Chart | Proportional distribution | +| Donut Chart | Pie with center label (good for KPIs) | +| Scatter Plot | Correlation between two numeric variables | +| Data Table | Paginated tabular display of query results | +| KPI Card | Single metric with optional trend indicator | +| Funnel Chart | Step-by-step conversion visualization | +| Gauge Chart | Progress toward a goal (0–100%) | +| Heatmap | Matrix of values colored by intensity | +| Combo Chart | Bar + Line overlaid on same axes | + +--- + +## Supported Data Sources + +| Source | Connection Type | Status | +|-------------|---------------------|----------| +| MySQL | Direct TCP | βœ… v1.0 | +| PostgreSQL | Direct TCP | βœ… v1.0 | +| MongoDB | URI | βœ… v1.0 | +| REST API | HTTP/HTTPS | βœ… v1.0 | +| CSV Upload | File Upload | βœ… v1.0 | +| SQLite | File Path | πŸ”œ v1.1 | +| BigQuery | OAuth / Service Key | πŸ”œ v1.2 | +| Snowflake | JDBC-style DSN | πŸ”œ v1.2 | +| Google Sheets | OAuth | πŸ”œ v1.2 | + +--- + +## REST API Reference + +Chart-Board exposes a REST API for programmatic access (authenticated via API token). + +**Base URL:** `https://yourdomain.com/api/v1` + +**Authentication Header:** +``` +Authorization: Bearer +``` + +| Method | Endpoint | Description | +|--------|--------------------------------------|-------------------------------| +| GET | `/workspaces` | List all workspaces | +| POST | `/workspaces` | Create a workspace | +| GET | `/workspaces/{id}/datasources` | List data sources | +| POST | `/workspaces/{id}/datasources` | Add a data source | +| GET | `/workspaces/{id}/charts` | List charts | +| POST | `/workspaces/{id}/charts` | Create a chart | +| GET | `/workspaces/{id}/dashboards` | List dashboards | +| POST | `/workspaces/{id}/dashboards` | Create a dashboard | +| GET | `/charts/{id}/data` | Fetch chart data (JSON) | +| POST | `/datasources/{id}/query` | Run a query | +| GET | `/alerts` | List alerts | + +--- + +## Roles & Permissions + +| Permission | Super Admin | Workspace Admin | Editor | Viewer | +|-------------------------|:-----------:|:---------------:|:------:|:------:| +| Manage users (global) | βœ… | ❌ | ❌ | ❌ | +| Manage workspaces | βœ… | βœ… | ❌ | ❌ | +| Invite members | βœ… | βœ… | ❌ | ❌ | +| Manage data sources | βœ… | βœ… | βœ… | ❌ | +| Create/edit charts | βœ… | βœ… | βœ… | ❌ | +| Create/edit dashboards | βœ… | βœ… | βœ… | ❌ | +| View dashboards | βœ… | βœ… | βœ… | βœ… | +| Share dashboards | βœ… | βœ… | βœ… | ❌ | +| Manage alerts | βœ… | βœ… | βœ… | ❌ | +| View audit logs | βœ… | βœ… | ❌ | ❌ | + +--- + +## Security Considerations + +- All data source credentials are encrypted using CI4's Encryption library (AES-256) +- SQL queries run in read-only mode on connected databases (use a dedicated read-only DB user) +- CSRF protection enabled on all forms +- XSS filtering on all user inputs +- Rate limiting on API endpoints (60 req/min per token) +- Public dashboard links support optional password and expiry +- HTTP-only, SameSite=Strict session cookies +- Content Security Policy (CSP) headers configured + +--- + +## Roadmap + +| Version | Planned Features | +|---------|-------------------------------------------------------------------------| +| v1.0 | Core dashboard, MySQL/PostgreSQL/REST API sources, basic chart types | +| v1.1 | SQLite support, improved mobile view, CSV export, chart comments | +| v1.2 | BigQuery, Snowflake, Google Sheets connectors | +| v1.3 | AI-powered natural language query ("Show me sales last month") | +| v1.4 | White-label mode, multi-tenant SaaS mode | +| v2.0 | Real-time streaming data (WebSocket), collaborative dashboard editing | + +--- + +## Contributing + +Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +```bash +# Run tests +php spark test + +# Check code style +vendor/bin/phpcs --standard=PSR12 app/ + +# Fix code style +vendor/bin/phpcbf --standard=PSR12 app/ +``` + +--- + +## License + +Chart-Board is open-source software licensed under the [MIT License](LICENSE). + +--- + +*Built with ❀️ using CodeIgniter 4* diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..cb38a82 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,621 @@ +# πŸ“‹ Chart-Board β€” Phased Work & Task Breakdown + +> **Project:** Chart-Board (CodeIgniter 4) +> **Stack:** PHP 8.1 Β· CI4 Β· MySQL 8 Β· Bootstrap 5 Β· ApexCharts.js Β· Alpine.js +> **Total Estimated Duration:** ~14 Weeks (Solo) / ~7 Weeks (2-Dev Team) + +--- + +## πŸ“Œ Legend + +| Symbol | Meaning | +|--------|---------| +| πŸ”΄ | Blocker β€” must be done before next task | +| 🟑 | Important β€” high priority | +| 🟒 | Normal priority | +| βš™οΈ | Backend task | +| 🎨 | Frontend task | +| πŸ§ͺ | Testing task | +| πŸ“„ | Documentation task | + +--- + +## ────────────────────────────────────────── +## PHASE 1 β€” Project Foundation & Setup +### Estimated Time: 3–4 Days +## ────────────────────────────────────────── + +### 1.1 Environment & Scaffolding + +- [x] πŸ”΄ βš™οΈ Install CodeIgniter 4 via Composer (`composer create-project codeigniter4/appstarter chart-board`) +- [x] πŸ”΄ βš™οΈ Configure `.env` file β€” `baseURL`, `database.*`, `CI_ENVIRONMENT` +- [X] πŸ”΄ βš™οΈ Set up MySQL database `chartboard` with `utf8mb4` charset +- [ ] πŸ”΄ βš™οΈ Run the full `chartboard.sql` schema to create all 15+ tables +- [x] 🟑 βš™οΈ Configure `app/Config/Database.php` for MySQL connection +- [x] 🟑 βš™οΈ Set up `app/Config/App.php` β€” timezone, base URL, session settings +- [x] 🟒 βš™οΈ Configure `app/Config/Cache.php` β€” file-based cache for dev, Redis for prod +- [x] 🟒 βš™οΈ Configure `app/Config/Email.php` β€” SMTP settings for alerts/verification +- [x] 🟒 βš™οΈ Set writable directory permissions (`chmod -R 777 writable/`) +- [x] 🟒 πŸ“„ Create `.gitignore` β€” exclude `.env`, `writable/`, `vendor/` + +### 1.2 Front-End Base Setup + +- [x] πŸ”΄ 🎨 Integrate Bootstrap 5 via CDN or npm build pipeline +- [x] πŸ”΄ 🎨 Integrate ApexCharts.js via CDN +- [x] 🟑 🎨 Integrate Alpine.js for lightweight reactivity +- [x] 🟑 🎨 Create base layout file `app/Views/layouts/main.php` β€” sidebar + topbar + content slot +- [x] 🟑 🎨 Create `app/Views/layouts/auth.php` β€” centered card layout for login/register +- [x] 🟒 🎨 Set up global CSS variables file `public/assets/css/variables.css` +- [x] 🟒 🎨 Set up global JS file `public/assets/js/app.js` β€” sidebar toggle, toast, helpers +- [x] 🟒 🎨 Add favicon, logo mark assets to `public/assets/images/` + +### 1.3 CI4 Structure Setup + +- [x] 🟑 βš™οΈ Create base `BaseController.php` β€” set shared data (user session, workspace) +- [x] 🟑 βš™οΈ Create `app/Config/Routes.php` skeleton β€” group routes by module +- [x] 🟑 βš™οΈ Create `AuthFilter.php` β€” redirect unauthenticated users to login +- [x] 🟑 βš™οΈ Create `RoleFilter.php` β€” check workspace role before allowing access +- [x] 🟒 βš™οΈ Create `ApiAuthFilter.php` β€” validate `Authorization: Bearer` token for API routes +- [x] 🟒 βš™οΈ Set up CI4 Encryption service config (`app/Config/Encryption.php`) +- [x] 🟒 βš™οΈ Generate encryption key with `php spark key:generate` + +--- + +## ────────────────────────────────────────── +## PHASE 2 β€” Authentication & User Management +### Estimated Time: 4–5 Days +## ────────────────────────────────────────── + +### 2.1 User Model & Migration + +- [x] πŸ”΄ βš™οΈ Create `UserModel.php` β€” CRUD, soft delete, findByEmail, findByApiToken +- [x] 🟑 βš™οΈ Create `Migration_CreateUsersTable.php` (already in SQL; create CI4 migration file) +- [x] 🟒 βš™οΈ Create `InitialSeeder.php` β€” seed default super admin + default workspace + +### 2.2 Registration + +- [x] πŸ”΄ βš™οΈ `Auth/RegisterController.php` β€” show form, validate input, hash password, save user +- [x] πŸ”΄ βš™οΈ Generate `verify_token`, send verification email on registration +- [x] 🟑 🎨 `app/Views/auth/register.php` β€” registration form with name, email, password, confirm password +- [x] 🟑 βš™οΈ `Auth/RegisterController::verify()` β€” handle email verification token link +- [x] 🟒 🎨 Show success flash message after registration +- [ ] 🟒 πŸ§ͺ Test: register with valid data, duplicate email, weak password + +### 2.3 Login & Logout + +- [x] πŸ”΄ βš™οΈ `Auth/LoginController.php` β€” validate credentials, check `email_verified`, start session +- [x] πŸ”΄ βš™οΈ Store user data in CI4 session: `user_id`, `name`, `email`, `role` +- [x] πŸ”΄ 🎨 `app/Views/auth/login.php` β€” email + password form, remember me checkbox +- [x] 🟑 βš™οΈ `Auth/LoginController::logout()` β€” destroy session, redirect to login +- [x] 🟑 βš™οΈ Insert record into `user_sessions` on login, delete on logout +- [ ] 🟒 πŸ§ͺ Test: wrong password, unverified email, active session redirect + +### 2.4 Password Reset + +- [x] 🟑 βš™οΈ `Auth/PasswordController::forgot()` β€” generate `reset_token`, set `reset_token_expiry`, send email +- [x] 🟑 βš™οΈ `Auth/PasswordController::reset()` β€” validate token, check expiry, update password +- [x] 🟑 🎨 `app/Views/auth/forgot.php` β€” email input form +- [x] 🟑 🎨 `app/Views/auth/reset.php` β€” new password + confirm form +- [ ] 🟒 πŸ§ͺ Test: expired token, already-used token, mismatched passwords + +### 2.5 User Profile + +- [x] 🟑 βš™οΈ `ProfileController.php` β€” show profile, update name/avatar, change password +- [x] 🟑 🎨 `app/Views/profile/index.php` β€” profile card with avatar upload +- [x] 🟑 βš™οΈ Handle avatar image upload to `writable/uploads/avatars/` +- [x] 🟑 βš™οΈ `ProfileController::generateApiToken()` β€” create/rotate personal API token, save hashed +- [x] 🟒 🎨 Show/copy API token UI with regenerate button +- [ ] 🟒 πŸ§ͺ Test: avatar upload size limits, password mismatch, token regeneration + +### 2.6 Super Admin β€” User Management + +- [x] 🟑 βš™οΈ `Admin/UserController.php` β€” list all users, activate/deactivate, change role +- [x] 🟑 🎨 `app/Views/admin/users/index.php` β€” paginated table with search and status filter +- [x] 🟒 🎨 `app/Views/admin/users/edit.php` β€” edit user role and active status +- [ ] 🟒 πŸ§ͺ Test: deactivate user blocks login, role change reflects immediately + +--- + +## ────────────────────────────────────────── +## PHASE 3 β€” Workspace Management +### Estimated Time: 3–4 Days +## ────────────────────────────────────────── + +### 3.1 Workspace CRUD + +- [x] πŸ”΄ βš™οΈ `WorkspaceController.php` β€” create, read, update, delete workspaces +- [x] πŸ”΄ βš™οΈ `WorkspaceModel.php` β€” with soft delete, slug generation, owner filter +- [x] πŸ”΄ 🎨 `app/Views/workspace/index.php` β€” workspace list/grid with create button +- [x] 🟑 🎨 `app/Views/workspace/create.php` β€” name, description, timezone, logo upload form +- [x] 🟑 🎨 `app/Views/workspace/settings.php` β€” edit workspace details +- [x] 🟑 βš™οΈ Auto-generate unique slug from workspace name on creation +- [x] 🟑 βš™οΈ On workspace creation, auto-insert creator as `workspace_members` with role `admin` +- [x] 🟒 βš™οΈ Soft delete workspace β€” cascade to members, data sources, charts, dashboards +- [ ] 🟒 πŸ§ͺ Test: duplicate slug, logo upload, owner-only delete restriction + +### 3.2 Members & Invitations + +- [x] πŸ”΄ βš™οΈ `WorkspaceMemberController.php` β€” list members, change role, remove member +- [x] πŸ”΄ βš™οΈ `WorkspaceInvitationController.php` β€” send invite email, accept invite, cancel invite +- [x] 🟑 🎨 `app/Views/workspace/members.php` β€” members table with role dropdown and remove button +- [x] 🟑 🎨 `app/Views/workspace/invite.php` β€” email + role form, pending invites list +- [x] 🟑 βš™οΈ Generate secure `token` for invite, store in `workspace_invitations`, set expiry (48h) +- [x] 🟑 βš™οΈ Public route `/invite/{token}` β€” if user exists log them in; else redirect to register +- [x] 🟒 βš™οΈ Prevent inviting existing members, prevent duplicate pending invites +- [ ] 🟒 πŸ§ͺ Test: expired token, already-accepted token, role change enforcement + +### 3.3 Workspace Context Switching + +- [x] 🟑 βš™οΈ Store `active_workspace_id` in session, set on login/switch +- [x] 🟑 🎨 Workspace switcher dropdown in sidebar β€” list user's workspaces, highlight active +- [x] 🟒 βš™οΈ Middleware: validate user is member of active workspace on every request +- [ ] 🟒 πŸ§ͺ Test: user with 0 workspaces, switching while on a dashboard page + +--- + +## ────────────────────────────────────────── +## PHASE 4 β€” Data Source Connections +### Estimated Time: 5–6 Days +## ────────────────────────────────────────── + +### 4.1 Data Source Model & Encryption + +- [x] πŸ”΄ βš™οΈ `DataSourceModel.php` β€” CRUD, filter by workspace, soft delete +- [x] πŸ”΄ βš™οΈ `Libraries/Encrypter.php` β€” wrap CI4 Encryption to encrypt/decrypt credentials +- [x] πŸ”΄ βš™οΈ Encrypt `password`, `api_auth_value` fields before saving; decrypt on retrieval +- [ ] 🟒 πŸ§ͺ Test: encrypted values are not plain text in DB, decryption returns correct value + +### 4.2 Connection UI + +- [x] πŸ”΄ 🎨 `app/Views/datasource/index.php` β€” list all data sources with type icon, status badge +- [x] πŸ”΄ 🎨 `app/Views/datasource/create.php` β€” dynamic form (type selector shows/hides fields) +- [x] 🟑 🎨 Alpine.js: show MySQL/PostgreSQL fields when DB type selected; show API fields for REST API +- [x] 🟑 🎨 `app/Views/datasource/edit.php` β€” edit form with masked password field +- [x] 🟑 🎨 Connection status badge β€” Untested / Connected (green) / Failed (red) +- [x] 🟒 🎨 Delete confirmation modal + +### 4.3 Connection Drivers + +- [x] πŸ”΄ βš™οΈ `Libraries/Connectors/MySQLConnector.php` β€” connect via PDO, run test query `SELECT 1` +- [x] πŸ”΄ βš™οΈ `Libraries/Connectors/PostgreSQLConnector.php` β€” connect via PDO pgsql +- [x] 🟑 βš™οΈ `Libraries/Connectors/MongoDBConnector.php` β€” connect via MongoDB PHP library URI +- [x] 🟑 βš™οΈ `Libraries/Connectors/RestApiConnector.php` β€” cURL GET/POST with auth headers +- [x] 🟑 βš™οΈ `Libraries/Connectors/CsvConnector.php` β€” parse uploaded CSV into in-memory array +- [x] 🟒 βš™οΈ `Libraries/ConnectionFactory.php` β€” factory to return correct connector by type +- [ ] 🟒 πŸ§ͺ Test: each connector with valid/invalid credentials + +### 4.4 Test Connection Endpoint + +- [x] πŸ”΄ βš™οΈ `POST /datasource/test` (AJAX) β€” instantiate connector, run test, return JSON `{success, message}` +- [x] πŸ”΄ 🎨 "Test Connection" button with spinner; show success/error inline below button +- [x] 🟑 βš™οΈ Update `status` and `last_tested_at` in DB after test +- [ ] 🟒 πŸ§ͺ Test: timeout handling (set cURL timeout 10s), wrong host, wrong credentials + +### 4.5 Schema Browser (for Query Builder) + +- [x] 🟑 βš™οΈ `GET /datasource/{id}/schema` (AJAX) β€” return tables list and columns per table as JSON +- [ ] 🟑 🎨 Schema sidebar in query builder β€” collapsible tree: Tables β†’ Columns with types +- [x] 🟒 βš™οΈ Cache schema response for 5 minutes per data source +- [ ] 🟒 πŸ§ͺ Test: DB with 100+ tables, special characters in column names + +--- + +## ────────────────────────────────────────── +## PHASE 5 β€” Query Builder +### Estimated Time: 5–6 Days +**Completion note:** Core MVP items are implemented (unified create/edit form, three query modes, multi-filter / multi–order-by visual builder, variables for raw + visual, API headers in `api_params`, preview table + execution log, query cache). Open: CodeMirror editor, datasource schema tree in UI, visual aggregates + GROUP BY controls, chart/dashboard variable wiring (Phase 6), and listed tests. + +--- + +### 5.1 Saved Query Model + +- [x] πŸ”΄ βš™οΈ `SavedQueryModel.php` β€” CRUD, filter by workspace and data source, soft delete +- [ ] 🟒 πŸ§ͺ Test: save and retrieve query with complex JSON config + +### 5.2 Visual Query Builder (No-Code) + +- [x] πŸ”΄ 🎨 Visual mode in `app/Views/query/_form.php` (create/edit) β€” table name input + comma-separated columns *(no separate `visual.php`; schema dropdown / per-column checkboxes not implemented)* +- [ ] πŸ”΄ 🎨 Schema-driven table dropdown + column checkboxes with optional aliases +- [x] 🟑 🎨 Filter builder β€” add/remove filter rows: field, operator (=, !=, >, <, LIKE, IS NULL, IS NOT NULL), value +- [ ] 🟑 🎨 Aggregate row β€” apply COUNT/SUM/AVG/MIN/MAX to numeric columns +- [ ] 🟑 🎨 GROUP BY selector β€” multi-select *(backend `QueryBuilder::toSQL()` supports `group_by[]`; no UI yet)* +- [x] 🟑 🎨 ORDER BY β€” multiple sort columns, each with ASC/DESC + add/remove rows +- [x] 🟑 🎨 LIMIT input β€” max rows (default 500); supports `{{variable}}` in visual fields +- [x] 🟑 βš™οΈ `QueryBuilder::toSQL()` β€” convert visual config JSON to safe parameterized SQL +- [x] 🟒 βš™οΈ Prevent destructive keywords: block `DROP`, `DELETE`, `UPDATE`, `INSERT`, `TRUNCATE` in generated SQL +- [ ] 🟒 πŸ§ͺ Test: multi-filter query, aggregate with group by, null filter + +### 5.3 Raw SQL Mode + +- [ ] πŸ”΄ 🎨 CodeMirror 6 editor integration β€” SQL syntax highlighting, auto-complete (table/column names) *(currently plain textarea in `_form.php`)* +- [x] πŸ”΄ 🎨 Mode selector β€” Raw SQL / Visual builder / API query +- [x] 🟑 βš™οΈ SQL safety check before execution β€” regex/parse to block DDL/DML mutations +- [x] 🟑 βš™οΈ `QueryController::execute()` β€” run sanitized SQL on the selected data source, return JSON results +- [x] 🟒 βš™οΈ Enforce query timeout β€” kill query after 30 seconds +- [ ] 🟒 πŸ§ͺ Test: malicious SQL injection attempt, timeout simulation, empty result set + + +### 5.4 Query Variables + +- [x] πŸ”΄ βš™οΈ `Libraries/QueryVariableParser.php` β€” scan query string for `{{ var_name }}` pattern using regex, return list of variable names +- [x] πŸ”΄ βš™οΈ `Libraries/QueryVariableResolver.php` β€” resolve system variables (`{{today}}`, `{{now}}`, etc.) and substitute user values via PDO bindings; `resolveTemplateString()` for visual builder identifiers/literals +- [x] πŸ”΄ 🎨 Variable panel β€” Raw SQL + Visual builder: detect / configure / test values; hidden for API mode on create/edit +- [x] πŸ”΄ 🎨 Variable config form per detected variable: + - Label (display name shown to end user) + - Type: `text` / `number` / `date` / `date_range` / `select` / `multi_select` + - Default value + - For `select`/`multi_select`: options list (comma-separated or from another query) + - Required toggle +- [x] 🟑 βš™οΈ Persist variable definitions in `query_variables` table (per `saved_query_id`) on save/update *(dashboard/chart `display_config` integration pending Phase 6)* +- [ ] 🟑 🎨 Dashboard view β€” render a variable input widget per variable above each chart that has variables defined: + - `text` β†’ `` + - `number` β†’ `` + - `date` β†’ date picker + - `date_range` β†’ dual date range picker + - `select` β†’ dropdown + - `multi_select` β†’ multi-select dropdown with checkboxes +- [ ] 🟑 🎨 On variable value change β†’ re-fetch chart data AJAX with new values, re-render chart without page reload +- [ ] 🟑 βš™οΈ Global dashboard filter β€” if multiple charts share a variable with the same name, a single widget controls all of them simultaneously +- [x] 🟑 βš™οΈ Resolve built-in system variables server-side before query execution (no user input needed for these) +- [x] 🟒 🎨 Variable widget in Query Builder preview β€” show input fields for each detected variable so creator can test values before saving +- [x] 🟒 βš™οΈ `multi_select` in raw SQL β€” `QueryVariableResolver::resolve()` expands array values to multiple `?` placeholders (use inside `IN ({{var}})` in SQL) +- [ ] 🟒 βš™οΈ Public shared dashboards β€” variable widgets still visible and functional for anonymous viewers +- [ ] 🟒 πŸ§ͺ Test: SQL injection attempt via variable value is blocked by PDO binding; date variable resolves correctly; missing required variable shows validation error; multi-select generates correct `IN` clause + + +### 5.5 API Query Builder + +- [x] 🟑 🎨 API query form β€” endpoint URL, JSON path, grouped sections (headers + field map with add/remove rows) +- [x] 🟑 🎨 HTTP headers β€” repeatable rows; persisted in `saved_queries.api_params` as JSON `{ "headers": [...] }` +- [x] 🟑 🎨 JSON path input β€” e.g. `data.results` to extract nested array +- [x] 🟑 🎨 Field map β€” source JSON key β†’ column alias rows +- [x] 🟑 βš™οΈ `Libraries/ApiConnector::fetch()` β€” variable substitution on URL, headers, extract by JSON path +- [ ] 🟒 πŸ§ͺ Test: nested JSON path, missing field graceful fallback, invalid URL + +### 5.6 Query Preview & Result Table + +- [x] πŸ”΄ 🎨 "Run Query" button β€” AJAX call, show spinner while loading +- [x] πŸ”΄ 🎨 Result preview table β€” first 100 rows, dynamic columns, sortable headers, pagination, CSV download, execution log tab +- [x] 🟑 🎨 Row count badge, execution time badge +- [x] 🟑 🎨 Save flow β€” name/description/meta on same form as builder; POST to store/update `saved_queries` +- [x] 🟒 🎨 Empty state β€” centered β€œNo rows returned” message in preview *(illustration asset optional)* +- [ ] 🟒 πŸ§ͺ Test: 0 rows, 1000+ rows truncated, columns with special characters + +### 5.7 Query Cache + +- [x] 🟑 βš™οΈ After execution, store result JSON in `query_cache` with MD5 cache key and TTL +- [x] 🟑 βš™οΈ On next execution, check `query_cache` first; serve from cache if not expired +- [x] 🟒 βš™οΈ CI4 Cron task: `DeleteExpiredQueryCache` β€” run every 30 minutes to purge expired rows +- [ ] 🟒 πŸ§ͺ Test: cache hit serves faster, cache miss goes to DB, expired cache re-fetches + +--- + +## ────────────────────────────────────────── +## PHASE 6 β€” Chart Builder +### Estimated Time: 6–7 Days +**Completion note:** Core MVP is in place: `ChartModel`, `SavedQueryRunner`, `ChartRenderer`, `ChartController`, multi-step builder (saved query first β€” no separate data-source step), ApexCharts live preview (`preview-query` / `preview-render`), `POST /chart/{id}/data` with CSRF refresh in JSON, chart list with Edit / Duplicate / Delete. Open: `public_token` generation, saved-query modal vs dropdown (dropdown implemented), unsaved-changes guard, table pagination/sort, legend position + custom hex + X-axis date format in UI, client `setInterval` refresh + spinner, search/filter, listed tests. + +--- + +### 6.1 Chart Model + +- [x] πŸ”΄ βš™οΈ `ChartModel.php` β€” CRUD, filter by workspace, soft delete +- [ ] 🟑 βš™οΈ Public token generator (`is_public` / `public_token`) β€” pending (sharing phase) +- [ ] 🟒 πŸ§ͺ Test: create chart with JSON display_config, retrieve and parse correctly + +### 6.2 Chart Builder UI β€” Step Flow + +- [x] πŸ”΄ 🎨 Multi-step chart builder UI (Step 1: Saved query β†’ Type β†’ Fields β†’ Style β†’ Preview & save) +- [x] πŸ”΄ 🎨 Step 1 β€” Choose saved query + run preview *(data source implied by query; no separate source step)* +- [x] πŸ”΄ 🎨 Step 2 β€” Chart type selector grid (12 types with icons) +- [x] 🟑 🎨 Step 3 β€” Field mapping: X-axis, Y-axis, Group By, Value (+ combo second metric); columns from preview +- [x] 🟑 🎨 Step 4 β€” Display settings: title, subtitle, palette presets, legend toggle, data label toggle, Y-axis min/max, number format, grid / smooth / stacked / horizontal bar +- [x] 🟑 🎨 Step navigation β€” Back / Next / Save; validation before advancing +- [x] 🟑 🎨 Live preview β€” ApexCharts with real query data (`chart/preview-render`) +- [ ] 🟒 🎨 "Use Saved Query" button β€” modal to pick query *(dropdown on Step 1 covers pick-from-list)* +- [ ] 🟒 🎨 Unsaved changes warning on browser back/close + +### 6.3 Chart Rendering Engine + +- [x] πŸ”΄ βš™οΈ `Libraries/ChartRenderer.php` β€” chart config + rows β†’ Apex-compatible options (`buildPayload`) +- [x] πŸ”΄ βš™οΈ Renderer: `bar`, `line`, `area`, `pie`, `donut` +- [x] 🟑 βš™οΈ Renderer: `scatter`, `kpi_card`, `funnel`, `gauge`, `heatmap`, `combo` +- [ ] 🟑 βš™οΈ `table` β€” paginated HTML table with sort *(basic truncated table only)* +- [x] 🟒 βš™οΈ Number formatter β€” currency (β‚Ή/$), percentage, decimals, K/M/B (server + client) +- [ ] 🟒 πŸ§ͺ Test: each chart type with realistic data, empty data, single-row data + +### 6.4 Chart Display Settings + +- [x] 🟑 🎨 Color palette presets (Ocean, Forest, Sunset, Mono) *(custom hex input β€” open)* +- [ ] 🟑 🎨 Legend position selector (top/bottom/left/right/none) in builder UI +- [x] 🟑 🎨 Refresh interval dropdown (Manual / 1 min / 5 min / 15 min / 1 hr) *(1 day option β€” open)* +- [ ] 🟒 🎨 Date format selector for time-series X-axis (dd/MM, MMM dd, MMM yyyy) +- [x] 🟒 🎨 Stacked bar/area toggle + +### 6.5 Chart Auto-Refresh + +- [ ] 🟑 🎨 JavaScript: `refresh_interval > 0` β†’ `setInterval` re-fetch +- [x] 🟑 βš™οΈ `POST /chart/{id}/data` β€” re-run query, JSON render payload *(POST + `variables_json` for CSRF/vars; spec listed GET)* +- [ ] 🟒 🎨 Refresh spinner overlay while loading +- [ ] 🟒 πŸ§ͺ Test: 1-minute refresh updates data without full page reload + +### 6.6 Chart List & Management + +- [x] 🟑 🎨 `app/Views/chart/index.php` β€” card grid, type, last updated, data source + query name +- [x] 🟑 🎨 Chart card actions β€” Edit, Duplicate, Delete *(Add to Dashboard / Share β€” Phase 7+)* +- [x] 🟑 βš™οΈ `ChartController::duplicate()` β€” clone with name suffix `(copy)` +- [ ] 🟒 🎨 Search and filter charts by type or data source +- [ ] 🟒 πŸ§ͺ Test: duplicate preserves all config, delete removes from all dashboards + +--- + +## ────────────────────────────────────────── +## PHASE 7 β€” Dashboard Builder +### Estimated Time: 6–7 Days +## ────────────────────────────────────────── + +### 7.1 Dashboard Model + +- [ ] πŸ”΄ βš™οΈ `DashboardModel.php` β€” CRUD, filter by workspace, soft delete, public token generator +- [ ] πŸ”΄ βš™οΈ `DashboardWidgetModel.php` β€” CRUD widgets per dashboard, store grid position +- [ ] 🟒 πŸ§ͺ Test: save layout_config JSON and retrieve widget positions correctly + +### 7.2 Dashboard List + +- [ ] πŸ”΄ 🎨 `app/Views/dashboard/index.php` β€” card grid of dashboards with pinned section at top +- [ ] 🟑 🎨 Dashboard card β€” name, description, chart count, last updated, share status badge +- [ ] 🟑 🎨 Create Dashboard button β€” modal with name + description input +- [ ] 🟒 🎨 Pin/Unpin dashboard toggle +- [ ] 🟒 πŸ§ͺ Test: 0 dashboards empty state, pinned order preserved on reload + +### 7.3 Dashboard View Mode + +- [ ] πŸ”΄ 🎨 `app/Views/dashboard/view.php` β€” render all widgets in their grid positions +- [ ] πŸ”΄ 🎨 Render chart widgets: fetch data via AJAX, render ApexCharts +- [ ] 🟑 🎨 Render text widgets β€” parse Markdown to HTML using `marked.js` +- [ ] 🟑 🎨 Render image widgets β€” `` with configurable object-fit +- [ ] 🟑 🎨 Global filter widgets β€” date range picker and dropdown filter +- [ ] 🟑 βš™οΈ When global date filter changes, re-fetch all chart data with new date params injected into queries +- [ ] 🟒 🎨 Fullscreen button β€” expand dashboard to fill viewport, hide sidebar/topbar +- [ ] 🟒 πŸ§ͺ Test: mixed widget types, dashboard with 20+ charts, date filter propagation + +### 7.4 Dashboard Edit Mode (Drag & Drop) + +- [ ] πŸ”΄ 🎨 Integrate `gridstack.js` or `Muuri` for drag-and-drop grid layout +- [ ] πŸ”΄ 🎨 "Edit Layout" toggle activates draggable/resizable mode on all widgets +- [ ] 🟑 🎨 Resize handles on widget cards β€” drag corner to resize (min 1Γ—1, max 4Γ—3 units) +- [ ] 🟑 🎨 "Add Widget" button in edit mode β€” opens modal to pick chart, text, image, or filter widget +- [ ] 🟑 🎨 Remove widget button (βœ•) visible only in edit mode +- [ ] 🟑 βš™οΈ "Save Layout" β€” AJAX POST grid positions (x, y, w, h) per widget to `dashboard_widgets` +- [ ] 🟑 🎨 "Discard Changes" β€” reload original layout from DB without saving +- [ ] 🟒 🎨 Widget title override input (optional per-widget title different from chart name) +- [ ] 🟒 πŸ§ͺ Test: save layout, reload β€” positions preserved exactly; concurrent edit race condition + +### 7.5 Dashboard Settings + +- [ ] 🟑 🎨 Dashboard settings panel β€” name, description, theme (light/dark/system), refresh interval +- [ ] 🟑 βš™οΈ `DashboardController::updateSettings()` β€” update name, theme, refresh +- [ ] 🟒 🎨 Danger zone β€” delete dashboard with confirmation typing +- [ ] 🟒 πŸ§ͺ Test: theme toggle persists on reload, refresh interval auto-starts + +--- + +## ────────────────────────────────────────── +## PHASE 8 β€” Alerts & Notifications +### Estimated Time: 4–5 Days +## ────────────────────────────────────────── + +### 8.1 Alert Model & CRUD + +- [ ] πŸ”΄ βš™οΈ `AlertModel.php` β€” CRUD, filter by workspace, soft delete +- [ ] πŸ”΄ βš™οΈ `AlertHistoryModel.php` β€” insert triggered log, fetch recent history per alert +- [ ] 🟑 🎨 `app/Views/alert/index.php` β€” list alerts with status (OK/Triggered/Muted), last triggered time +- [ ] 🟑 🎨 `app/Views/alert/create.php` β€” form: chart selector, metric field, condition, threshold, channels +- [ ] 🟑 🎨 `app/Views/alert/edit.php` β€” edit + mute/unmute toggle +- [ ] 🟒 πŸ§ͺ Test: create alert with all fields, edit threshold, delete alert + +### 8.2 Alert Engine (Background Check) + +- [ ] πŸ”΄ βš™οΈ `Libraries/AlertEngine.php` β€” load active alerts, run chart query, compare value to threshold +- [ ] πŸ”΄ βš™οΈ CI4 Cron (`php spark alert:check`) β€” runs every minute via system cron job +- [ ] 🟑 βš™οΈ `AlertEngine::evaluate()` β€” conditions: `gt`, `lt`, `eq`, `gte`, `lte` +- [ ] 🟑 βš™οΈ Skip alert if `is_muted_until` is in the future +- [ ] 🟑 βš™οΈ On trigger: send Email via CI4 Email library, send Slack message via Webhook HTTP POST +- [ ] 🟑 βš™οΈ Log result to `alert_history` β€” value, channels notified, status (sent/failed/muted) +- [ ] 🟒 βš™οΈ Prevent duplicate notifications β€” if same alert triggered within last 5 minutes, skip +- [ ] 🟒 πŸ§ͺ Test: threshold breach triggers notification, muted alert is skipped, failed webhook logs error + +### 8.3 Alert Notifications UI + +- [ ] 🟑 🎨 Alerts sidebar panel (as in POC) β€” show recent triggered alerts with current value +- [ ] 🟑 🎨 Alert history sub-page β€” paginated log of all past triggers per alert +- [ ] 🟑 🎨 "Mute for" button β€” snooze alert for 1h / 4h / 24h +- [ ] 🟒 🎨 Red badge count on sidebar Alerts nav item for active triggered alerts +- [ ] 🟒 πŸ§ͺ Test: snooze clears badge, alert re-triggers after snooze expires + +--- + +## ────────────────────────────────────────── +## PHASE 9 β€” Sharing & Embedding +### Estimated Time: 3–4 Days +## ────────────────────────────────────────── + +### 9.1 Public Share Links + +- [ ] πŸ”΄ βš™οΈ `SharedLinkModel.php` β€” create token, find by token, increment view count +- [ ] πŸ”΄ βš™οΈ `SharingController::generate()` β€” create record in `shared_links`, return public URL +- [ ] πŸ”΄ βš™οΈ Public route `GET /share/{token}` β€” no auth required, load dashboard/chart view-only +- [ ] 🟑 🎨 Share modal in dashboard/chart view β€” show public URL with copy button, QR code +- [ ] 🟑 🎨 Optional password field β€” bcrypt hash stored, prompt on public page if set +- [ ] 🟑 🎨 Optional expiry date picker β€” after expiry show "This link has expired" page +- [ ] 🟑 βš™οΈ `SharingController::revoke()` β€” set `is_active = 0`, invalidate link +- [ ] 🟒 🎨 View count display in share modal ("Viewed 42 times") +- [ ] 🟒 πŸ§ͺ Test: password protection, expired link, revoked link, view count increment + +### 9.2 iFrame Embed + +- [ ] 🟑 🎨 Embed tab in share modal β€” show iframe HTML snippet with correct URL +- [ ] 🟑 βš™οΈ Public share route: set `X-Frame-Options: ALLOWALL` header for embed URLs +- [ ] 🟑 🎨 Embed view β€” stripped layout (no sidebar/topbar), chart/dashboard only +- [ ] 🟒 🎨 Embed size presets (640Γ—480, 800Γ—600, 1200Γ—800, custom) +- [ ] 🟒 πŸ§ͺ Test: embed renders in external `'; + const ta = document.getElementById('cbEmbedCode'); + if (ta) ta.value = code; + } + + async function loadLinks() { + const type = document.getElementById('cbShareType')?.value || 'chart'; + const rid = document.getElementById('cbShareResourceId')?.value || ''; + const wrap = document.getElementById('cbShareLinksWrap'); + const err = document.getElementById('cbShareErr'); + if (!wrap || !rid) return; + err.classList.add('d-none'); + wrap.innerHTML = '

Loading…

'; + try { + const r = await fetch(urlList + '?type=' + encodeURIComponent(type) + '&resource_id=' + encodeURIComponent(rid), { + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + const j = await r.json(); + if (!j.success || !j.links) { + wrap.innerHTML = '

No links yet.

'; + return; + } + if (j.links.length === 0) { + wrap.innerHTML = '

No active links. Generate one above.

'; + setEmbedSnippet(''); + return; + } + let html = '
    '; + j.links.forEach((L) => { + const qr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=' + encodeURIComponent(L.url); + html += '
  • ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    Views: ' + (L.view_count ?? 0) + + (L.expires_at ? ' Β· Expires: ' + L.expires_at : '') + + (L.has_password ? ' Β· Password protected' : '') + '
    ' + + '
    ' + + '
    ' + + '
    QR code
  • '; + }); + html += '
'; + wrap.innerHTML = html; + setEmbedSnippet(j.links[0].url); + wrap.querySelectorAll('.cb-copy-url').forEach((btn) => { + btn.addEventListener('click', () => { + const inp = btn.closest('.input-group')?.querySelector('input'); + if (!inp) return; + inp.select(); + document.execCommand('copy'); + }); + }); + wrap.querySelectorAll('.cb-revoke-form').forEach((f) => { + f.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const id = f.getAttribute('data-id'); + const csrf = getCsrf(); + const body = new URLSearchParams(); + body.append(csrf.name, csrf.hash); + const res = await fetch(urlRevoke + id, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + const jj = await res.json(); + if (jj.csrf) { + modal.setAttribute('data-csrf-name', jj.csrf.name); + const hi = modal.querySelector('.cb-share-csrf'); + if (hi) { hi.name = jj.csrf.name; hi.value = jj.csrf.hash; } + } + loadLinks(); + }); + }); + } catch (e) { + wrap.innerHTML = '

Could not load links.

'; + } + } + + modal.addEventListener('show.bs.modal', (ev) => { + const t = ev.relatedTarget; + const type = t?.getAttribute('data-share-type') || 'chart'; + const rid = t?.getAttribute('data-resource-id') || ''; + const st = document.getElementById('cbShareType'); + const sr = document.getElementById('cbShareResourceId'); + if (st) st.value = type; + if (sr) sr.value = rid; + loadLinks(); + }); + + document.getElementById('cbShareGenerateForm')?.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const err = document.getElementById('cbShareErr'); + err.classList.add('d-none'); + const form = ev.target; + const fd = new FormData(form); + try { + const r = await fetch(urlGen, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body: new URLSearchParams(fd), + }); + const j = await r.json(); + if (j.csrf) { + const hi = modal.querySelector('.cb-share-csrf'); + if (hi) { hi.name = j.csrf.name; hi.value = j.csrf.hash; } + } + if (!j.success) { + err.textContent = j.message || (j.errors ? Object.values(j.errors).flat().join(' ') : 'Failed'); + err.classList.remove('d-none'); + return; + } + if (j.csrf) { + const hi = modal.querySelector('.cb-share-csrf'); + if (hi) { hi.name = j.csrf.name; hi.value = j.csrf.hash; } + modal.setAttribute('data-csrf-name', j.csrf.name); + } + form.querySelector('[name="password"]').value = ''; + form.querySelector('[name="expires_at"]').value = ''; + loadLinks(); + } catch (e) { + err.textContent = 'Request failed.'; + err.classList.remove('d-none'); + } + }); + + document.getElementById('cbEmbedPreset')?.addEventListener('change', () => { + const c = document.getElementById('cbEmbedCustomDims'); + if (c) c.classList.toggle('d-none', document.getElementById('cbEmbedPreset').value !== 'custom'); + const first = document.querySelector('#cbShareLinksWrap input[readonly]'); + setEmbedSnippet(first ? first.value : ''); + }); + document.getElementById('cbEmbedW')?.addEventListener('input', () => { + const first = document.querySelector('#cbShareLinksWrap input[readonly]'); + setEmbedSnippet(first ? first.value : ''); + }); + document.getElementById('cbEmbedH')?.addEventListener('input', () => { + const first = document.querySelector('#cbShareLinksWrap input[readonly]'); + setEmbedSnippet(first ? first.value : ''); + }); + + document.getElementById('cbEmbedCopy')?.addEventListener('click', () => { + const ta = document.getElementById('cbEmbedCode'); + if (!ta) return; + ta.select(); + document.execCommand('copy'); + }); +})(); + diff --git a/app/Views/share/password.php b/app/Views/share/password.php new file mode 100644 index 0000000..ece9dfe --- /dev/null +++ b/app/Views/share/password.php @@ -0,0 +1,18 @@ +extend('layouts/embed') ?> +section('content') ?> +
+

Password required

+ getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ +
+ + +
+ + +
+ +
+
+endSection() ?> diff --git a/app/Views/share/revoked.php b/app/Views/share/revoked.php new file mode 100644 index 0000000..e67ac8f --- /dev/null +++ b/app/Views/share/revoked.php @@ -0,0 +1,7 @@ +extend('layouts/embed') ?> +section('content') ?> +
+

Link revoked

+

The owner has turned off this share link.

+
+endSection() ?> diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php new file mode 100644 index 0000000..c18eca3 --- /dev/null +++ b/app/Views/welcome_message.php @@ -0,0 +1,331 @@ + + + + + Welcome to CodeIgniter 4! + + + + + + + + + + + +
+ + + +
+ +

Welcome to CodeIgniter

+ +

The small framework with powerful features

+ +
+ +
+ + + +
+ +

About this page

+ +

The page you are looking at is being generated dynamically by CodeIgniter.

+ +

If you would like to edit this page you will find it located at:

+ +
app/Views/welcome_message.php
+ +

The corresponding controller for this page can be found at:

+ +
app/Controllers/Home.php
+ +
+ +
+ +
+ +

Go further

+ +

+ + Learn +

+ +

The User Guide contains an introduction, tutorial, a number of "how to" + guides, and then reference documentation for the components that make up + the framework. Check the User Guide !

+ +

+ + Discuss +

+ +

CodeIgniter is a community-developed open source project, with several + venues for the community members to gather and exchange ideas. View all + the threads on CodeIgniter's forum, or chat on Slack !

+ +

+ + Contribute +

+ +

CodeIgniter is a community driven project and accepts contributions + of code and documentation from the community. Why not + + join us ?

+ +
+ +
+ + + +
+
+ +

Page rendered in {elapsed_time} seconds using {memory_usage} MB of memory.

+ +

Environment:

+ +
+ +
+ +

© CodeIgniter Foundation. CodeIgniter is open source project released under the MIT + open source licence.

+ +
+ +
+ + + + + + + + + diff --git a/app/Views/workspace/create.php b/app/Views/workspace/create.php new file mode 100644 index 0000000..b852053 --- /dev/null +++ b/app/Views/workspace/create.php @@ -0,0 +1,72 @@ +extend('layouts/main') ?> + +section('content') ?> +
+
+

Create Workspace

+

Set up a new isolated data environment for your team.

+
+ Cancel +
+ +
+ getFlashdata('errors')): ?> +
+
    + getFlashdata('errors') as $error): ?> +
  • + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancel +
+
+
+
+
+
+endSection() ?> diff --git a/app/Views/workspace/index.php b/app/Views/workspace/index.php new file mode 100644 index 0000000..03426ab --- /dev/null +++ b/app/Views/workspace/index.php @@ -0,0 +1,66 @@ +extend('layouts/main') ?> + +section('content') ?> +
+
+

Workspaces

+

Organize your data assets, dashboards, and team access across isolated workspace environments.

+
+ +  New Workspace +
+ +
+getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ +getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
Total Workspaces
+
Total Members
+
Data Sources
+
Dashboards
+
+ +
+
+ search + +
+ + + +
+ +
+ +
+
+
+
+
+ account_tree +
+ + + +
+

+
chartboard.io/
+

+ +
+
+ +
+
+endSection() ?> diff --git a/app/Views/workspace/invite.php b/app/Views/workspace/invite.php new file mode 100644 index 0000000..ea6899e --- /dev/null +++ b/app/Views/workspace/invite.php @@ -0,0 +1,89 @@ +extend('layouts/main') ?> + +section('content') ?> +
+
+

Workspace Invitations

+

Send invites and manage pending links.

+
+ Back to Members +
+ +
+getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ +getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+

Send Invitation

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+

Pending Invitations

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
EmailRoleExpiresActions
No pending invites.
+
+
+ + +
+
+ + +
+
+
+
+
+
+endSection() ?> diff --git a/app/Views/workspace/members.php b/app/Views/workspace/members.php new file mode 100644 index 0000000..7c4723c --- /dev/null +++ b/app/Views/workspace/members.php @@ -0,0 +1,73 @@ +extend('layouts/main') ?> + +section('content') ?> +
+
+

Workspace Members

+

Roles and access for people in this workspace.

+
+ +
+ +
+getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ +getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRoleActions
No members yet. Invite someone to collaborate.
+
+
+
+ + + +
+
+
+
+ + +
+
+
+
+
+
+endSection() ?> diff --git a/app/Views/workspace/settings.php b/app/Views/workspace/settings.php new file mode 100644 index 0000000..c60d6ca --- /dev/null +++ b/app/Views/workspace/settings.php @@ -0,0 +1,92 @@ +extend('layouts/main') ?> + +section('content') ?> +
+
+

Workspace Settings

+

Name, slug, defaults, and status for this workspace.

+
+ Back +
+ +
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + getFlashdata('errors')): ?> +
+
    + getFlashdata('errors') as $error): ?> +
  • + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+ + Manage Members + Invitations +
+
+
+
+
+ +
+

Danger Zone

+

Only the workspace owner can delete this workspace.

+
+ + +
+
+
+endSection() ?> diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..69df4e1 --- /dev/null +++ b/app/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/builds b/builds new file mode 100755 index 0000000..cc2ca08 --- /dev/null +++ b/builds @@ -0,0 +1,125 @@ +#!/usr/bin/env php + 'vcs', + 'url' => GITHUB_URL, + ]; + } + + $array['require']['codeigniter4/codeigniter4'] = 'dev-develop'; + unset($array['require']['codeigniter4/framework']); + } else { + unset($array['minimum-stability']); + + if (isset($array['repositories'])) { + foreach ($array['repositories'] as $i => $repository) { + if ($repository['url'] === GITHUB_URL) { + unset($array['repositories'][$i]); + break; + } + } + + if (empty($array['repositories'])) { + unset($array['repositories']); + } + } + + $array['require']['codeigniter4/framework'] = LATEST_RELEASE; + unset($array['require']['codeigniter4/codeigniter4']); + } + + file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $modified[] = $file; + } else { + echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL; + } + } else { + echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL; + } +} + +$files = [ + __DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml.dist', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml', +]; + +foreach ($files as $file) { + if (is_file($file)) { + $contents = file_get_contents($file); + + if ($dev) { + $contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents); + } else { + $contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents); + } + + file_put_contents($file, $contents); + + $modified[] = $file; + } +} + +if ($modified === []) { + echo 'No files modified.' . PHP_EOL; +} else { + echo 'The following files were modified:' . PHP_EOL; + + foreach ($modified as $file) { + echo " * {$file}" . PHP_EOL; + } + + echo 'Run `composer update` to sync changes with your vendor folder.' . PHP_EOL; +} diff --git a/chartboard.sql b/chartboard.sql new file mode 100644 index 0000000..ad88174 --- /dev/null +++ b/chartboard.sql @@ -0,0 +1,624 @@ +-- ============================================================ +-- Chart-Board β€” MySQL Database Schema +-- Version : 1.0.0 +-- Engine : InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; +SET SQL_MODE = 'NO_ENGINE_SUBSTITUTION'; + +CREATE DATABASE IF NOT EXISTS `chartboard` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE `chartboard`; + + +-- ============================================================ +-- TABLE: users +-- Stores all registered user accounts +-- ============================================================ +CREATE TABLE `users` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(150) NOT NULL, + `email` VARCHAR(255) NOT NULL UNIQUE, + `password` VARCHAR(255) NOT NULL COMMENT 'bcrypt hashed', + `avatar` VARCHAR(500) NULL DEFAULT NULL COMMENT 'URL or file path', + `role` ENUM('superadmin','user') NOT NULL DEFAULT 'user', + `email_verified` TINYINT(1) NOT NULL DEFAULT 0, + `verify_token` VARCHAR(255) NULL DEFAULT NULL, + `reset_token` VARCHAR(255) NULL DEFAULT NULL, + `reset_token_expiry` DATETIME NULL DEFAULT NULL, + `api_token` VARCHAR(255) NULL DEFAULT NULL UNIQUE COMMENT 'Personal API token', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `theme_preference` VARCHAR(20) NOT NULL DEFAULT 'light' COMMENT 'UI theme: light, dark, system', + `last_login_at` DATETIME NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL COMMENT 'Soft delete', + PRIMARY KEY (`id`), + INDEX `idx_users_email` (`email`), + INDEX `idx_users_api_token` (`api_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='All registered Chart-Board users'; + + +-- ============================================================ +-- TABLE: workspaces +-- Top-level container for data sources, charts, dashboards +-- ============================================================ +CREATE TABLE `workspaces` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(150) NOT NULL, + `slug` VARCHAR(160) NOT NULL UNIQUE COMMENT 'URL-friendly identifier', + `description` TEXT NULL DEFAULT NULL, + `logo` VARCHAR(500) NULL DEFAULT NULL, + `timezone` VARCHAR(80) NOT NULL DEFAULT 'UTC', + `default_refresh` INT UNSIGNED NOT NULL DEFAULT 300 COMMENT 'Default refresh in seconds', + `owner_id` INT UNSIGNED NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_workspaces_owner` (`owner_id`), + CONSTRAINT `fk_workspaces_owner` + FOREIGN KEY (`owner_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Workspace β€” team or project level grouping'; + + +-- ============================================================ +-- TABLE: workspace_members +-- Many-to-many: users ↔ workspaces with role +-- ============================================================ +CREATE TABLE `workspace_members` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `role` ENUM('admin','editor','viewer') NOT NULL DEFAULT 'viewer', + `invited_by` INT UNSIGNED NULL DEFAULT NULL, + `joined_at` DATETIME NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_workspace_member` (`workspace_id`, `user_id`), + CONSTRAINT `fk_wm_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_wm_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_wm_invited_by` + FOREIGN KEY (`invited_by`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Workspace membership and roles'; + + +-- ============================================================ +-- TABLE: workspace_invitations +-- Pending email invitations to join a workspace +-- ============================================================ +CREATE TABLE `workspace_invitations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `invited_by` INT UNSIGNED NOT NULL, + `email` VARCHAR(255) NOT NULL, + `role` ENUM('admin','editor','viewer') NOT NULL DEFAULT 'viewer', + `token` VARCHAR(255) NOT NULL UNIQUE, + `accepted` TINYINT(1) NOT NULL DEFAULT 0, + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_invitations_token` (`token`), + CONSTRAINT `fk_inv_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_inv_invited_by` + FOREIGN KEY (`invited_by`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Pending workspace invitations via email'; + + +-- ============================================================ +-- TABLE: data_sources +-- Connection configurations for databases, APIs, CSV files +-- ============================================================ +CREATE TABLE `data_sources` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(150) NOT NULL, + `type` ENUM('mysql','postgresql','mongodb','rest_api','csv') NOT NULL, + `host` VARCHAR(255) NULL DEFAULT NULL, + `port` SMALLINT UNSIGNED NULL DEFAULT NULL, + `database_name` VARCHAR(150) NULL DEFAULT NULL, + `username` VARCHAR(150) NULL DEFAULT NULL, + `password` TEXT NULL DEFAULT NULL COMMENT 'AES-256 encrypted', + `connection_uri` TEXT NULL DEFAULT NULL COMMENT 'For MongoDB or DSN-style', + `ssl_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `ssl_ca` TEXT NULL DEFAULT NULL, + -- REST API fields + `api_base_url` VARCHAR(500) NULL DEFAULT NULL, + `api_method` ENUM('GET','POST') NULL DEFAULT 'GET', + `api_auth_type` ENUM('none','bearer','basic','api_key') NULL DEFAULT 'none', + `api_auth_value` TEXT NULL DEFAULT NULL COMMENT 'Encrypted token/password', + `api_headers` JSON NULL DEFAULT NULL COMMENT 'Static headers as key-value JSON', + -- CSV fields + `csv_file_path` VARCHAR(500) NULL DEFAULT NULL, + `csv_delimiter` CHAR(1) NULL DEFAULT ',', + -- Status + `last_tested_at` DATETIME NULL DEFAULT NULL, + `status` ENUM('untested','connected','failed') NOT NULL DEFAULT 'untested', + `error_message` TEXT NULL DEFAULT NULL, + `created_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_ds_workspace` (`workspace_id`), + CONSTRAINT `fk_ds_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ds_created_by` + FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Data source connection configurations'; + + +-- ============================================================ +-- TABLE: saved_queries +-- Reusable queries (SQL or API-mapped) per data source +-- ============================================================ +CREATE TABLE `saved_queries` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `data_source_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(150) NOT NULL, + `description` TEXT NULL DEFAULT NULL, + `query_type` ENUM('visual','raw_sql','api') NOT NULL DEFAULT 'raw_sql', + `raw_sql` TEXT NULL DEFAULT NULL, + `visual_config` JSON NULL DEFAULT NULL COMMENT 'Visual builder settings (table, filters, columns)', + `api_endpoint` VARCHAR(500) NULL DEFAULT NULL COMMENT 'Override base URL for specific query', + `api_params` JSON NULL DEFAULT NULL COMMENT 'Query params / body', + `response_path` VARCHAR(255) NULL DEFAULT NULL COMMENT 'JSON path to data array e.g. data.results', + `field_map` JSON NULL DEFAULT NULL COMMENT 'Map response fields to aliases', + `cache_ttl` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Cache result for N seconds (0 = no cache)', + `created_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_sq_workspace` (`workspace_id`), + INDEX `idx_sq_datasource` (`data_source_id`), + CONSTRAINT `fk_sq_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sq_datasource` + FOREIGN KEY (`data_source_id`) REFERENCES `data_sources` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sq_created_by` + FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Reusable saved queries for charts'; + + +-- ============================================================ +-- TABLE: query_variables +-- Variable definitions detected/configured per saved query +-- ============================================================ +CREATE TABLE `query_variables` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `saved_query_id` INT UNSIGNED NOT NULL, + `workspace_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL COMMENT 'Variable token name without braces', + `label` VARCHAR(120) NULL DEFAULT NULL COMMENT 'Display label for form widgets', + `type` ENUM('text','number','date','date_range','select','multi_select') NOT NULL DEFAULT 'text', + `default_value` TEXT NULL DEFAULT NULL, + `options_json` JSON NULL DEFAULT NULL COMMENT 'Option list for select/multi_select', + `is_required` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_qv_query_name` (`saved_query_id`, `name`), + INDEX `idx_qv_workspace` (`workspace_id`), + INDEX `idx_qv_query` (`saved_query_id`), + CONSTRAINT `fk_qv_saved_query` + FOREIGN KEY (`saved_query_id`) REFERENCES `saved_queries` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_qv_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Variable definitions for parameterized saved queries'; + + +-- ============================================================ +-- TABLE: charts +-- Individual chart definitions +-- ============================================================ +CREATE TABLE `charts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `data_source_id` INT UNSIGNED NOT NULL, + `saved_query_id` INT UNSIGNED NULL DEFAULT NULL, + `name` VARCHAR(200) NOT NULL, + `description` TEXT NULL DEFAULT NULL, + `chart_type` ENUM( + 'bar','line','area','pie','donut', + 'scatter','table','kpi_card','funnel', + 'gauge','heatmap','combo', + 'spline','stepline','radar','bubble','polar_area' + ) NOT NULL, + `query_type` ENUM('visual','raw_sql','api') NOT NULL DEFAULT 'raw_sql', + `raw_sql` TEXT NULL DEFAULT NULL, + `visual_config` JSON NULL DEFAULT NULL, + `api_endpoint` VARCHAR(500) NULL DEFAULT NULL, + `api_params` JSON NULL DEFAULT NULL, + `response_path` VARCHAR(255) NULL DEFAULT NULL, + `field_map` JSON NULL DEFAULT NULL, + -- Axis & field mapping + `x_field` VARCHAR(150) NULL DEFAULT NULL, + `y_field` VARCHAR(150) NULL DEFAULT NULL, + `group_field` VARCHAR(150) NULL DEFAULT NULL, + `value_field` VARCHAR(150) NULL DEFAULT NULL COMMENT 'Used for KPI/Gauge', + -- Display settings + `display_config` JSON NULL DEFAULT NULL COMMENT 'Colors, labels, legend, formatting options', + `refresh_interval` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Auto-refresh in seconds (0 = manual)', + `cache_ttl` INT UNSIGNED NOT NULL DEFAULT 300 COMMENT 'Cache result TTL in seconds', + `is_public` TINYINT(1) NOT NULL DEFAULT 0, + `public_token` VARCHAR(100) NULL DEFAULT NULL UNIQUE, + `created_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_charts_workspace` (`workspace_id`), + INDEX `idx_charts_datasource` (`data_source_id`), + INDEX `idx_charts_public` (`public_token`), + CONSTRAINT `fk_charts_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_charts_datasource` + FOREIGN KEY (`data_source_id`) REFERENCES `data_sources` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_charts_query` + FOREIGN KEY (`saved_query_id`) REFERENCES `saved_queries` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_charts_created_by` + FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Chart definitions with query and display configuration'; + + +-- ============================================================ +-- TABLE: dashboards +-- Dashboard containers that hold multiple charts +-- ============================================================ +CREATE TABLE `dashboards` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(200) NOT NULL, + `description` TEXT NULL DEFAULT NULL, + `slug` VARCHAR(220) NOT NULL, + `layout_config` JSON NULL DEFAULT NULL COMMENT 'Grid layout positions for all widgets', + `filters_config` JSON NULL DEFAULT NULL COMMENT 'Global filter widget definitions', + `refresh_interval` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Dashboard-level refresh (0 = manual)', + `theme` ENUM('light','dark','system') NOT NULL DEFAULT 'system', + `is_public` TINYINT(1) NOT NULL DEFAULT 0, + `public_token` VARCHAR(100) NULL DEFAULT NULL UNIQUE, + `public_password` VARCHAR(255) NULL DEFAULT NULL COMMENT 'Optional bcrypt hashed password for public link', + `public_expires_at` DATETIME NULL DEFAULT NULL, + `is_pinned` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Pinned dashboards appear first', + `created_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dashboard_slug` (`workspace_id`, `slug`), + INDEX `idx_dashboards_workspace` (`workspace_id`), + INDEX `idx_dashboards_public` (`public_token`), + CONSTRAINT `fk_dashboards_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dashboards_created_by` + FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Dashboard containers'; + + +-- ============================================================ +-- TABLE: dashboard_widgets +-- Each widget on a dashboard (chart, text, image, filter) +-- ============================================================ +CREATE TABLE `dashboard_widgets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `dashboard_id` INT UNSIGNED NOT NULL, + `chart_id` INT UNSIGNED NULL DEFAULT NULL COMMENT 'NULL for non-chart widgets', + `widget_type` ENUM('chart','text','image','filter_date','filter_dropdown') NOT NULL DEFAULT 'chart', + `title` VARCHAR(200) NULL DEFAULT NULL COMMENT 'Widget-level title override', + -- Grid position (react-grid-layout style) + `grid_x` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `grid_y` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `grid_w` TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT 'Width in grid units', + `grid_h` TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT 'Height in grid units', + -- Content for non-chart widgets + `content` TEXT NULL DEFAULT NULL COMMENT 'Markdown for text widgets, URL for image widgets', + `widget_config` JSON NULL DEFAULT NULL COMMENT 'Extra config (filter field bindings, image fit, etc.)', + `sort_order` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_dw_dashboard` (`dashboard_id`), + INDEX `idx_dw_chart` (`chart_id`), + CONSTRAINT `fk_dw_dashboard` + FOREIGN KEY (`dashboard_id`) REFERENCES `dashboards` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dw_chart` + FOREIGN KEY (`chart_id`) REFERENCES `charts` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Widgets placed on a dashboard'; + + +-- ============================================================ +-- TABLE: alerts +-- Threshold-based alert definitions on chart metrics +-- ============================================================ +CREATE TABLE `alerts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `chart_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(200) NOT NULL, + `metric_field` VARCHAR(150) NOT NULL COMMENT 'Which data field to watch', + `condition` ENUM('gt','lt','eq','gte','lte') NOT NULL COMMENT 'greater than, less than, etc.', + `threshold` DECIMAL(20,4) NOT NULL, + `check_interval` SMALLINT UNSIGNED NOT NULL DEFAULT 15 COMMENT 'Check every N minutes', + -- Notification + `notify_email` TINYINT(1) NOT NULL DEFAULT 0, + `email_addresses` TEXT NULL DEFAULT NULL COMMENT 'Comma-separated list of emails', + `notify_slack` TINYINT(1) NOT NULL DEFAULT 0, + `slack_webhook` VARCHAR(500) NULL DEFAULT NULL, + -- State + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `is_muted_until` DATETIME NULL DEFAULT NULL, + `last_checked_at` DATETIME NULL DEFAULT NULL, + `last_triggered_at` DATETIME NULL DEFAULT NULL, + `created_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_alerts_workspace` (`workspace_id`), + INDEX `idx_alerts_chart` (`chart_id`), + CONSTRAINT `fk_alerts_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_alerts_chart` + FOREIGN KEY (`chart_id`) REFERENCES `charts` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_alerts_created_by` + FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Alert rules defined on chart metrics'; + + +-- ============================================================ +-- TABLE: alert_history +-- Log of every time an alert was triggered +-- ============================================================ +CREATE TABLE `alert_history` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `alert_id` INT UNSIGNED NOT NULL, + `triggered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `metric_value` DECIMAL(20,4) NOT NULL COMMENT 'Value that triggered the alert', + `channels_notified` JSON NULL DEFAULT NULL COMMENT 'Which channels were notified', + `status` ENUM('sent','failed','muted') NOT NULL DEFAULT 'sent', + `error_message` TEXT NULL DEFAULT NULL, + PRIMARY KEY (`id`), + INDEX `idx_ah_alert` (`alert_id`), + CONSTRAINT `fk_ah_alert` + FOREIGN KEY (`alert_id`) REFERENCES `alerts` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='History of triggered alert events'; + + +-- ============================================================ +-- TABLE: chart_exports +-- Log of chart/dashboard exports and downloads +-- ============================================================ +CREATE TABLE `chart_exports` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `chart_id` INT UNSIGNED NULL DEFAULT NULL, + `dashboard_id` INT UNSIGNED NULL DEFAULT NULL, + `export_type` ENUM('png','svg','csv','excel','pdf') NOT NULL, + `file_path` VARCHAR(500) NULL DEFAULT NULL, + `file_size` INT UNSIGNED NULL DEFAULT NULL COMMENT 'File size in bytes', + `exported_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_ce_workspace` (`workspace_id`), + CONSTRAINT `fk_ce_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ce_exported_by` + FOREIGN KEY (`exported_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Log of chart and dashboard export requests'; + + +-- ============================================================ +-- TABLE: shared_links +-- Public share links for dashboards and charts +-- ============================================================ +CREATE TABLE `shared_links` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `token` VARCHAR(100) NOT NULL UNIQUE, + `type` ENUM('dashboard','chart') NOT NULL, + `resource_id` INT UNSIGNED NOT NULL, + `workspace_id` INT UNSIGNED NOT NULL, + `password_hash` VARCHAR(255) NULL DEFAULT NULL, + `expires_at` DATETIME NULL DEFAULT NULL, + `view_count` INT UNSIGNED NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_by` INT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_sl_token` (`token`), + CONSTRAINT `fk_sl_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sl_created_by` + FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Public sharing links for dashboards and charts'; + + +-- ============================================================ +-- TABLE: audit_logs +-- Immutable record of significant user actions +-- ============================================================ +CREATE TABLE `audit_logs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NULL DEFAULT NULL COMMENT 'NULL for super-admin actions', + `user_id` INT UNSIGNED NULL DEFAULT NULL COMMENT 'NULL for system actions', + `action` VARCHAR(100) NOT NULL COMMENT 'e.g. dashboard.created, user.login', + `resource_type` VARCHAR(50) NULL DEFAULT NULL COMMENT 'e.g. dashboard, chart, data_source', + `resource_id` INT UNSIGNED NULL DEFAULT NULL, + `old_value` JSON NULL DEFAULT NULL COMMENT 'State before the action', + `new_value` JSON NULL DEFAULT NULL COMMENT 'State after the action', + `ip_address` VARCHAR(45) NULL DEFAULT NULL COMMENT 'IPv4 or IPv6', + `user_agent` VARCHAR(500) NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_al_workspace` (`workspace_id`), + INDEX `idx_al_user` (`user_id`), + INDEX `idx_al_action` (`action`), + INDEX `idx_al_created_at` (`created_at`), + CONSTRAINT `fk_al_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_al_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Immutable audit trail of all user actions'; + + +-- ============================================================ +-- TABLE: user_sessions +-- Tracks active login sessions (for multi-device management) +-- ============================================================ +CREATE TABLE `user_sessions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `session_token` VARCHAR(255) NOT NULL UNIQUE, + `ip_address` VARCHAR(45) NULL DEFAULT NULL, + `user_agent` VARCHAR(500) NULL DEFAULT NULL, + `last_active` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_us_user` (`user_id`), + INDEX `idx_us_token` (`session_token`), + CONSTRAINT `fk_us_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Active user login sessions'; + + +-- ============================================================ +-- TABLE: tags +-- Tagging system for charts and dashboards +-- ============================================================ +CREATE TABLE `tags` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(80) NOT NULL, + `color` VARCHAR(7) NOT NULL DEFAULT '#6366f1' COMMENT 'Hex color code', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_tag_name` (`workspace_id`, `name`), + CONSTRAINT `fk_tags_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Tags for organizing charts and dashboards'; + + +-- ============================================================ +-- TABLE: taggables +-- Polymorphic tag assignments (chart or dashboard) +-- ============================================================ +CREATE TABLE `taggables` ( + `tag_id` INT UNSIGNED NOT NULL, + `taggable_id` INT UNSIGNED NOT NULL, + `taggable_type` ENUM('chart','dashboard') NOT NULL, + PRIMARY KEY (`tag_id`, `taggable_id`, `taggable_type`), + CONSTRAINT `fk_taggables_tag` + FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Polymorphic join table for tagging'; + + +-- ============================================================ +-- TABLE: query_cache +-- Stores cached query results to reduce DB load +-- ============================================================ +CREATE TABLE `query_cache` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `cache_key` VARCHAR(255) NOT NULL UNIQUE COMMENT 'MD5 hash of datasource+query', + `data_source_id` INT UNSIGNED NOT NULL, + `result_data` LONGTEXT NOT NULL COMMENT 'JSON encoded query result', + `row_count` INT UNSIGNED NOT NULL DEFAULT 0, + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_qc_cache_key` (`cache_key`), + INDEX `idx_qc_expires_at` (`expires_at`), + CONSTRAINT `fk_qc_datasource` + FOREIGN KEY (`data_source_id`) REFERENCES `data_sources` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Cached query results with TTL'; + + +-- ============================================================ +-- TABLE: settings +-- Key-value application settings (per workspace or global) +-- ============================================================ +CREATE TABLE `settings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `workspace_id` INT UNSIGNED NULL DEFAULT NULL COMMENT 'NULL = global/system setting', + `key` VARCHAR(150) NOT NULL, + `value` TEXT NULL DEFAULT NULL, + `type` ENUM('string','integer','boolean','json') NOT NULL DEFAULT 'string', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_setting` (`workspace_id`, `key`), + CONSTRAINT `fk_settings_workspace` + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Key-value settings store'; + + +-- ============================================================ +-- INITIAL SEED DATA +-- ============================================================ + +-- Default Super Admin +INSERT INTO `users` (`name`, `email`, `password`, `role`, `email_verified`, `is_active`) +VALUES ( + 'Super Admin', + 'admin@chartboard.local', + '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- Admin@1234 + 'superadmin', + 1, + 1 +); + +-- Default Workspace +INSERT INTO `workspaces` (`name`, `slug`, `description`, `owner_id`) +VALUES ('Default Workspace', 'default-workspace', 'The default Chart-Board workspace', 1); + +-- Add super admin as workspace admin +INSERT INTO `workspace_members` (`workspace_id`, `user_id`, `role`, `joined_at`) +VALUES (1, 1, 'admin', NOW()); + +-- Global Settings +INSERT INTO `settings` (`workspace_id`, `key`, `value`, `type`) VALUES +(NULL, 'app_name', 'Chart-Board', 'string'), +(NULL, 'app_version', '1.0.0', 'string'), +(NULL, 'allow_registration', '1', 'boolean'), +(NULL, 'max_workspaces', '10', 'integer'), +(NULL, 'default_theme', 'light', 'string'), +(NULL, 'smtp_configured', '0', 'boolean'); + + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- End of chart-board schema +-- ============================================================ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a608342 --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "codeigniter4/appstarter", + "description": "CodeIgniter4 starter app", + "license": "MIT", + "type": "project", + "homepage": "https://codeigniter.com", + "support": { + "forum": "https://forum.codeigniter.com/", + "source": "https://github.com/codeigniter4/CodeIgniter4", + "slack": "https://codeigniterchat.slack.com" + }, + "require": { + "php": "^8.1", + "codeigniter4/framework": "^4.0", + "phpoffice/phpspreadsheet": "^5.5" + }, + "require-dev": { + "fakerphp/faker": "^1.9", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": "^10.5.16" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Config\\": "app/Config/" + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\Support\\": "tests/_support" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..01378e6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2534 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "33df5fc713c9a9454c5380111d6aac0d", + "packages": [ + { + "name": "codeigniter4/framework", + "version": "v4.6.5", + "source": { + "type": "git", + "url": "https://github.com/codeigniter4/framework.git", + "reference": "116e0919590a412c09d2b9e4f6b8addda18224d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/codeigniter4/framework/zipball/116e0919590a412c09d2b9e4f6b8addda18224d8", + "reference": "116e0919590a412c09d2b9e4f6b8addda18224d8", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "ext-mbstring": "*", + "laminas/laminas-escaper": "^2.17", + "php": "^8.1", + "psr/log": "^3.0" + }, + "require-dev": { + "codeigniter/coding-standard": "^1.7", + "fakerphp/faker": "^1.24", + "friendsofphp/php-cs-fixer": "^3.47.1", + "kint-php/kint": "^6.1", + "mikey179/vfsstream": "^1.6.12", + "nexusphp/cs-config": "^3.6", + "phpunit/phpunit": "^10.5.16 || ^11.2", + "predis/predis": "^3.0" + }, + "suggest": { + "ext-curl": "If you use CURLRequest class", + "ext-dom": "If you use TestResponse", + "ext-exif": "If you run Image class tests", + "ext-fileinfo": "Improves mime type detection for files", + "ext-gd": "If you use Image class GDHandler", + "ext-imagick": "If you use Image class ImageMagickHandler", + "ext-libxml": "If you use TestResponse", + "ext-memcache": "If you use Cache class MemcachedHandler with Memcache", + "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", + "ext-mysqli": "If you use MySQL", + "ext-oci8": "If you use Oracle Database", + "ext-pgsql": "If you use PostgreSQL", + "ext-readline": "Improves CLI::input() usability", + "ext-redis": "If you use Cache class RedisHandler", + "ext-simplexml": "If you format XML", + "ext-sodium": "If you use Encryption SodiumHandler", + "ext-sqlite3": "If you use SQLite3", + "ext-sqlsrv": "If you use SQL Server", + "ext-xdebug": "If you use CIUnitTestCase::assertHeaderEmitted()" + }, + "type": "project", + "autoload": { + "psr-4": { + "CodeIgniter\\": "system/" + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The CodeIgniter framework v4", + "homepage": "https://codeigniter.com", + "support": { + "forum": "https://forum.codeigniter.com/", + "slack": "https://codeigniterchat.slack.com", + "source": "https://github.com/codeigniter4/CodeIgniter4" + }, + "time": "2026-02-01T17:59:34+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.17.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/df1ef9503299a8e3920079a16263b578eaf7c3ba", + "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.29.8", + "laminas/laminas-coding-standard": "~3.0.1", + "phpunit/phpunit": "^10.5.45", + "psalm/plugin-phpunit": "^0.19.2", + "vimeo/psalm": "^6.6.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-05-06T19:29:36+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.1" + }, + "require-dev": { + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan MΓ€nnchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "AndrΓ‘s KolesΓ‘r", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2024-10-10T12:33:01+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba", + "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0" + }, + "time": "2026-03-01T00:58:56+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "FranΓ§ois Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "mikey179/vfsstream", + "version": "v1.6.12", + "source": { + "type": "git", + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5||^8.5||^9.6", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" + } + ], + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "support": { + "issues": "https://github.com/bovigo/vfsStream/issues", + "source": "https://github.com/bovigo/vfsStream/tree/master", + "wiki": "https://github.com/bovigo/vfsStream/wiki" + }, + "time": "2024-08-29T18:43:31+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.2.0" +} diff --git a/env b/env new file mode 100644 index 0000000..f359ec2 --- /dev/null +++ b/env @@ -0,0 +1,69 @@ +#-------------------------------------------------------------------- +# Example Environment Configuration file +# +# This file can be used as a starting point for your own +# custom .env files, and contains most of the possible settings +# available in a default install. +# +# By default, all of the settings are commented out. If you want +# to override the setting, you must un-comment it by removing the '#' +# at the beginning of the line. +#-------------------------------------------------------------------- + +#-------------------------------------------------------------------- +# ENVIRONMENT +#-------------------------------------------------------------------- + +# CI_ENVIRONMENT = production + +#-------------------------------------------------------------------- +# APP +#-------------------------------------------------------------------- + +# app.baseURL = '' +# If you have trouble with `.`, you could also use `_`. +# app_baseURL = '' +# app.forceGlobalSecureRequests = false +# app.CSPEnabled = false + +#-------------------------------------------------------------------- +# DATABASE +#-------------------------------------------------------------------- + +# database.default.hostname = localhost +# database.default.database = ci4 +# database.default.username = root +# database.default.password = root +# database.default.DBDriver = MySQLi +# database.default.DBPrefix = +# database.default.port = 3306 + +# If you use MySQLi as tests, first update the values of Config\Database::$tests. +# database.tests.hostname = localhost +# database.tests.database = ci4_test +# database.tests.username = root +# database.tests.password = root +# database.tests.DBDriver = MySQLi +# database.tests.DBPrefix = +# database.tests.charset = utf8mb4 +# database.tests.DBCollat = utf8mb4_general_ci +# database.tests.port = 3306 + +#-------------------------------------------------------------------- +# ENCRYPTION +#-------------------------------------------------------------------- + +# encryption.key = + +#-------------------------------------------------------------------- +# SESSION +#-------------------------------------------------------------------- + +# session.driver = 'CodeIgniter\Session\Handlers\FileHandler' +# session.savePath = null + +#-------------------------------------------------------------------- +# LOGGER +#-------------------------------------------------------------------- + +# logger.threshold = 4 diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..b408a99 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,63 @@ + + + + + + + + + + + + + ./tests + + + + + + + + + + ./app + + + ./app/Views + ./app/Config/Routes.php + + + + + + + + + + + + + + + diff --git a/preload.php b/preload.php new file mode 100644 index 0000000..288b30b --- /dev/null +++ b/preload.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Boot; +use Config\Paths; + +/* + *--------------------------------------------------------------- + * Sample file for Preloading + *--------------------------------------------------------------- + * See https://www.php.net/manual/en/opcache.preloading.php + * + * How to Use: + * 0. Copy this file to your project root folder. + * 1. Set the $paths property of the preload class below. + * 2. Set opcache.preload in php.ini. + * php.ini: + * opcache.preload=/path/to/preload.php + */ + +// Load the paths config file +require __DIR__ . '/app/Config/Paths.php'; + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +class preload +{ + /** + * @var array Paths to preload. + */ + private array $paths = [ + [ + 'include' => __DIR__ . '/vendor/codeigniter4/framework/system', // Change this path if using manual installation + 'exclude' => [ + // Not needed if you don't use them. + '/system/Database/OCI8/', + '/system/Database/Postgre/', + '/system/Database/SQLite3/', + '/system/Database/SQLSRV/', + // Not needed for web apps. + '/system/Database/Seeder.php', + '/system/Test/', + '/system/CLI/', + '/system/Commands/', + '/system/Publisher/', + '/system/ComposerScripts.php', + // Not Class/Function files. + '/system/Config/Routes.php', + '/system/Language/', + '/system/bootstrap.php', + '/system/util_bootstrap.php', + '/system/rewrite.php', + '/Views/', + // Errors occur. + '/system/ThirdParty/', + ], + ], + ]; + + public function __construct() + { + $this->loadAutoloader(); + } + + private function loadAutoloader(): void + { + $paths = new Paths(); + require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'Boot.php'; + + Boot::preload($paths); + } + + /** + * Load PHP files. + */ + public function load(): void + { + foreach ($this->paths as $path) { + $directory = new RecursiveDirectoryIterator($path['include']); + $fullTree = new RecursiveIteratorIterator($directory); + $phpFiles = new RegexIterator( + $fullTree, + '/.+((? $file) { + foreach ($path['exclude'] as $exclude) { + if (str_contains($file[0], $exclude)) { + continue 2; + } + } + + require_once $file[0]; + // Uncomment only for debugging (to inspect which files are included). + // Never use this in production - preload scripts must not generate output. + // echo 'Loaded: ' . $file[0] . "\n"; + } + } + } +} + +(new preload())->load(); diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..abac3cb --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,49 @@ +# Disable directory browsing +Options -Indexes + +# ---------------------------------------------------------------------- +# Rewrite engine +# ---------------------------------------------------------------------- + +# Turning on the rewrite engine is necessary for the following rules and features. +# FollowSymLinks must be enabled for this to work. + + Options +FollowSymlinks + RewriteEngine On + + # If you installed CodeIgniter in a subfolder, you will need to + # change the following line to match the subfolder you need. + # http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase + # RewriteBase / + + # Redirect Trailing Slashes... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Rewrite "www.example.com -> example.com" + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] + + # Checks to see if the user is attempting to access a valid file, + # such as an image or css document, if this isn't true it sends the + # request to the front controller, index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA] + + # Ensure Authorization header is passed along + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + + + # If we don't have mod_rewrite installed, all 404's + # can be sent to index.php, and everything works as normal. + ErrorDocument 404 index.php + + +# Disable server signature start +ServerSignature Off +# Disable server signature end diff --git a/public/assets/css/chart-builder.css b/public/assets/css/chart-builder.css new file mode 100644 index 0000000..98bb846 --- /dev/null +++ b/public/assets/css/chart-builder.css @@ -0,0 +1,218 @@ +/* Chart builder β€” Phase 6 (aligned with Chart-Board tokens) */ +:root { + --cb-cb-surface: #f8fafc; + --cb-cb-elevated: #ffffff; + --cb-cb-muted: #64748b; + --cb-cb-border: rgba(148, 163, 184, 0.35); + --cb-cb-ink: #0f172a; + --cb-cb-accent: #0f172a; +} + +.cb-chart-builder { + font-family: Inter, system-ui, sans-serif; + color: var(--cb-cb-ink); +} + +.cb-cb-stepbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem 0.25rem; + padding: 0.75rem 0 1.25rem; + border-bottom: 1px solid var(--cb-cb-border); + margin-bottom: 1.5rem; +} + +.cb-cb-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.85rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--cb-cb-muted); + background: #f1f5f9; + border: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.cb-cb-pill:hover { + color: var(--cb-cb-ink); + background: #e2e8f0; +} + +.cb-cb-pill.active { + background: var(--cb-cb-ink); + color: #fff; +} + +.cb-cb-pill.done { + background: #ecfdf5; + color: #047857; +} + +.cb-cb-pill-num { + width: 1.35rem; + height: 1.35rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + background: rgba(255, 255, 255, 0.25); +} + +.cb-cb-pill:not(.active) .cb-cb-pill-num { + background: #e2e8f0; + color: #475569; +} + +.cb-cb-pill.done .cb-cb-pill-num { + background: #a7f3d0; + color: #065f46; +} + +.cb-cb-connector { + width: 1.25rem; + height: 2px; + background: var(--cb-cb-border); + flex-shrink: 0; +} + +.cb-cb-card { + background: var(--cb-cb-elevated); + border-radius: 0.75rem; + border: 1px solid var(--cb-cb-border); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.cb-cb-card-h { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--cb-cb-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.cb-cb-card-b { + padding: 1.25rem; +} + +.cb-cb-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); + gap: 0.5rem; +} + +.cb-cb-type { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + padding: 0.75rem 0.35rem; + border-radius: 0.5rem; + border: 2px solid transparent; + background: #f8fafc; + cursor: pointer; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--cb-cb-muted); + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.cb-cb-type:hover { + background: #e0e7ff; + color: #3730a3; +} + +.cb-cb-type.selected { + border-color: var(--cb-cb-accent); + background: #0f172a; + color: #fff; +} + +.cb-cb-type-ico { + font-size: 1.25rem; + line-height: 1; +} + +.cb-cb-preview-host { + min-height: 320px; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + border-radius: 0.5rem; + border: 1px dashed var(--cb-cb-border); +} + +.cb-cb-hint { + display: flex; + gap: 0.75rem; + padding: 0.9rem 1rem; + border-radius: 0.5rem; + background: #0f172a; + color: #e2e8f0; + font-size: 0.8125rem; + line-height: 1.5; +} + +.cb-cb-fm-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 0.75rem; + align-items: center; + padding: 0.65rem 0; + border-bottom: 1px solid #f1f5f9; +} + +.cb-cb-fm-row:last-child { + border-bottom: none; +} + +.cb-cb-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; +} + +.cb-cb-kpi { + text-align: center; + padding: 1.25rem; + background: #f8fafc; + border-radius: 0.65rem; + border: 1px solid var(--cb-cb-border); +} + +.cb-cb-kpi-val { + font-size: 1.65rem; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--cb-cb-ink); +} + +.cb-cb-kpi-lbl { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--cb-cb-muted); + margin-top: 0.35rem; +} + +.cb-cb-color-swatch { + width: 2.25rem; + height: 2.25rem; + padding: 0.15rem; + border-radius: 0.35rem; + border: 1px solid var(--cb-cb-border); + cursor: pointer; +} + +@media (max-width: 768px) { + .cb-cb-fm-row { + grid-template-columns: 1fr; + } +} diff --git a/public/assets/css/dashboard.css b/public/assets/css/dashboard.css new file mode 100644 index 0000000..c6e5373 --- /dev/null +++ b/public/assets/css/dashboard.css @@ -0,0 +1,377 @@ +/* Dashboard module β€” list + builder shell */ +.cb-dash-btn-primary { + background: #fff; + color: #0f172a; + font-weight: 700; + border: none; + border-radius: 12px; + padding: 10px 18px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.cb-dash-btn-primary:hover { + background: #f1f5f9; + color: #0f172a; +} + +.cb-dash-section-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #9aa0b4; + margin-bottom: 14px; +} + +.cb-dash-empty { + background: #fff; + border-radius: 20px; + border: 1px dashed #cbd5e1; +} + +.cb-dash-empty-icon { + width: 72px; + height: 72px; + border-radius: 18px; + background: linear-gradient(145deg, #e0e7ff, #f8fafc); + display: flex; + align-items: center; + justify-content: center; + color: #4338ca; +} + +.cb-dash-empty-icon .material-symbols-outlined { + font-size: 36px; +} + +.cb-dash-card { + position: relative; + background: #fff; + border-radius: 16px; + border: 1px solid #e8ecf1; + overflow: hidden; + transition: box-shadow 0.2s ease, transform 0.2s ease; +} + +.cb-dash-card:hover { + box-shadow: 0 14px 40px rgba(15, 23, 42, 0.08); + transform: translateY(-2px); +} + +.cb-dash-card-accent { + height: 4px; + background: linear-gradient(90deg, #7c3aed, #ec4899); +} + +.cb-dash-card-body { + padding: 20px 20px 18px; +} + +.cb-dash-list-page .row { + --bs-gutter-x: 16px; + --bs-gutter-y: 16px; +} + +.cb-dash-card-body .btn-dark { + background: #1a1d23; + border-color: #1a1d23; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + padding: 6px 14px; +} + +.cb-dash-card-body .btn-outline-secondary { + border-color: #e2e4e9; + color: #5a6072; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + padding: 6px 14px; +} + +.cb-dash-card-title { + font-weight: 800; + font-size: 1.05rem; + color: #0f172a; + letter-spacing: -0.02em; +} + +.cb-dash-card-title:hover { + color: #4338ca; +} + +.text-truncate-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.cb-dash-badge { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + background: #eff4ff; + color: #1a56db; +} + +.cb-dash-badge-live { + background: #ecfdf5; + color: #059669; +} + +.cb-dash-badge-muted { + background: #f3f4f6; + color: #6b7280; +} + +.cb-dash-badge-shared { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; +} + +.cb-dash-meta-row { + gap: 8px !important; +} + +.cb-dash-modal { + border-radius: 16px; +} + +.cb-dash-pin { + color: #64748b; +} + +.cb-dash-pin:hover { + color: #4338ca; + background: #eef2ff !important; +} + +/* β€”β€” Builder canvas β€”β€” */ +.cb-dash-shell { + /* Bleed horizontally into page gutters only; keep top margin β‰₯0 so toolbar clears the fixed topbar. */ + margin-top: 0; + margin-bottom: 0; + margin-left: calc(-1 * var(--page-padding-x)); + margin-right: calc(-1 * var(--page-padding-x)); + padding: calc(var(--page-padding-top) + 10px) var(--page-padding-x) 32px; + min-height: calc(100vh - var(--topbar-height)); +} + +.cb-dash-shell.cb-theme-dark { + background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); + color: #e2e8f0; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-toolbar { + background: rgba(30, 41, 59, 0.85); + border-color: rgba(148, 163, 184, 0.2); +} + +.cb-dash-shell.cb-theme-dark .cb-dash-widget-card { + background: rgba(30, 41, 59, 0.9); + border-color: rgba(148, 163, 184, 0.15); + color: #e2e8f0; +} + +.cb-dash-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(10px); + border: 1px solid #e5e7eb; + border-radius: 16px; + margin-bottom: 18px; + box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06); +} + +.cb-dash-toolbar h1 { + font-size: 1.15rem; + font-weight: 800; + letter-spacing: -0.02em; + margin: 0; +} + +.cb-dash-toolbar .cb-dash-meta { + font-size: 12px; + color: #64748b; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-toolbar .cb-dash-meta { + color: #94a3b8; +} + +.cb-dash-grid-wrap { + min-height: 400px; +} + +/* Stretch widgets to the full GridStack cell so paired cards align visually */ +.cb-dash-grid-wrap .grid-stack-item > .grid-stack-item-content { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cb-dash-grid-wrap .cb-dash-widget-mount { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.grid-stack-item-content { + overflow: hidden; +} + +.cb-dash-widget-card { + height: 100%; + background: #fff; + border: 1px solid #e8ecf1; + border-radius: 14px; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05); + display: flex; + flex-direction: column; +} + +.cb-dash-widget-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid #f1f5f9; + font-size: 13px; + font-weight: 700; + color: #0f172a; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-widget-head { + border-bottom-color: rgba(148, 163, 184, 0.15); + color: #f1f5f9; +} + +.cb-dash-widget-body { + flex: 1 1 auto; + min-height: 0; + position: relative; + padding: 8px 10px 12px; + display: flex; + flex-direction: column; +} + +.cb-dash-widget-body .cb-dash-chart-inner { + flex: 1 1 auto; + min-height: 180px; + height: auto; + display: flex; + flex-direction: column; +} + +.cb-dash-widget-body .cb-dash-chart-inner > div[id^="cb-chart-"] { + flex: 1 1 auto; + min-height: 180px; + width: 100%; +} + +.cb-dash-widget-remove { + border: none; + background: transparent; + color: #94a3b8; + padding: 2px; + line-height: 1; + border-radius: 6px; +} + +.cb-dash-widget-remove:hover { + background: #fef2f2; + color: #dc2626; +} + +.cb-dash-edit-hint { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #6366f1; +} + +.cb-dash-prose { + font-size: 13px; + line-height: 1.55; + color: #334155; + padding: 4px 6px; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-prose { + color: #cbd5e1; +} + +.cb-dash-prose h1, .cb-dash-prose h2, .cb-dash-prose h3 { + font-size: 1rem; + font-weight: 800; + margin: 0.5em 0 0.25em; +} + +.cb-dash-img { + width: 100%; + height: 100%; + min-height: 140px; + object-fit: cover; + border-radius: 10px; +} + +.cb-dash-filter-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 6px; +} + +.cb-dash-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.65); + font-size: 12px; + font-weight: 600; + color: #64748b; + z-index: 2; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-loading { + background: rgba(15, 23, 42, 0.55); + color: #94a3b8; +} + +/* Fullscreen: toggled on body */ +body.cb-dash-fs .cb-topbar { + display: none !important; +} + +body.cb-dash-fs .cb-content { + margin-left: 0 !important; + padding-top: 0 !important; +} + +body.cb-dash-fs .cb-dash-shell { + min-height: 100vh; + margin: 0; + padding: 16px; +} + +.grid-stack.cb-dash-editing .grid-stack-item { + cursor: grab; +} diff --git a/public/assets/css/query-builder.css b/public/assets/css/query-builder.css new file mode 100644 index 0000000..1ba9e47 --- /dev/null +++ b/public/assets/css/query-builder.css @@ -0,0 +1,363 @@ +/* Query builder β€” edit/create (typography from .cb-page in variables.css) */ +.qb-root { + max-width: 1200px; + margin: 0 auto; +} + +.qb-text-link { + color: #64748b; + font-size: var(--cb-font-topnav, 14px); + font-weight: 600; + text-decoration: none; +} + +.qb-text-link:hover { + color: #0f172a; + text-decoration: underline; +} + +.qb-flash { + border-radius: 8px; + padding: 10px 14px; + font-size: var(--cb-font-topnav, 14px); + font-weight: 600; +} + +.qb-flash-success { + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #065f46; +} + +.qb-flash-icon { + font-size: 20px !important; + color: #059669; +} + +.qb-meta-panel .qb-label-caps, +.qb-label-caps { + display: block; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #94a3b8; + margin-bottom: 6px; +} + +.qb-label-caps-tiny { + display: block; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #94a3b8; + margin-bottom: 4px; +} + +.qb-vars-heading { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #64748b; +} + +.qb-add-from-sql { + font-size: 11px !important; + font-weight: 700 !important; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #2563eb !important; +} + +.qb-vars-column { + background: #fafbfc !important; + border-color: #e5e7eb !important; +} + +.qb-var-card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 14px; + margin-bottom: 12px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.qb-var-card:last-child { + margin-bottom: 0; +} + +.qb-var-title { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: var(--cb-font-topnav, 14px); + font-weight: 700; + color: #0f172a; +} + +.qb-var-details summary { + cursor: pointer; + list-style: none; +} + +.qb-var-details summary::-webkit-details-marker { + display: none; +} + +.qb-var-details summary::before { + content: "β–Έ "; + font-size: 10px; + color: #94a3b8; +} + +.qb-var-details[open] summary::before { + content: "β–Ύ "; +} + +/* Dark SQL IDE */ +.qb-sql-stack { + border-radius: 10px; + overflow: auto; + border: 1px solid #1e293b; + box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12); +} + +.qb-sql-ide-head { + background: #1e293b; + color: #94a3b8; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 8px 14px; + border-bottom: 1px solid #334155; +} + +.qb-sql-ide-body { + background: #0f172a; +} + +/* Light text on dark field; Bootstrap .form-control:focus sets body color (higher specificity) β€” keep contrast in all states */ +.cb-page textarea.form-control.qb-sql-textarea-dark, +.qb-sql-textarea-dark { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: var(--cb-font-topnav, 14px); + line-height: 1.55; + color: #e2e8f0 !important; + -webkit-text-fill-color: #e2e8f0; + caret-color: #f1f5f9; + background: #0f172a !important; + border: none !important; + border-radius: 0 !important; + min-height: 280px; + resize: vertical; + padding: 14px 16px; + pointer-events: auto; + -webkit-user-select: text; + user-select: text; +} + +.qb-sql-textarea-dark::placeholder { + color: #64748b !important; + -webkit-text-fill-color: #64748b; +} + +.cb-page textarea.form-control.qb-sql-textarea-dark:focus, +.cb-page textarea.form-control.qb-sql-textarea-dark:focus-visible, +.qb-sql-textarea-dark:focus, +.qb-sql-textarea-dark:focus-visible { + color: #e2e8f0 !important; + -webkit-text-fill-color: #e2e8f0; + box-shadow: none !important; + outline: 2px solid rgba(59, 130, 246, 0.45); + outline-offset: -2px; +} + +.qb-sql-textarea-dark::selection { + background: rgba(59, 130, 246, 0.35); + color: #f8fafc; +} + +.qb-sql-footnote { + font-size: var(--cb-font-sidebar, 13px); +} + +.qb-sql-footnote code { + color: #7c3aed; + font-size: inherit; +} + +/* Runner strip */ +.qb-runner-strip { + border-top: 1px solid #e5e7eb; + background: #f8fafc; +} + +.qb-runner-strip--dark { + background: #1e293b; + border-top-color: #334155; +} + +.qb-runner-strip--dark .qb-runner-meta { + color: rgba(248, 250, 252, 0.55) !important; +} + +.qb-runner-strip--dark .btn-secondary { + background: #334155; + border-color: #475569; + color: #f1f5f9; +} + +.qb-runner-strip--dark .btn-secondary:hover { + background: #475569; + border-color: #64748b; + color: #fff; +} + +.qb-conn-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; +} + +.qb-runner-strip--dark .qb-conn-status { + color: rgba(248, 250, 252, 0.75); +} + +.qb-conn-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #cbd5e1; + flex-shrink: 0; +} + +.qb-conn-dot--on { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35); +} + +/* Preview */ +.qb-preview-title { + font-size: var(--cb-font-topnav, 14px); + font-weight: 700; + letter-spacing: -0.01em; + color: #0f172a; +} + +.qb-preview-thead th { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; + background: #f8fafc !important; + border-bottom: 1px solid #e5e7eb; +} + +.qb-preview-td { + font-size: var(--cb-font-sidebar, 13px); + vertical-align: middle; +} + +.qb-preview-td--strong { + font-weight: 700; + color: #0f172a; +} + +.qb-cell-badge { + display: inline-block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #0369a1; + border: 1px solid #bae6fd; +} + +.qb-preview-tabs .nav-link { + font-size: var(--cb-font-sidebar, 13px); + font-weight: 700; + color: #64748b; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; +} + +.qb-preview-tabs .nav-link.active { + color: #0f172a; + border-bottom-color: #0f172a; + background: transparent; +} + +.qb-tab-panel { + display: block; +} + +.qb-tab-panel.qb-tab-panel--hidden { + display: none !important; +} + +.qb-preview-meta { + font-size: var(--cb-font-sidebar, 13px); + color: #64748b; +} + +.qb-preview-table-wrap { + max-height: 420px; + overflow: auto; +} + +.qb-preview-table tbody tr:nth-child(even) { + background: #fafafa; +} + +.qb-pagination-bar { + border-top: 1px solid #e5e7eb; + padding: 12px 14px; + background: #fff; + font-size: var(--cb-font-sidebar, 13px); +} + +.qb-page-num--active { + background: #0f172a !important; + border-color: #0f172a !important; + color: #fff !important; + font-weight: 700; + min-width: 34px; +} + +.qb-page-indicator { + color: #0f172a; +} + +.qb-preview-card--fullscreen { + position: fixed; + inset: 52px 12px 12px 12px; + z-index: 1030; + max-height: none !important; + display: flex; + flex-direction: column; +} + +.qb-preview-card--fullscreen .qb-preview-table-wrap { + max-height: none; + flex: 1; + min-height: 0; +} + +@media (max-width: 991.98px) { + .qb-vars-column { + border-right: none !important; + border-bottom: 1px solid #e5e7eb; + } +} diff --git a/public/assets/css/variables.css b/public/assets/css/variables.css new file mode 100644 index 0000000..3b2199a --- /dev/null +++ b/public/assets/css/variables.css @@ -0,0 +1,1448 @@ +/* Chart Board β€” shell + shared UI tokens (see CHARTBOARD_UI_IMPROVEMENT spec) */ +:root { + --card-bg: #ffffff; + --card-border: 1px solid #e2e4e9; + --card-radius: 10px; + --card-padding: 20px 24px; + --page-padding-x: 28px; + --page-padding-top: 28px; + --table-header-bg: #f9fafb; + --table-row-divider: #f0f1f3; + --text-primary: #1a1d23; + --text-secondary: #5a6072; + --text-muted: #9aa0b4; + --accent-blue: #1a56db; + --accent-blue-lt: #eff4ff; + --success-green: #059669; + --success-green-lt: #ecfdf5; + --success-green-border: #a7f3d0; + --danger-red: #dc2626; + --surface-bg: #f4f5f7; + --divider-color: #e2e4e9; + --topbar-height: 52px; + + /* Legacy aliases used elsewhere in the app */ + --cb-black: var(--text-primary); + --cb-text: var(--text-primary); + --cb-muted: var(--text-muted); + --cb-bg: var(--surface-bg); + --cb-panel: var(--card-bg); + --cb-line: #e2e4e9; + --cb-soft: #f4f5f7; + --cb-font-topnav: 14px; + --cb-font-sidebar: 13px; +} + +body.cb-body { + margin: 0; + background: var(--cb-bg); + color: var(--cb-text); + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; +} + +.cb-auth-shell { + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; +} + +/* β€”β€” Top bar (full width, no sidebar) β€”β€” */ +.cb-topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #fff; + border-bottom: 1px solid var(--cb-line); + z-index: 1040; +} + +.cb-topbar-row { + min-height: var(--topbar-height); +} + +.cb-brand-unit { + flex-shrink: 0; +} + +.cb-brand-text { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text-primary); +} + +.cb-topbar-divider { + width: 1px; + height: 24px; + background: var(--divider-color); + margin: 0 8px; + flex-shrink: 0; +} + +@media (min-width: 768px) { + .cb-topbar-divider { + margin: 0 16px; + } +} + +.cb-top-nav-scroll { + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding-right: 2px; +} + +.cb-top-main-nav { + gap: 2px; + flex-wrap: nowrap; +} + +.cb-top-main-link, +a.cb-top-main-link { + color: var(--text-secondary); + text-decoration: none; + font-size: var(--cb-font-topnav); + font-weight: 400; + padding: 6px 12px; + border-radius: 7px; + white-space: nowrap; + border: none; + box-shadow: none; +} + +button.cb-top-main-link { + background: transparent; + font-family: inherit; + line-height: 1.25; + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.cb-top-main-link:hover { + color: var(--text-primary); + background: #f4f5f7; +} + +.cb-top-main-link.active { + color: var(--accent-blue); + font-weight: 600; + background: var(--accent-blue-lt); +} + +.cb-top-main-link.active:hover { + color: var(--accent-blue); + background: #e0e8fc; +} + +.cb-top-nav-dropdown .cb-top-main-link.dropdown-toggle::after { + margin-left: 0.2em; + vertical-align: 0.12em; + border-top-color: currentColor; +} + +.cb-top-nav-dropdown .cb-top-main-link.dropdown-toggle.active::after { + border-top-color: var(--accent-blue); +} + +.cb-top-nav-badge { + font-size: 0.65rem; + vertical-align: middle; +} + +.cb-topbar-actions { + gap: 10px; +} + +@media (min-width: 768px) { + .cb-topbar-actions { + gap: 12px; + } +} + +.cb-topbar-action-icon { + font-size: 20px; + cursor: default; +} + +.cb-top-dropdown-menu { + min-width: 232px; + border-radius: 10px; + border: 1px solid var(--divider-color); + margin-top: 6px !important; + padding-top: 6px; + padding-bottom: 6px; +} + +.cb-top-dropdown-item { + font-size: 13px; + padding: 8px 14px; + border-radius: 6px; + margin: 2px 8px; + color: var(--text-primary); +} + +.cb-top-dropdown-item:hover, +.cb-top-dropdown-item:focus { + background: #f4f5f7; + color: var(--text-primary); +} + +.cb-top-dropdown-item.active, +.cb-top-dropdown-item:active { + background: var(--accent-blue-lt); + color: var(--accent-blue); + font-weight: 600; +} + +.cb-top-dropdown-icon { + font-size: 18px; + width: 22px; + flex-shrink: 0; + opacity: 0.72; +} + +.cb-top-dropdown-item.active .cb-top-dropdown-icon { + opacity: 1; +} + +.cb-top-ws-wrap { + max-width: min(200px, 38vw); + min-width: 0; +} + +.cb-top-ws-select { + width: 100%; + max-width: 100%; + font-size: 12px; + font-weight: 600; + padding: 5px 26px 5px 10px; + border-radius: 7px; + border: 1px solid var(--divider-color); + background-color: #fff; + color: var(--text-primary); + cursor: pointer; +} + +.cb-top-ws-select:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.12); + outline: none; +} + +.cb-user-avatar { + width: 28px; + height: 28px; + font-size: 10px; + font-weight: 700; + border: 2px solid var(--accent-blue); + flex-shrink: 0; +} + +.cb-user-avatar.dropdown-toggle::after { + display: none; +} + +.cb-content { + margin-left: 0; + padding-top: var(--topbar-height); +} + +/* β€”β€” Page shell β€”β€” */ +.cb-page { + padding: 0; + font-size: var(--cb-font-sidebar); + color: var(--cb-text); +} + +.cb-page-header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding: var(--page-padding-top) var(--page-padding-x) 20px; +} + +.cb-page-header--center { + align-items: center; +} + +.cb-page-title { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; + margin: 0; + line-height: 1.2; +} + +.cb-page-subtitle { + font-size: 13px; + color: var(--text-secondary); + margin-top: 4px; + margin-bottom: 0; + max-width: 640px; +} + +.cb-page-primary-btn.btn, +a.cb-page-primary-btn { + background: #1a1d23; + border-color: #1a1d23; + color: #fff; + border-radius: 7px; + padding: 9px 18px; + font-size: 13px; + font-weight: 600; +} + +.cb-page-primary-btn.btn:hover, +a.cb-page-primary-btn:hover { + background: #111318; + border-color: #111318; + color: #fff; + filter: brightness(0.92); +} + +.cb-page-primary-btn.btn:focus-visible { + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.2); +} + +.cb-page-body { + padding: 0 var(--page-padding-x) var(--page-padding-x); +} + +/* β€”β€” Stats + workspace list β€”β€” */ +.cb-stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; +} + +@media (max-width: 991.98px) { + .cb-stat-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 575.98px) { + .cb-stat-grid { + grid-template-columns: 1fr; + } +} + +.cb-stat-card { + background: var(--card-bg); + border-radius: var(--card-radius); + border: var(--card-border); + padding: 20px 24px; +} + +.cb-stat-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); +} + +.cb-stat-value { + margin-top: 6px; + font-size: 2rem; + font-weight: 700; + line-height: 1.15; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.cb-toolbar-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 20px; +} + +.cb-search-wrap { + position: relative; + flex: 1 1 200px; + min-width: 160px; +} + +.cb-search-input { + width: 100%; + background: #ffffff; + border: 1px solid var(--divider-color); + border-radius: 7px; + padding: 8px 14px 8px 38px; + font-size: 13px; + color: var(--text-primary); +} + +.cb-search-input:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.1); + outline: none; +} + +.cb-view-toggle { + border: 1px solid var(--divider-color); + border-radius: 7px; + background: #fff; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.cb-view-toggle:hover { + background: #f4f5f7; +} + +.cb-view-toggle.active { + background: #1a1d23; + color: #fff; + border-color: #1a1d23; +} + +.cb-workspace-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +@media (max-width: 1199.98px) { + .cb-workspace-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 767.98px) { + .cb-workspace-grid { + grid-template-columns: 1fr; + } +} + +.cb-workspace-card { + border: var(--card-border); + border-radius: var(--card-radius); + overflow: hidden; + background: var(--card-bg); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.cb-workspace-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); + border-color: #d0d3dc; + transform: none; +} + +.cb-workspace-card .top-strip { + height: 4px; + background: var(--accent-blue); + border-radius: var(--card-radius) var(--card-radius) 0 0; +} + +.cb-workspace-card-body { + padding: 20px; +} + +.cb-ws-card-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: #e8eaf6; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-blue); +} + +.cb-ws-card-icon .material-symbols-outlined { + font-size: 18px; +} + +.cb-badge-active { + background: var(--success-green-lt); + color: var(--success-green); + border: 1px solid var(--success-green-border); + border-radius: 20px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; +} + +.cb-badge-archived { + background: #f3f4f6; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 20px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; +} + +.cb-ws-card-title { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + margin-top: 14px; + margin-bottom: 0; +} + +.cb-ws-slug { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.cb-ws-desc { + font-size: 13px; + color: var(--text-secondary); + margin-top: 8px; + min-height: 40px; + margin-bottom: 0; +} + +.cb-ws-card-footer { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid #f0f1f3; +} + +.cb-ws-role-badge { + font-size: 11px; + font-weight: 700; + border-radius: 5px; + padding: 3px 10px; + background: #1a1d23; + color: #fff; +} + +.cb-ws-action-btn { + border: 1px solid var(--divider-color); + background: #fff; + color: var(--text-secondary); + border-radius: 6px; + padding: 5px 12px; + font-size: 12px; + font-weight: 500; + text-decoration: none; + display: inline-block; + line-height: 1.25; +} + +.cb-ws-action-btn:hover { + background: #f9fafb; + color: var(--text-primary); +} + +.cb-ws-action-btn--primary { + border-color: #bfdbfe; + color: var(--accent-blue); +} + +.cb-ws-action-btn--primary:hover { + background: var(--accent-blue-lt); + color: var(--accent-blue); +} + +.cb-ws-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +/* β€”β€” Data tables β€”β€” */ +.cb-table-shell { + background: var(--card-bg); + border: var(--card-border); + border-radius: var(--card-radius); + overflow: hidden; +} + +/* Pagination below data tables (Bootstrap .pagination) */ +.cb-pager-wrap { + padding: 12px 16px; + border-top: 1px solid var(--table-row-divider); + background: var(--card-bg); +} + +.cb-pager-nav .pagination { + gap: 4px; +} + +.cb-pager-nav .page-link { + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + color: var(--text-secondary); + border-color: var(--divider-color); + background: var(--card-bg); +} + +.cb-pager-nav .page-item.active .page-link { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #fff; +} + +.cb-pager-nav .page-link:hover { + background: #f4f5f7; + color: var(--text-primary); + border-color: var(--divider-color); +} + +.cb-pager-nav .page-item.active .page-link:hover { + background: var(--accent-blue); + color: #fff; +} + +.cb-admin-role-pill { + display: inline-block; + font-size: 11.5px; + font-weight: 600; + text-transform: lowercase; + padding: 3px 10px; + border-radius: 999px; + background: #1a1d23; + color: #fff; +} + +.cb-audit-action-code { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #9d174d; + background: #fce7f3; + padding: 3px 8px; + border-radius: 5px; + border: 1px solid #fbcfe8; + word-break: break-all; +} + +/* Full-width form blocks (same width as list / table pages inside cb-page-body) */ +.cb-form-shell { + background: var(--card-bg); + border: var(--card-border); + border-radius: var(--card-radius); + padding: 20px 24px; + margin-bottom: 14px; +} + +.cb-form-shell:last-child { + margin-bottom: 0; +} + +.cb-form-shell .form-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.cb-form-shell .form-control, +.cb-form-shell .form-select { + font-size: 13px; +} + +.cb-form-shell .form-control:focus, +.cb-form-shell .form-select:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.1); +} + +/* Panel title above forms / tables (invitations, etc.) */ +.cb-shell-section-title { + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text-primary); + margin: 0 0 14px; +} + +.cb-table-shell-banner { + padding: 14px 16px 4px; +} + +.cb-table-shell-banner .cb-shell-section-title { + margin-bottom: 10px; +} + +.cb-role-pill { + display: inline-block; + font-size: 11.5px; + font-weight: 600; + text-transform: capitalize; + padding: 3px 10px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; +} + +.cb-page-header.cb-page-header--compact { + padding-bottom: 12px; +} + +.cb-page-body.cb-page-body--form-tight { + padding-bottom: 16px; +} + +.cb-ws-danger-shell { + border-color: #fca5a5; + padding: 16px 20px; +} + +.cb-data-table.table { + margin-bottom: 0; + font-size: 13px; + color: var(--text-primary); +} + +.cb-data-table thead th { + background: var(--table-header-bg); + border-bottom: 1px solid var(--divider-color); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + padding: 11px 16px; + vertical-align: middle; +} + +.cb-data-table tbody td { + padding: 13px 16px; + vertical-align: middle; + border-bottom: 1px solid var(--table-row-divider); +} + +.cb-data-table tbody tr:last-child td { + border-bottom: none; +} + +.cb-data-table tbody tr:hover { + background: #fafafa; +} + +.cb-cell-name { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); +} + +.cb-cell-sub { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 2px; +} + +.cb-badge-connected { + display: inline-block; + background: var(--success-green-lt); + color: var(--success-green); + border: 1px solid var(--success-green-border); + border-radius: 20px; + padding: 3px 10px; + font-size: 11.5px; + font-weight: 600; +} + +.cb-table-actions { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + align-items: center; +} + +.cb-action-btn { + border-radius: 6px; + padding: 5px 12px; + font-size: 12px; + font-weight: 500; + line-height: 1.25; + text-decoration: none; + border: 1px solid var(--divider-color); + background: #fff; + color: var(--text-secondary); + display: inline-block; +} + +.cb-action-btn:hover { + filter: brightness(0.97); +} + +.cb-action-btn--ghost { + border-color: var(--divider-color); + color: var(--text-secondary); +} + +.cb-action-btn--test { + border-color: var(--success-green-border); + color: var(--success-green); + background: #fff; +} + +.cb-action-btn--edit { + border-color: #bfdbfe; + color: var(--accent-blue); + background: #fff; +} + +.cb-action-btn--delete { + border-color: #fca5a5; + color: var(--danger-red); + background: #fff; +} + +.cb-action-btn--chart { + border-color: var(--divider-color); + color: var(--text-primary); + font-weight: 600; +} + +button.cb-action-btn { + cursor: pointer; +} + +/* Inline role editor in member / team tables */ +.cb-member-role-form { + margin: 0; + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: 8px; +} + +/* Bootstrap .form-select defaults to width:100%; that stacks the Update button below */ +.cb-member-role-form .form-select { + width: auto; + flex: 0 0 auto; + max-width: 168px; + min-width: 120px; + font-size: 13px; + padding-top: 5px; + padding-bottom: 5px; +} + +.cb-member-role-form .cb-action-btn { + flex-shrink: 0; +} + +.cb-data-table .cb-table-cell--inline-form { + white-space: nowrap; +} + +/* Query type badges */ +.cb-badge-type { + border-radius: 5px; + padding: 2px 8px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.cb-badge-type--sql { + background: #f3f4f6; + color: #374151; +} + +.cb-badge-type--api { + background: var(--accent-blue-lt); + color: var(--accent-blue); +} + +.cb-badge-type--visual { + background: #f5f3ff; + color: #7c3aed; +} + +/* β€”β€” Charts list β€”β€” */ +.cb-chart-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +@media (max-width: 1199.98px) { + .cb-chart-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 767.98px) { + .cb-chart-grid { + grid-template-columns: 1fr; + } +} + +.cb-chart-card { + position: relative; + background: var(--card-bg); + border: var(--card-border); + border-radius: var(--card-radius); + padding: 0; + overflow: hidden; + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.cb-chart-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); + border-color: #d0d3dc; +} + +.cb-chart-card-accent { + display: block; + height: 3px; + width: 100%; + background: var(--accent-blue); + border-radius: var(--card-radius) var(--card-radius) 0 0; +} + +.cb-chart-card-inner { + padding: 16px 20px 20px; +} + +.cb-chart-type-badge { + background: #f3f4f6; + color: #374151; + border-radius: 5px; + padding: 3px 9px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.cb-chart-card-name { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); +} + +.cb-chart-meta { + font-size: 12px; + color: var(--text-muted); +} + +.cb-chart-card-actions { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #f0f1f3; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.cb-chart-btn { + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 500; + line-height: 1.25; +} + +.cb-chart-btn-share, +.cb-chart-btn-dup { + border: 1px solid var(--divider-color); + background: #fff; + color: var(--text-secondary); +} + +.cb-chart-btn-share:hover, +.cb-chart-btn-dup:hover { + background: #f9fafb; +} + +.cb-chart-btn-edit.btn-primary { + background: var(--accent-blue); + border-color: var(--accent-blue); + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 500; +} + +.cb-chart-btn-del { + border: 1px solid #fca5a5; + color: var(--danger-red); + background: #fff; + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 500; +} + +/* β€”β€” Alerts β€”β€” */ +.cb-alert-panel { + background: var(--card-bg); + border: var(--card-border); + border-radius: var(--card-radius); + padding: 20px; +} + +.cb-alert-panel-title { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0; +} + +.cb-alert-empty-text { + font-size: 13px; + color: var(--text-muted); + margin-top: 12px; + margin-bottom: 0; +} + +.cb-alert-empty-panel { + background: var(--card-bg); + border: var(--card-border); + border-radius: var(--card-radius); + min-height: 280px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 32px 24px; +} + +.cb-alert-empty-icon { + font-size: 48px; + color: var(--text-muted); + opacity: 0.5; +} + +.cb-alert-empty-msg { + font-size: 14px; + color: var(--text-secondary); + margin-top: 12px; + margin-bottom: 0; +} + +.cb-alert-create-btn.btn { + margin-top: 16px; + background: #1a1d23; + border-color: #1a1d23; + color: #fff; + border-radius: 7px; + padding: 9px 20px; + font-size: 13px; + font-weight: 600; +} + +/* β€”β€” Settings: appearance (theme) β€”β€” */ +.cb-settings-section-title { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; + letter-spacing: -0.01em; +} + +.cb-settings-section-hint { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 20px; + line-height: 1.45; +} + +.cb-theme-tiles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 575.98px) { + .cb-theme-tiles { + grid-template-columns: 1fr; + } +} + +.cb-theme-tile-form { + margin: 0; + height: 100%; +} + +.cb-theme-tile { + width: 100%; + min-height: 118px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 18px 14px; + border: 1px solid var(--divider-color); + border-radius: var(--card-radius); + background: var(--card-bg); + color: var(--text-primary); + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease; + text-align: center; + font-family: inherit; +} + +.cb-theme-tile:hover { + border-color: #d0d3dc; + background: #f9fafb; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.cb-theme-tile:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.22); +} + +.cb-theme-tile.active { + border-color: var(--accent-blue); + background: var(--accent-blue-lt); + box-shadow: 0 0 0 1px var(--accent-blue); +} + +.cb-theme-tile-icon { + font-size: 30px; + line-height: 1; + color: var(--accent-blue); +} + +.cb-theme-tile.active .cb-theme-tile-icon { + color: var(--accent-blue); +} + +.cb-theme-tile-title { + font-size: 14px; + font-weight: 700; + display: block; +} + +.cb-theme-tile-desc { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; + display: block; + line-height: 1.35; + max-width: 200px; +} + +.cb-theme-tile.active .cb-theme-tile-desc { + color: var(--text-secondary); +} + +/* β€”β€” Settings form β€”β€” */ +.cb-settings-form-card { + background: var(--card-bg); + border: var(--card-border); + border-radius: var(--card-radius); + padding: 28px 32px; + max-width: 760px; + margin-left: auto; + margin-right: auto; +} + +.cb-settings-form-card .form-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.cb-settings-form-card .form-control, +.cb-settings-form-card .form-select, +.cb-settings-form-card textarea.form-control { + background: #ffffff; + border: 1px solid var(--divider-color); + border-radius: 7px; + padding: 9px 12px; + font-size: 13px; + color: var(--text-primary); +} + +.cb-settings-form-card textarea.form-control { + min-height: 100px; + resize: vertical; +} + +.cb-settings-form-card .form-control:focus, +.cb-settings-form-card .form-select:focus, +.cb-settings-form-card textarea.form-control:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.1); + outline: none; +} + +.cb-settings-back.btn { + border: 1px solid var(--divider-color); + background: #fff; + color: var(--text-secondary); + border-radius: 7px; + padding: 7px 16px; + font-size: 13px; +} + +.cb-settings-grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +@media (max-width: 767.98px) { + .cb-settings-grid-3 { + grid-template-columns: 1fr; + } +} + +/* β€”β€” Shared card (sidebar workspace uses border token) β€”β€” */ +.cb-card { + background: var(--cb-panel); + border: var(--card-border); + border-radius: var(--card-radius); +} + +/* Legacy page headings */ +.cb-page h1.h4, +.cb-page .h4.cb-page-title-legacy { + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.cb-page .h6, +.cb-page h2.h6, +.cb-page h3.h6 { + font-size: var(--cb-font-topnav); + font-weight: 700; +} + +.cb-page h2.h5, +.cb-page .h5 { + font-size: var(--cb-font-topnav); + font-weight: 700; +} + +.cb-page .text-muted { + font-size: inherit; +} + +.cb-page .form-control:not(.form-control-sm), +.cb-page .form-select:not(.form-select-sm), +.cb-page textarea.form-control:not(.form-control-sm) { + font-size: var(--cb-font-topnav); +} + +.cb-page .btn:not(.btn-sm) { + font-size: var(--cb-font-topnav); +} + +/* Auth screens */ +.cb-auth-body { + font-size: var(--cb-font-sidebar); + color: var(--cb-text); +} + +.cb-auth-body h1.h4 { + font-weight: 700; + letter-spacing: -0.01em; + color: var(--cb-black); +} + +.cb-auth-body .form-control, +.cb-auth-body .form-select { + font-size: var(--cb-font-topnav); +} + +.cb-auth-body .btn { + font-size: var(--cb-font-topnav); +} + +/* β€”β€” Dark shell β€”β€” */ +html[data-cb-theme="dark"] { + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --card-bg: #1e293b; + --surface-bg: #0f172a; + --table-header-bg: #334155; + --table-row-divider: rgba(148, 163, 184, 0.15); + --divider-color: #334155; + --accent-blue-lt: rgba(26, 86, 219, 0.2); + --cb-black: #f1f5f9; + --cb-text: #e2e8f0; + --cb-muted: #94a3b8; + --cb-bg: #0f172a; + --cb-panel: #1e293b; + --cb-line: #334155; + --cb-soft: #1e293b; +} + +html[data-cb-theme="dark"] .cb-topbar { + background: #1e293b; + border-bottom-color: var(--cb-line); +} + +html[data-cb-theme="dark"] .cb-brand-text { + color: var(--text-primary); +} + +html[data-cb-theme="dark"] .cb-topbar-divider { + background: var(--cb-line); +} + +html[data-cb-theme="dark"] .cb-top-main-link { + color: var(--text-secondary); +} + +html[data-cb-theme="dark"] .cb-top-main-link:hover { + background: #334155; + color: #f8fafc; +} + +html[data-cb-theme="dark"] .cb-top-main-link.active { + color: #93c5fd; + background: rgba(26, 86, 219, 0.22); +} + +html[data-cb-theme="dark"] .cb-top-main-link.active:hover { + color: #bfdbfe; + background: rgba(26, 86, 219, 0.32); +} + +html[data-cb-theme="dark"] .cb-top-nav-dropdown .cb-top-main-link.dropdown-toggle.active::after { + border-top-color: #93c5fd; +} + +html[data-cb-theme="dark"] .cb-user-avatar { + border-color: var(--accent-blue); +} + +html[data-cb-theme="dark"] .cb-top-dropdown-menu { + background: var(--cb-panel); + border-color: var(--cb-line); +} + +html[data-cb-theme="dark"] .cb-top-dropdown-item { + color: var(--text-primary); +} + +html[data-cb-theme="dark"] .cb-top-dropdown-item:hover, +html[data-cb-theme="dark"] .cb-top-dropdown-item:focus { + background: #334155; + color: #f8fafc; +} + +html[data-cb-theme="dark"] .cb-top-dropdown-item.active { + background: rgba(26, 86, 219, 0.28); + color: #93c5fd; +} + +html[data-cb-theme="dark"] .cb-top-ws-select { + background: #0f172a; + border-color: var(--cb-line); + color: var(--text-primary); +} + +html[data-cb-theme="dark"] .cb-card { + border-color: var(--cb-line); +} + +html[data-cb-theme="dark"] .cb-stat-value, +html[data-cb-theme="dark"] .cb-page-title { + color: var(--text-primary); +} + +html[data-cb-theme="dark"] .cb-workspace-card, +html[data-cb-theme="dark"] .cb-table-shell, +html[data-cb-theme="dark"] .cb-form-shell, +html[data-cb-theme="dark"] .cb-chart-card, +html[data-cb-theme="dark"] .cb-alert-panel, +html[data-cb-theme="dark"] .cb-alert-empty-panel { + border-color: var(--cb-line); + background: var(--cb-panel); +} + +html[data-cb-theme="dark"] .cb-data-table thead th { + background: var(--table-header-bg); + color: var(--text-muted); + border-color: var(--cb-line); +} + +html[data-cb-theme="dark"] .cb-data-table tbody tr:hover { + background: rgba(30, 41, 59, 0.85); +} + +html[data-cb-theme="dark"] .cb-search-input, +html[data-cb-theme="dark"] .cb-action-btn, +html[data-cb-theme="dark"] .cb-ws-action-btn { + background: var(--cb-panel); + border-color: var(--cb-line); + color: var(--text-secondary); +} + +html[data-cb-theme="dark"] .cb-settings-form-card .form-control, +html[data-cb-theme="dark"] .cb-settings-form-card .form-select, +html[data-cb-theme="dark"] .cb-form-shell .form-control, +html[data-cb-theme="dark"] .cb-form-shell .form-select, +html[data-cb-theme="dark"] .cb-member-role-form .form-select { + background: #0f172a; + border-color: var(--cb-line); + color: var(--text-primary); +} + +html[data-cb-theme="dark"] .cb-role-pill { + background: rgba(148, 163, 184, 0.12); + color: var(--text-secondary); + border-color: var(--cb-line); +} + +html[data-cb-theme="dark"] .cb-pager-wrap { + background: var(--cb-panel); + border-top-color: var(--table-row-divider); +} + +html[data-cb-theme="dark"] .cb-pager-nav .page-link { + background: var(--cb-panel); + border-color: var(--cb-line); + color: var(--text-secondary); +} + +html[data-cb-theme="dark"] .cb-pager-nav .page-link:hover { + background: #334155; + color: var(--text-primary); +} + +html[data-cb-theme="dark"] .cb-admin-role-pill { + background: #334155; + color: #f1f5f9; +} + +html[data-cb-theme="dark"] .cb-audit-action-code { + color: #f9a8d4; + background: rgba(157, 23, 77, 0.25); + border-color: rgba(244, 114, 182, 0.35); +} + +html[data-cb-theme="dark"] .cb-theme-tile { + background: var(--cb-panel); +} + +html[data-cb-theme="dark"] .cb-theme-tile:hover { + background: #334155; + border-color: #475569; +} + +html[data-cb-theme="dark"] .cb-theme-tile.active { + background: rgba(26, 86, 219, 0.28); + border-color: #60a5fa; + box-shadow: 0 0 0 1px #60a5fa; +} diff --git a/public/assets/images/logo-mark.svg b/public/assets/images/logo-mark.svg new file mode 100644 index 0000000..8157fa4 --- /dev/null +++ b/public/assets/images/logo-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..b645f79 --- /dev/null +++ b/public/assets/js/app.js @@ -0,0 +1,122 @@ +function applyChartBoardTheme() { + const el = document.documentElement; + const pref = el.getAttribute('data-cb-theme-pref') || 'light'; + let resolved = pref; + if (pref === 'system') { + resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + if (resolved !== 'dark') { + resolved = 'light'; + } + el.setAttribute('data-cb-theme', resolved); + el.setAttribute('data-bs-theme', resolved); + try { + localStorage.setItem('cb-theme-pref', pref); + } catch (e) { + /* ignore */ + } +} + +function showToast(message, type = 'success') { + // Placeholder helper for Phase 1 base setup. + console.log(`[${type}] ${message}`); +} + +document.addEventListener('DOMContentLoaded', function () { + applyChartBoardTheme(); + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { + if ((document.documentElement.getAttribute('data-cb-theme-pref') || '') === 'system') { + applyChartBoardTheme(); + } + }); + } catch (e) { + /* ignore */ + } + + const testButtons = document.querySelectorAll('[data-ds-test-btn]'); + testButtons.forEach(function (button) { + button.addEventListener('click', async function () { + const form = button.closest('form'); + if (!form) { + return; + } + + const resultContainer = form.querySelector('[data-ds-test-result]'); + const url = button.getAttribute('data-test-url'); + if (!url) { + return; + } + + const originalLabel = button.innerHTML; + button.disabled = true; + button.innerHTML = 'Testing...'; + + if (resultContainer) { + resultContainer.innerHTML = ''; + } + + try { + const response = await fetch(url, { + method: 'POST', + body: new FormData(form), + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }); + + const result = await response.json(); + const isSuccess = !!result.success; + const message = result.message || (isSuccess ? 'Connection successful.' : 'Connection failed.'); + + if (resultContainer) { + resultContainer.innerHTML = '
' + message + '
'; + } + } catch (error) { + if (resultContainer) { + resultContainer.innerHTML = '
Connection test failed due to a network error.
'; + } + } finally { + button.disabled = false; + button.innerHTML = originalLabel; + } + }); + }); + + const proofRefreshButton = document.querySelector('[data-proof-refresh-btn]'); + if (proofRefreshButton) { + proofRefreshButton.addEventListener('click', async function () { + const proofUrl = proofRefreshButton.getAttribute('data-proof-url'); + const proofContainer = document.querySelector('[data-proof-content]'); + if (!proofUrl || !proofContainer) { + return; + } + + const originalLabel = proofRefreshButton.innerHTML; + proofRefreshButton.disabled = true; + proofRefreshButton.innerHTML = 'Refreshing...'; + + try { + const response = await fetch(proofUrl, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }); + const result = await response.json(); + if (result && result.success && typeof result.html === 'string') { + proofContainer.innerHTML = result.html; + } else { + showToast(result.message || 'Unable to refresh proof.', 'error'); + } + } catch (error) { + showToast('Unable to refresh proof due to a network error.', 'error'); + } finally { + proofRefreshButton.disabled = false; + proofRefreshButton.innerHTML = originalLabel; + } + }); + } +}); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..7ecfce2 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..a0a20db --- /dev/null +++ b/public/index.php @@ -0,0 +1,59 @@ +systemDirectory . '/Boot.php'; + +exit(Boot::bootWeb($paths)); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..9e60f97 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/public/ui-sample-html-asset/PHASE 2/login.html b/public/ui-sample-html-asset/PHASE 2/login.html new file mode 100644 index 0000000..089e674 --- /dev/null +++ b/public/ui-sample-html-asset/PHASE 2/login.html @@ -0,0 +1,204 @@ + + + + + +Chart Board - Secure Login + + + + + + + + + + + + +
+
+Chart Board +
+ +
+
+ +
+
+
+
+ +
+ + + +
+
+

Login

+

Access your secure workspace

+
+
+ +
+ +
+
+mail +
+ +
+
+ +
+
+ +FORGOT? +
+
+
+lock +
+ +
+visibility +
+
+
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+verified_user +Encrypted Session +
+
+Privacy +Terms +
+
+
+ \ No newline at end of file diff --git a/public/ui-sample-html-asset/PHASE 2/register.html b/public/ui-sample-html-asset/PHASE 2/register.html new file mode 100644 index 0000000..9b0a03d --- /dev/null +++ b/public/ui-sample-html-asset/PHASE 2/register.html @@ -0,0 +1,191 @@ + + + + + +Chart Board - Register + + + + + + + + + + +
+
+ +
+
+
+dashboard +
+
+

Chart Board

+

Sentinel Protocol

+
+
+
+

Create Account

+

+ Establish your secure access to the management environment. +

+
+
+ +
+ +
+ +
+ +person +
+
+ +
+ +
+ +alternate_email +
+
+ +
+ +
+ +
+ +lock +
+
+ +
+ +
+ +verified +
+
+
+ +
+policy +

+ By registering, you agree to the Governance Framework and data protection protocols. +

+
+ + +
+ +
+

+ Already have a secure key? + Login to Portal +

+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/public/ui-sample-html-asset/PHASE 2/reset.html b/public/ui-sample-html-asset/PHASE 2/reset.html new file mode 100644 index 0000000..600cdbf --- /dev/null +++ b/public/ui-sample-html-asset/PHASE 2/reset.html @@ -0,0 +1,172 @@ + + + + + +Chart Board - Forgot Password + + + + + + + + + + + +
+ +
+
+
+
+
+ +
+
+
+lock_reset +
+
+

Chart Board

+
+
+ +
+ +
+
+
+

Reset your access

+

+ Enter the email associated with your identity. We'll verify your credentials and send a secure recovery link to your inbox. +

+
+
+ +
+
+ +
+
+
+mail +
+ + +
+
+ +
+ +
+
+ +
+
+info +
+

Security Verification

+

+ For your protection, recovery links expire in 30 minutes. If you don't see the email, please check your spam folder or contact your system administrator. +

+
+
+ +keyboard_backspace +Back to secure sign in + +
+
+
+ +
+
+
+
+
+
+
+ +
+

+ Β© 2024 Sentinel Identity Management Systems. Built with absolute integrity. +

+
+ \ No newline at end of file diff --git a/public/ui-sample-html-asset/PHASE 3/workspace-module.html b/public/ui-sample-html-asset/PHASE 3/workspace-module.html new file mode 100644 index 0000000..1afb9b2 --- /dev/null +++ b/public/ui-sample-html-asset/PHASE 3/workspace-module.html @@ -0,0 +1,1058 @@ + + + + + +Chart Board | Workspaces + + + + + + + + + +
+
+
+
+ bar_chart +
+ Chart Board +
+ +
+
+ + + +
+ JD +
+
+
+ + + + + +
+ + +
+ +
+
+

Organization

+

Workspaces

+

Organize your data assets, dashboards, and team access across isolated workspace environments.

+
+ +
+ + +
+
+ search + +
+
+ + +
+
+ +
+
+ + +
+
+

Total Workspaces

+

3

+
+
+

Total Members

+

14

+
+
+

Data Sources

+

7

+
+
+

Dashboards

+

22

+
+
+ + +
+ + +
+ +
+
+
+
+ account_tree +
+
+ + Active + + +
+
+

Project Alpha

+

chartboard.io/project-alpha

+

Strategic management workspace for high-level infrastructure planning and asset tracking.

+
+
+
+

68% storage used Β· 3.4 GB / 5 GB

+
+
+
JD
+
SR
+
+5
+
+
+ database 3 sources + bar_chart 12 charts +
+
+
+ EST Β· Refresh 5m + Admin +
+
+
+ + +
+
+
+
+
+ palette +
+
+ + Active + + +
+
+

Design Studio

+

chartboard.io/design-studio

+

Creative analytics for the UX/design team β€” tracking engagement metrics and user flows.

+
+
+
+

31% storage used Β· 1.5 GB / 5 GB

+
+
+
RL
+
KP
+
+2
+
+
+ database 2 sources + bar_chart 7 charts +
+
+
+ PST Β· Refresh 15m + Editor +
+
+
+ + +
+
+
+
+
+ storage +
+
+ + Archived + + +
+
+

Data Ops

+

chartboard.io/data-ops

+

Legacy ETL pipeline monitoring. Archived β€” data retained for 30 days.

+
+
+
+

Purge in 12 days Β· Data retained

+
+
+
MT
+
+
+ database 2 sources + bar_chart 3 charts +
+
+
+ UTC Β· Archived + Viewer +
+
+
+ + +
+
+ add +
+

New Workspace

+

Isolate a new team or project

+
+
+ + + +
+
+ + + + + + + + + + + +
+ + + + + + + diff --git a/spark b/spark new file mode 100755 index 0000000..e7871ea --- /dev/null +++ b/spark @@ -0,0 +1,87 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Boot; +use Config\Paths; + +/* + * -------------------------------------------------------------------- + * CODEIGNITER COMMAND-LINE TOOLS + * -------------------------------------------------------------------- + * The main entry point into the CLI system and allows you to run + * commands and perform maintenance on your application. + */ + +/* + *--------------------------------------------------------------- + * CHECK SERVER API + *--------------------------------------------------------------- + */ + +// Refuse to run when called from php-cgi +if (str_starts_with(PHP_SAPI, 'cgi')) { + exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n"); +} + +/* + *--------------------------------------------------------------- + * CHECK PHP VERSION + *--------------------------------------------------------------- + */ + +$minPhpVersion = '8.1'; // If you update this, don't forget to update `public/index.php`. +if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { + $message = sprintf( + 'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', + $minPhpVersion, + PHP_VERSION, + ); + + exit($message); +} + +// We want errors to be shown when using it from the CLI. +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +/* + *--------------------------------------------------------------- + * SET THE CURRENT DIRECTORY + *--------------------------------------------------------------- + */ + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +// Ensure the current directory is pointing to the front controller's directory +chdir(FCPATH); + +/* + *--------------------------------------------------------------- + * BOOTSTRAP THE APPLICATION + *--------------------------------------------------------------- + * This process sets up the path constants, loads and registers + * our autoloader, along with Composer's, loads our constants + * and fires up an environment-specific bootstrapping. + */ + +// LOAD OUR PATHS CONFIG FILE +// This is the line that might need to be changed, depending on your folder structure. +require FCPATH . '../app/Config/Paths.php'; +// ^^^ Change this line if you move your application folder + +$paths = new Paths(); + +// LOAD THE FRAMEWORK BOOTSTRAP FILE +require $paths->systemDirectory . '/Boot.php'; + +exit(Boot::bootSpark($paths)); diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/tests/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php b/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php new file mode 100644 index 0000000..a73356d --- /dev/null +++ b/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php @@ -0,0 +1,37 @@ +forge->addField('id'); + $this->forge->addField([ + 'name' => ['type' => 'varchar', 'constraint' => 31], + 'uid' => ['type' => 'varchar', 'constraint' => 31], + 'class' => ['type' => 'varchar', 'constraint' => 63], + 'icon' => ['type' => 'varchar', 'constraint' => 31], + 'summary' => ['type' => 'varchar', 'constraint' => 255], + 'created_at' => ['type' => 'datetime', 'null' => true], + 'updated_at' => ['type' => 'datetime', 'null' => true], + 'deleted_at' => ['type' => 'datetime', 'null' => true], + ]); + + $this->forge->addKey('name'); + $this->forge->addKey('uid'); + $this->forge->addKey(['deleted_at', 'id']); + $this->forge->addKey('created_at'); + + $this->forge->createTable('factories'); + } + + public function down(): void + { + $this->forge->dropTable('factories'); + } +} diff --git a/tests/_support/Database/Seeds/ExampleSeeder.php b/tests/_support/Database/Seeds/ExampleSeeder.php new file mode 100644 index 0000000..619fc27 --- /dev/null +++ b/tests/_support/Database/Seeds/ExampleSeeder.php @@ -0,0 +1,41 @@ + 'Test Factory', + 'uid' => 'test001', + 'class' => 'Factories\Tests\NewFactory', + 'icon' => 'fas fa-puzzle-piece', + 'summary' => 'Longer sample text for testing', + ], + [ + 'name' => 'Widget Factory', + 'uid' => 'widget', + 'class' => 'Factories\Tests\WidgetPlant', + 'icon' => 'fas fa-puzzle-piece', + 'summary' => 'Create widgets in your factory', + ], + [ + 'name' => 'Evil Factory', + 'uid' => 'evil-maker', + 'class' => 'Factories\Evil\MyFactory', + 'icon' => 'fas fa-book-dead', + 'summary' => 'Abandon all hope, ye who enter here', + ], + ]; + + $builder = $this->db->table('factories'); + + foreach ($factories as $factory) { + $builder->insert($factory); + } + } +} diff --git a/tests/_support/Libraries/ConfigReader.php b/tests/_support/Libraries/ConfigReader.php new file mode 100644 index 0000000..0bb4a8f --- /dev/null +++ b/tests/_support/Libraries/ConfigReader.php @@ -0,0 +1,19 @@ +findAll(); + + // Make sure the count is as expected + $this->assertCount(3, $objects); + } + + public function testSoftDeleteLeavesRow(): void + { + $model = new ExampleModel(); + $this->setPrivateProperty($model, 'useSoftDeletes', true); + $this->setPrivateProperty($model, 'tempUseSoftDeletes', true); + + /** @var stdClass $object */ + $object = $model->first(); + $model->delete($object->id); + + // The model should no longer find it + $this->assertNull($model->find($object->id)); + + // ... but it should still be in the database + $result = $model->builder()->where('id', $object->id)->get()->getResult(); + + $this->assertCount(1, $result); + } +} diff --git a/tests/index.html b/tests/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/tests/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/tests/session/ExampleSessionTest.php b/tests/session/ExampleSessionTest.php new file mode 100644 index 0000000..33242a4 --- /dev/null +++ b/tests/session/ExampleSessionTest.php @@ -0,0 +1,17 @@ +set('logged_in', 123); + $this->assertSame(123, $session->get('logged_in')); + } +} diff --git a/tests/unit/HealthTest.php b/tests/unit/HealthTest.php new file mode 100644 index 0000000..b3e480f --- /dev/null +++ b/tests/unit/HealthTest.php @@ -0,0 +1,49 @@ +assertTrue(defined('APPPATH')); + } + + public function testBaseUrlHasBeenSet(): void + { + $validation = service('validation'); + + $env = false; + + // Check the baseURL in .env + if (is_file(HOMEPATH . '.env')) { + $env = preg_grep('/^app\.baseURL = ./', file(HOMEPATH . '.env')) !== false; + } + + if ($env) { + // BaseURL in .env is a valid URL? + // phpunit.xml.dist sets app.baseURL in $_SERVER + // So if you set app.baseURL in .env, it takes precedence + $config = new App(); + $this->assertTrue( + $validation->check($config->baseURL, 'valid_url'), + 'baseURL "' . $config->baseURL . '" in .env is not valid URL', + ); + } + + // Get the baseURL in app/Config/App.php + // You can't use Config\App, because phpunit.xml.dist sets app.baseURL + $reader = new ConfigReader(); + + // BaseURL in app/Config/App.php is a valid URL? + $this->assertTrue( + $validation->check($reader->baseURL, 'valid_url'), + 'baseURL "' . $reader->baseURL . '" in app/Config/App.php is not valid URL', + ); + } +} diff --git a/writable/.htaccess b/writable/.htaccess new file mode 100755 index 0000000..3462048 --- /dev/null +++ b/writable/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/writable/cache/index.html b/writable/cache/index.html new file mode 100755 index 0000000..b702fbc --- /dev/null +++ b/writable/cache/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/debugbar/index.html b/writable/debugbar/index.html new file mode 100755 index 0000000..b702fbc --- /dev/null +++ b/writable/debugbar/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/index.html b/writable/index.html new file mode 100755 index 0000000..b702fbc --- /dev/null +++ b/writable/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/logs/index.html b/writable/logs/index.html new file mode 100755 index 0000000..b702fbc --- /dev/null +++ b/writable/logs/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/session/index.html b/writable/session/index.html new file mode 100755 index 0000000..b702fbc --- /dev/null +++ b/writable/session/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/uploads/index.html b/writable/uploads/index.html new file mode 100755 index 0000000..b702fbc --- /dev/null +++ b/writable/uploads/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + +