Compare commits

..
Author SHA1 Message Date
Claude e4d2abd496 Add PropTypes and improve key handling in DockerContainersTable
Improvements:
- Add PropTypes validation for component props (containers, errors, captureVersion)
- Use composite key for ports map instead of index for better React reconciliation
- Ensures type safety and follows codebase PropTypes conventions

This improves code quality and prevents potential runtime issues.
2025-11-15 22:54:27 +00:00
Claude 8f3edac281 Fix DockerContainersTable to use Checkmate theme palette colors
Replace MUI shorthand color strings with proper Checkmate theme palette paths:
- color="textSecondary" → theme.palette.primary.contrastTextTertiary
- color="error.main" → theme.palette.error.main
- color="warning.main" → theme.palette.warning.main
- color="success.main" → theme.palette.success.main

This ensures consistent theming across the application and follows Checkmate component standards.
2025-11-15 22:48:45 +00:00
Claude fd9d265d23 Fix UX issues and improve Docker monitoring user experience
Critical fixes:
1. Monitor type detection on edit
   - When editing a Docker monitor, type is now correctly detected from monitor object
   - Previously defaulted to "hardware" causing incorrect form fields to show
   - Fix: Detect type from query params on create, from monitor.type on edit

2. Default interval for Docker monitors
   - Docker monitors now default to 30 seconds (0.5 minutes) on creation
   - Hardware monitors remain at 15 seconds (0.25 minutes)
   - Pass monitorType to initializeInfrastructureMonitorForCreate

3. Type-aware fallback links
   - Hardware tab empty state: links to /infrastructure/create?type=hardware
   - Docker tab empty state: links to /infrastructure/create?type=docker
   - Ensures correct form is shown when creating from empty state

UX improvements:
4. Enhanced error messaging in Docker container table
   - Differentiate between "No containers" vs "Docker daemon unavailable"
   - Show error details when Capture API fails
   - Display critical errors (docker.client) with red background
   - Show partial error messages when some containers fail to inspect

5. Display Capture metadata
   - Show Capture version in container table header
   - Display error count when errors are present (⚠ N error(s) encountered)
   - Help users identify API issues vs legitimate empty states

6. Improved empty state handling
   - "Docker daemon unavailable" - when Docker socket unreachable on Capture server
   - "No containers found" - when Capture is working but no containers exist
   - "Partial data available" - when some containers failed to inspect
   - Show specific error messages from Capture API

These changes improve the user experience when:
- Editing existing Docker monitors (correct form fields shown)
- Creating Docker monitors (proper default interval)
- Troubleshooting Capture API issues (clear error messages)
- Understanding container availability (version info, error details)
2025-11-15 22:42:02 +00:00
Claude 0a90a30045 Complete Docker monitoring integration with Capture API
This commit completes the Docker container monitoring feature using Capture API.

