diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1535e84cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# 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 diff --git a/client/src/Components/v1/Host/index.jsx b/client/src/Components/v1/Host/index.jsx index 935224994..6670722c1 100644 --- a/client/src/Components/v1/Host/index.jsx +++ b/client/src/Components/v1/Host/index.jsx @@ -23,12 +23,12 @@ const Host = ({ url, title, percentageColor, percentage, showURL, status }) => { direction="row" position="relative" alignItems="center" - gap={theme.spacing(5)} + gap={theme.spacing(4)} > {title} @@ -38,9 +38,9 @@ const Host = ({ url, title, percentageColor, percentage, showURL, status }) => { <> diff --git a/client/src/Pages/Incidents2/Components/ActiveIncidentsPanel/index.jsx b/client/src/Pages/Incidents2/Components/ActiveIncidentsPanel/index.jsx index e98c2a143..fda257695 100644 --- a/client/src/Pages/Incidents2/Components/ActiveIncidentsPanel/index.jsx +++ b/client/src/Pages/Incidents2/Components/ActiveIncidentsPanel/index.jsx @@ -42,25 +42,24 @@ const ActiveIncidentsPanel = ({ totalCount = 0, isLoading = false, error = null if (!totalCount || totalCount === 0) { return ( - + {t("incidentsPage.allSystemsAreOperational")} @@ -88,21 +85,20 @@ const ActiveIncidentsPanel = ({ totalCount = 0, isLoading = false, error = null } return ( - + {totalCount} - - {t("incidentsPage.incidentsActivePanelTitle")} - ); diff --git a/client/src/Pages/Incidents2/Components/IncidentsSummaryPanel/index.jsx b/client/src/Pages/Incidents2/Components/IncidentsSummaryPanel/index.jsx index 43071fa03..33ba21cd1 100644 --- a/client/src/Pages/Incidents2/Components/IncidentsSummaryPanel/index.jsx +++ b/client/src/Pages/Incidents2/Components/IncidentsSummaryPanel/index.jsx @@ -53,7 +53,7 @@ const IncidentsSummaryPanel = ({ updateTrigger }) => { <> { const duration = calculateDuration(); const iconWrapperStyle = { - px: theme.spacing(2), display: "flex", justifyContent: "center", + mx: theme.spacing(2), color: theme.palette.primary.contrastTextTertiary, + "& svg": { + width: 18, + height: 18, + }, "& svg path": { stroke: "currentColor", + strokeWidth: 1.5, }, }; return ( - + + + - - - - - - - {t("incidentsPage.incidentItemMonitor")}: - - - {incident.monitorName || t("incidentsPage.unknownMonitor")} - - - - - - - - - - - - {t("incidentsPage.incidentItemStatus")}: - - - - - - - - - - {duration} - - + {incident.monitorName || t("incidentsPage.unknownMonitor")} + + + + + + + + {duration} + ); }; diff --git a/client/src/Pages/Incidents2/Components/LatestIncidentsPanel/index.jsx b/client/src/Pages/Incidents2/Components/LatestIncidentsPanel/index.jsx index 21d2330c4..d24bcd0ef 100644 --- a/client/src/Pages/Incidents2/Components/LatestIncidentsPanel/index.jsx +++ b/client/src/Pages/Incidents2/Components/LatestIncidentsPanel/index.jsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; import IncidentItem from "./IncidentItem.jsx"; import SummaryCard from "../SummaryCard/index.jsx"; +import Icon from "@/Components/v1/Icon"; /** * LatestIncidentsPanel Component @@ -51,21 +52,35 @@ const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null return ( {!incidents || incidents.length === 0 ? ( - + + + + {t("incidentsPage.incidentsLatestPanelEmpty")} - + ) : ( {incidents.map((incident, index) => ( diff --git a/client/src/Pages/Incidents2/Components/StatisticsPanel/index.jsx b/client/src/Pages/Incidents2/Components/StatisticsPanel/index.jsx index 4aeffb80f..7f07f688f 100644 --- a/client/src/Pages/Incidents2/Components/StatisticsPanel/index.jsx +++ b/client/src/Pages/Incidents2/Components/StatisticsPanel/index.jsx @@ -1,9 +1,8 @@ import PropTypes from "prop-types"; -import { Box, Stack, Typography } from "@mui/material"; +import { Box, Stack, Typography, Divider } from "@mui/material"; import { useTheme } from "@emotion/react"; import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; import { useTranslation } from "react-i18next"; -import { Divider } from "@mui/material"; import Icon from "@/Components/v1/Icon"; import SummaryCard from "../SummaryCard/index.jsx"; @@ -32,18 +31,19 @@ const StatisticsPanel = ({ isLoading = false, error = null, summary = {} }) => { const { t } = useTranslation(); const totalResolutions = (summary?.totalManualResolutions || 0) + (summary?.totalAutomaticResolutions || 0); - const automaticPercentage = - totalResolutions > 0 - ? (summary?.totalAutomaticResolutions / totalResolutions) * 100 - : 0; const iconWrapperStyle = { display: "flex", justifyContent: "center", - mx: theme.spacing(3), + mx: theme.spacing(2), color: theme.palette.primary.contrastTextTertiary, + "& svg": { + width: 18, + height: 18, + }, "& svg path": { stroke: "currentColor", + strokeWidth: 1.5, }, }; if (isLoading) { @@ -73,162 +73,87 @@ const StatisticsPanel = ({ isLoading = false, error = null, summary = {} }) => { ); } + const getMostAffectedMonitor = () => { + if (!summary.total || summary.total === 0) { + return t("incidentsPage.none"); + } + return summary.topMonitor?.monitorName || t("incidentsPage.none"); + }; + + const rowStyle = { + py: theme.spacing(0.5), + minHeight: 32, + }; + return ( - - - - - - - {t("incidentsPage.totalIncidents")} : {summary.total || 0} - - - - - - - - - - - {t("incidentsPage.mostAffectedMonitor")} :{" "} - {summary.topMonitor?.monitorName || t("incidentsPage.unknownMonitor")} - - - - - - - - - - - {t("incidentsPage.avgResolutionTime")} :{" "} - {summary.avgResolutionTimeHours || 0} {t("incidentsPage.hours")} - - - - - - + - - - - {t("incidentsPage.resolutions")}: {totalResolutions} - - + + {t("incidentsPage.totalIncidents")}: {summary.total || 0} + - - + + + - + + + + {t("incidentsPage.mostAffectedMonitor")}: {getMostAffectedMonitor()} + + + + + + + - - {summary.totalAutomaticResolutions > 0 && ( - - {t("incidentsPage.automatic")} ({summary.totalAutomaticResolutions}) - - )} - {summary.totalManualResolutions > 0 && ( - - {t("incidentsPage.manual")} ({summary.totalManualResolutions}) - - )} - - + {t("incidentsPage.avgResolutionTime")}:{" "} + {summary.total > 0 + ? `${summary.avgResolutionTimeHours || 0} ${t("incidentsPage.hours")}` + : "N/A"} + + ); diff --git a/client/src/Pages/Incidents2/Components/SummaryCard/index.jsx b/client/src/Pages/Incidents2/Components/SummaryCard/index.jsx index cb895dc50..3a8589dc1 100644 --- a/client/src/Pages/Incidents2/Components/SummaryCard/index.jsx +++ b/client/src/Pages/Incidents2/Components/SummaryCard/index.jsx @@ -8,17 +8,14 @@ const SummaryCard = ({ children, isHighPriority = false, sx = {}, title = null } {title} @@ -49,9 +45,9 @@ const SummaryCard = ({ children, isHighPriority = false, sx = {}, title = null } )} {children} diff --git a/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx b/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx index b38837cf2..be6dc99aa 100644 --- a/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx +++ b/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx @@ -12,13 +12,16 @@ import { Box } from "@mui/material"; const BaseContainer = ({children}) => { const theme = useTheme() return( - {children} @@ -99,7 +102,7 @@ const Gauges = ({ diagnostics, isLoading }) => { return ( { +const LevelBadge = ({ level }) => { + const theme = useTheme(); + const levelColors = { info: theme.palette.success.main, warn: theme.palette.warning.main, @@ -17,31 +20,38 @@ const formatLog = (theme, log, idx) => { debug: theme.palette.accent.main, }; - const color = levelColors[log.level] || theme.palette.primary.contrastText; + const color = levelColors[level] || theme.palette.primary.contrastText; return ( - - [{log.timestamp}]{" "} - {log.level.toUpperCase()} - {": "} - {`(${log.service})`} - {`(${log.method})`} - {": "} - {log.message} -
-
+ + {level} + ); }; -const Logs = () => { - // Local state - const [logLevel, setLogLevel] = useState("all"); +const formatTimestamp = (timestamp) => { + if (!timestamp) return "-"; + const date = new Date(timestamp); + return date.toLocaleString(); +}; + +const Logs = () => { + const [logLevel, setLogLevel] = useState("all"); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(15); - // Hooks const theme = useTheme(); const { t } = useTranslation(); const [logs, isLoading, error] = useFetchLogs(); - // Setup + const LOG_LEVELS = [ { _id: "all", name: t("logsPage.logLevelSelect.values.all") }, { _id: "info", name: t("logsPage.logLevelSelect.values.info") }, @@ -49,52 +59,116 @@ const Logs = () => { { _id: "error", name: t("logsPage.logLevelSelect.values.error") }, { _id: "debug", name: t("logsPage.logLevelSelect.values.debug") }, ]; + + const headers = [ + { + id: "timestamp", + content: t("logsPage.table.timestamp"), + render: (row) => ( + + {formatTimestamp(row.timestamp)} + + ), + }, + { + id: "level", + content: t("logsPage.table.level"), + render: (row) => , + }, + { + id: "service", + content: t("logsPage.table.service"), + render: (row) => ( + {row.service || "-"} + ), + }, + { + id: "method", + content: t("logsPage.table.method"), + render: (row) => ( + {row.method || "-"} + ), + }, + { + id: "message", + content: t("logsPage.table.message"), + render: (row) => ( + + {row.message || "-"} + + ), + }, + ]; + + const filteredLogs = logs + ?.filter((log) => { + if (logLevel === "all") return true; + return log.level === logLevel; + }) + .reverse() + .map((log, idx) => ({ ...log, id: idx })); + + const paginatedLogs = filteredLogs?.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage + ); + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const handleLogLevelChange = (e) => { + setLogLevel(e.target.value); + setPage(0); + }; + return ( {t("logsPage.logLevelSelect.title")}