diff --git a/README.md b/README.md
index ab7383a..b985b46 100644
--- a/README.md
+++ b/README.md
@@ -4,20 +4,79 @@
[](https://packagist.org/packages/flightphp/apm)
[](https://packagist.org/packages/flightphp/apm)
-An APM (Application Performance Monitoring) library for [FlightPHP](https://github.com/flightphp/core) framework.
+A lightweight Application Performance Monitoring (APM) library for the [FlightPHP](https://github.com/flightphp/core) framework. Keep your app fast, spot bottlenecks, and sleep better at night!
+
+## What is FlightPHP APM?
+
+FlightPHP APM helps you track how your app performs in real-time—think of it as a fitness tracker for your code! It logs metrics like request times, memory usage, database queries, and custom events, then gives you a slick dashboard to see it all. Why care? Because slow apps lose users, and finding performance hiccups *before* they bite saves you headaches (and maybe a few angry emails).
+
+Built to be fast and simple, it slots right into your FlightPHP project with minimal fuss.
## Installation
-Simply install with Composer
+Grab it with Composer:
```bash
composer require flightphp/apm
```
+## Quick Start
+
+1. **Log APM Metrics**
+ Add this to your `index.php` or services file to start tracking:
+ ```php
+ use flight\apm\logger\LoggerFactory;
+ use flight\Apm;
+
+ $ApmLogger = LoggerFactory::create(__DIR__ . '/../../.runway-config.json');
+ $Apm = new Apm($ApmLogger);
+ $Apm->bindEventsToFlightInstance($app);
+ ```
+
+2. **Set Up Config**
+ Run this to create your `.runway-config.json`:
+ ```bash
+ php vendor/bin/runway apm:init
+ ```
+
+3. **Process Metrics**
+ Fire up the worker to crunch those metrics (runs once by default):
+ ```bash
+ php vendor/bin/runway apm:worker
+ ```
+ Want it continuous? Try `--daemon`:
+ ```bash
+ php vendor/bin/runway apm:worker --daemon
+ ```
+
+4. **View Your Dashboard**
+ Launch the dashboard to see your app’s pulse:
+ ```bash
+ php vendor/bin/runway apm:dashboard --host localhost --port 8001
+ ```
+
+## Keeping the Worker Running
+
+The worker processes your metrics—here’s how to keep it humming:
+- **Daemon Mode**: `php vendor/bin/runway apm:worker --daemon` (runs forever!)
+- **Crontab**: `* * * * * php /path/to/project/vendor/bin/runway apm:worker` (runs every minute)
+- **Tmux/Screen**: Start it in a detachable session with `tmux` or `screen` for easy monitoring.
+
+## Requirements
+
+- PHP 7.4 or higher
+- [FlightPHP Core](https://github.com/flightphp/core) v3.15+
+
## Documentation
-Coming soon
+Want the full scoop? Check out the [FlightPHP APM Documentation](https://docs.flightphp.com/awesome-plugins/apm) for setup details, worker options, dashboard tricks, and more!
+
+## Community
+
+Join us on [Matrix IRC #flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) to chat, ask questions, or share your APM wins!
+
+[](https://discord.gg/Ysr4zqHfbX)
## License
-MIT
+MIT—free and open for all!
\ No newline at end of file
diff --git a/dashboard/css/style.css b/dashboard/css/style.css
new file mode 100644
index 0000000..1242d7f
--- /dev/null
+++ b/dashboard/css/style.css
@@ -0,0 +1,291 @@
+:root {
+ --bg-color: #f8f9fa;
+ --text-color: #333;
+ --card-bg: #fff;
+ --card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ --header-bg: #fff;
+ --badge-primary: #0d6efd;
+ --badge-success: #198754;
+ --badge-danger: #dc3545;
+ --badge-info: #0dcaf0;
+ --badge-warning: #ffc107;
+}
+
+[data-theme="dark"] {
+ --bg-color: #212529;
+ --text-color: #f8f9fa;
+ --card-bg: #343a40;
+ --card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
+ --header-bg: #2c3034;
+ --badge-primary: #6ea8fe;
+ --badge-success: #20c997;
+ --badge-danger: #f27474;
+ --badge-info: #6edff6;
+ --badge-warning: #ffca2c;
+}
+
+[data-theme="dark"] .card-header {
+ color: var(--text-color);
+}
+
+[data-theme="dark"] .list-group-item {
+ color: var(--text-color);
+}
+
+[data-theme="dark"] .card-body {
+ color: var(--text-color);
+}
+
+[data-theme="dark"] .badge {
+ color: #fff; /* Ensure badge text is always white for readability */
+}
+
+[data-theme="dark"] .table {
+ background-color: var(--card-bg); /* Match the card background */
+ color: var(--text-color);
+}
+
+[data-theme="dark"] .table thead th, .table>:not(caption)>*>* {
+ background-color: var(--header-bg); /* Match the header background */
+ color: var(--text-color);
+ border-bottom: 2px solid #495057; /* Slightly lighter border for contrast */
+}
+
+[data-theme="dark"] .table tbody tr, .table>:not(caption)>*>* {
+ background-color: var(--card-bg);
+ border-bottom: 1px solid #495057; /* Subtle border between rows */
+}
+
+[data-theme="dark"] .table-hover tbody tr:hover {
+ background-color: rgba(255, 255, 255, 0.1); /* Keep the hover effect */
+}
+
+[data-theme="dark"] .form-control {
+ background-color: #495057;
+ color: var(--text-color);
+ border-color: #6c757d;
+}
+
+[data-theme="dark"] .form-control::placeholder {
+ color: #ced4da; /* Lighter gray for better visibility */
+ opacity: 1; /* Ensure full opacity for readability */
+}
+
+body {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ font-family: 'Inter', sans-serif;
+ transition: background-color 0.3s, color 0.3s;
+}
+
+.card {
+ border: none;
+ border-radius: 10px;
+ background-color: var(--card-bg);
+ box-shadow: var(--card-shadow);
+ transition: transform 0.2s, background-color 0.3s;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+}
+
+.card-header {
+ background-color: var(--header-bg);
+ border-bottom: none;
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+.list-group-item {
+ border: none;
+ padding: 0.5rem 1rem;
+ font-size: 0.95rem;
+ background-color: var(--card-bg);
+ color: var(--text-color);
+}
+
+.badge {
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.badge-primary { background-color: var(--badge-primary); }
+.badge-success { background-color: var(--badge-success); }
+.badge-danger { background-color: var(--badge-danger); }
+.badge-info { background-color: var(--badge-info); }
+.badge-warning { background-color: var(--badge-warning); }
+
+.chart-container {
+ background-color: var(--card-bg);
+ border-radius: 10px;
+ padding: 1rem;
+ box-shadow: var(--card-shadow);
+}
+
+.btn-outline-primary {
+ transition: all 0.3s;
+ color: var(--text-color);
+ border-color: var(--badge-primary);
+}
+
+.btn-outline-primary:hover {
+ background-color: var(--badge-primary);
+ color: #fff;
+}
+
+/* Truncate long URLs with ellipsis and show full text on hover */
+#request-log td:nth-child(2) {
+ max-width: 200px; /* Adjust this width as needed */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+#request-log td:nth-child(2):hover {
+ position: relative;
+}
+
+#request-log td:nth-child(2):hover::after {
+ content: attr(data-full-url);
+ position: absolute;
+ left: 0;
+ top: 100%;
+ z-index: 10;
+ background-color: var(--card-bg);
+ color: var(--text-color);
+ padding: 5px 10px;
+ border-radius: 4px;
+ box-shadow: var(--card-shadow);
+ white-space: normal;
+ max-width: 400px;
+ word-wrap: break-word;
+}
+
+/* Ensure table header stays fixed while body scrolls */
+.table-responsive table {
+ width: 100%;
+}
+
+.table-responsive thead {
+ position: sticky;
+ top: 0;
+ background-color: var(--header-bg);
+ z-index: 1;
+}
+
+/* Fix for request log table scrolling */
+.table-responsive {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+/* Important: Make tbody display as table-row-group, not block */
+#request-log {
+ display: table-row-group !important;
+}
+
+/* Truncate long URLs with ellipsis and show full text on hover */
+#request-log td:nth-child(2) {
+ max-width: 300px; /* Adjust this width as needed */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ position: relative;
+}
+
+/* Only show hover effect for elements with the 'truncated-url' class */
+#request-log td.truncated-url:hover {
+ overflow: visible;
+ white-space: normal;
+ word-break: break-all;
+ z-index: 1;
+}
+
+#request-log td.truncated-url:hover::before {
+ content: attr(data-full-url);
+ position: absolute;
+ left: 0;
+ top: 100%;
+ width: 300px;
+ background-color: var(--card-bg);
+ border: 1px solid var(--text-color);
+ padding: 5px;
+ border-radius: 3px;
+ box-shadow: var(--card-shadow);
+ word-break: break-all;
+ white-space: normal;
+ z-index: 10;
+}
+
+/* Ensure table column widths are appropriate */
+#request-log td:nth-child(1) { /* Timestamp */
+ width: 160px;
+}
+#request-log td:nth-child(3) { /* Total Time */
+ width: 120px;
+}
+#request-log td:nth-child(4) { /* Response Code */
+ width: 120px;
+}
+#request-log td:nth-child(5) { /* Details */
+ width: 100px;
+}
+
+/* JSON formatter styles */
+.json-formatter {
+ background: #f8f9fa;
+ border-radius: 4px;
+ padding: 10px;
+ font-family: monospace;
+ font-size: 13px;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+[data-theme="dark"] .json-formatter {
+ background: #2c3034;
+}
+
+.json-key {
+ color: #0d6efd;
+}
+
+.json-string {
+ color: #20c997;
+}
+
+.json-number {
+ color: #fd7e14;
+}
+
+.json-boolean {
+ color: #dc3545;
+}
+
+.json-null {
+ color: #6c757d;
+}
+
+/* Adjust table styles */
+.card-body .table {
+ font-size: 0.9rem;
+}
+
+.card-body .table td code {
+ white-space: pre-wrap;
+ max-width: 300px;
+ display: block;
+ overflow-x: auto;
+}
+
+/* Enhance accordion for custom events */
+.accordion-button {
+ padding: 0.5rem 1rem;
+}
+
+.accordion-body {
+ padding: 1rem;
+}
diff --git a/dashboard/js/script.js b/dashboard/js/script.js
new file mode 100644
index 0000000..06120cd
--- /dev/null
+++ b/dashboard/js/script.js
@@ -0,0 +1,544 @@
+// Initialize variables
+const rangeSelector = document.getElementById('range');
+const timezoneSelector = document.getElementById('timezone');
+const themeToggle = document.getElementById('theme-toggle');
+let isDarkMode = localStorage.getItem('darkMode') === 'true';
+let currentPage = 1;
+let totalPages = 1;
+let perPage = 50;
+let searchTerm = '';
+let dashboardData = null;
+let latencyChart, responseCodeChart;
+let searchDebounceTimer = null;
+const DEBOUNCE_DELAY = 300; // milliseconds
+let selectedTimezone = localStorage.getItem('selectedTimezone') || 'UTC';
+
+// Apply stored theme or default to light
+if (isDarkMode) {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ themeToggle.innerHTML = ' Light Mode';
+}
+
+// Set the timezone dropdown to the stored value
+if (selectedTimezone) {
+ timezoneSelector.value = selectedTimezone;
+}
+
+// Toggle theme
+themeToggle.addEventListener('click', () => {
+ isDarkMode = !isDarkMode;
+ document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : '');
+ themeToggle.innerHTML = ` ${isDarkMode ? 'Light' : 'Dark'} Mode`;
+ localStorage.setItem('darkMode', isDarkMode);
+});
+
+// Function to format a UTC timestamp to the selected timezone
+function formatTimestamp(utcTimestamp) {
+ try {
+ let date;
+
+ // Check if timestamp is in ISO format or contains 'Z' (indicating UTC)
+ if (utcTimestamp.includes('T') && (utcTimestamp.includes('Z') || utcTimestamp.includes('+'))) {
+ // Already in ISO format with timezone info, just parse it
+ date = new Date(utcTimestamp);
+ } else {
+ // Assume format: YYYY-MM-DD HH:MM:SS and explicitly treat as UTC
+ // MySQL timestamp format from database
+ const parts = utcTimestamp.split(/[- :]/);
+ if (parts.length >= 6) {
+ date = new Date(Date.UTC(
+ parseInt(parts[0]),
+ parseInt(parts[1])-1,
+ parseInt(parts[2]),
+ parseInt(parts[3]),
+ parseInt(parts[4]),
+ parseInt(parts[5])
+ ));
+ } else {
+ // If no specific format is detected, append 'Z' to signal UTC
+ date = new Date(utcTimestamp + 'Z');
+ }
+ }
+
+ // Check if date parsing succeeded
+ if (isNaN(date.getTime())) {
+ throw new Error("Invalid timestamp format");
+ }
+
+ // get the locale from the browser
+ const locale = navigator.language || 'en-US';
+
+ // Format the date in the selected timezone
+ return date.toLocaleString(locale, {
+ timeZone: selectedTimezone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+ } catch (error) {
+ console.error("Error formatting timestamp:", error, utcTimestamp);
+ return utcTimestamp; // Return original if there's an error
+ }
+}
+
+// Main data loading functions
+function loadData() {
+ loadDashboardData();
+ loadRequestLogData();
+}
+
+// Function to load dashboard widget data
+function loadDashboardData() {
+ const range = rangeSelector.value;
+ fetch(`/apm/data/dashboard?range=${range}`)
+ .then(response => {
+ if (!response.ok) throw new Error('Network response was not ok');
+ return response.json();
+ })
+ .then(data => {
+ console.log('Dashboard data received:', data);
+ dashboardData = data;
+ populateWidgets(data);
+ drawCharts(data);
+ })
+ .catch(error => console.error('Error loading dashboard data:', error));
+}
+
+// Function to load only request log data
+function loadRequestLogData() {
+ const range = rangeSelector.value;
+ fetch(`/apm/data/requests?range=${range}&page=${currentPage}&search=${encodeURIComponent(searchTerm)}`)
+ .then(response => {
+ if (!response.ok) throw new Error('Network response was not ok');
+ return response.json();
+ })
+ .then(data => {
+ console.log('Request log data received:', data);
+ populateRequestLog(data.requests);
+ updatePagination(data.pagination);
+ })
+ .catch(error => console.error('Error loading request log data:', error));
+}
+
+// Populate widgets with data
+function populateWidgets(data) {
+ const slowRequests = document.getElementById('slow-requests');
+ slowRequests.innerHTML = data.slowRequests.map(r => {
+ const time = parseFloat(r.total_time) * 1000;
+ return `
+ ${r.request_url}
+ ${time.toFixed(3)} ms
+ `;
+ }).join('');
+
+ const slowRoutes = document.getElementById('slow-routes');
+ slowRoutes.innerHTML = data.slowRoutes.map(r => {
+ const time = parseFloat(r.avg_time) * 1000;
+ return `
+ ${r.route_pattern}
+ ${time.toFixed(3)} ms
+ `;
+ }).join('');
+
+ document.getElementById('error-rate').textContent = `${(data.errorRate * 100).toFixed(2)}%`;
+
+ const longQueries = document.getElementById('long-queries');
+ longQueries.innerHTML = data.longQueries.map(q => {
+ const time = parseFloat(q.execution_time) * 1000;
+ const queryText = q.query.length > 50 ? q.query.substring(0, 50) + '...' : q.query;
+ return `
+ ${queryText}
+ ${time.toFixed(3)} ms
+ `;
+ }).join('');
+
+ const slowMiddleware = document.getElementById('slow-middleware');
+ slowMiddleware.innerHTML = data.slowMiddleware.map(m => {
+ const time = parseFloat(m.execution_time) * 1000;
+ return `
+ ${m.middleware_name}
+ ${time.toFixed(3)} ms
+ `;
+ }).join('');
+
+ const allRequestsCount = document.getElementById('all-requests-count');
+ allRequestsCount.textContent = data.allRequestsCount;
+
+ document.getElementById('cache-hit-rate').textContent = `${(data.cacheHitRate * 100).toFixed(2)}% Hits`;
+
+ document.getElementById('p95').textContent = (data.p95 * 1000).toFixed(3);
+ document.getElementById('p99').textContent = (data.p99 * 1000).toFixed(3);
+}
+
+// Pretty format JSON
+function formatJson(json) {
+ if (!json) return 'No data';
+ try {
+ // If it's already a string, parse it to ensure valid JSON
+ const obj = typeof json === 'string' ? JSON.parse(json) : json;
+ return JSON.stringify(obj, null, 2)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
+ let cls = 'json-number';
+ if (/^"/.test(match)) {
+ if (/:$/.test(match)) {
+ cls = 'json-key';
+ } else {
+ cls = 'json-string';
+ }
+ } else if (/true|false/.test(match)) {
+ cls = 'json-boolean';
+ } else if (/null/.test(match)) {
+ cls = 'json-null';
+ }
+ return '' + match + '';
+ });
+ } catch (e) {
+ return String(json);
+ }
+}
+
+// Populate request log table with improved details
+function populateRequestLog(requests) {
+ const requestLog = document.getElementById('request-log');
+ requestLog.innerHTML = requests.map((r, index) => {
+ const time = parseFloat(r.total_time) * 1000;
+
+ // Convert UTC timestamp to selected timezone
+ const formattedTimestamp = formatTimestamp(r.timestamp);
+
+ // Build details sections conditionally
+ let detailSections = [];
+
+ // Middleware section
+ if (r.middleware && r.middleware.length > 0) {
+ detailSections.push(`
+
+
Middleware
+
+
+
+
+ Name |
+ Execution Time |
+
+
+
+ ${r.middleware.map(m => `
+
+ ${m.middleware_name} |
+ ${(parseFloat(m.execution_time) * 1000).toFixed(3)} ms |
+
+ `).join('')}
+
+
+
+
+ `);
+ }
+
+ // Database Queries section
+ if (r.queries && r.queries.length > 0) {
+ detailSections.push(`
+
+
Database Queries
+
+
+
+
+ Query |
+ Execution Time |
+ Rows |
+
+
+
+ ${r.queries.map(q => `
+
+ ${q.query} |
+ ${(parseFloat(q.execution_time) * 1000).toFixed(3)} ms |
+ ${q.row_count} |
+
+ `).join('')}
+
+
+
+
+ `);
+ }
+
+ // Errors section
+ if (r.errors && r.errors.length > 0) {
+ detailSections.push(`
+
+
Errors
+
+
+
+
+ Error Message |
+ Error Code |
+
+
+
+ ${r.errors.map(e => `
+
+ ${e.error_message} |
+ ${e.error_code} |
+
+ `).join('')}
+
+
+
+
+ `);
+ }
+
+ // Cache Operations section
+ if (r.cache && r.cache.length > 0) {
+ detailSections.push(`
+
+
Cache Operations
+
+
+
+
+ Key |
+ Result |
+ Time |
+
+
+
+ ${r.cache.map(c => `
+
+ ${c.cache_key} |
+ ${c.hit ? 'Hit' : 'Miss'} |
+ ${(parseFloat(c.execution_time) * 1000).toFixed(3)} ms |
+
+ `).join('')}
+
+
+
+
+ `);
+ }
+
+ // Custom Events section
+ if (r.custom_events && r.custom_events.length > 0) {
+ detailSections.push(`
+
+
Custom Events
+
+ ${r.custom_events.map((event, eventIndex) => `
+
+
+
+
+
${formatJson(event.data)}
+
+
+
+ `).join('')}
+
+
+ `);
+ }
+
+ // If no details available
+ if (detailSections.length === 0) {
+ detailSections.push(`No detailed information available for this request.
`);
+ }
+
+ // Check if URL is long enough to need truncation
+ const urlTooLong = r.request_url.length > 40;
+ const urlCellClass = urlTooLong ? 'truncated-url' : '';
+
+ return `
+
+ ${formattedTimestamp} |
+ ${r.request_url} |
+ ${time.toFixed(3)} ms |
+ ${r.response_code} |
+
+
+
+
+ ${detailSections.join('')}
+
+
+ |
+
+ `;
+ }).join('');
+}
+
+// Update pagination controls
+function updatePagination(pagination) {
+ currentPage = pagination.currentPage;
+ totalPages = pagination.totalPages;
+ perPage = pagination.perPage;
+
+ // Update pagination info
+ const start = (currentPage - 1) * perPage + 1;
+ const end = Math.min(currentPage * perPage, pagination.totalRequests);
+ document.getElementById('pagination-info').textContent = `Showing ${start} to ${end} of ${pagination.totalRequests} requests`;
+
+ // Update pagination buttons
+ const prevPage = document.getElementById('prev-page');
+ const nextPage = document.getElementById('next-page');
+ prevPage.classList.toggle('disabled', currentPage === 1);
+ nextPage.classList.toggle('disabled', currentPage === totalPages);
+
+ prevPage.onclick = () => {
+ if (currentPage > 1) {
+ currentPage--;
+ loadRequestLogData(); // Only reload request log data
+ }
+ return false;
+ };
+
+ nextPage.onclick = () => {
+ if (currentPage < totalPages) {
+ currentPage++;
+ loadRequestLogData(); // Only reload request log data
+ }
+ return false;
+ };
+}
+
+// Draw charts with data
+function drawCharts(data) {
+ // Latency Chart
+ const ctxLatency = document.getElementById('latencyChart').getContext('2d');
+ if (latencyChart) latencyChart.destroy();
+ latencyChart = new Chart(ctxLatency, {
+ type: 'line',
+ data: {
+ labels: data.chartData.map(d => d.timestamp),
+ datasets: [{
+ label: 'Average Latency (ms)',
+ data: data.chartData.map(d => d.average_time * 1000),
+ borderColor: '#0d6efd',
+ backgroundColor: 'rgba(13, 110, 253, 0.1)',
+ fill: true,
+ tension: 0.4,
+ }]
+ },
+ options: {
+ scales: {
+ x: { title: { display: true, text: 'Time' } },
+ y: {
+ title: { display: true, text: 'Latency (ms)' },
+ beginAtZero: true,
+ suggestedMax: 1
+ }
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top',
+ }
+ }
+ }
+ });
+
+ // Response Code Distribution Over Time (Stacked Bar Chart)
+ const ctxResponse = document.getElementById('responseCodeChart').getContext('2d');
+ if (responseCodeChart) responseCodeChart.destroy();
+
+ // Extract all unique response codes
+ const responseCodes = [...new Set(data.responseCodeOverTime.flatMap(d => Object.keys(d).filter(k => k !== 'timestamp')))];
+
+ // Create datasets for each response code
+ const datasets = responseCodes.map(code => {
+ // Determine color based on response code
+ let color;
+ if (code >= 500 && code <= 599) {
+ color = '#dc3545'; // Red for 5xx errors
+ } else if (code >= 400 && code <= 499) {
+ color = '#ffc107'; // Yellow for 4xx errors
+ } else if (code >= 300 && code <= 399) {
+ color = '#0dcaf0'; // Cyan for 3xx redirects
+ } else {
+ color = '#198754'; // Green for 2xx success
+ }
+
+ return {
+ label: `Code ${code}`,
+ data: data.responseCodeOverTime.map(d => d[code] || 0),
+ backgroundColor: color,
+ borderWidth: 1,
+ };
+ });
+
+ responseCodeChart = new Chart(ctxResponse, {
+ type: 'bar',
+ data: {
+ labels: data.responseCodeOverTime.map(d => d.timestamp),
+ datasets: datasets
+ },
+ options: {
+ scales: {
+ x: {
+ title: { display: true, text: 'Time' },
+ stacked: true,
+ },
+ y: {
+ title: { display: true, text: 'Request Count' },
+ beginAtZero: true,
+ stacked: true,
+ }
+ },
+ plugins: {
+ legend: {
+ position: 'bottom',
+ }
+ }
+ }
+ });
+}
+
+// Debounce function
+function debounce(func, delay) {
+ return function() {
+ const context = this;
+ const args = arguments;
+ clearTimeout(searchDebounceTimer);
+ searchDebounceTimer = setTimeout(() => func.apply(context, args), delay);
+ };
+}
+
+// Event listeners
+rangeSelector.addEventListener('change', loadData);
+
+// Add event listener for timezone selector
+timezoneSelector.addEventListener('change', () => {
+ selectedTimezone = timezoneSelector.value;
+ localStorage.setItem('selectedTimezone', selectedTimezone);
+ // Refresh only the request log data since it contains timestamps
+ loadRequestLogData();
+});
+
+// Search functionality with debounce
+const searchInput = document.getElementById('request-search');
+searchInput.addEventListener('input', debounce(() => {
+ searchTerm = searchInput.value;
+ currentPage = 1; // Reset to first page on search
+ loadRequestLogData(); // Only reload request log data
+}, DEBOUNCE_DELAY));
+
+// Initialize dashboard on page load
+document.addEventListener('DOMContentLoaded', () => {
+ loadData();
+});
diff --git a/dashboard/views/dashboard.php b/dashboard/views/dashboard.php
index e51aa58..122fbf1 100644
--- a/dashboard/views/dashboard.php
+++ b/dashboard/views/dashboard.php
@@ -9,134 +9,7 @@
-
+
@@ -149,12 +22,39 @@
+
+
+
+
+
+
+ Total Requests: Loading...
+
+
+
@@ -299,531 +199,6 @@
-
-
+