Frontend changes:
- Updated Infrastructure create/edit form to support Docker monitors
  - Conditional UI based on monitor type (hardware vs docker)
  - Docker-specific labels and placeholders
  - Capture server URL field (e.g., http://192.168.1.100:59232)
  - API secret field for Bearer token authentication
  - Enforced 30-second minimum polling interval for Docker
  - Conditionally hide hardware thresholds for Docker monitors

- Updated useInfrastructureSubmit hook
  - Accept monitorType parameter (hardware or docker)
  - Build form with type-specific fields
  - Only add thresholds for hardware monitors
  - Submit with correct monitor type to backend

- Created DockerContainersTable component
  - Displays all containers from Capture API response
  - Shows: container name, ID, status, health, image, uptime, ports
  - Color-coded status indicators (green=healthy, yellow=warning, red=error)
  - Formats uptime/downtime duration
  - Handles empty state gracefully

- Updated Infrastructure Details page
  - Conditionally render Docker container table for docker monitors
  - Keep existing hardware metrics (gauges, charts) for hardware monitors
  - Hide network tab for Docker monitors (not applicable)
  - Display container data from latest check

Implementation complete and ready for testing.
2025-11-15 22:33:05 +00:00
Claude 0f3c81c052 Add Docker monitoring integration with Capture API (partial implementation)
Backend changes:
- Update Check model to store Docker container data (container_id, status, health, ports, etc.)
- Refactor networkService to support both local Docker and remote Capture API
  - requestLocalDocker: Existing local Docker socket monitoring
  - requestCaptureDocker: New Capture API endpoint integration
  - requestDocker: Routes based on URL pattern (http/https vs container name)
- Update statusService to handle Docker check data storage
- Enforce proper error handling for Capture API (401, 404, 500, 207 responses)

Frontend changes:
- Add Hardware/Docker tabs to Infrastructure page
- Add query parameter support for monitor type selection
- Prepare form for Docker monitor creation (partial)

Status: Backend complete, frontend in progress
Next: Complete Docker monitor form, add container table component, test end-to-end
2025-11-15 22:27:31 +00:00
Claude 2d58facb97 Add comprehensive architecture documentation for Docker monitoring integration
Generated detailed architecture guide covering:
- Project structure and technology stack
- Monitor types and implementation patterns
- Database schema and models
- API patterns and endpoints
- Job scheduling and polling mechanisms
- Frontend components and UI structure
- Authentication and configuration flow

This documentation supports the Docker monitoring integration with Capture API.
2025-11-14 18:51:28 +00:00
816 changed files with 41780 additions and 37149 deletions

No files matched your search

+1 -4
View File
@@ -1,6 +1,3 @@
.idea
.vscode
.VSCodeCounter
*.sh
mongo
node_modules/
.VSCodeCounter
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
echo "Running lint-staged..."
cd client && npx lint-staged && cd ..
cd server && npx lint-staged && cd ..
+993
View File
@@ -0,0 +1,993 @@
# Checkmate Codebase Architecture - Docker Monitoring Integration Guide
## 1. PROJECT STRUCTURE
### Directory Layout
```
Checkmate/
├── server/ # Backend Node.js/Express
│ ├── src/
│ │ ├── db/v1|v2/ # Database (MongoDB)
│ │ ├── service/v1|v2/ # Business logic
│ │ ├── routes/v1|v2/ # API routes
│ │ ├── controllers/v1|v2/ # HTTP request handlers
│ │ ├── middleware/v1/ # Express middleware
│ │ ├── config/ # Service initialization
│ │ ├── validation/ # Input validation
│ │ └── utils/ # Helper functions
│ └── package.json # Dependencies (Node >=20)
├── client/ # Frontend React + TypeScript
│ ├── src/
│ │ ├── Components/v1|v2/ # UI Components
│ │ ├── Pages/v1|v2/ # Page layouts
│ │ ├── Hooks/ # React hooks (data fetching)
│ │ ├── Features/ # Redux slices
│ │ └── Utils/ # Utilities & API client
│ └── package.json # React, Vite, MUI
└── docker/ # Deployment configs
```
### Technology Stack
**Backend:**
- Express 4.19 (HTTP server)
- MongoDB 8.3 (database)
- Mongoose (ODM)
- BullMQ 5.41 (job queue) - V2 only
- super-simple-scheduler 1.4 (job scheduling) - V1
- Dockerode 4.0.6 (Docker API client)
- JWT authentication
- Winston (logging)
**Frontend:**
- React 18.3 + TypeScript
- Vite (build tool)
- MUI v6 (Material-UI)
- Redux Toolkit (state management)
- Axios (HTTP client)
- React Router (navigation)
- i18next (internationalization)
**Architectures:**
- V1: Simpler, SuperSimpleQueue-based job scheduling
- V2: TypeScript-based, BullMQ, more structured (in development)
---
## 2. MONITOR TYPES & IMPLEMENTATION
### Supported Monitor Types
Defined in `/server/src/db/v1/models/Monitor.js` line 46:
```javascript
type: {
enum: ["http", "ping", "pagespeed", "hardware", "docker", "port", "game"],
required: true
}
```
### How Monitor Types Are Handled
#### Request Dispatch (NetworkService)
**Location:** `/server/src/service/v1/infrastructure/networkService.js` (580 lines)
Entry point: `requestStatus(monitor)` - Routes to type-specific handlers:
```javascript
async requestStatus(monitor) {
switch(monitor.type) {
case "ping": return await this.requestPing(monitor);
case "http": return await this.requestHttp(monitor);
case "pagespeed": return await this.requestPageSpeed(monitor);
case "hardware": return await this.requestHardware(monitor);
case "docker": return await this.requestDocker(monitor);
case "port": return await this.requestPort(monitor);
case "game": return await this.requestGame(monitor);
}
}
```
#### DOCKER IMPLEMENTATION (Lines 289-383)
```javascript
async requestDocker(monitor) {
// Uses Dockerode to connect to /var/run/docker.sock
const docker = new this.Docker({
socketPath: "/var/run/docker.sock",
handleError: true,
});
// Container matching (priority-based):
// 1. Exact full ID match (64-char)
// 2. Exact name match (case-insensitive)
// 3. Partial ID match (backwards compatibility)
// Returns: { status: bool, code: int, message: string, responseTime: ms }
}
```
**Docker Monitor Storage:**
- `monitor.url`: Container ID or name (e.g., "my-app" or "a1b2c3d4...")
- `monitor.port`: Not used for Docker
- Returns: Only status and response time (no detailed metrics)
#### HARDWARE IMPLEMENTATION (Lines 279-287)
```javascript
async requestHardware(monitor) {
return await this.requestHttp(monitor); // Delegates to HTTP!
}
```
Hardware monitors send HTTP requests to Capture API endpoint with secret authentication.
### Hardware/Docker Check Data Structure
**Location:** `/server/src/db/v1/models/Check.js` (176 lines)
Check schema includes:
```javascript
// Common fields
monitorId, teamId, type, status, responseTime, message
// Hardware/Docker specific (capture data):
cpu: {
physical_core, logical_core, frequency, temperature[],
free_percent, usage_percent
},
memory: {
total_bytes, available_bytes, used_bytes, usage_percent
},
disk: [{
read_speed_bytes, write_speed_bytes,
total_bytes, free_bytes, usage_percent
}],
host: { os, platform, kernel_version },
errors: [{ metric[], err }],
capture: { version, mode },
net: [{ name, bytes_sent, bytes_recv, ... }]
```
**TTL:** Auto-deletes after 30 days
**Indexes:** monitorId, type, status, updatedAt (optimized for queries)
---
## 3. DATABASE SCHEMA
### Core Models
#### Monitor Model
**File:** `/server/src/db/v1/models/Monitor.js` (208 lines)
```javascript
{
userId: ObjectId (immutable, required),
teamId: ObjectId (immutable, required),
name: String (required),
description: String,
type: String (enum: http|ping|pagespeed|hardware|docker|port|game),
url: String (required) // container name/ID for docker
port: Number,
interval: Number (ms, default: 60000),
isActive: Boolean (default: true),
status: Boolean, // Current up/down
statusWindow: [Boolean], // Last N checks
statusWindowSize: Number (default: 5),
statusWindowThreshold: Number (default: 60),
// Hardware/Docker thresholds
thresholds: {
usage_cpu: Number (0-1),
usage_memory: Number (0-1),
usage_disk: Number (0-1),
usage_temperature: Number
},
// Notification thresholds
alertThreshold: Number (default: 5),
cpuAlertThreshold, memoryAlertThreshold, diskAlertThreshold, tempAlertThreshold,
// Relationships
notifications: [ObjectId], // refs to Notification model
// Advanced
secret: String, // For Capture API auth
ignoreTlsErrors: Boolean,
jsonPath: String,
expectedValue: String,
matchMethod: String (equal|include|regex),
gameId: String,
group: String (max 50 chars),
// Computed
uptimePercentage: Number,
timestamps: true // createdAt, updatedAt
}
// Indexes
MonitorSchema.index({ teamId: 1, type: 1 });
// Pre-hooks
pre("findOneAndDelete"): Deletes checks, stats, removes from status pages
pre("deleteMany"): Cascade deletes
pre("save"): Syncs alertThresholds
pre("findOneAndUpdate"): Updates thresholds
```
#### Check Model
**File:** `/server/src/db/v1/models/Check.js`
```javascript
{
monitorId: ObjectId (ref: Monitor, immutable, indexed),
teamId: ObjectId (ref: Team, immutable, indexed),
type: String (enum: http|ping|pagespeed|hardware|docker|port|game, indexed),
status: Boolean (indexed),
responseTime: Number,
timings: Object,
statusCode: Number,
message: String,
// Acknowledgment
ack: Boolean (default: false),
ackAt: Date,
// Hardware fields (see above)
cpu, memory, disk[], host, errors[], capture, net[]
// PageSpeed only
accessibility, bestPractices, seo, performance, audits,
// Expiry
expiry: Date (TTL index: 30 days),
timestamps: true
}
// Indexes
CheckSchema.index({ updatedAt: 1 });
CheckSchema.index({ monitorId: 1, updatedAt: 1 });
CheckSchema.index({ monitorId: 1, updatedAt: -1 });
CheckSchema.index({ teamId: 1, updatedAt: -1 });
```
#### MonitorStats Model
**File:** `/server/src/db/v1/models/MonitorStats.js`
```javascript
{
monitorId: ObjectId (ref: Monitor, immutable, indexed),
avgResponseTime: Number,
totalChecks: Number,
totalUpChecks: Number,
totalDownChecks: Number,
uptimePercentage: Number,
lastCheckTimestamp: Number,
lastResponseTime: Number,
timeOfLastFailure: Number,
timestamps: true
}
```
### Database Access Layer
**Location:** `/server/src/db/v1/modules/`
- `MonitorModule.js` (593 lines) - Monitor CRUD + aggregations
- `CheckModule.js` - Check recording
- `MonitorModuleQueries.js` - MongoDB aggregation pipelines
**Key Methods:**
```javascript
// Creation
Monitor.createMonitor({ body, teamId, userId })
Monitor.createBulkMonitors(monitors)
// Retrieval
Monitor.getMonitorById(id)
Monitor.getMonitorsByTeamId()
Monitor.getMonitorStatsById()
Monitor.getHardwareDetailsById()
// Updates
Monitor.updateMonitor(id, updateData)
// Deletions
Monitor.deleteMonitor(id)
```
---
## 4. API PATTERNS
### REST Endpoint Structure
**Base:** `/api/v1/`
#### Monitor Routes
**File:** `/server/src/routes/v1/monitorRoute.js`
```
GET /monitors getAllMonitors
POST /monitors createMonitor
DELETE /monitors deleteAllMonitors (superadmin)
GET /monitors/:monitorId getMonitorById
PUT /monitors/:monitorId editMonitor (admin)
DELETE /monitors/:monitorId deleteMonitor (admin)
GET /monitors/team getMonitorsByTeamId
GET /monitors/team/with-checks getMonitorsWithChecksByTeamId
GET /monitors/team/summary getMonitorsSummaryByTeamId
GET /monitors/team/groups getGroupsByTeamId
GET /monitors/stats/:monitorId getMonitorStatsById
GET /monitors/uptime/details/:monitorId getUptimeDetailsById
GET /monitors/hardware/details/:monitorId getHardwareDetailsById
POST /monitors/pause/:monitorId pauseMonitor (admin)
POST /monitors/demo addDemoMonitors
POST /monitors/bulk createBulkMonitors (CSV upload)
POST /monitors/test-email sendTestEmail
GET /monitors/games getAllGames
GET /monitors/certificate/:monitorId fetchCertificate
```
#### Check Routes
**File:** `/server/src/routes/v1/checkRoute.js`
```
GET /checks/:monitorId getChecksByMonitorId
GET /checks/team/:teamId getChecksByTeamId
GET /checks/:monitorId/:checkId getCheckById
PUT /checks/:checkId updateCheck (ack)
```
### Request/Response Pattern
**Create Monitor Request:**
```javascript
POST /api/v1/monitors
Authorization: Bearer <JWT>
Content-Type: application/json
{
name: "My Docker Container",
description: "Monitors app container",
type: "docker",
url: "my-app-container", // Container name
interval: 60000, // 1 minute
isActive: true,
secret: "api-key-for-capture", // If using hardware monitoring
notifications: ["notif-id-1"],
thresholds: {
usage_cpu: 0.8, // 80%
usage_memory: 0.9, // 90%
usage_disk: 0.95
},
alertThreshold: 5, // Consecutive failures
statusWindowSize: 5, // Last 5 checks
statusWindowThreshold: 60 // % threshold
}
Response 200:
{
data: {
_id: "monitor-id",
name: "My Docker Container",
type: "docker",
status: true,
interval: 60000,
...
},
msg: "Monitor created successfully"
}
```
**Edit Monitor Request:**
```javascript
PUT /api/v1/monitors/:monitorId
{
name: "Updated Name",
interval: 120000,
thresholds: { usage_cpu: 0.85 },
// Partial updates allowed
}
```
### Authentication & Authorization
**JWT Middleware:** `/server/src/middleware/v1/verifyJWT.js`
- Header: `Authorization: Bearer <token>`
- Validates signature using `jwtSecret` from AppSettings
- Extracts user info: `req.user = { userId, teamId, role, ... }`
**Role-Based Access:**
```javascript
// isAllowed middleware checks req.user.role
router.post("/monitors", isAllowed(["admin", "superadmin"]), createMonitor);
router.delete("/monitors/:monitorId", isAllowed(["admin", "superadmin"]), deleteMonitor);
```
### Validation
**File:** `/server/src/validation/joi.js`
```javascript
const createMonitorBodyValidation = joi.object({
name: joi.string().required(),
description: joi.string().required(),
type: joi.string().required(),
url: joi.string().required(),
interval: joi.number(),
port: joi.number(),
thresholds: joi.object().keys({
usage_cpu: joi.number(),
usage_memory: joi.number(),
usage_disk: joi.number(),
usage_temperature: joi.number(),
}),
notifications: joi.array().items(joi.string()),
secret: joi.string(),
// ... more fields
});
```
---
## 5. POLLING/JOBS IMPLEMENTATION
### Job Queue Architecture
#### V1: SuperSimpleScheduler (Production)
**Files:**
- `/server/src/service/v1/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js`
- `/server/src/service/v1/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js`
**Job Lifecycle:**
1. **Initialization** (on server startup):
```javascript
// Load all monitors from DB
const monitors = await db.monitorModule.getAllMonitors();
for (const monitor of monitors) {
// Add job with random offset (0-100ms)
this.addJob(monitor._id, monitor);
}
// Register job template
scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
```
2. **Job Template** (executed per interval):
```javascript
getMonitorJob = () => {
return async (monitor) => {
// Check maintenance window
if (await this.isInMaintenanceWindow(monitorId, teamId)) {
return; // Skip
}
// Execute monitor check
const networkResponse = await this.networkService.requestStatus(monitor);
// Update monitor status
const { monitor: updated, statusChanged } =
await this.statusService.updateStatus(networkResponse);
// Send notifications if status changed
if (statusChanged) {
await this.notificationService.handleNotifications({
...networkResponse,
monitor: updated,
prevStatus,
statusChanged
});
}
};
};
```
3. **Job Control:**
```javascript
// Add job
addJob(monitorId, monitor) {
scheduler.addJob({
id: monitorId.toString(),
template: "monitor-job",
repeat: monitor.interval, // Milliseconds
active: monitor.isActive,
data: monitor.toObject()
});
}
// Pause/Resume
pauseJob(monitor) { scheduler.pauseJob(monitor._id); }
resumeJob(monitor) { scheduler.resumeJob(monitor._id); }
// Update interval
updateJob(monitor) {
scheduler.updateJob(monitor._id, {
repeat: monitor.interval,
data: monitor.toObject()
});
}
// Delete
deleteJob(monitor) { scheduler.removeJob(monitor._id); }
```
#### V2: BullMQ (TypeScript, Planned)
**Files:**
- `/server/src/service/v2/infrastructure/JobQueue.ts`
- `/server/src/service/v2/infrastructure/JobGenerator.ts`
**Key Differences:**
- Uses Redis backing store
- TypeScript interfaces (type safety)
- More robust queue management
- Cleanup jobs for orphaned checks
```typescript
// Cleanup job runs every 24 hours
scheduler.addJob({
id: "cleanup-orphaned-checks",
template: "cleanup-job",
repeat: 24 * 60 * 60 * 1000,
active: true
});
```
### Monitor Execution Flow
```
Monitor Created/Updated
↓
MonitorService.createMonitor()
↓
jobQueue.addJob(monitor)
↓
Job scheduled at interval
↓
[Job Execution]
├─ Check maintenance window
├─ NetworkService.requestStatus(monitor)
│ └─ Routes to type handler (requestDocker, requestHttp, etc)
├─ Create Check record (database)
├─ StatusService.updateMonitorStatus()
│ └─ Updates Monitor.status field
├─ MonitorStatsService.updateStats()
├─ If status changed → NotificationService.handleNotifications()
└─ Job completes / reschedules for next interval
```
### Status Update Logic
**Location:** `/server/src/service/v1/infrastructure/statusService.js`
```javascript
async updateStatus(networkResponse) {
// networkResponse = { monitorId, type, status, code, message, responseTime }
// 1. Create Check record
const check = await Check.create({
monitorId, teamId, type, status,
responseTime, statusCode: code, message
});
// 2. Get previous status
const prevStatus = monitor.status;
// 3. Apply statusWindow logic (sliding window of checks)
monitor.statusWindow.push(status);
if (monitor.statusWindow.length > monitor.statusWindowSize) {
monitor.statusWindow.shift();
}
// 4. Determine if DOWN (majority of window is false)
const downCount = monitor.statusWindow.filter(s => !s).length;
const isDown = (downCount / monitor.statusWindowSize) >
(monitor.statusWindowThreshold / 100);
monitor.status = isDown ? false : true;
// 5. Return status change info
return {
monitor: await monitor.save(),
statusChanged: prevStatus !== monitor.status,
prevStatus
};
}
```
---
## 6. FRONTEND COMPONENTS
### Monitor Management Pages
#### Infrastructure Monitors (Hardware/Docker)
**Location:** `/client/src/Pages/v1/Infrastructure/`
```
Infrastructure/
├── Create/
│ ├── index.jsx (Main form - 10K lines)
│ ├── Components/
│ │ ├── MonitorStatusHeader.jsx
│ │ ├── MonitorActionButtons.jsx
│ │ └── CustomAlertsSection.jsx
│ └── hooks/
│ ├── useInfrastructureMonitorForm.jsx
│ ├── useValidateInfrastructureForm.jsx
│ └── useInfrastructureSubmit.jsx
├── Details/
├── Monitors/
│ └── Components/
│ └── MonitorsTable.jsx
```
#### Form Structure (useInfrastructureMonitorForm hook)
```javascript
const [infrastructureMonitor, setInfrastructureMonitor] = useState({
url: "", // Container name/IP
name: "", // Display name
notifications: [], // Notification IDs
interval: 0.25, // Minutes (0.25 = 15s, 1 = 60s)
// Threshold toggles
cpu: false,
usage_cpu: "", // Percentage
memory: false,
usage_memory: "",
disk: false,
usage_disk: "",
temperature: false,
usage_temperature: "",
secret: "", // Capture API secret
statusWindowSize: 5,
statusWindowThreshold: 60
});
```
**Frequency Options:**
```javascript
[
{ _id: 0.25, name: "15 Seconds" },
{ _id: 0.5, name: "30 Seconds" },
{ _id: 1, name: "1 Minute" },
{ _id: 2, name: "2 Minutes" },
{ _id: 5, name: "5 Minutes" },
{ _id: 10, name: "10 Minutes" }
]
```
#### Form Submission (useInfrastructureSubmit hook)
```javascript
// Converts frontend form → backend model
const finalForm = {
url: `http${https ? "s" : ""}://` + infrastructureMonitor.url,
name: infrastructureMonitor.name || infrastructureMonitor.url,
interval: infrastructureMonitor.interval * 60000, // Convert to ms
type: "hardware", // Always "hardware" for infrastructure monitors
// Thresholds (convert % to decimal 0-1)
thresholds: {
...(cpu ? { usage_cpu: usage_cpu / 100 } : {}),
...(memory ? { usage_memory: usage_memory / 100 } : {}),
...(disk ? { usage_disk: usage_disk / 100 } : {}),
...(temperature ? { usage_temperature: usage_temperature / 100 } : {})
},
notifications: infrastructureMonitor.notifications
};
// API call
isCreate ?
await createMonitor({ monitor: finalForm, redirect: "/infrastructure" }) :
await updateMonitor({ monitor: finalForm, redirect: "/infrastructure" });
```
### Monitor Display Components
#### Monitor Status Component
**Location:** `/client/src/Components/v2/Monitors/MonitorStatus.tsx`
Displays:
- Status indicator (up/down)
- Response time
- Last check time
- Incident badges
#### Monitoring Hooks
**Location:** `/client/src/Hooks/v1/monitorHooks.js`
```javascript
// Fetch monitors
useFetchMonitorsByTeamId({ types, limit, page, ... })
useFetchMonitorsWithSummary({ types, monitorUpdateTrigger })
useFetchMonitorsWithChecks({ types, limit, ... })
// Fetch individual monitor
useFetchHardwareMonitorById({ monitorId, updateTrigger })
useFetchStatsByMonitorId({ monitorId, dateRange, ... })
// Manage monitors
useCreateMonitor() // POST /monitors
useUpdateMonitor() // PUT /monitors/:id
useDeleteMonitor() // DELETE /monitors/:id
usePauseMonitor() // POST /monitors/pause/:id
```
### Network Service (API Client)
**Location:** `/client/src/Utils/NetworkService.js` (1157 lines)
```javascript
// Constructor
constructor(store, dispatch, navigate) {
// Axios instance with:
// - Bearer token injection
// - Language header
// - 401 redirect to login
// - Network error handling
}
// Monitor endpoints
async createMonitor(config) {
return this.axiosInstance.post(`/monitors`, config.monitor);
}
async updateMonitor(config) {
return this.axiosInstance.put(`/monitors/${config.monitorId}`, config.monitor);
}
async getMonitorsByTeamId(config) {
// Query params: limit, types[], page, rowsPerPage, filter, field, order
return this.axiosInstance.get(`/monitors/team?...`);
}
async getHardwareDetailsByMonitorId(config) {
return this.axiosInstance.get(`/monitors/hardware/details/${config.monitorId}`);
}
async getStatsByMonitorId(config) {
// Query params: sortOrder, limit, dateRange, numToDisplay, normalize
return this.axiosInstance.get(`/monitors/stats/${config.monitorId}?...`);
}
```
---
## 7. AUTHENTICATION & API KEYS
### User Authentication (JWT)
**Registration/Login:**
```javascript
// POST /auth/register
{
firstName: "John",
lastName: "Doe",
email: "john@example.com",
password: "SecurePass123!"
}
Response:
{
user: { _id, email, role, teamId, ... },
token: "eyJhbGciOiJIUzI1NiIs..." // JWT
}
```
**Token Storage:** Redux store (`Features/Auth/authSlice`)
**Token Injection:** Automatic by NetworkService
```javascript
config.headers.Authorization = `Bearer ${authToken}`;
```
### Monitor-Specific Authentication
#### For Hardware/Docker Monitors via Capture API
Monitors can have a `secret` field for authenticating requests to the Capture API:
```javascript
{
type: "hardware",
url: "http://192.168.1.100:3000", // Capture endpoint
secret: "api-key-12345", // Passed to Capture API
thresholds: { ... }
}
```
**Implementation:** The secret is stored in the Monitor model and could be:
1. Sent as HTTP header: `Authorization: Bearer <secret>`
2. Sent as query param: `?secret=<secret>`
3. Sent in request body for POST requests
---
## 8. CONFIGURATION & USER SETTINGS
### Global Settings
**Model:** `/server/src/db/v1/models/AppSettings.js`
```javascript
{
jwtSecret: String, // For signing JWTs
sslCertificate: String, // SSL cert content
smtpConfig: Object, // Email settings
globalThresholds: { // Default thresholds
cpu: Number,
memory: Number,
disk: Number,
temperature: Number
},
siteTitle: String,
defaultLanguage: String,
maintenanceMode: Boolean
}
```
**Fetching Settings:**
```javascript
// Frontend
useFetchGlobalSettings()
→ NetworkService.getGlobalSettings()
→ GET /settings
// Backend
SettingsService.loadSettings()
→ Loads from AppSettings collection
```
### Monitor Configuration UI Flow
1. **Create Page:** `/infrastructure/create`
- Fetch global settings for default thresholds
- Form includes:
- Monitor name
- URL/Container name
- Interval (dropdown)
- CPU/Memory/Disk/Temp thresholds (checkboxes + inputs)
- Notification channels (multi-select)
- Secret (if using Capture API)
2. **Edit Page:** `/infrastructure/configure/:monitorId`
- Load existing monitor data
- Update form fields
- Save changes with PUT request
3. **Details Page:** `/infrastructure/:monitorId`
- Display monitor stats
- Show uptime graph
- List recent checks
- Status history
### Team-Based Isolation
**Access Control:**
```javascript
// All queries filtered by teamId
await Monitor.find({ teamId: req.user.teamId });
// verifyTeamAccess middleware
await verifyTeamAccess(req.user.teamId, req.params.monitorId);
```
---
## KEY PATTERNS FOR DOCKER MONITORING INTEGRATION
### 1. Adding Docker Monitor Type
Already supported! Located in:
- `Monitor.schema` type enum ✓
- `NetworkService.requestDocker()` ✓
- `Check.schema` with type ✓
### 2. Docker-Specific Data Collection
Currently: Only status + response time
**To Add Detailed Metrics:**
- Extend `Check.schema` with Docker stats fields
- Modify `NetworkService.requestDocker()` to call `container.stats()`
- Stream stats to `Check.docker` field
### 3. Capture API Integration (Hardware Monitoring)
**Flow:**
```
Monitor with type="hardware" and url="http://capture-endpoint"
↓
NetworkService.requestHardware()
↓ (delegates to)
NetworkService.requestHttp()
↓
Expects JSON response: { cpu, memory, disk, net, ... }
↓
Check record stores full payload in hardware fields
```
### 4. Database Queries
**Get hardware checks:**
```javascript
Check.find({
monitorId: id,
type: "hardware",
createdAt: { $gte: startDate, $lte: endDate }
})
.sort({ createdAt: -1 })
.limit(100)
```
### 5. UI Display
- Stats dashboard: `/infrastructure/:monitorId`
- Uptime details: `/monitor/uptime/details/:monitorId`
- Hardware details: `/monitor/hardware/details/:monitorId`
---
## SERVICE ARCHITECTURE
### Service Registry Pattern
**Location:** `/server/src/service/v1/system/serviceRegistry.js`
```javascript
// Singleton pattern
ServiceRegistry.set(serviceName, serviceInstance);
const service = ServiceRegistry.get(serviceName);
```
### Service Dependencies
**Initialization:** `/server/src/config/services.js`
```javascript
// Infrastructure services
new NetworkService({
axios, got, https, jmespath, GameDig, ping,
logger, http, Docker, // ← Docker client injected
net, stringService, settingsService
})
// Business services
new MonitorService({
db, settingsService, jobQueue,
stringService, emailService, logger
})
```
---
## KEY FILES SUMMARY
| Component | File | Lines | Purpose |
|-----------|------|-------|---------|
| Monitor Model | `server/src/db/v1/models/Monitor.js` | 209 | Schema, hooks |
| Check Model | `server/src/db/v1/models/Check.js` | 177 | Check records, TTL |
| NetworkService | `server/src/service/v1/infrastructure/networkService.js` | 580 | Type handlers |
| MonitorService | `server/src/service/v1/business/monitorService.js` | 277 | CRUD, validation |
| MonitorModule | `server/src/db/v1/modules/monitorModule.js` | 593 | DB queries |
| JobQueue | `server/src/service/v1/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js` | 177 | Job scheduling |
| MonitorController | `server/src/controllers/v1/monitorController.js` | 475 | HTTP handlers |
| MonitorRoute | `server/src/routes/v1/monitorRoute.js` | 61 | Endpoint definitions |
| Frontend Hooks | `client/src/Hooks/v1/monitorHooks.js` | 500+ | Data fetching |
| NetworkService (FE) | `client/src/Utils/NetworkService.js` | 1157 | API client |
| Form Hook | `client/src/Pages/v1/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx` | 97 | Form state |
---
## DEPLOYMENT & DOCKER INTEGRATION
### Docker-Compose Setup
**Files:** `/docker/dev/docker-compose.yaml`, `/docker/prod/docker-compose.yaml`
**Volumes for Docker Monitoring:**
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Required for Dockerode
```
### Environment Variables
**Backend (.env):**
```
MONGODB_URI=mongodb://mongo:27017/checkmate
JWT_SECRET=your-secret-key
NODE_ENV=production|development
PORT=5000
```
**Frontend (.env):**
```
VITE_APP_API_BASE_URL=http://localhost:5000/api/v1
```
-145
View File
@@ -1,145 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Checkmate is an open-source uptime and infrastructure monitoring application. It monitors server hardware, uptime, response times, and incidents with real-time alerts. The companion agent [Capture](https://github.com/bluewave-labs/capture) provides infrastructure metrics (CPU, RAM, disk, temperature).
## Development Commands
### Client (React/Vite)
```bash
cd client
npm install
npm run dev # Start dev server at http://localhost:5173
npm run build # TypeScript check + production build
npm run lint # ESLint (strict, max-warnings 0)
npm run format # Prettier formatting
npm run format-check # Check formatting
```
### Server (Node.js/Express)
```bash
cd server
npm install
npm run dev # Start with hot-reload (nodemon + tsx) at http://localhost:52345
npm run build # TypeScript compile + path alias resolution
npm run test # Run Mocha tests with c8 coverage
npm run lint # ESLint v9
npm run lint-fix # Auto-fix lint issues
npm run format # Prettier formatting
```
### Docker Development
```bash
cd docker/dev
./build_images.sh
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
```
## Environment Setup
### Server `.env` (minimum required)
```env
CLIENT_HOST="http://localhost:5173"
JWT_SECRET="my_secret_key_change_this"
DB_CONNECTION_STRING="mongodb://localhost:27017/uptime_db"
TOKEN_TTL="99d"
ORIGIN="localhost"
LOG_LEVEL="debug"
```
### Client `.env`
```env
VITE_APP_API_BASE_URL="http://localhost:52345/api/v1"
VITE_APP_LOG_LEVEL="debug"
```
## Architecture
### Monorepo Structure
- `/client` - React 18 + TypeScript + Vite + MUI frontend
- `/server` - Node.js 20+ + Express + TypeScript backend
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
### Backend Layers
```
server/src/
├── controllers/ # Route handlers (authController, monitorController, etc.)
├── service/ # Business logic
│ ├── business/ # Core monitoring logic
│ ├── infrastructure/ # Server/system utilities
│ └── system/ # App-level settings
├── db/
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
│ ├── migration/ # Database migrations (run on startup)
│ └── modules/ # Database-specific modules
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
├── routes/v1/ # API route definitions
├── validation/ # Joi input validation schemas
└── repositories/ # Data access layer
```
### Frontend Structure
```
client/src/
├── Components/ # Reusable UI components
├── Pages/ # Page components (Auth, Uptime, Infrastructure, Incidents, etc.)
├── Features/ # Redux slices (Auth, UI)
├── Hooks/ # Custom React hooks
├── Utils/ # Utilities (NetworkService.js is main API client)
├── Validation/ # Input validation
└── locales/ # i18n translations
```
### API
- Base URL: `/api/v1`
- Documentation: `http://localhost:52345/api-docs` (Swagger UI)
- OpenAPI spec: `/server/openapi.json`
### Key Technologies
- **State Management**: Redux Toolkit + Redux-Persist
- **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
- **i18n**: i18next + react-i18next (translations via PoEditor)
## Code Conventions
### Internationalization
All user-facing strings must use the translation function:
```javascript
t('your.key') // Never hardcode UI strings
```
### Branching
- Always branch from `develop` (not master)
- Use descriptive names: `feat/add-alerts`, `fix/login-error`
- PRs target `develop` branch
### Formatting
- **Client**: Prettier with `printWidth: 90`, tabs, double quotes
- **Server**: Prettier with `printWidth: 150`, tabs, double quotes
- Both use ESLint with strict settings
### Testing
Server tests use Mocha + Chai + Sinon:
```bash
npm test # Run all tests with coverage
npm test -- --grep "pattern" # Run specific tests
```
Test files: `server/tests/**/*.test.js`
## Database Models
Key Mongoose models in `/server/src/db/models/`:
- **Monitor** - Monitoring configuration (website, infrastructure, port, etc.)
- **Check** - Individual monitoring check results
- **Incident** - Downtime incidents
- **User** - User accounts
- **Team** - Team/workspace management
- **StatusPage** - Public status pages
- **Notification** - Alert configuration (email, Discord, Slack, webhooks)
- **MaintenanceWindow** - Scheduled maintenance periods
- **AppSettings** - Global application settings
-25
View File
@@ -10,31 +10,6 @@ PS: **We work closely with contributors on our [Discord channel](https://discord
---
## 🚀 Quick Setup Checklist
Before you dive in, make sure you have these installed:
```bash
# Check Node.js (v16+ required)
node --version
# Check npm
npm --version
# Check Docker
docker --version
# Check Git
git --version
```
**New to contributing?** Start here:
1. Pick a [`good-first-issue`](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
2. Comment that you'd like to work on it
3. Follow the [setup guide](#set-up-checkmate-locally) below
4. Join our [Discord](https://discord.com/invite/NAb6H3UTjK) if you get stuck
## Table of contents
- [How do I...?](#how-do-i)
+3 -3
View File
@@ -79,8 +79,9 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
- [Discord channel](https://discord.gg/NAb6H3UTjK)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -90,7 +91,6 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Website monitoring
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection
- Docker monitoring
- Ping monitoring
- SSL monitoring
-73
View File
@@ -26,7 +26,6 @@ Edit `values.yaml` to update:
- Old: `persistence.redisSize` → New: `persistence.redis.size`
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
### 3. Deploy the Helm chart
```bash
@@ -42,75 +41,3 @@ kubectl get svc
```
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
## Enabling TLS/HTTPS with cert-manager
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
### Prerequisites
- cert-manager installed in your cluster
- A ClusterIssuer or Issuer configured (e.g., `letsencrypt-prod`)
### Configuration
Edit `values.yaml` to enable TLS (and update protocols to https):
```yaml
client:
protocol: https
ingress:
enabled: true
host: checkmate.example.com
className: nginx
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: true
secretName: checkmate-client-tls
server:
protocol: https
ingress:
enabled: true
host: checkmate.example.com
className: nginx
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: true
secretName: checkmate-server-tls
```
### Alternative: Using --set flags
You can also enable TLS during installation using Helm's `--set` flags:
```bash
helm install checkmate ./charts/helm/checkmate \
--set client.protocol=https \
--set server.protocol=https \
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set client.ingress.tls.enabled=true \
--set client.ingress.tls.secretName=checkmate-client-tls \
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set server.ingress.tls.enabled=true \
--set server.ingress.tls.secretName=checkmate-server-tls
```
### Verification
After deployment, cert-manager will automatically create the TLS secrets. You can verify the certificate status:
```bash
# Check certificates
kubectl get certificates
# Check certificate details
kubectl describe certificate checkmate-client-tls
kubectl describe certificate checkmate-server-tls
# Verify the secrets were created
kubectl get secrets | grep checkmate-tls
```
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
@@ -0,0 +1,12 @@
{{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- if eq .Values.server.ingress.host "change_me" }}
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $protocol := .Values.server.protocol }}
{{- if not (or (eq $protocol "http") (eq $protocol "https")) }}
{{- fail "server.protocol must be either 'http' or 'https'" }}
{{- end }}
@@ -11,12 +11,6 @@ metadata:
{{- end }}
spec:
ingressClassName: {{ .Values.client.ingress.className }}
{{- if .Values.client.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.client.ingress.host }}
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.client.ingress.host }}
http:
@@ -1,48 +0,0 @@
{{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- if eq .Values.server.ingress.host "change_me" }}
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $serverProtocol := .Values.server.protocol }}
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
{{- fail "server.protocol must be either 'http' or 'https'" }}
{{- end }}
{{- $clientProtocol := .Values.client.protocol }}
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
{{- fail "client.protocol must be either 'http' or 'https'" }}
{{- end }}
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
{{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
{{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
{{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
@@ -18,12 +18,6 @@ metadata:
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec:
ingressClassName: {{ .Values.server.ingress.className }}
{{- if .Values.server.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.server.ingress.host }}
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.server.ingress.host }}
http:
-18
View File
@@ -7,15 +7,6 @@ client:
host: change_me
className: nginx
annotations: {}
# Example annotations for cert-manager:
# annotations:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
# Note: when enabling TLS, also set client.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
server:
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
@@ -26,15 +17,6 @@ server:
host: change_me
className: nginx
annotations: {}
# Example annotations for cert-manager:
# annotations:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
# Note: when enabling TLS, also set server.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
redis:
enabled: false
+1 -2
View File
@@ -28,5 +28,4 @@ dist-ssr
# Scripts
*.sh
!env.sh
.env.development
!env.sh
+1683 -1681
View File
File diff suppressed because it is too large. Load diff
+10 -15
View File
@@ -12,18 +12,16 @@
"format": "prettier --write .",
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "npm run format"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
"@mui/material": "7.3.7",
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@mui/icons-material": "6.4.11",
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
"@mui/material": "6.4.11",
"@mui/x-charts": "^7.5.1",
"@mui/x-date-pickers": "7.29.0",
"@reduxjs/toolkit": "2.7.0",
"axios": "^1.7.4",
"dayjs": "1.11.13",
@@ -32,13 +30,11 @@
"human-interval": "2.0.1",
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.63.0",
"react-hook-form": "7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
"react-redux": "9.2.0",
@@ -47,9 +43,9 @@
"react-toastify": "^10.0.5",
"recharts": "2.15.2",
"redux-persist": "6.0.0",
"swr": "^2.3.6",
"swr": "2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "^4.1.11"
"zod": "4.1.11"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -60,7 +56,6 @@
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.3.6"
+2 -18
View File
@@ -22,29 +22,13 @@ function App() {
};
}, []);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
<ThemeProvider theme={theme}>
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.main,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer
newestOnTop={true}
theme={mode}
style={{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
}}
/>
<ToastContainer />
</ThemeProvider>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ const langMap = {
const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language = "en" } = useSelector((state) => state.ui);
const { language } = useSelector((state) => state.ui);
const dispatch = useDispatch();
const handleChange = (event) => {
const newLang = event.target.value;
+7 -11
View File
@@ -2,7 +2,7 @@
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Icon from "../Icon";
import Settings from "../../../assets/icons/settings-bold.svg?react";
import Dialog from "../Dialog/index.jsx";
// Utils
@@ -12,7 +12,7 @@ import { useNavigate } from "react-router-dom";
import { createToast } from "../../../Utils/toastUtils.jsx";
import PropTypes from "prop-types";
import { usePauseMonitor, useDeleteMonitor } from "../../../Hooks/monitorHooks.js";
import { usePauseMonitor, useDeleteMonitor } from "../../../Hooks/v1/monitorHooks.js";
const ActionsMenu = ({
monitor,
@@ -31,7 +31,7 @@ const ActionsMenu = ({
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { id: actions.id };
let monitor = { _id: actions.id };
await deleteMonitor({ monitor });
updateRowCallback();
};
@@ -39,7 +39,7 @@ const ActionsMenu = ({
const handlePause = async () => {
try {
setIsLoading(true);
await pauseMonitor({ monitorId: monitor.id });
await pauseMonitor({ monitorId: monitor._id });
pauseCallback();
} catch (error) {
createToast({ body: "Failed to pause monitor." });
@@ -72,7 +72,7 @@ const ActionsMenu = ({
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event, monitor.id, monitor.type === "ping" ? null : monitor.url);
openMenu(event, monitor._id, monitor.type === "ping" ? null : monitor.url);
}}
sx={{
"&:focus": {
@@ -83,10 +83,7 @@ const ActionsMenu = ({
},
}}
>
<Icon
name="Settings"
size={20}
/>
<Settings />
</IconButton>
<Menu
@@ -200,7 +197,6 @@ const ActionsMenu = ({
confirmationButtonLabel="Delete"
/* Do we need stop propagation? */
onConfirm={(e) => {
console.log(e);
e.stopPropagation();
handleRemove(e);
}}
@@ -214,7 +210,7 @@ const ActionsMenu = ({
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
id: PropTypes.string,
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
isActive: PropTypes.bool,
+11 -22
View File
@@ -1,7 +1,10 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, IconButton, Stack, Typography } from "@mui/material";
import Icon from "../Icon";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined";
import CloseIcon from "@mui/icons-material/Close";
import "./index.css";
/**
@@ -10,24 +13,9 @@ import "./index.css";
*/
const icons = {
info: (
<Icon
name="Info"
size={24}
/>
),
error: (
<Icon
name="AlertCircle"
size={24}
/>
),
warning: (
<Icon
name="AlertTriangle"
size={24}
/>
),
info: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
};
/**
@@ -109,9 +97,10 @@ const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
},
}}
>
<Icon
name="X"
size={20}
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
)}
+9 -10
View File
@@ -1,28 +1,27 @@
import Icon from "../Icon";
import LeftArrow from "../../../assets/icons/left-arrow.svg?react";
import LeftArrowDouble from "../../../assets/icons/left-arrow-double.svg?react";
import LeftArrowLong from "../../../assets/icons/left-arrow-long.svg?react";
import PropTypes from "prop-types";
const ArrowLeft = ({ type, color = "#667085", ...props }) => {
if (type === "double") {
return (
<Icon
name="ChevronsLeft"
color={color}
<LeftArrowDouble
style={{ color }}
{...props}
/>
);
} else if (type === "long") {
return (
<Icon
name="ArrowLeft"
color={color}
<LeftArrowLong
style={{ color }}
{...props}
/>
);
} else {
return (
<Icon
name="ChevronLeft"
color={color}
<LeftArrow
style={{ color }}
{...props}
/>
);
@@ -1,20 +1,19 @@
import Icon from "../Icon";
import RightArrow from "../../../assets/icons/right-arrow.svg?react";
import RightArrowDouble from "../../../assets/icons/right-arrow-double.svg?react";
import PropTypes from "prop-types";
const ArrowRight = ({ type, color = "#667085", ...props }) => {
if (type === "double") {
return (
<Icon
name="ChevronsRight"
color={color}
<RightArrowDouble
style={{ color }}
{...props}
/>
);
} else {
return (
<Icon
name="ChevronRight"
color={color}
<RightArrow
style={{ color }}
{...props}
/>
);
@@ -1,6 +1,6 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { useEffect, useState } from "react";
import "./index.css";
import { useSelector } from "react-redux";
@@ -103,7 +103,7 @@ const BarChart = ({ checks = [] }) => {
</>
}
placement="top"
key={`check-${check?.id}`}
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
@@ -145,7 +145,7 @@ const BarChart = ({ checks = [] }) => {
position="relative"
width="9px"
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
backgroundColor={theme.palette.primary.lowContrast} // CAIO_REVIEW
sx={{
borderRadius: theme.spacing(1.5),
/*
@@ -5,7 +5,7 @@ import { Stack, Box, Tooltip, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { useSelector } from "react-redux";
const PlaceholderCheck = ({ daysToShow }) => {
@@ -12,7 +12,7 @@ import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import {
tooltipDateFormatLookup,
tickDateFormatLookup,
@@ -1,6 +1,6 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { useEffect, useState, forwardRef } from "react";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
@@ -148,7 +148,7 @@ const StatusPageBarChart = ({ checks = [] }) => {
</>
}
placement="top"
key={`check-${check?.id}`}
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useTheme } from "@mui/material";
import { Text } from "recharts";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { Box, Stack, Typography } from "@mui/material";
import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js";
/**
+12 -18
View File
@@ -1,6 +1,7 @@
import "./check.css";
import PropTypes from "prop-types";
import Icon from "../Icon";
import CheckGrey from "../../../assets/icons/check.svg?react";
import CheckOutlined from "../../../assets/icons/check-outlined.svg?react";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
@@ -34,16 +35,16 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) =>
alignItems="center"
>
{outlined ? (
<Icon
name="CheckCircle"
size={20}
/>
<CheckOutlined alt="check" />
) : (
<Icon
name="Check"
size={16}
color={colors[variant]}
/>
<Box
lineHeight={0}
sx={{
"& svg > path": { fill: colors[variant] },
}}
>
<CheckGrey alt="form checks" />
</Box>
)}
<Typography
component="span"
@@ -56,14 +57,7 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) =>
fontWeight: 450,
}}
>
{noHighlightText && (
<Typography
component="span"
sx={{ color: "inherit" }}
>
{noHighlightText}
</Typography>
)}{" "}
{noHighlightText && <Typography component="span">{noHighlightText}</Typography>}{" "}
{text}
</Typography>
</Stack>
+4 -11
View File
@@ -8,7 +8,8 @@ import IconButton from "@mui/material/IconButton";
import Container from "@mui/material/Container";
import MenuItem from "@mui/material/MenuItem";
import Drawer from "@mui/material/Drawer";
import Icon from "../Icon";
import MenuIcon from "@mui/icons-material/Menu";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import ThemeSwitch from "../ThemeSwitch/index.jsx";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -152,11 +153,7 @@ const AppAppBar = () => {
aria-label="Menu button"
onClick={toggleDrawer(true)}
>
<Icon
name="Menu"
size={24}
color={theme.palette.text.primary}
/>
<MenuIcon sx={{ color: theme.palette.text.primary }} />
</IconButton>
<Drawer
anchor="top"
@@ -179,11 +176,7 @@ const AppAppBar = () => {
}}
>
<IconButton onClick={toggleDrawer(false)}>
<Icon
name="X"
size={24}
color={theme.palette.text.primary}
/>
<CloseRoundedIcon sx={{ color: theme.palette.text.primary }} />
</IconButton>
</Box>
@@ -0,0 +1,99 @@
import { Checkbox, FormControl, ListItemText, MenuItem, Select } from "@mui/material";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
/**
* A reusable filter header component that displays a dropdown menu with selectable options.
*
* @component
* @param {Object} props - The component props.
* @param {string} props.header - The header text to display when no options are selected.
* @param {Array} props.options - An array of options to display in the dropdown menu. Each option should have a `value` and `label`.
* @param {Array} [props.value] - The currently selected values.
* @param {Function} props.onChange - The callback function to handle changes in the selected values.
* @param {boolean} [props.multiple=true] - Whether multiple options can be selected.
* @returns {JSX.Element} The rendered FilterHeader component.
*/
const FilterHeader = ({ header, options, value, onChange, multiple = true }) => {
const theme = useTheme();
const controlledValue = value === undefined ? [] : value; // Ensure value is always treated as an array for controlled component purposes
return (
<FormControl
sx={{ minWidth: "10%" }}
size="small"
>
<Select
multiple={multiple}
IconComponent={(props) => (
<AddCircleOutlineIcon
{...props}
sx={{ fontSize: "medium" }}
/>
)}
displayEmpty
value={controlledValue}
onChange={onChange}
renderValue={(selected) => {
if (!selected?.length) {
return header;
}
return (
header +
" | " +
selected
.map((value) => options.find((option) => option.value === value)?.label)
.filter(Boolean)
.join(", ")
);
}}
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
}}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
sx={{
height: theme.spacing(17),
padding: 0,
}}
>
<Checkbox
checked={controlledValue.includes(option.value)}
size="small"
/>
<ListItemText primary={option.label} />
</MenuItem>
))}
</Select>
</FormControl>
);
};
FilterHeader.propTypes = {
header: PropTypes.string.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
value: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func.isRequired,
multiple: PropTypes.bool,
};
export default FilterHeader;
@@ -40,13 +40,13 @@ const GenericFallback = ({ children }) => {
<Box
component="img"
src={mode === "light" ? OutputAnimation : DarkmodeOutput}
Background="transparent"
alt="Loading animation"
sx={{
zIndex: 1,
border: "none",
borderRadius: theme.spacing(8),
width: "100%",
backgroundColor: "transparent",
}}
/>
<Box
+5 -5
View File
@@ -23,12 +23,12 @@ const Host = ({ url, title, percentageColor, percentage, showURL, status }) => {
direction="row"
position="relative"
alignItems="center"
gap={theme.spacing(4)}
gap={theme.spacing(5)}
>
<Typography
component="span"
variant="h6"
sx={{
fontWeight: 500,
fontWeight: 600,
}}
>
{title}
@@ -38,9 +38,9 @@ const Host = ({ url, title, percentageColor, percentage, showURL, status }) => {
<>
<Dot />
<Typography
component="span"
variant="h6"
sx={{
fontWeight: 500,
fontWeight: 600,
color: percentageColor,
}}
>
@@ -2,7 +2,7 @@ import i18n from "../../../Utils/i18n.js";
import { useSelector } from "react-redux";
import { useEffect } from "react";
const I18nLoader = () => {
const language = useSelector((state) => state.ui.language ?? "en");
const language = useSelector((state) => state.ui.language);
useEffect(() => {
if (language && i18n.language !== language) {
-120
View File
@@ -1,120 +0,0 @@
/**
* Maps legacy icon names to Lucide icon names
* Use: import { iconMap } from './iconMap'; LucideIcons[iconMap['monitors']]
*/
export const iconMap = {
// Navigation/Sidebar icons
monitors: "Globe",
incidents: "AlertTriangle",
integrations: "Link",
"page-speed": "Gauge",
settings: "Settings",
notifications: "Bell",
maintenance: "Wrench",
"status-pages": "Wifi",
docs: "FileText",
discussions: "MessageCircle",
support: "HelpCircle",
changeLog: "Code",
logs: "Database",
// User icons
user: "User",
"user-two": "Users",
"user-edit": "UserPen",
groups: "Users",
lock: "Lock",
key: "Key",
logout: "LogOut",
// Action icons
edit: "Pencil",
"trash-bin": "Trash2",
search: "Search",
"close-icon": "X",
check: "Check",
"check-icon": "CheckCircle",
"check-outlined": "CheckCircle",
// Arrow icons
"left-arrow": "ChevronLeft",
"right-arrow": "ChevronRight",
"up-arrow": "ChevronUp",
"down-arrow": "ChevronDown",
"left-arrow-double": "ChevronsLeft",
"right-arrow-double": "ChevronsRight",
"left-arrow-long": "ArrowLeft",
"top-right-arrow": "ArrowUpRight",
"open-in-new-page": "ExternalLink",
"selector-vertical": "ChevronsUpDown",
// Status/Alert icons
"alert-icon": "Bell",
"warning-icon": "AlertCircle",
"history-icon": "History",
// Data/Chart icons
dashboard: "LayoutGrid",
"monitor-graph-line": "TrendingUp",
"response-time-icon": "TrendingUp",
"uptime-icon": "Activity",
"speedometer-icon": "Gauge",
"spedometer-icon": "Gauge",
"ruler-icon": "Ruler",
"performance-report": "Layers",
"average-response-icon": "BarChart3",
"distributed-uptime": "Network",
// Misc icons
calendar: "Calendar",
"calendar-check": "CalendarCheck",
"clock-snooze": "Clock",
mail: "Mail",
email: "Mail",
folder: "Folder",
"dots-vertical": "MoreVertical",
"pause-icon": "PauseCircle",
"resume-icon": "PlayCircle",
"checkbox-filled": "CheckSquare",
"checkbox-outline": "Square",
"radio-checked": "Circle",
"cpu-chip": "Cpu",
certificate: "Award",
"interval-check": "RefreshCw",
};
/**
* MUI icon to Lucide mapping
*/
export const muiToLucideMap = {
AddCircleOutline: "PlusCircle",
ArrowDownwardRounded: "ArrowDown",
ArrowUpwardRounded: "ArrowUp",
ArrowDropDown: "ChevronDown",
ArrowOutward: "ExternalLink",
CheckCircle: "CheckCircle2",
Clear: "X",
Close: "X",
CloseRounded: "X",
CloudUpload: "Upload",
Delete: "Trash2",
DeleteOutlineRounded: "Trash2",
Email: "Mail",
ErrorOutline: "AlertTriangle",
ErrorOutlineOutlined: "AlertTriangle",
Image: "ImageIcon",
InfoOutlined: "Info",
KeyboardArrowDown: "ChevronDown",
Menu: "Menu",
PauseCircleOutline: "PauseCircle",
PauseOutlined: "Pause",
PlayArrowOutlined: "Play",
PlayCircleOutlineRounded: "PlayCircle",
RadioButtonChecked: "Circle",
ReorderRounded: "GripVertical",
SettingsOutlined: "Settings",
Share: "Share2",
Visibility: "Eye",
VisibilityOff: "EyeOff",
WarningAmberOutlined: "AlertTriangle",
};
-198
View File
@@ -1,198 +0,0 @@
import { useTheme } from "@mui/material";
import PropTypes from "prop-types";
import {
Activity,
AlertCircle,
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowUp,
ArrowUpRight,
Bell,
Calendar,
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
Circle,
CircleDot,
Clock,
Code,
Cpu,
Database,
ExternalLink,
Eye,
EyeOff,
FileText,
Gauge,
Globe,
GripVertical,
HelpCircle,
History,
Image as ImageIcon,
Info,
Key,
Layers,
Link,
Lock,
LogOut,
Mail,
Menu,
MessageCircle,
MoreVertical,
Pause,
PauseCircle,
Play,
PlayCircle,
PlusCircle,
RefreshCw,
Ruler,
Search,
Settings,
Square,
Trash2,
TrendingUp,
Upload,
User,
Users,
Wifi,
Wrench,
X,
} from "lucide-react";
/**
* Map of icon names to Lucide icon components.
* Only icons explicitly imported here will be included in the bundle (tree-shaking).
* To add a new icon: import it above and add it to this object.
*/
const iconComponents = {
Activity,
AlertCircle,
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowUp,
ArrowUpRight,
Bell,
Calendar,
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
Circle,
CircleDot,
Clock,
Code,
Cpu,
Database,
ExternalLink,
Eye,
EyeOff,
FileText,
Gauge,
Globe,
GripVertical,
HelpCircle,
History,
Image: ImageIcon,
Info,
Key,
Layers,
Link,
Lock,
LogOut,
Mail,
Menu,
MessageCircle,
MoreVertical,
Pause,
PauseCircle,
Play,
PlayCircle,
PlusCircle,
RefreshCw,
Ruler,
Search,
Settings,
Square,
Trash2,
TrendingUp,
Upload,
User,
Users,
Wifi,
Wrench,
X,
};
/**
* Theme-aware icon component wrapping Lucide icons
*
* @param {string} name - Lucide icon name (e.g., "Bell", "Check", "ArrowLeft")
* @param {number} size - Icon size in pixels (default: 20)
* @param {string} color - Direct color value OR theme path like "primary.contrastText"
* @param {number} strokeWidth - Stroke width (default: 1.5)
* @param {string} fill - Fill color (default: "none")
*/
const Icon = ({ name, size = 20, color, strokeWidth = 1.5, fill = "none", ...props }) => {
const theme = useTheme();
// Resolve color from theme path or use direct value
const resolveColor = (colorValue) => {
if (!colorValue) {
return theme.palette.primary.contrastTextTertiary; // Default icon color
}
// If it's a theme path like "primary.contrastText"
if (typeof colorValue === "string" && colorValue.includes(".")) {
const parts = colorValue.split(".");
let resolved = theme.palette;
for (const part of parts) {
resolved = resolved?.[part];
}
return resolved || colorValue;
}
// Direct color value
return colorValue;
};
const LucideIcon = iconComponents[name];
if (!LucideIcon) {
console.warn(`Icon "${name}" not found in Icon component`);
return null;
}
return (
<LucideIcon
size={size}
color={resolveColor(color)}
strokeWidth={strokeWidth}
fill={fill}
{...props}
/>
);
};
Icon.propTypes = {
name: PropTypes.string.isRequired,
size: PropTypes.number,
color: PropTypes.string,
strokeWidth: PropTypes.number,
fill: PropTypes.string,
};
export default Icon;
@@ -1,7 +1,8 @@
import PropTypes from "prop-types";
import { FormControlLabel, Checkbox as MuiCheckbox } from "@mui/material";
import { useTheme } from "@emotion/react";
import Icon from "../../Icon";
import CheckboxOutline from "../../../../assets/icons/checkbox-outline.svg?react";
import CheckboxFilled from "../../../../assets/icons/checkbox-filled.svg?react";
/**
* Checkbox Component
@@ -64,8 +65,8 @@ const Checkbox = ({
name={name}
value={value}
onChange={onChange}
icon={<Icon name="Square" />}
checkedIcon={<Icon name="CheckSquare" />}
icon={<CheckboxOutline />}
checkedIcon={<CheckboxFilled />}
inputProps={{
"aria-label": "controlled checkbox",
id: id,
@@ -1,10 +1,11 @@
// Components
import { Box, Stack, Typography } from "@mui/material";
import Icon from "../../Icon";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import Image from "../../Image/index.jsx";
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import ProgressUpload from "../../ProgressBars/index.jsx";
import ImageIcon from "@mui/icons-material/Image";
// Utils
import PropTypes from "prop-types";
@@ -175,10 +176,7 @@ const ImageUpload = ({
boxShadow: theme.shape.boxShadow,
}}
>
<Icon
name="Upload"
size={24}
/>
<CloudUploadIcon />
</IconButton>
<Typography
component="h2"
@@ -205,12 +203,7 @@ const ImageUpload = ({
</Box>
{(localError || progress.isLoading || progress.value !== 0) && (
<ProgressUpload
icon={
<Icon
name="Image"
size={20}
/>
}
icon={<ImageIcon />}
label={file?.name || "Upload failed"}
size={file?.size}
progress={progress.value}
@@ -1,7 +1,7 @@
import PropTypes from "prop-types";
import { FormControlLabel, Radio as MUIRadio, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import Icon from "../../Icon";
import RadioChecked from "../../../../assets/icons/radio-checked.svg?react";
import "./index.css";
/**
@@ -47,12 +47,7 @@ const Radio = ({
<MUIRadio
id={id}
size={size}
checkedIcon={
<Icon
name="CircleDot"
size={16}
/>
}
checkedIcon={<RadioChecked />}
sx={{
color: "transparent",
width: 16,
@@ -8,7 +8,7 @@ import {
Checkbox,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import Icon from "../../Icon";
import SearchIcon from "../../../../assets/icons/search.svg?react";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import FieldWrapper from "../FieldWrapper/index.jsx";
@@ -31,13 +31,20 @@ import FieldWrapper from "../FieldWrapper/index.jsx";
const SearchAdornment = () => {
const theme = useTheme();
return (
<Box height={16}>
<Icon
name="Search"
size={16}
color="primary.contrastTextTertiary"
strokeWidth={1.2}
/>
<Box
height={16}
sx={{
"& svg": {
width: 16,
height: 16,
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
strokeWidth: 1.2,
},
},
}}
>
<SearchIcon />
</Box>
);
};
@@ -78,12 +85,7 @@ const Search = ({
const enhancedOptions = React.useMemo(() => {
return multiple && isAdorned
? [
{
[filteredBy]: t("selectAll"),
isSelectAll: true,
_id: "select_all",
id: "select_all",
},
{ [filteredBy]: t("selectAll"), isSelectAll: true, _id: "select_all" },
...options,
]
: options;
@@ -91,7 +93,7 @@ const Search = ({
const isOptionSelected = (option) => {
if (!multiple && !isAdorned) return false;
if (Array.isArray(value)) {
return value.some((item) => (item._id ?? item.id) === (option._id ?? option.id));
return value.some((item) => item._id === option._id);
}
return false;
};
@@ -143,9 +145,7 @@ const Search = ({
disableClearable
options={enhancedOptions}
getOptionLabel={(option) => option[filteredBy]}
isOptionEqualToValue={(option, value) =>
(option._id ?? option.id) === (value?._id ?? value?.id)
} // Compare by unique identifier
isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
renderInput={(params) => (
<FieldWrapper
label={label}
@@ -206,7 +206,7 @@ const Search = ({
return filtered;
}}
getOptionKey={(option) => {
return option._id ?? option.id;
return option._id;
}}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
@@ -1,7 +1,7 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { MenuItem, Select as MuiSelect, Stack, Typography } from "@mui/material";
import Icon from "../../Icon";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import FieldWrapper from "../FieldWrapper/index.jsx";
import "./index.css";
@@ -62,7 +62,6 @@ const Select = ({
fieldWrapperSx = {},
}) => {
const theme = useTheme();
const getItemValue = (item) => item?._id ?? item?.id;
const itemStyles = {
fontSize: "var(--env-var-font-size-medium)",
color: theme.palette.primary.contrastTextTertiary,
@@ -98,13 +97,7 @@ const Select = ({
error={error}
name={name}
inputProps={{ id: id }}
IconComponent={(props) => (
<Icon
name="ChevronDown"
size={20}
{...props}
/>
)}
IconComponent={KeyboardArrowDownIcon}
MenuProps={{ disableScrollLock: true }}
sx={{
fontSize: 13,
@@ -130,7 +123,7 @@ const Select = ({
...sx,
}}
renderValue={(selected) => {
const selectedItem = items.find((item) => getItemValue(item) === selected);
const selectedItem = items.find((item) => item._id === selected);
const displayName = selectedItem ? selectedItem.name : placeholder;
return (
<Typography
@@ -159,25 +152,17 @@ const Select = ({
{placeholder}
</MenuItem>
)}
{items
.map((item) => {
const itemValue = getItemValue(item);
if (itemValue === undefined || itemValue === null) {
return null;
}
return (
<MenuItem
value={itemValue}
key={`${id}-${itemValue}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
);
})
.filter(Boolean)}
{items.map((item) => (
<MenuItem
value={item._id}
key={`${id}-${item._id}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
))}
</MuiSelect>
</FieldWrapper>
);
@@ -194,8 +179,8 @@ Select.propTypes = {
.isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool])
.isRequired,
name: PropTypes.string.isRequired,
})
@@ -1,7 +1,10 @@
import { Stack, Typography, InputAdornment, IconButton } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
import Icon from "../../../Icon";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
import DeleteIcon from "../../../../../assets/icons/trash-bin.svg?react";
export const HttpAdornment = ({ https }) => {
const theme = useTheme();
@@ -53,17 +56,7 @@ export const PasswordEndAdornment = ({ fieldType, setFieldType }) => {
},
}}
>
{fieldType === "password" ? (
<Icon
name="EyeOff"
size={20}
/>
) : (
<Icon
name="Eye"
size={20}
/>
)}
{fieldType === "password" ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
);
@@ -3,8 +3,6 @@
min-height: 100vh;
margin: 0 auto;
padding: 0;
overflow-x: hidden;
width: 100%;
}
/* TODO go for this approach for responsiveness. The aside needs to be taken care of */
@@ -14,7 +12,15 @@
}
} */
.home-layout > .home-content-wrapper {
/* .home-layout aside {
position: sticky;
top: 0;
left: 0;
height: 100vh;
max-width: var(--env-var-side-bar-width);
} */
.home-layout > div {
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
flex: 1;
}
@@ -24,6 +30,4 @@
max-width: 1400px;
margin: 0 auto;
flex: 1;
min-width: 0;
overflow-x: hidden;
}
@@ -1,27 +1,17 @@
import Sidebar from "../../Sidebar/index.jsx";
import { Outlet } from "react-router";
import { Box, Stack } from "@mui/material";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Stack } from "@mui/material";
import "./index.css";
const HomeLayout = () => {
const { width, transition } = useSidebar();
return (
<Stack
className="home-layout"
flexDirection="row"
gap={14}
>
<Sidebar />
{/* Spacer for fixed sidebar */}
<Box
sx={{
width,
flexShrink: 0,
transition,
}}
/>
<Stack className="home-content-wrapper">
<Outlet />
</Stack>
@@ -0,0 +1,116 @@
import * as React from "react";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { createToast } from "../../../Utils/toastUtils.jsx";
import { useExportMonitors } from "../../../Hooks/v1/monitorHooks.js";
const MonitorActions = ({ isLoading }) => {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const navigate = useNavigate();
const { t } = useTranslation();
const [exportMonitors, isExporting] = useExportMonitors();
const options = [t("monitorActions.import"), t("monitorActions.export")];
const handleClick = async () => {
if (selectedIndex === 0) {
// Import
navigate("/uptime/bulk-import");
} else {
// Export
const [success, error] = await exportMonitors();
if (!success) {
createToast({ body: error || t("export.failed") });
}
}
};
const handleMenuItemClick = (event, index) => {
setSelectedIndex(index);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
return (
<React.Fragment>
<ButtonGroup
variant="contained"
color="accent"
ref={anchorRef}
aria-label="Monitor actions"
disabled={isLoading || isExporting}
>
<Button onClick={handleClick}>{options[selectedIndex]}</Button>
<Button
size="small"
aria-controls={open ? "split-button-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select monitor action"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
sx={{ zIndex: 1 }}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList
id="split-button-menu"
autoFocusItem
>
{options.map((option, index) => (
<MenuItem
key={option}
selected={index === selectedIndex}
onClick={(event) => handleMenuItemClick(event, index)}
>
{option}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</React.Fragment>
);
};
export default MonitorActions;
@@ -0,0 +1,52 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton.jsx";
const MonitorCountHeader = ({
isLoading = false,
monitorCount,
heading = "monitors",
sx,
children,
}) => {
const theme = useTheme();
if (isLoading) return <SkeletonLayout />;
if (monitorCount === 1) {
heading = "monitor";
}
return (
<Stack
direction="row"
alignItems="center"
display="flex"
width="fit-content"
height={theme.spacing(18)}
gap={theme.spacing(2)}
mt={theme.spacing(2)}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.spacing(1)}
sx={{
...sx,
backgroundColor: theme.palette.secondary.main,
}}
>
{monitorCount} <Typography component="h2">{heading}</Typography>
{children}
</Stack>
);
};
MonitorCountHeader.propTypes = {
isLoading: PropTypes.bool,
monitorCount: PropTypes.number,
heading: PropTypes.string,
children: PropTypes.node,
sx: PropTypes.object,
};
export default MonitorCountHeader;
@@ -0,0 +1,26 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Skeleton
variant="text"
width={100}
height={32}
/>
<Skeleton
variant="circular"
width={40}
height={40}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import MonitorActions from "../MonitorActions/index.jsx";
const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => {
const navigate = useNavigate();
@@ -0,0 +1,141 @@
import Stack from "@mui/material/Stack";
import Status from "./status.jsx";
import Skeleton from "./skeleton.jsx";
import Button from "@mui/material/Button";
import { Tooltip } from "@mui/material";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import PauseOutlinedIcon from "@mui/icons-material/PauseOutlined";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import EmailIcon from "@mui/icons-material/Email";
// Utils
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { usePauseMonitor } from "../../../Hooks/v1/monitorHooks.js";
import { useSendTestEmail } from "../../../Hooks/v1/useSendTestEmail.js";
import { useTranslation } from "react-i18next";
import { useTestAllNotifications } from "../../../Hooks/v1/useNotifications.js";
/**
* MonitorDetailsControlHeader component displays the control header for monitor details.
* It includes status display, pause/resume button, and a configure button for admins.
*
* @component
* @param {Object} props - Component props
* @param {string} props.path - The base path for navigation
* @param {boolean} [props.isLoading=false] - Flag indicating if the data is loading
* @param {boolean} [props.isAdmin=false] - Flag indicating if the user is an admin
* @param {Object} props.monitor - The monitor object containing details
* @param {Function} props.triggerUpdate - Function to trigger an update
* @returns {JSX.Element} The rendered component
*/
const MonitorDetailsControlHeader = ({
path,
isLoading = false,
isAdmin = false,
monitor,
triggerUpdate,
}) => {
const navigate = useNavigate();
const theme = useTheme();
const { t } = useTranslation();
const [pauseMonitor, isPausing, error] = usePauseMonitor();
const isTestNotificationsDisabled = monitor?.notifications?.length === 0;
const tooltipTitle = isTestNotificationsDisabled ? t("testNotificationsDisabled") : "";
// const [isSending, emailError, sendTestEmail] = useSendTestEmail();
const [testAllNotifications, isSending, errorAllNotifications] =
useTestAllNotifications();
if (isLoading) {
return <Skeleton />;
}
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Status monitor={monitor} />
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Tooltip
key={monitor?._id}
placement="bottom"
title={tooltipTitle}
>
<span>
<Button
variant="contained"
color="secondary"
loading={isSending}
startIcon={<EmailIcon />}
disabled={isTestNotificationsDisabled}
onClick={() => {
testAllNotifications({ monitorId: monitor?._id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("sendTestNotifications")}
</Button>
</span>
</Tooltip>
<Button
variant="contained"
color="secondary"
onClick={(e) => {
navigate(`/incidents/${monitor?._id}`);
}}
>
{t("menu.incidents")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPausing}
startIcon={
monitor?.isActive ? <PauseOutlinedIcon /> : <PlayArrowOutlinedIcon />
}
onClick={() => {
pauseMonitor({
monitorId: monitor?._id,
triggerUpdate,
});
}}
>
{monitor?.isActive ? "Pause" : "Resume"}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={<SettingsOutlinedIcon />}
onClick={() => navigate(`/${path}/configure/${monitor._id}`)}
>
Configure
</Button>
)}
</Stack>
</Stack>
);
};
MonitorDetailsControlHeader.propTypes = {
path: PropTypes.string,
isLoading: PropTypes.bool,
isAdmin: PropTypes.bool,
monitor: PropTypes.object,
triggerUpdate: PropTypes.func,
};
export default MonitorDetailsControlHeader;
@@ -0,0 +1,23 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -0,0 +1,51 @@
// Components
import Stack from "@mui/material/Stack";
import PulseDot from "../Animated/PulseDot.jsx";
import Typography from "@mui/material/Typography";
import Dot from "../Dot/index.jsx";
// Utils
import { formatDurationRounded } from "../../../Utils/timeUtils.js";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../../Hooks/v1/useMonitorUtils.js";
import { formatMonitorUrl } from "../../../Utils/utils.js";
/**
* Status component displays the status information of a monitor.
* It includes the monitor's name, URL, and check interval.
*
* @component
* @param {Object} props - Component props
* @param {Object} props.monitor - The monitor object containing details
* @param {string} props.monitor.name - The name of the monitor
* @param {string} props.monitor.url - The URL of the monitor
* @param {number} props.monitor.interval - The interval at which the monitor checks
* @returns {JSX.Element} The rendered component
*/
const Status = ({ monitor }) => {
const theme = useTheme();
const { statusColor, determineState } = useMonitorUtils();
return (
<Stack>
<Typography variant="monitorName">{monitor?.name}</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<PulseDot color={statusColor[determineState(monitor)]} />
<Typography variant="monitorUrl">{formatMonitorUrl(monitor?.url)}</Typography>
<Dot />
<Typography>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Stack>
);
};
Status.propTypes = {
monitor: PropTypes.object,
};
export default Status;
@@ -0,0 +1,125 @@
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import Search from "../Inputs/Search/index.jsx";
// Utils
import { useState, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
const NotificationConfig = ({
notifications,
setMonitor,
setNotifications,
//FieldWrapper's props
gap,
labelMb,
labelFontWeight,
labelVariant,
labelSx = {},
sx = {},
}) => {
// Local state
const [notificationsSearch, setNotificationsSearch] = useState("");
const [selectedNotifications, setSelectedNotifications] = useState([]);
const handleSearch = (value) => {
setSelectedNotifications(value);
setMonitor((prev) => {
return {
...prev,
notifications: value.map((notification) => notification._id),
};
});
};
// Handlers
const handleDelete = (id) => {
const updatedNotifications = selectedNotifications.filter(
(notification) => notification._id !== id
);
setSelectedNotifications(updatedNotifications);
setMonitor((prev) => {
return {
...prev,
notifications: updatedNotifications.map((notification) => notification._id),
};
});
};
// Setup
const theme = useTheme();
useEffect(() => {
if (setNotifications) {
const toSet = setNotifications.map((notification) => {
return notifications.find((n) => n._id === notification);
});
setSelectedNotifications(toSet);
}
}, [setNotifications, notifications]);
return (
<Stack gap={theme.spacing(6)}>
<Search
type="notifications"
label="Notifications"
options={notifications}
filteredBy="notificationName"
multiple={true}
value={selectedNotifications}
inputValue={notificationsSearch}
handleInputChange={setNotificationsSearch}
handleChange={(value) => {
handleSearch(value);
}}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
...sx,
}}
/>
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification._id}
width="100%"
>
<Typography
flexGrow={1} // <-- This will take up all available horizontal space
>
{notification.notificationName}
</Typography>
<DeleteOutlineRoundedIcon
onClick={() => {
handleDelete(notification._id);
}}
sx={{ cursor: "pointer" }}
/>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
</Stack>
);
};
NotificationConfig.propTypes = {
notifications: PropTypes.array,
setMonitor: PropTypes.func,
setNotifications: PropTypes.array,
};
export default NotificationConfig;
@@ -0,0 +1,419 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
import {
Dialog,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Tabs,
Tab,
Stack,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import TabPanel from "./TabPanel.jsx";
import TabComponent from "./TabComponent.jsx";
import useNotifications from "../Hooks/useNotification.js";
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: "slack",
DISCORD: "discord",
TELEGRAM: "telegram",
WEBHOOK: "webhook",
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: "webhook",
TOKEN: "token",
CHAT_ID: "chatId",
URL: "url",
};
const NotificationIntegrationModal = ({
open,
onClose,
monitor,
setMonitor,
// Optional prop to configure available notification types
notificationTypes = null,
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [tabValue, setTabValue] = useState(0);
const [loading, _, sendTestNotification] = useNotifications();
// Helper to get the field state key with error handling
const getFieldKey = useCallback(
(typeId, fieldId) => {
if (typeof typeId !== "string" || typeId === "") {
throw new Error(t("errorInvalidTypeId"));
}
if (typeof fieldId !== "string" || fieldId === "") {
throw new Error(t("errorInvalidFieldId"));
}
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
},
[t]
);
// Define notification types
const DEFAULT_NOTIFICATION_TYPES = [
{
id: NOTIFICATION_TYPES.SLACK,
label: t("notifications.slack.label"),
description: t("notifications.slack.description"),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t("notifications.slack.webhookLabel"),
placeholder: t("notifications.slack.webhookPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.DISCORD,
label: t("notifications.discord.label"),
description: t("notifications.discord.description"),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t("notifications.discord.webhookLabel"),
placeholder: t("notifications.discord.webhookPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.TELEGRAM,
label: t("notifications.telegram.label"),
description: t("notifications.telegram.description"),
fields: [
{
id: FIELD_IDS.TOKEN,
label: t("notifications.telegram.tokenLabel"),
placeholder: t("notifications.telegram.tokenPlaceholder"),
type: "text",
},
{
id: FIELD_IDS.CHAT_ID,
label: t("notifications.telegram.chatIdLabel"),
placeholder: t("notifications.telegram.chatIdPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.WEBHOOK,
label: t("notifications.webhook.label"),
description: t("notifications.webhook.description"),
fields: [
{
id: FIELD_IDS.URL,
label: t("notifications.webhook.urlLabel"),
placeholder: t("notifications.webhook.urlPlaceholder"),
type: "text",
},
],
},
];
// Use provided notification types or default to our translated ones
const activeNotificationTypes = notificationTypes || DEFAULT_NOTIFICATION_TYPES;
// Memoized function to initialize integrations state
const initialIntegrationsState = useMemo(() => {
const state = {};
activeNotificationTypes.forEach((type) => {
// Add enabled flag for each notification type
state[type.id] = false;
// Add state for each field in the notification type
type.fields.forEach((field) => {
const fieldKey = getFieldKey(type.id, field.id);
state[fieldKey] = "";
});
});
return state;
}, [activeNotificationTypes, getFieldKey]); // Only recompute when these dependencies change
const [integrations, setIntegrations] = useState(initialIntegrationsState);
useEffect(() => {
if (open) {
const extractNotificationValues = () => {
const values = {};
if (!monitor?.notifications || !Array.isArray(monitor.notifications)) {
return values;
}
monitor.notifications.forEach((notification) => {
// Handle notification based on its structure
if (notification.type === "webhook" && notification.platform) {
if (typeof notification.config === "undefined") return;
const platform = notification.platform;
values[platform] = true; // Set platform as enabled
// Extract configuration based on platform
switch (platform) {
case NOTIFICATION_TYPES.SLACK:
case NOTIFICATION_TYPES.DISCORD:
if (notification.config.webhookUrl) {
values[getFieldKey(platform, FIELD_IDS.WEBHOOK)] =
notification.config.webhookUrl;
}
break;
case NOTIFICATION_TYPES.TELEGRAM:
if (notification.config.botToken) {
values[getFieldKey(platform, FIELD_IDS.TOKEN)] =
notification.config.botToken;
}
if (notification.config.chatId) {
values[getFieldKey(platform, FIELD_IDS.CHAT_ID)] =
notification.config.chatId;
}
break;
case NOTIFICATION_TYPES.WEBHOOK:
if (notification.config.webhookUrl) {
values[getFieldKey(platform, FIELD_IDS.URL)] =
notification.config.webhookUrl;
}
break;
}
}
});
return values;
};
const extractedValues = extractNotificationValues();
setIntegrations((prev) => ({
...prev,
...extractedValues,
}));
}
}, [open, monitor, getFieldKey]);
const handleChangeTab = (event, newValue) => {
setTabValue(newValue);
};
const handleIntegrationChange = (type, checked) => {
setIntegrations((prev) => ({
...prev,
[type]: checked,
}));
};
const handleInputChange = (type, value) => {
setIntegrations((prev) => ({
...prev,
[type]: value,
}));
};
const handleTestNotification = async (type) => {
// Get the notification type details
const notificationType = activeNotificationTypes.find((t) => t.id === type);
if (typeof notificationType === "undefined") {
return;
}
// Prepare config object based on notification type
const config = {};
// Add each field value to the config object
notificationType.fields.forEach((field) => {
const fieldKey = getFieldKey(type, field.id);
config[field.id] = integrations[fieldKey];
});
await sendTestNotification(type, config);
};
// In NotificationIntegrationModal.jsx, update the handleSave function:
const handleSave = () => {
// Get existing notifications
const notifications = [...(monitor?.notifications || [])];
// Get all notification types IDs
const existingTypes = activeNotificationTypes.map((type) => type.id);
// Filter out notifications that are configurable in this modal
const filteredNotifications = notifications.filter((notification) => {
if (notification.platform) {
return !existingTypes.includes(notification.platform);
}
return !existingTypes.includes(notification.type);
});
// Add each enabled notification with its configured fields
activeNotificationTypes.forEach((type) => {
if (integrations[type.id]) {
let notificationObject = {
type: "webhook",
platform: type.id, // Set platform to identify the specific service
config: {},
};
// Configure based on notification type
switch (type.id) {
case "slack":
case "discord":
notificationObject.config.webhookUrl =
integrations[getFieldKey(type.id, "webhook")];
break;
case "telegram":
notificationObject.config.botToken =
integrations[getFieldKey(type.id, "token")];
notificationObject.config.chatId =
integrations[getFieldKey(type.id, "chatId")];
break;
case "webhook":
notificationObject.config.webhookUrl =
integrations[getFieldKey(type.id, "url")];
break;
}
filteredNotifications.push(notificationObject);
}
});
// Update monitor with new notifications
setMonitor((prev) => ({
...prev,
notifications: filteredNotifications,
}));
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
sx={{
"& .MuiDialog-paper": {
width: `calc(80% - ${theme.spacing(40)})`,
maxWidth: `${theme.breakpoints.values.md - 70}px`,
},
}}
>
<DialogContent>
<Stack
direction="row"
sx={{
height: `calc(30vh - ${theme.spacing(20)})`,
}}
>
{/* Left sidebar with tabs */}
<Box
sx={{
borderRight: 1,
borderColor: theme.palette.primary.lowContrast,
width: "30%",
maxWidth: theme.spacing(120),
pr: theme.spacing(10),
}}
>
<Typography variant="h2">
{t("notifications.addOrEditNotifications")}
</Typography>
<Tabs
orientation="vertical"
variant="scrollable"
value={tabValue}
onChange={handleChangeTab}
aria-label="Notification tabs"
>
{activeNotificationTypes.map((type) => (
<Tab
key={type.id}
label={type.label}
orientation="vertical"
disableRipple
/>
))}
</Tabs>
</Box>
{/* Right side content */}
<Box
sx={{
flex: 1,
pl: theme.spacing(7.5),
overflowY: "auto",
}}
>
{activeNotificationTypes.map((type, index) => (
<TabPanel
key={type.id}
value={tabValue}
index={index}
>
<TabComponent
type={type}
integrations={integrations}
handleIntegrationChange={handleIntegrationChange}
handleInputChange={handleInputChange}
handleTestNotification={handleTestNotification}
isLoading={loading}
/>
</TabPanel>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions
sx={{
p: theme.spacing(4),
display: "flex",
justifyContent: "flex-end",
mb: theme.spacing(5),
mr: theme.spacing(5),
}}
>
<Button
variant="contained"
color="accent"
onClick={handleSave}
loading={loading}
sx={{
width: "auto",
minWidth: theme.spacing(60),
px: theme.spacing(8),
}}
>
{t("commonSave")}
</Button>
</DialogActions>
</Dialog>
);
};
NotificationIntegrationModal.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
monitor: PropTypes.object.isRequired,
setMonitor: PropTypes.func.isRequired,
notificationTypes: PropTypes.array,
};
export default NotificationIntegrationModal;
@@ -0,0 +1,115 @@
import React from "react";
import { Typography, Box, Button, CircularProgress } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import TextInput from "../../Inputs/TextInput/index.jsx";
import Checkbox from "../../Inputs/Checkbox/index.jsx";
const TabComponent = ({
type,
integrations,
handleIntegrationChange,
handleInputChange,
handleTestNotification,
isLoading,
}) => {
const theme = useTheme();
const { t } = useTranslation();
// Helper to get the field state key (e.g., slackWebhook, telegramToken)
const getFieldKey = (typeId, fieldId) => {
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
};
// Check if all fields have values to enable test button
const areAllFieldsFilled = () => {
return type.fields.every((field) => {
const fieldKey = getFieldKey(type.id, field.id);
return integrations[fieldKey];
});
};
return (
<>
<Typography
variant="subtitle1"
component="h4"
sx={{
fontWeight: "bold",
color: theme.palette.primary.contrastTextSecondary,
}}
>
{type.label}
</Typography>
<Typography
sx={{
mt: theme.spacing(0.5),
mb: theme.spacing(1.5),
color: theme.palette.primary.contrastTextTertiary,
}}
>
{type.description}
</Typography>
<Box sx={{ pl: theme.spacing(1.5) }}>
<Checkbox
id={`enable-${type.id}`}
label={t("notifications.enableNotifications", { platform: type.label })}
isChecked={integrations[type.id]}
onChange={(e) => handleIntegrationChange(type.id, e.target.checked)}
disabled={isLoading}
/>
</Box>
{type.fields.map((field) => {
const fieldKey = getFieldKey(type.id, field.id);
return (
<Box
key={field.id}
sx={{ mt: theme.spacing(1) }}
>
<Typography
sx={{
mb: theme.spacing(2),
fontWeight: "bold",
color: theme.palette.primary.contrastTextSecondary,
}}
>
{field.label}
</Typography>
<TextInput
id={`${type.id}-${field.id}`}
type={field.type}
placeholder={field.placeholder}
value={integrations[fieldKey]}
onChange={(e) => handleInputChange(fieldKey, e.target.value)}
disabled={!integrations[type.id] || isLoading}
/>
</Box>
);
})}
<Box sx={{ mt: theme.spacing(1) }}>
<Button
variant="text"
color="info"
onClick={() => handleTestNotification(type.id)}
disabled={!integrations[type.id] || !areAllFieldsFilled() || isLoading}
>
{isLoading ? (
<CircularProgress
size={theme.spacing(8)}
sx={{ mr: theme.spacing(1), color: theme.palette.accent.main }}
/>
) : null}
{t("notifications.testNotification")}
</Button>
</Box>
</>
);
};
export default TabComponent;
@@ -0,0 +1,41 @@
import React from "react";
import PropTypes from "prop-types";
import { Box } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* TabPanel component that displays content for the selected tab.
*
* @component
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The content to be displayed when this tab panel is selected.
* @param {number} props.value - The currently selected tab value.
* @param {number} props.index - The index of this specific tab panel.
* @param {Object} props.other - Any additional props to be spread to the root element.
* @returns {React.ReactElement|null} The rendered tab panel or null if not selected.
*/
function TabPanel({ children, value, index, ...other }) {
const theme = useTheme();
return (
<div
role="tabpanel"
hidden={value !== index}
id={`notification-tabpanel-${index}`}
aria-labelledby={`notification-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: theme.spacing(3) }}>{children}</Box>}
</div>
);
}
TabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired,
};
export default TabPanel;
@@ -0,0 +1,129 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { networkService } from "../../../../Utils/NetworkService.js";
import { createToast } from "../../../../Utils/toastUtils.jsx";
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: "slack",
DISCORD: "discord",
TELEGRAM: "telegram",
WEBHOOK: "webhook",
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: "webhook",
TOKEN: "token",
CHAT_ID: "chatId",
URL: "url",
};
/**
* Custom hook for notification-related operations
*/
const useNotifications = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(undefined);
const { t } = useTranslation();
/**
* Send a test notification
* @param {string} type - The notification type (slack, discord, telegram, webhook)
* @param {object} config - Configuration object with necessary params
*/
const sendTestNotification = async (type, config) => {
setLoading(true);
setError(undefined);
// Validation based on notification type
let payload = { platform: type };
let isValid = true;
let errorMessage = "";
switch (type) {
case NOTIFICATION_TYPES.SLACK:
payload.webhookUrl = config.webhook;
if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") {
isValid = false;
errorMessage = t("notifications.slack.webhookRequired");
}
break;
case NOTIFICATION_TYPES.DISCORD:
payload.webhookUrl = config.webhook;
if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") {
isValid = false;
errorMessage = t("notifications.discord.webhookRequired");
}
break;
case NOTIFICATION_TYPES.TELEGRAM:
payload.botToken = config.token;
payload.chatId = config.chatId;
if (
typeof payload.botToken === "undefined" ||
payload.botToken === "" ||
typeof payload.chatId === "undefined" ||
payload.chatId === ""
) {
isValid = false;
errorMessage = t("notifications.telegram.fieldsRequired");
}
break;
case NOTIFICATION_TYPES.WEBHOOK:
payload.webhookUrl = config.url;
payload.platform = NOTIFICATION_TYPES.SLACK;
if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") {
isValid = false;
errorMessage = t("notifications.webhook.urlRequired");
}
break;
default:
isValid = false;
errorMessage = t("notifications.unsupportedType");
}
// If validation fails, show error and return
if (isValid === false) {
createToast({
body: errorMessage,
variant: "error",
});
setLoading(false);
return;
}
try {
const response = await networkService.testNotification({
platform: type,
payload: payload,
});
if (response.data.success === true) {
createToast({
body: t("notifications.testSuccess"),
variant: "info",
});
} else {
throw new Error(response.data.msg || t("notifications.testFailed"));
}
} catch (error) {
const errorMsg =
error.response?.data?.msg || error.message || t("notifications.networkError");
createToast({
body: `${t("notifications.testFailed")}: ${errorMsg}`,
variant: "error",
});
setError(errorMsg);
} finally {
setLoading(false);
}
};
return [loading, error, sendTestNotification];
};
export default useNotifications;
@@ -3,7 +3,7 @@ import Fallback from "../Fallback/index.jsx";
import { Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useIsAdmin } from "../../../Hooks/v1/useIsAdmin.js";
import PropTypes from "prop-types";
const PageStateWrapper = ({
networkError,
@@ -1,7 +1,8 @@
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { Box, IconButton, LinearProgress, Stack, Typography } from "@mui/material";
import Icon from "../Icon";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import "./index.css";
/**
@@ -50,10 +51,7 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
alignItems={error ? "center" : "flex-start"}
>
{error ? (
<Icon
name="AlertTriangle"
size={20}
/>
<ErrorOutlineOutlinedIcon />
) : icon ? (
<Box
sx={{
@@ -129,9 +127,10 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
}
}
>
<Icon
name="X"
size={20}
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
</Stack>
@@ -1,22 +1,15 @@
import React from "react";
import { Step, Stepper, StepLabel, Typography } from "@mui/material";
import Icon from "../Icon";
import RadioButtonCheckedIcon from "@mui/icons-material/RadioButtonChecked";
import CheckCircle from "@mui/icons-material/CheckCircle";
import PropTypes from "prop-types";
const CustomStepIcon = (props) => {
const { completed, active } = props;
return completed ? (
<Icon
name="CheckCircle2"
size={24}
color="accent.main"
/>
<CheckCircle color="accent" />
) : (
<Icon
name="Circle"
size={24}
color={active ? "accent.main" : "text.disabled"}
/>
<RadioButtonCheckedIcon color={active ? "accent" : "disabled"} />
);
};
@@ -1,5 +1,5 @@
import html2canvas from "html2canvas";
import Icon from "../Icon";
import ShareIcon from "@mui/icons-material/Share";
import { Button } from "@mui/material";
import { useTheme } from "@emotion/react";
@@ -55,13 +55,7 @@ const ShareComponent = ({ elementToCapture, fileName = "screenshot" }) => {
return (
<Button
variant="outlined"
startIcon={
<Icon
name="Share2"
size={18}
color="success.main"
/>
}
startIcon={<ShareIcon sx={{ color: theme.palette.success.main }} />}
color="success"
onClick={captureAndShare}
>
@@ -7,7 +7,8 @@ import Avatar from "../../Avatar/index.jsx";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Divider from "@mui/material/Divider";
import Icon from "../../Icon";
import DotsVertical from "../../../../assets/icons/dots-vertical.svg?react";
import LogoutSvg from "../../../../assets/icons/logout.svg?react";
import { useTheme } from "@emotion/react";
import { useSelector } from "react-redux";
@@ -80,10 +81,6 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => {
gap: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(4),
color: theme.palette.primary.contrastTextTertiary,
"& svg": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
{item.icon}
@@ -163,14 +160,19 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => {
ml: "50px",
"&:focus": { outline: "none" },
alignSelf: "center",
"& svg": {
width: "22px",
height: "22px",
},
"& svg path": {
/* Vertical three dots */
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
onClick={(event) => openPopup(event)}
>
<Icon
name="MoreVertical"
size={22}
color="primary.contrastTextTertiary"
/>
<DotsVertical />
</IconButton>
</Tooltip>
</Stack>
@@ -254,13 +256,12 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => {
gap: theme.spacing(4),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(4),
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Icon
name="LogOut"
size={20}
color="primary.contrastTextTertiary"
/>
<LogoutSvg />
{t("menu.logOut", "Log out")}
</MenuItem>
</Menu>
@@ -52,11 +52,12 @@ const NavItem = ({ item, collapsed, selected, onClick }) => {
<ListItemIcon
sx={{
minWidth: 0,
color: iconStroke,
"& svg": {
height: 20,
width: 20,
opacity: 0.81,
},
"& svg path": {
stroke: iconStroke,
},
}}
+46 -27
View File
@@ -8,13 +8,28 @@ import NavItem from "./components/navItem.jsx";
import AuthFooter from "./components/authFooter.jsx";
import StarPrompt from "../StarPrompt/index.jsx";
import Icon from "../Icon";
import LockSvg from "../../../assets/icons/lock.svg?react";
import UserSvg from "../../../assets/icons/user.svg?react";
import TeamSvg from "../../../assets/icons/user-two.svg?react";
import Support from "../../../assets/icons/support.svg?react";
import Maintenance from "../../../assets/icons/maintenance.svg?react";
import Monitors from "../../../assets/icons/monitors.svg?react";
import Incidents from "../../../assets/icons/incidents.svg?react";
import Integrations from "../../../assets/icons/integrations.svg?react";
import PageSpeed from "../../../assets/icons/page-speed.svg?react";
import Settings from "../../../assets/icons/settings.svg?react";
import ChangeLog from "../../../assets/icons/changeLog.svg?react";
import Docs from "../../../assets/icons/docs.svg?react";
import StatusPages from "../../../assets/icons/status-pages.svg?react";
import Discussions from "../../../assets/icons/discussions.svg?react";
import Notifications from "../../../assets/icons/notifications.svg?react";
import Logs from "../../../assets/icons/logs.svg?react";
// Utils
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useSidebar } from "@/Hooks/useSidebar.js";
const URL_MAP = {
support: "https://discord.com/invite/NAb6H3UTjK",
@@ -24,48 +39,51 @@ const URL_MAP = {
};
const getMenu = (t) => [
{ name: t("menu.uptime"), path: "uptime", icon: <Icon name="Globe" /> },
{ name: t("menu.pagespeed"), path: "pagespeed", icon: <Icon name="Gauge" /> },
{ name: t("menu.infrastructure"), path: "infrastructure", icon: <Icon name="Link" /> },
{ name: t("menu.uptime"), path: "uptime", icon: <Monitors /> },
{ name: t("menu.pagespeed"), path: "pagespeed", icon: <PageSpeed /> },
{ name: t("menu.infrastructure"), path: "infrastructure", icon: <Integrations /> },
{
name: t("menu.notifications"),
path: "notifications",
icon: <Icon name="Bell" />,
icon: <Notifications />,
},
{ name: t("menu.checks"), path: "checks", icon: <Icon name="FileText" /> },
{ name: t("menu.incidents"), path: "incidents", icon: <Icon name="AlertTriangle" /> },
{ name: t("menu.statusPages"), path: "status", icon: <Icon name="Wifi" /> },
{ name: t("menu.maintenance"), path: "maintenance", icon: <Icon name="Wrench" /> },
{ name: t("menu.logs"), path: "logs", icon: <Icon name="Database" /> },
{ name: t("menu.incidents"), path: "incidents", icon: <Incidents /> },
{ name: t("menu.statusPages"), path: "status", icon: <StatusPages /> },
{ name: t("menu.maintenance"), path: "maintenance", icon: <Maintenance /> },
{ name: t("menu.logs"), path: "logs", icon: <Logs /> },
{
name: t("menu.settings"),
icon: <Icon name="Settings" />,
icon: <Settings />,
path: "settings",
},
];
const getOtherMenuItems = (t) => [
{ name: t("menu.support"), path: "support", icon: <Icon name="HelpCircle" /> },
{ name: t("menu.support"), path: "support", icon: <Support /> },
{
name: t("menu.discussions"),
path: "discussions",
icon: <Icon name="MessageCircle" />,
icon: <Discussions />,
},
{ name: t("menu.docs"), path: "docs", icon: <Icon name="FileText" /> },
{ name: t("menu.changelog"), path: "changelog", icon: <Icon name="Code" /> },
{ name: t("menu.docs"), path: "docs", icon: <Docs /> },
{ name: t("menu.changelog"), path: "changelog", icon: <ChangeLog /> },
];
const getAccountMenuItems = (t) => [
{ name: t("menu.profile"), path: "account/profile", icon: <Icon name="User" /> },
{ name: t("menu.password"), path: "account/password", icon: <Icon name="Lock" /> },
{ name: t("menu.team"), path: "account/team", icon: <Icon name="Users" /> },
{ name: t("menu.profile"), path: "account/profile", icon: <UserSvg /> },
{ name: t("menu.password"), path: "account/password", icon: <LockSvg /> },
{ name: t("menu.team"), path: "account/team", icon: <TeamSvg /> },
];
const Sidebar = () => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const { collapsed, width, transition } = useSidebar();
// Redux state
const collapsed = useSelector((state) => state.ui.sidebar.collapsed);
const menu = getMenu(t);
const otherMenuItems = getOtherMenuItems(t);
@@ -74,19 +92,20 @@ const Sidebar = () => {
return (
<Stack
height="100vh"
width={width}
width={
collapsed
? "var(--env-var-side-bar-collapsed-width)"
: "var(--env-var-side-bar-width)"
}
component="aside"
position="fixed"
position="sticky"
top={0}
left={0}
borderRight={`1px solid ${theme.palette.primary.lowContrast}`}
paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(6)}
gap={theme.spacing(6)}
sx={{
transition,
backgroundColor: theme.palette.background.main,
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
zIndex: 1000,
transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)",
}}
>
<CollapseButton collapsed={collapsed} />
@@ -1,6 +1,6 @@
import React from "react";
import { Typography, IconButton, Stack, Box } from "@mui/material";
import Icon from "../Icon";
import CloseIcon from "@mui/icons-material/Close";
import { useTheme } from "@emotion/react";
import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
@@ -67,10 +67,7 @@ const StarPrompt = ({ repoUrl = "https://github.com/bluewave-labs/checkmate" })
},
}}
>
<Icon
name="X"
size={20}
/>
<CloseIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Stack>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Stack, Typography } from "@mui/material";
import Image from "../Image/index.jsx";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js";
import { useMonitorUtils } from "../../../Hooks/v1/useMonitorUtils.js";
/**
* StatBox Component
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Stack, TablePagination, Typography } from "@mui/material";
import { TablePaginationActions } from "./Actions/index.jsx";
import Icon from "../../Icon";
import SelectorVertical from "../../../../assets/icons/selector-vertical.svg?react";
Pagination.propTypes = {
paginationLabel: PropTypes.string, // Label for the pagination.
@@ -100,12 +100,7 @@ function Pagination({
},
},
inputProps: { id: "pagination-dropdown" },
IconComponent: () => (
<Icon
name="ChevronsUpDown"
size={16}
/>
),
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
+1 -3
View File
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Link as RouterLink } from "react-router-dom";
const TextLink = ({ text, linkText, href, state, target = "_self" }) => {
const TextLink = ({ text, linkText, href, target = "_self" }) => {
const theme = useTheme();
return (
@@ -19,7 +19,6 @@ const TextLink = ({ text, linkText, href, state, target = "_self" }) => {
to={href}
component={RouterLink}
target={target}
state={state}
>
{linkText}
</Link>
@@ -31,7 +30,6 @@ TextLink.propTypes = {
text: PropTypes.string,
linkText: PropTypes.string,
href: PropTypes.string,
state: PropTypes.object,
target: PropTypes.string,
};
+9 -27
View File
@@ -3,31 +3,19 @@ import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import ToastBody from "./body.jsx";
import Icon from "../Icon";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined";
import CloseIcon from "@mui/icons-material/Close";
// Utils
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const icons = {
info: (
<Icon
name="Info"
size={24}
/>
),
error: (
<Icon
name="AlertCircle"
size={24}
/>
),
warning: (
<Icon
name="AlertTriangle"
size={24}
/>
),
info: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
};
const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => {
@@ -62,10 +50,7 @@ const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => {
)}
{title && (
<IconButton onClick={onClick}>
<Icon
name="X"
size={20}
/>
<CloseIcon />
</IconButton>
)}
</Stack>
@@ -78,10 +63,7 @@ const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => {
<ToastBody body={body} />
{!title && (
<IconButton onClick={onClick}>
<Icon
name="X"
size={20}
/>
<CloseIcon />
</IconButton>
)}
</Stack>
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
import Settings from "@/assets/icons/settings-bold.svg?react";
export type ActionMenuItem = {
id: number | string;
@@ -28,10 +28,7 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return (
<div>
<IconButton onClick={handleClick}>
<Settings
size={20}
strokeWidth={1.5}
/>
<Settings />
</IconButton>
<Menu
anchorEl={anchorEl}
@@ -0,0 +1,36 @@
import LeftArrow from "@/assets/icons/left-arrow.svg?react";
import LeftArrowDouble from "@/assets/icons/left-arrow-double.svg?react";
import LeftArrowLong from "@/assets/icons/left-arrow-long.svg?react";
export const ArrowLeft = ({
type,
color = "#667085",
...props
}: {
type?: string;
color?: string | undefined;
[key: string]: any;
}) => {
if (type === "double") {
return (
<LeftArrowDouble
style={{ color }}
{...props}
/>
);
} else if (type === "long") {
return (
<LeftArrowLong
style={{ color }}
{...props}
/>
);
} else {
return (
<LeftArrow
style={{ color }}
{...props}
/>
);
}
};
@@ -0,0 +1,28 @@
import RightArrow from "@/assets/icons/right-arrow.svg?react";
import RightArrowDouble from "@/assets/icons/right-arrow-double.svg?react";
export const ArrowRight = ({
type,
color = "#667085",
...props
}: {
type?: string;
color?: string | undefined;
[key: string]: any;
}) => {
if (type === "double") {
return (
<RightArrowDouble
style={{ color }}
{...props}
/>
);
} else {
return (
<RightArrow
style={{ color }}
{...props}
/>
);
}
};
@@ -0,0 +1,48 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { HeaderAuth } from "@/Components/v2/Auth";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { Typography } from "@mui/material";
interface AuthBasePageProps extends StackProps {
title?: string;
subtitle?: string;
children: React.ReactNode;
}
export const AuthBasePage: React.FC<AuthBasePageProps> = ({
children,
title,
subtitle,
...props
}) => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(10)}
minHeight="100vh"
{...props}
>
<HeaderAuth />
<Stack
alignItems="center"
margin="auto"
width="100%"
gap={theme.spacing(4)}
>
<Box
width={{ xs: 60, sm: 70, md: 80 }}
mb={theme.spacing(10)}
>
<Logo style={{ width: "100%", height: "100%" }} />
</Box>
<Typography variant="h1">{title}</Typography>
<Typography variant="h1">{subtitle}</Typography>
{children}
</Stack>
</Stack>
);
};
@@ -0,0 +1,26 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { LanguageSelector, ThemeSwitch } from "@/Components/v2/Inputs";
export const HeaderAuth = () => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
width={"100%"}
direction="row"
alignItems="center"
justifyContent="flex-end"
py={theme.spacing(4)}
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<LanguageSelector />
<ThemeSwitch color="red" />
</Stack>
);
};
+2
View File
@@ -0,0 +1,2 @@
export { HeaderAuth } from "./HeaderAuth";
export { AuthBasePage } from "./AuthBasePage";
@@ -0,0 +1,23 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { SxProps } from "@mui/material/styles";
type BaseBoxProps = React.PropsWithChildren<{ sx?: SxProps }>;
export const BaseBox: React.FC<BaseBoxProps> = ({ children, sx }) => {
const theme = useTheme();
return (
<Box
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
...sx,
}}
>
{children}
</Box>
);
};
@@ -0,0 +1,80 @@
import Stack from "@mui/material/Stack";
import { ErrorFallback, EmptyFallback } from "./Fallback";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
interface BasePageProps extends StackProps {
children: React.ReactNode;
}
export const BasePage: React.FC<BasePageProps> = ({
children,
...props
}: {
children: React.ReactNode;
}) => {
const theme = useTheme();
return (
<Stack
spacing={theme.spacing(10)}
{...props}
>
{children}
</Stack>
);
};
interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
items: any[];
page: string;
actionLink?: string;
children: React.ReactNode;
}
const isEmpty = (items: any[]) => {
if (!items) return true;
if (Array.isArray(items) && items.length === 0) return true;
return false;
};
export const BasePageWithStates: React.FC<BasePageWithStatesProps> = ({
loading,
error,
items,
page,
actionLink,
children,
...props
}: BasePageWithStatesProps) => {
const { t } = useTranslation();
if (loading) {
return null;
}
if (error) {
return (
<ErrorFallback
title="Something went wrong..."
subtitle="Please try again later"
/>
);
}
if (isEmpty(items)) {
return (
<EmptyFallback
page={page}
title={t(`${page}Monitor.fallback.title`)}
bullets={t(`${page}Monitor.fallback.checks`, { returnObjects: true })}
actionButtonText={t(`${page}Monitor.fallback.actionButton`)}
actionLink={actionLink || ""}
/>
);
}
return <BasePage {...props}>{children}</BasePage>;
};
@@ -1,6 +1,6 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Check } from "lucide-react";
import CheckOutlined from "@/assets/icons/check-outlined.svg?react";
import { useTheme } from "@mui/material/styles";
export const BulletPointCheck = ({
@@ -15,25 +15,24 @@ export const BulletPointCheck = ({
const colors: Record<string, string | undefined> = {
success: theme.palette.success.main,
error: theme.palette.error.main,
info: theme.palette.text.secondary,
info: theme.palette.primary.contrastTextSecondary,
};
return (
<Stack
direction="row"
className="check"
gap={theme.spacing(6)}
alignItems="center"
>
<Check
size={16}
strokeWidth={1.5}
style={{
flexShrink: 0,
}}
/>
<CheckOutlined />
<Typography
component="span"
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
color={
variant === "info"
? theme.palette.primary.contrastTextSecondary
: colors[variant]
}
fontWeight={450}
sx={{
opacity: 0.9,
@@ -1,12 +1,12 @@
import type { CSSProperties } from "react";
interface DotProps {
export const Dot = ({
color = "gray",
size = "4px",
style,
}: {
color?: string;
size?: string;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
style?: React.CSSProperties;
}) => {
return (
<span
style={{
@@ -0,0 +1,158 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import OutputAnimation from "@/assets/Animations/output.gif";
import DarkmodeOutput from "@/assets/Animations/darkmodeOutput.gif";
import Typography from "@mui/material/Typography";
import { BulletPointCheck } from "@/Components/v2/DesignElements";
import { Button } from "@/Components/v2/Inputs";
import { useNavigate } from "react-router";
import { useMediaQuery } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { BoxProps } from "@mui/material";
interface BaseFallbackProps extends BoxProps {
children: React.ReactNode;
}
export const BaseFallback: React.FC<BaseFallbackProps> = ({ children, ...props }) => {
const theme = useTheme();
const mode = useSelector((state: any) => state.ui.mode);
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Box
margin={isSmall ? "inherit" : "auto"}
marginTop={isSmall ? "33%" : "auto"}
width={{
sm: "90%",
md: "70%",
lg: "50%",
xl: "40%",
}}
padding={theme.spacing(16)}
bgcolor={theme.palette.primary.main}
position="relative"
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
overflow="hidden"
sx={{
borderStyle: "dashed",
}}
{...props}
>
<Stack
alignItems="center"
gap={theme.spacing(20)}
sx={{
width: "fit-content",
margin: "auto",
marginTop: "100px",
}}
>
<Box
component="img"
src={mode === "light" ? OutputAnimation : DarkmodeOutput}
bgcolor="transparent"
alt="Loading animation"
width="100%"
sx={{
zIndex: 1,
border: "none",
borderRadius: theme.spacing(8),
}}
/>
<Stack
gap={theme.spacing(4)}
alignItems="center"
maxWidth={"300px"}
zIndex={1}
>
{children}
</Stack>
</Stack>
</Box>
);
};
export const ErrorFallback = ({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) => {
const theme = useTheme();
return (
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
<Typography>{subtitle}</Typography>
</BaseFallback>
);
};
export const EmptyFallback = ({
page,
title,
bullets,
actionButtonText,
actionLink,
}: {
page: string;
title: string;
bullets: any;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(10)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(2),
maxWidth: { xs: "90%", md: "80%", lg: "75%" },
}}
>
{bullets?.map((bullet: string, index: number) => (
<BulletPointCheck
text={bullet}
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="accent"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
);
};
@@ -1,12 +1,8 @@
import { Box, Stack, useTheme } from "@mui/material";
interface PulseDotProps {
color: string;
}
export const PulseDot = ({ color }: PulseDotProps) => {
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
export const PulseDot = ({ color }: { color: string }) => {
const theme = useTheme();
return (
<Stack
width="26px"
@@ -36,7 +32,7 @@ export const PulseDot = ({ color }: PulseDotProps) => {
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: theme.palette.background.paper,
backgroundColor: theme.palette.accent.contrastText,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
@@ -1,8 +1,9 @@
import { Fragment } from "react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useMediaQuery } from "@mui/material";
export const SplitBox = ({
left,
right,
@@ -15,23 +16,17 @@ export const SplitBox = ({
return (
<Stack
direction={isSmall ? "column" : "row"}
bgcolor={theme.palette.background.paper}
bgcolor={theme.palette.primary.main}
border={1}
borderColor={theme.palette.divider}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.spacing(2)}
>
<Box
padding={theme.spacing(15)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider}
borderColor={theme.palette.primary.lowContrast}
flex={0.7}
sx={{
background:
theme.palette.mode === "dark"
? "linear-gradient(135deg, rgba(255, 255, 255, 0.01) 0%, rgba(255, 255, 255, 0.02) 100%)"
: "linear-gradient(135deg, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.02) 100%)",
}}
>
{left}
</Box>
@@ -48,29 +43,24 @@ export const SplitBox = ({
export const ConfigBox = ({
title,
subtitle,
leftContent,
rightContent,
}: {
title: string;
subtitle: string;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
const theme = useTheme();
return (
<SplitBox
left={
<Stack spacing={theme.spacing(4)}>
<Fragment>
<Typography
textTransform={"capitalize"}
component="h2"
variant="h2"
>
{title}
</Typography>
<Typography component="p">{subtitle}</Typography>
{leftContent}
</Stack>
</Fragment>
}
right={rightContent}
/>
@@ -0,0 +1,57 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useMediaQuery } from "@mui/material";
import type { PaletteKey } from "@/Utils/Theme/v2/theme";
import { BaseBox } from "@/Components/v2/DesignElements";
type GradientBox = React.PropsWithChildren<{ palette?: PaletteKey }>;
export const GradientBox: React.FC<GradientBox> = ({ children, palette }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const bg = palette
? `linear-gradient(to bottom right, ${theme.palette[palette].main} 30%, ${theme.palette[palette].lowContrast} 70%)`
: `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`;
return (
<BaseBox
sx={{
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall
? `calc(50% - (1 * ${theme.spacing(8)} / 2))`
: `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
}}
>
{children}
</BaseBox>
);
};
type StatBoxProps = React.PropsWithChildren<{
title: string;
subtitle: string;
palette?: PaletteKey;
}>;
export const StatBox: React.FC<StatBoxProps> = ({
title,
subtitle,
palette,
children,
}) => {
const theme = useTheme();
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
return (
<GradientBox palette={palette}>
<Stack>
<Typography color={textColor}>{title}</Typography>
<Typography color={textColor}>{subtitle}</Typography>
{children}
</Stack>
</GradientBox>
);
};
@@ -0,0 +1,100 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/DesignElements";
import Background from "@/assets/Images/background-grid.svg?react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{}>;
export const BGBox: React.FC<StatusBoxProps> = ({ children }) => {
const theme = useTheme();
return (
<BaseBox
sx={{
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(8),
}}
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
{children}
</BaseBox>
);
};
const StatusBox = ({
label,
n,
color,
}: {
label: string;
n: number;
color: string | undefined;
}) => {
const theme = useTheme();
return (
<BGBox>
<Stack spacing={theme.spacing(8)}>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.primary.contrastTextTertiary}
>
{label}
</Typography>
<Typography
variant="h1"
color={color}
>
{n}
</Typography>
</Stack>
</BGBox>
);
};
export const UpStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("monitorStatus.up")}
n={n}
color={theme.palette.success.lowContrast}
/>
);
};
export const DownStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("monitorStatus.down")}
n={n}
color={theme.palette.error.lowContrast}
/>
);
};
export const PausedStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("monitorStatus.paused")}
n={n}
color={theme.palette.warning.lowContrast}
/>
);
};
@@ -0,0 +1,41 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/DesignElements";
import type { MonitorStatus } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/v2/MonitorUtils";
import { useTheme } from "@mui/material/styles";
export const StatusLabel = ({
status,
isActive,
}: {
status: MonitorStatus;
isActive?: boolean;
}) => {
const theme = useTheme();
const palette = getStatusPalette(status);
const transformedText = status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
return (
<BaseBox
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: theme.spacing(3, 5),
color: theme.palette[palette].main,
borderColor: theme.palette[palette].lowContrast,
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].lowContrast}
borderRadius="50%"
marginRight="5px"
/>
{isActive === false ? "Paused" : transformedText}
</BaseBox>
);
};
@@ -0,0 +1,215 @@
import Paper from "@mui/material/Paper";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import IconButton from "@mui/material/IconButton";
import LastPageIcon from "@mui/icons-material/LastPage";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import Box from "@mui/material/Box";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationProps } from "@mui/material/TablePagination";
import { useTheme } from "@mui/material/styles";
import { useMediaQuery } from "@mui/material";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
headers: Header<T>[];
data: T[];
onRowClick?: (row: T) => void;
};
export function DataTable<
T extends {
id?: string | number;
_id?: string | number;
onRowClick?: (row: T) => void;
},
>({ headers, data, onRowClick }: DataTableProps<T>) {
const theme = useTheme();
if (data.length === 0 || headers.length === 0) return <div>No data</div>;
return (
<TableContainer component={Paper}>
<Table
stickyHeader
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(8),
},
"& :is(th)": {
backgroundColor: theme.palette.secondary.main,
color: theme.palette.secondary.contrastText,
fontWeight: 600,
},
"& :is(td)": {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
},
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
}}
>
<TableHead>
<TableRow>
{headers.map((header, idx) => {
return (
<TableCell
align={idx === 0 ? "left" : "center"}
key={header.id}
>
{header.content}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => {
const key = row.id || row._id || Math.random();
return (
<TableRow
key={key}
sx={{ cursor: onRowClick ? "pointer" : "default" }}
onClick={() => (onRowClick ? onRowClick(row) : null)}
>
{headers.map((header, index) => {
return (
<TableCell
align={index === 0 ? "left" : "center"}
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
}
>
{header.render(row)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
interface TablePaginationActionsProps {
count: number;
page: number;
rowsPerPage: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box
sx={{ flexShrink: 0, ml: 2.5 }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? <KeyboardArrowRight /> : <KeyboardArrowLeft />}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? <KeyboardArrowLeft /> : <KeyboardArrowRight />}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</Box>
);
}
export const Pagination: React.FC<TablePaginationProps> = ({ ...props }) => {
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
return (
<TablePagination
ActionsComponent={TablePaginationActions}
rowsPerPageOptions={[5, 10, 25]}
{...props}
sx={{
"& .MuiTablePagination-toolbar": {
display: isSmall ? "grid" : "flex",
},
"& .MuiTablePagination-selectLabel": {
gridColumn: "1",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-select": {
gridColumn: "2",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-displayedRows": {
gridColumn: "2",
gridRow: "2",
justifySelf: "center ",
},
"& .table-pagination-actions": {
gridColumn: "1",
gridRow: "2",
justifySelf: "center",
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
},
}}
/>
);
};
@@ -0,0 +1,9 @@
export { SplitBox as HorizontalSplitBox, ConfigBox } from "./SplitBox";
export { BasePage, BasePageWithStates } from "./BasePage";
export { BGBox, UpStatusBox, DownStatusBox, PausedStatusBox } from "./StatusBox";
export { DataTable as Table, Pagination } from "./Table";
export { GradientBox, StatBox } from "./StatBox";
export { BaseBox } from "./BaseBox";
export { StatusLabel } from "./StatusLabel";
export { BaseFallback, ErrorFallback, EmptyFallback } from "./Fallback";
export { BulletPointCheck } from "./BulletPointCheck";
@@ -0,0 +1,48 @@
import Autocomplete from "@mui/material/Autocomplete";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import { CheckboxInput } from "@/Components/v2/Inputs/Checkbox";
import ListItem from "@mui/material/ListItem";
import { useTheme } from "@mui/material/styles";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
"renderInput"
> & {
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
};
export const AutoCompleteInput: React.FC<AutoCompleteInputProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Autocomplete
{...props}
disableCloseOnSelect
renderInput={(params) => (
<TextInput
{...params}
placeholder="Type to search"
/>
)}
getOptionKey={(option) => option._id}
renderTags={() => null}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
>
<CheckboxInput checked={selected} />
{option.name}
</ListItem>
);
}}
sx={{
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
padding: `0 ${theme.spacing(5)}`,
},
}}
/>
);
};
@@ -0,0 +1,11 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
export const ButtonInput: React.FC<ButtonProps> = ({ sx, ...props }) => {
return (
<Button
{...props}
sx={{ textTransform: "none", height: 34, fontWeight: 400, borderRadius: 2, ...sx }}
/>
);
};
@@ -0,0 +1,13 @@
import ButtonGroup from "@mui/material/ButtonGroup";
import type { ButtonGroupProps } from "@mui/material/ButtonGroup";
export const ButtonGroupInput: React.FC<ButtonGroupProps> = ({
orientation,
...props
}) => {
return (
<ButtonGroup
orientation={orientation}
{...props}
/>
);
};
@@ -0,0 +1,23 @@
import Checkbox from "@mui/material/Checkbox";
import type { CheckboxProps } from "@mui/material/Checkbox";
import CheckboxOutline from "@/assets/icons/checkbox-outline.svg?react";
import CheckboxFilled from "@/assets/icons/checkbox-filled.svg?react";
import { useTheme } from "@mui/material/styles";
type CheckboxInputProps = CheckboxProps & {
label?: string;
};
export const CheckboxInput: React.FC<CheckboxInputProps> = ({ label, ...props }) => {
const theme = useTheme();
return (
<Checkbox
{...props}
icon={<CheckboxOutline />}
checkedIcon={<CheckboxFilled />}
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: theme.spacing(8), height: theme.spacing(8) },
}}
/>
);
};
@@ -0,0 +1,64 @@
import "flag-icons/css/flag-icons.min.css";
import { Select } from "@/Components/v2/Inputs";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { SelectChangeEvent } from "@mui/material/Select";
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const dispatch = useDispatch();
const language = useSelector((state: any) => state.ui.language);
const languages = Object.keys(i18n.options.resources || {});
const languageMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
};
const handleChange = (event: SelectChangeEvent<unknown>) => {
const newLang = event.target.value;
dispatch(setLanguage(newLang));
};
const languagesForDisplay = languages.map((l) => {
let formattedLanguage = l === "en" ? "gb" : l;
formattedLanguage = formattedLanguage.includes("-")
? formattedLanguage.split("-")[1].toLowerCase()
: formattedLanguage;
formattedLanguage = languageMap[formattedLanguage] || formattedLanguage;
const flag = formattedLanguage ? `fi fi-${formattedLanguage}` : null;
return (
<MenuItem
key={l}
value={l}
>
<Stack
direction="row"
gap={theme.spacing(4)}
>
{flag && <span className={flag} />}
<Typography textTransform={"uppercase"}>{l}</Typography>
</Stack>
</MenuItem>
);
});
return (
<Select
value={language}
onChange={handleChange}
>
{languagesForDisplay}
</Select>
);
};
@@ -1,59 +1,37 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
import { Circle, CircleDot } from "lucide-react";
import RadioChecked from "@/assets/icons/radio-checked.svg?react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput = ({ ...props }: RadioInputProps) => {
export const RadioInput: React.FC<RadioInputProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
checkedIcon={<RadioChecked />}
sx={{
padding: 0,
mt: theme.spacing(0.5),
color: theme.palette.text.secondary,
"&.Mui-checked": {
color: theme.palette.primary.main,
"& svg circle": {
fill: theme.palette.primary.main,
},
color: "transparent",
boxShadow: `inset 0 0 0 1px ${theme.palette.secondary.main}`,
"&:not(.Mui-checked)": {
boxShadow: `inset 0 0 0 1px ${theme.palette.primary.contrastText}70`, // Use theme text color for the outline
},
mt: theme.spacing(0.5),
padding: 0,
"& .MuiSvgIcon-root": {
fontSize: 16,
},
"& svg": {
stroke: "currentColor",
},
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
stroke: "currentColor",
fill: "none",
},
}}
/>
);
};
export const RadioWithDescription = ({
label,
description,
...props
}: RadioInputProps & { label: string; description: string }) => {
export const RadioWithDescription: React.FC<
RadioInputProps & { label: string; description: string }
> = ({ label, description, ...props }) => {
const theme = useTheme();
return (
<FormControlLabel
@@ -63,7 +41,7 @@ export const RadioWithDescription = ({
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.text.secondary}
color={theme.palette.primary.contrastTextSecondary}
>
{description}
</Typography>
@@ -76,7 +54,7 @@ export const RadioWithDescription = ({
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.background.paper,
backgroundColor: theme.palette.tertiary.main,
},
"& .MuiButtonBase-root": {
p: 0,
@@ -0,0 +1,88 @@
import { Typography, Select } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import type { SelectProps } from "@mui/material/Select";
import { useTheme } from "@mui/material/styles";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
export const SelectInput: React.FC<SelectProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Select
{...props}
sx={{
height: "34px",
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.primary.lowContrast,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
}}
/>
);
};
type ItemTypes = string | number;
interface SelectItem {
_id: ItemTypes;
name: string;
}
export type CustomSelectProps = SelectProps & {
items: SelectItem[];
placeholder?: string;
isHidden?: boolean;
hasError?: boolean;
};
export const SelectFromItems: React.FC<CustomSelectProps> = ({
items,
placeholder,
isHidden = false,
hasError = false,
...props
}) => {
return (
<SelectInput
error={hasError}
IconComponent={KeyboardArrowDownIcon}
displayEmpty
MenuProps={{ disableScrollLock: true }}
renderValue={(selected) => {
if (!selected) {
return (
<Typography
noWrap
color="text.secondary"
>
{placeholder ?? ""}
</Typography>
);
}
const selectedItem = items.find((item) => item._id === selected);
const displayName = selectedItem ? selectedItem.name : placeholder;
return (
<Typography
noWrap
title={displayName}
>
{displayName}
</Typography>
);
}}
{...props}
>
{items.map((item) => (
<MenuItem
key={item._id}
value={item._id}
>
{item.name}
</MenuItem>
))}
</SelectInput>
);
};
SelectInput.displayName = "SelectInput";
SelectFromItems.displayName = "SelectFromItems";
@@ -0,0 +1,22 @@
import { forwardRef } from "react";
import TextField from "@mui/material/TextField";
import type { TextFieldProps } from "@mui/material";
import { typographyLevels } from "@/Utils/Theme/v2/palette";
export const TextInput = forwardRef<HTMLInputElement, TextFieldProps>(
function TextInput(props, ref) {
return (
<TextField
{...props}
inputRef={ref}
sx={{
"& .MuiOutlinedInput-root": {
height: 34,
fontSize: typographyLevels.base,
},
}}
/>
);
}
);
TextInput.displayName = "TextInput";
@@ -1,36 +1,31 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { StackProps } from "@mui/material/Stack";
import Link from "@mui/material/Link";
import { Link as RouterLink } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
interface TextLinkProps extends StackProps {
text: string;
linkText: string;
href: string;
target?: string;
}
export const TextLink = ({
text,
linkText,
href,
target = "_self",
...props
}: TextLinkProps) => {
}: {
text: string;
linkText: string;
href: string;
target?: string;
}) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
{...props}
>
<Typography>{text}</Typography>
<Link
color="primary"
color="accent"
to={href}
component={RouterLink}
target={target}
@@ -0,0 +1,135 @@
import { useTheme } from "@mui/material/styles";
import { useDispatch, useSelector } from "react-redux";
import { setMode } from "@/Features/UI/uiSlice.js";
import { useTranslation } from "react-i18next";
import IconButton from "@mui/material/IconButton";
const SunAndMoonIcon = () => {
const theme = useTheme();
return (
<svg
className="sun-and-moon"
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
>
<mask
className="moon"
id="moon-mask"
>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="#fff"
/>
<circle
cx="24"
cy="10"
r="6"
fill="#000"
/>
</mask>
<circle
className="sun"
cx="12"
cy="12"
r="6"
fill={theme.palette.primary.contrastTextSecondary}
mask="url(#moon-mask)"
/>
<g
className="sun-beams"
stroke={theme.palette.primary.contrastTextSecondary}
>
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</g>
</svg>
);
};
export const ThemeSwitch = ({
width = 48,
height = 48,
}: {
width?: number;
height?: number;
}) => {
const mode = useSelector((state: any) => state.ui.mode);
const dispatch = useDispatch();
const { t } = useTranslation();
const handleChange = () => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
};
return (
<IconButton
id="theme-toggle"
title={t("common.buttons.toggleTheme")}
className={`theme-${mode}`}
aria-label="auto"
aria-live="polite"
onClick={handleChange}
sx={{
width,
height,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<SunAndMoonIcon />
</IconButton>
);
};
@@ -0,0 +1,7 @@
export { ButtonInput as Button } from "./Button";
export { ButtonGroupInput as ButtonGroup } from "./ButtonGroup";
export { TextInput } from "./TextInput";
export { SelectInput as Select } from "./Select";
export { LanguageSelector } from "./LanguageSelector";
export { ThemeSwitch } from "./ThemeSwitch";
export { TextLink } from "./TextLink";
@@ -0,0 +1,23 @@
import { Outlet } from "react-router";
import Stack from "@mui/material/Stack";
import { SideBar } from "@/Components/v2/Layouts/Sidebar";
import { useTheme } from "@mui/material/styles";
const RootLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
minHeight="100vh"
>
<SideBar />
<Stack
flex={1}
padding={theme.spacing(12)}
>
<Outlet />
</Stack>
</Stack>
);
};
export default RootLayout;
@@ -0,0 +1,16 @@
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
export const BottomControls = ({}) => {
const theme = useTheme();
return (
<Stack
direction="row"
height={50}
py={theme.spacing(4)}
px={theme.spacing(8)}
gap={theme.spacing(2)}
></Stack>
);
};
@@ -0,0 +1,50 @@
import IconButton from "@mui/material/IconButton";
import { ArrowRight } from "@/Components/v2/Arrows/ArrowRight";
import { ArrowLeft } from "@/Components/v2/Arrows/ArrowLeft";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { toggleSidebar } from "../../../../Features/UI/uiSlice.js";
export const CollapseButton = ({ collapsed }: { collapsed: boolean }) => {
const theme = useTheme();
const dispatch = useDispatch();
const arrowIcon = collapsed ? (
<ArrowRight
height={theme.spacing(8)}
width={theme.spacing(8)}
color={theme.palette.primary.contrastTextSecondary}
/>
) : (
<ArrowLeft
height={theme.spacing(8)}
width={theme.spacing(8)}
color={theme.palette.primary.contrastTextSecondary}
/>
);
return (
<IconButton
sx={{
position: "absolute",
/* TODO 60 is a magic number. if logo chnges size this might break */
top: 60,
right: 0,
transform: `translate(50%, 0)`,
backgroundColor: theme.palette.tertiary.main,
border: `1px solid ${theme.palette.primary.lowContrast}`,
p: theme.spacing(2.5),
"&:focus": { outline: "none" },
"&:hover": {
backgroundColor: theme.palette.primary.lowContrast,
borderColor: theme.palette.primary.lowContrast,
},
}}
onClick={() => {
dispatch(toggleSidebar());
}}
>
{arrowIcon}
</IconButton>
);
};
@@ -0,0 +1,59 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
export const Logo = ({ collapsed }: { collapsed: boolean }) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
return (
<Stack
pt={theme.spacing(6)}
pb={theme.spacing(12)}
pl={theme.spacing(8)}
direction="row"
alignItems="center"
gap={theme.spacing(4)}
onClick={() => navigate("/")}
sx={{ cursor: "pointer" }}
>
<Typography
pl={theme.spacing("1px")}
minWidth={theme.spacing(16)}
minHeight={theme.spacing(16)}
display={"flex"}
justifyContent={"center"}
alignItems={"center"}
bgcolor={theme.palette.accent.main}
borderRadius={theme.shape.borderRadius}
color={theme.palette.accent.contrastText}
fontSize={18}
>
C
</Typography>
<Box
overflow={"hidden"}
sx={{
transition: "opacity 900ms ease, width 900ms ease",
opacity: collapsed ? 0 : 1,
whiteSpace: "nowrap",
width: collapsed ? 0 : "100%",
}}
>
{" "}
<Typography
lineHeight={1}
mt={theme.spacing(2)}
color={theme.palette.primary.contrastText}
variant="h2"
>
{t("common.appName")}
</Typography>
</Box>
</Stack>
);
};
@@ -0,0 +1,58 @@
// import Notifications from "@/assets/icons/notifications.svg?react";
import Monitors from "@/assets/icons/monitors.svg?react";
// import PageSpeed from "@/assets/icons/page-speed.svg?react";
// import Integrations from "@/assets/icons/integrations.svg?react";
// import Incidents from "@/assets/icons/incidents.svg?react";
// import StatusPages from "@/assets/icons/status-pages.svg?react";
// import Maintenance from "@/assets/icons/maintenance.svg?react";
// import Logs from "@/assets/icons/logs.svg?react";
// import Settings from "@/assets/icons/settings.svg?react";
import Support from "@/assets/icons/support.svg?react";
import Discussions from "@/assets/icons/discussions.svg?react";
import Docs from "@/assets/icons/docs.svg?react";
import ChangeLog from "@/assets/icons/changeLog.svg?react";
export const getMenu = (t: Function) => [
{ name: t("menu.uptime"), path: "v2/uptime", icon: <Monitors /> },
// { name: t("menu.pagespeed"), path: "pagespeed", icon: <PageSpeed /> },
// { name: t("menu.infrastructure"), path: "infrastructure", icon: <Integrations /> },
// {
// name: t("menu.notifications"),
// path: "notifications",
// icon: <Notifications />,
// },
// { name: t("menu.incidents"), path: "incidents", icon: <Incidents /> },
// { name: t("menu.statusPages"), path: "status", icon: <StatusPages /> },
// { name: t("menu.maintenance"), path: "maintenance", icon: <Maintenance /> },
// { name: t("menu.logs"), path: "logs", icon: <Logs /> },
// {
// name: t("menu.settings"),
// icon: <Settings />,
// path: "settings",
// },
];
export const getBottomMenu = (t: Function) => [
{ name: t("menu.support"), path: "support", icon: <Support />, url: "invite" },
{
name: t("menu.discussions"),
path: "discussions",
icon: <Discussions />,
url: "https://github.com/bluewave-labs/checkmate/discussions",
},
{
name: t("menu.docs"),
path: "docs",
icon: <Docs />,
url: "https://bluewavelabs.gitbook.io/checkmate",
},
{
name: t("menu.changelog"),
path: "changelog",
icon: <ChangeLog />,
url: "https://github.com/bluewave-labs/checkmate/releases",
},
];
Loaded 100 of 816 files, more files were not shown because too many files have changed in this diff. Show more