From f83648dd54749e10357d75998286deb7ba3dc101 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:10:44 +0000 Subject: [PATCH 1/5] Initial plan From 62b0565253a9f897a506fb26e83d075a313b603e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:17:34 +0000 Subject: [PATCH 2/5] Implement complete IncidentOps platform with React frontend, Java Spring Boot backend, and PostgreSQL database Co-authored-by: camjaycecamp <82195306+camjaycecamp@users.noreply.github.com> --- .gitignore | 44 +++ README.md | 275 +++++++++++++++- backend/Dockerfile | 11 + backend/pom.xml | 97 ++++++ .../incidentops/IncidentOpsApplication.java | 14 + .../com/incidentops/config/CorsConfig.java | 24 ++ .../controller/AlertController.java | 45 +++ .../controller/HealthCheckController.java | 21 ++ .../controller/IncidentController.java | 69 ++++ .../controller/RunbookController.java | 56 ++++ .../controller/ServiceController.java | 49 +++ .../com/incidentops/dto/IncidentRequest.java | 12 + .../java/com/incidentops/entity/Alert.java | 58 ++++ .../com/incidentops/entity/HealthCheck.java | 50 +++ .../java/com/incidentops/entity/Incident.java | 73 +++++ .../java/com/incidentops/entity/Runbook.java | 45 +++ .../java/com/incidentops/entity/Service.java | 59 ++++ .../repository/AlertRepository.java | 14 + .../repository/HealthCheckRepository.java | 15 + .../repository/IncidentRepository.java | 14 + .../repository/RunbookRepository.java | 12 + .../repository/ServiceRepository.java | 11 + .../scheduler/HealthCheckScheduler.java | 42 +++ .../com/incidentops/service/AlertService.java | 71 +++++ .../service/HealthCheckService.java | 79 +++++ .../incidentops/service/IncidentService.java | 77 +++++ .../incidentops/service/RunbookService.java | 47 +++ .../incidentops/service/ServiceService.java | 54 ++++ backend/src/main/resources/application.yml | 37 +++ .../IncidentOpsApplicationTests.java | 12 + .../service/ServiceServiceTest.java | 108 +++++++ .../src/test/resources/application-test.yml | 18 ++ database/schema.sql | 68 ++++ docker-compose.yml | 58 ++++ frontend/Dockerfile | 7 + frontend/package.json | 35 ++ frontend/public/index.html | 14 + frontend/src/App.css | 298 ++++++++++++++++++ frontend/src/App.js | 41 +++ frontend/src/index.js | 10 + frontend/src/pages/Alerts.js | 134 ++++++++ frontend/src/pages/Dashboard.js | 146 +++++++++ frontend/src/pages/Incidents.js | 204 ++++++++++++ frontend/src/pages/Runbooks.js | 213 +++++++++++++ frontend/src/pages/Services.js | 208 ++++++++++++ frontend/src/services/api.js | 49 +++ 46 files changed, 3097 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/incidentops/IncidentOpsApplication.java create mode 100644 backend/src/main/java/com/incidentops/config/CorsConfig.java create mode 100644 backend/src/main/java/com/incidentops/controller/AlertController.java create mode 100644 backend/src/main/java/com/incidentops/controller/HealthCheckController.java create mode 100644 backend/src/main/java/com/incidentops/controller/IncidentController.java create mode 100644 backend/src/main/java/com/incidentops/controller/RunbookController.java create mode 100644 backend/src/main/java/com/incidentops/controller/ServiceController.java create mode 100644 backend/src/main/java/com/incidentops/dto/IncidentRequest.java create mode 100644 backend/src/main/java/com/incidentops/entity/Alert.java create mode 100644 backend/src/main/java/com/incidentops/entity/HealthCheck.java create mode 100644 backend/src/main/java/com/incidentops/entity/Incident.java create mode 100644 backend/src/main/java/com/incidentops/entity/Runbook.java create mode 100644 backend/src/main/java/com/incidentops/entity/Service.java create mode 100644 backend/src/main/java/com/incidentops/repository/AlertRepository.java create mode 100644 backend/src/main/java/com/incidentops/repository/HealthCheckRepository.java create mode 100644 backend/src/main/java/com/incidentops/repository/IncidentRepository.java create mode 100644 backend/src/main/java/com/incidentops/repository/RunbookRepository.java create mode 100644 backend/src/main/java/com/incidentops/repository/ServiceRepository.java create mode 100644 backend/src/main/java/com/incidentops/scheduler/HealthCheckScheduler.java create mode 100644 backend/src/main/java/com/incidentops/service/AlertService.java create mode 100644 backend/src/main/java/com/incidentops/service/HealthCheckService.java create mode 100644 backend/src/main/java/com/incidentops/service/IncidentService.java create mode 100644 backend/src/main/java/com/incidentops/service/RunbookService.java create mode 100644 backend/src/main/java/com/incidentops/service/ServiceService.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/test/java/com/incidentops/IncidentOpsApplicationTests.java create mode 100644 backend/src/test/java/com/incidentops/service/ServiceServiceTest.java create mode 100644 backend/src/test/resources/application-test.yml create mode 100644 database/schema.sql create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/package.json create mode 100644 frontend/public/index.html create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.js create mode 100644 frontend/src/index.js create mode 100644 frontend/src/pages/Alerts.js create mode 100644 frontend/src/pages/Dashboard.js create mode 100644 frontend/src/pages/Incidents.js create mode 100644 frontend/src/pages/Runbooks.js create mode 100644 frontend/src/pages/Services.js create mode 100644 frontend/src/services/api.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c212e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Backend +backend/target/ +backend/.mvn/ +backend/mvnw +backend/mvnw.cmd +*.class +*.jar +*.war +*.ear + +# Frontend +frontend/node_modules/ +frontend/build/ +frontend/.env.local +frontend/.env.development.local +frontend/.env.test.local +frontend/.env.production.local +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* + +# IDE +.idea/ +*.iml +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +*.log + +# Database +*.db +*.sqlite + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/README.md b/README.md index 1653957..c13749a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,275 @@ # IncidentOps -A lightweight service-health and incident tracking platform. It runs scheduled health checks, generates alerts, and tracks incident lifecycle with runbooks. Currently built to function as a modular codebase with future plans to introduce distributed worker and cloud deployment features. + +A lightweight service-health and incident tracking platform. It runs scheduled health checks, generates alerts, and tracks incident lifecycle with runbooks. Currently built to function as a modular codebase (React + Java API + Postgres) with future plans to introduce distributed worker and cloud deployment features. + +## ๐ŸŽฏ Features + +- **Service Health Monitoring**: Automated health checks for monitored services +- **Alert Management**: Automatic alert generation when services become unhealthy +- **Incident Tracking**: Complete incident lifecycle management (Open โ†’ Acknowledged โ†’ Investigating โ†’ Resolved โ†’ Closed) +- **Runbook Library**: Create and manage runbooks for incident resolution procedures +- **Real-time Dashboard**: Visual overview of service health and system status +- **RESTful API**: Full-featured backend API for all operations + +## ๐Ÿ—๏ธ Architecture + +The application is built with a modular, three-tier architecture: + +- **Frontend**: React 18 with React Router for navigation +- **Backend**: Java 17 with Spring Boot 3.2 +- **Database**: PostgreSQL 15 + +## ๐Ÿ“‹ Prerequisites + +- Java 17 or higher +- Node.js 18 or higher +- Maven 3.6+ +- PostgreSQL 15 or higher +- Docker & Docker Compose (for containerized deployment) + +## ๐Ÿš€ Quick Start + +### Using Docker Compose (Recommended) + +1. Clone the repository: +```bash +git clone https://github.com/camjaycecamp/IncidentOps.git +cd IncidentOps +``` + +2. Start all services with Docker Compose: +```bash +docker-compose up -d +``` + +3. Access the application: + - Frontend: http://localhost:3000 + - Backend API: http://localhost:8080/api + - PostgreSQL: localhost:5432 + +### Manual Setup + +#### Backend Setup + +1. Navigate to the backend directory: +```bash +cd backend +``` + +2. Build the project: +```bash +mvn clean install +``` + +3. Configure database connection in `src/main/resources/application.yml` or set environment variables: +```bash +export DB_HOST=localhost +export DB_PORT=5432 +export DB_NAME=incidentops +export DB_USER=postgres +export DB_PASSWORD=postgres +``` + +4. Run the application: +```bash +mvn spring-boot:run +``` + +The backend API will be available at http://localhost:8080 + +#### Frontend Setup + +1. Navigate to the frontend directory: +```bash +cd frontend +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Start the development server: +```bash +npm start +``` + +The frontend will be available at http://localhost:3000 + +#### Database Setup + +1. Create the database: +```sql +CREATE DATABASE incidentops; +``` + +2. (Optional) Run the schema file: +```bash +psql -U postgres -d incidentops -f database/schema.sql +``` + +Note: The application uses Hibernate with `ddl-auto: update`, so tables will be created automatically on first run. + +## ๐Ÿ“š API Documentation + +### Services API +- `GET /api/services` - List all services +- `GET /api/services/{id}` - Get service by ID +- `POST /api/services` - Create new service +- `PUT /api/services/{id}` - Update service +- `DELETE /api/services/{id}` - Delete service + +### Health Checks API +- `GET /api/health-checks/service/{serviceId}` - Get health checks for a service + +### Alerts API +- `GET /api/alerts` - List all alerts +- `GET /api/alerts/active` - List active alerts +- `PUT /api/alerts/{id}/acknowledge` - Acknowledge an alert +- `PUT /api/alerts/{id}/resolve` - Resolve an alert + +### Incidents API +- `GET /api/incidents` - List all incidents +- `GET /api/incidents/open` - List open incidents +- `GET /api/incidents/{id}` - Get incident by ID +- `POST /api/incidents` - Create new incident +- `PUT /api/incidents/{id}/status?status={status}` - Update incident status +- `PUT /api/incidents/{id}/runbook/{runbookId}` - Assign runbook to incident + +### Runbooks API +- `GET /api/runbooks` - List all runbooks +- `GET /api/runbooks/{id}` - Get runbook by ID +- `GET /api/runbooks/search?query={query}` - Search runbooks +- `POST /api/runbooks` - Create new runbook +- `PUT /api/runbooks/{id}` - Update runbook +- `DELETE /api/runbooks/{id}` - Delete runbook + +## ๐Ÿ”ง Configuration + +### Backend Configuration + +Key configuration properties in `application.yml`: + +```yaml +incidentops: + health-check: + interval: 60000 # Health check interval in milliseconds (60 seconds) + timeout: 5000 # HTTP request timeout in milliseconds (5 seconds) +``` + +### Frontend Configuration + +Create a `.env` file in the frontend directory: + +``` +REACT_APP_API_URL=http://localhost:8080/api +``` + +## ๐Ÿ“Š Features Overview + +### 1. Service Management +- Add and configure services to monitor +- Set custom health check intervals +- View real-time service status +- Delete services when no longer needed + +### 2. Automated Health Checks +- Scheduled health checks run automatically +- HTTP-based availability monitoring +- Response time tracking +- Status code validation +- Automatic service status updates + +### 3. Alert System +- Automatic alert generation on service failures +- Severity-based alerting (Low, Medium, High, Critical) +- Alert lifecycle: Active โ†’ Acknowledged โ†’ Resolved +- Duplicate alert prevention + +### 4. Incident Management +- Create incidents manually or from alerts +- Track incident lifecycle with multiple statuses +- Link incidents to services +- Associate runbooks with incidents for guided resolution +- Timestamp tracking for all lifecycle events + +### 5. Runbook Library +- Create comprehensive resolution guides +- Organize with tags +- Search functionality +- Easy editing and updating +- Version tracking through timestamps + +## ๐Ÿงช Testing + +### Backend Tests +```bash +cd backend +mvn test +``` + +### Frontend Tests +```bash +cd frontend +npm test +``` + +## ๐Ÿ”ฎ Future Enhancements + +- **Distributed Workers**: Scale health checks across multiple worker nodes +- **Cloud Deployment**: Kubernetes-ready containerization +- **Advanced Alerting**: Integration with PagerDuty, Slack, email +- **Metrics & Analytics**: Historical trends and SLA tracking +- **Authentication**: User management and role-based access control +- **Webhooks**: Custom integrations and notifications +- **API Rate Limiting**: Protection against abuse +- **Advanced Monitoring**: Custom health check types (TCP, database queries, etc.) + +## ๐Ÿ“ Project Structure + +``` +IncidentOps/ +โ”œโ”€โ”€ backend/ # Java Spring Boot backend +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ main/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ java/com/incidentops/ +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ config/ # Configuration classes +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controller/# REST controllers +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ dto/ # Data transfer objects +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ entity/ # JPA entities +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ repository/# Data repositories +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ scheduler/ # Scheduled tasks +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ service/ # Business logic +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ resources/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ application.yml +โ”‚ โ”‚ โ””โ”€โ”€ test/ # Unit and integration tests +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ””โ”€โ”€ pom.xml +โ”œโ”€โ”€ frontend/ # React frontend +โ”‚ โ”œโ”€โ”€ public/ +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # Reusable components +โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # Page components +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # API client +โ”‚ โ”‚ โ”œโ”€โ”€ App.js +โ”‚ โ”‚ โ”œโ”€โ”€ App.css +โ”‚ โ”‚ โ””โ”€โ”€ index.js +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ””โ”€โ”€ package.json +โ”œโ”€โ”€ database/ # Database schema and migrations +โ”‚ โ””โ”€โ”€ schema.sql +โ”œโ”€โ”€ docker-compose.yml # Docker orchestration +โ””โ”€โ”€ README.md +``` + +## ๐Ÿค Contributing + +This is a personal portfolio project for learning full-stack development. Feel free to fork and experiment! + +## ๐Ÿ“„ License + +MIT License - feel free to use this project for learning and development. + +## ๐Ÿ‘จโ€๐Ÿ’ป Author + +Built as a learning project to demonstrate full-stack development skills with modern technologies. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..112f19d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..ba43756 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.incidentops + incidentops-backend + 1.0.0-SNAPSHOT + IncidentOps Backend + Backend API for IncidentOps - Service Health and Incident Tracking Platform + + + 17 + 17 + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.h2database + h2 + test + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/backend/src/main/java/com/incidentops/IncidentOpsApplication.java b/backend/src/main/java/com/incidentops/IncidentOpsApplication.java new file mode 100644 index 0000000..706f821 --- /dev/null +++ b/backend/src/main/java/com/incidentops/IncidentOpsApplication.java @@ -0,0 +1,14 @@ +package com.incidentops; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class IncidentOpsApplication { + + public static void main(String[] args) { + SpringApplication.run(IncidentOpsApplication.class, args); + } +} diff --git a/backend/src/main/java/com/incidentops/config/CorsConfig.java b/backend/src/main/java/com/incidentops/config/CorsConfig.java new file mode 100644 index 0000000..fcb8112 --- /dev/null +++ b/backend/src/main/java/com/incidentops/config/CorsConfig.java @@ -0,0 +1,24 @@ +package com.incidentops.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import java.util.Arrays; + +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(Arrays.asList("*")); + config.setAllowedHeaders(Arrays.asList("*")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/backend/src/main/java/com/incidentops/controller/AlertController.java b/backend/src/main/java/com/incidentops/controller/AlertController.java new file mode 100644 index 0000000..3a71d96 --- /dev/null +++ b/backend/src/main/java/com/incidentops/controller/AlertController.java @@ -0,0 +1,45 @@ +package com.incidentops.controller; + +import com.incidentops.entity.Alert; +import com.incidentops.service.AlertService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/alerts") +@CrossOrigin(origins = "*") +public class AlertController { + + @Autowired + private AlertService alertService; + + @GetMapping + public List getAllAlerts() { + return alertService.getAllAlerts(); + } + + @GetMapping("/active") + public List getActiveAlerts() { + return alertService.getActiveAlerts(); + } + + @PutMapping("/{id}/acknowledge") + public ResponseEntity acknowledgeAlert(@PathVariable Long id) { + try { + return ResponseEntity.ok(alertService.acknowledgeAlert(id)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + @PutMapping("/{id}/resolve") + public ResponseEntity resolveAlert(@PathVariable Long id) { + try { + return ResponseEntity.ok(alertService.resolveAlert(id)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/com/incidentops/controller/HealthCheckController.java b/backend/src/main/java/com/incidentops/controller/HealthCheckController.java new file mode 100644 index 0000000..b1cafef --- /dev/null +++ b/backend/src/main/java/com/incidentops/controller/HealthCheckController.java @@ -0,0 +1,21 @@ +package com.incidentops.controller; + +import com.incidentops.entity.HealthCheck; +import com.incidentops.service.HealthCheckService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/health-checks") +@CrossOrigin(origins = "*") +public class HealthCheckController { + + @Autowired + private HealthCheckService healthCheckService; + + @GetMapping("/service/{serviceId}") + public List getHealthChecksByService(@PathVariable Long serviceId) { + return healthCheckService.getHealthChecksByService(serviceId); + } +} diff --git a/backend/src/main/java/com/incidentops/controller/IncidentController.java b/backend/src/main/java/com/incidentops/controller/IncidentController.java new file mode 100644 index 0000000..866b233 --- /dev/null +++ b/backend/src/main/java/com/incidentops/controller/IncidentController.java @@ -0,0 +1,69 @@ +package com.incidentops.controller; + +import com.incidentops.dto.IncidentRequest; +import com.incidentops.entity.Incident; +import com.incidentops.service.IncidentService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/incidents") +@CrossOrigin(origins = "*") +public class IncidentController { + + @Autowired + private IncidentService incidentService; + + @GetMapping + public List getAllIncidents() { + return incidentService.getAllIncidents(); + } + + @GetMapping("/open") + public List getOpenIncidents() { + return incidentService.getOpenIncidents(); + } + + @GetMapping("/{id}") + public ResponseEntity getIncidentById(@PathVariable Long id) { + try { + return ResponseEntity.ok(incidentService.getIncidentById(id)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping + public Incident createIncident(@RequestBody IncidentRequest request) { + return incidentService.createIncident( + request.getServiceId(), + request.getTitle(), + request.getDescription(), + request.getSeverity() + ); + } + + @PutMapping("/{id}/status") + public ResponseEntity updateIncidentStatus( + @PathVariable Long id, + @RequestParam Incident.IncidentStatus status) { + try { + return ResponseEntity.ok(incidentService.updateIncidentStatus(id, status)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + @PutMapping("/{id}/runbook/{runbookId}") + public ResponseEntity assignRunbook( + @PathVariable Long id, + @PathVariable Long runbookId) { + try { + return ResponseEntity.ok(incidentService.assignRunbook(id, runbookId)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/com/incidentops/controller/RunbookController.java b/backend/src/main/java/com/incidentops/controller/RunbookController.java new file mode 100644 index 0000000..2c3b8e1 --- /dev/null +++ b/backend/src/main/java/com/incidentops/controller/RunbookController.java @@ -0,0 +1,56 @@ +package com.incidentops.controller; + +import com.incidentops.entity.Runbook; +import com.incidentops.service.RunbookService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/runbooks") +@CrossOrigin(origins = "*") +public class RunbookController { + + @Autowired + private RunbookService runbookService; + + @GetMapping + public List getAllRunbooks() { + return runbookService.getAllRunbooks(); + } + + @GetMapping("/{id}") + public ResponseEntity getRunbookById(@PathVariable Long id) { + try { + return ResponseEntity.ok(runbookService.getRunbookById(id)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/search") + public List searchRunbooks(@RequestParam String query) { + return runbookService.searchRunbooks(query); + } + + @PostMapping + public Runbook createRunbook(@RequestBody Runbook runbook) { + return runbookService.createRunbook(runbook); + } + + @PutMapping("/{id}") + public ResponseEntity updateRunbook(@PathVariable Long id, @RequestBody Runbook runbook) { + try { + return ResponseEntity.ok(runbookService.updateRunbook(id, runbook)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteRunbook(@PathVariable Long id) { + runbookService.deleteRunbook(id); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/incidentops/controller/ServiceController.java b/backend/src/main/java/com/incidentops/controller/ServiceController.java new file mode 100644 index 0000000..09a0e6e --- /dev/null +++ b/backend/src/main/java/com/incidentops/controller/ServiceController.java @@ -0,0 +1,49 @@ +package com.incidentops.controller; + +import com.incidentops.entity.Service; +import com.incidentops.service.ServiceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/services") +@CrossOrigin(origins = "*") +public class ServiceController { + + @Autowired + private ServiceService serviceService; + + @GetMapping + public List getAllServices() { + return serviceService.getAllServices(); + } + + @GetMapping("/{id}") + public ResponseEntity getServiceById(@PathVariable Long id) { + return serviceService.getServiceById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public Service createService(@RequestBody Service service) { + return serviceService.createService(service); + } + + @PutMapping("/{id}") + public ResponseEntity updateService(@PathVariable Long id, @RequestBody Service service) { + try { + return ResponseEntity.ok(serviceService.updateService(id, service)); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteService(@PathVariable Long id) { + serviceService.deleteService(id); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/incidentops/dto/IncidentRequest.java b/backend/src/main/java/com/incidentops/dto/IncidentRequest.java new file mode 100644 index 0000000..03805d9 --- /dev/null +++ b/backend/src/main/java/com/incidentops/dto/IncidentRequest.java @@ -0,0 +1,12 @@ +package com.incidentops.dto; + +import com.incidentops.entity.Incident; +import lombok.Data; + +@Data +public class IncidentRequest { + private Long serviceId; + private String title; + private String description; + private Incident.Severity severity; +} diff --git a/backend/src/main/java/com/incidentops/entity/Alert.java b/backend/src/main/java/com/incidentops/entity/Alert.java new file mode 100644 index 0000000..21b8b4b --- /dev/null +++ b/backend/src/main/java/com/incidentops/entity/Alert.java @@ -0,0 +1,58 @@ +package com.incidentops.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Table(name = "alerts") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Alert { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "service_id", nullable = false) + private Service service; + + @Column(nullable = false) + private String message; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Severity severity; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AlertStatus status = AlertStatus.ACTIVE; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public enum Severity { + LOW, + MEDIUM, + HIGH, + CRITICAL + } + + public enum AlertStatus { + ACTIVE, + ACKNOWLEDGED, + RESOLVED + } +} diff --git a/backend/src/main/java/com/incidentops/entity/HealthCheck.java b/backend/src/main/java/com/incidentops/entity/HealthCheck.java new file mode 100644 index 0000000..f548ad6 --- /dev/null +++ b/backend/src/main/java/com/incidentops/entity/HealthCheck.java @@ -0,0 +1,50 @@ +package com.incidentops.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Table(name = "health_checks") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HealthCheck { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "service_id", nullable = false) + private Service service; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private CheckStatus status; + + @Column(name = "response_time") + private Long responseTime; // milliseconds + + @Column(name = "status_code") + private Integer statusCode; + + @Column(name = "error_message", length = 1000) + private String errorMessage; + + @Column(name = "checked_at") + private LocalDateTime checkedAt; + + @PrePersist + protected void onCreate() { + checkedAt = LocalDateTime.now(); + } + + public enum CheckStatus { + SUCCESS, + FAILURE, + TIMEOUT + } +} diff --git a/backend/src/main/java/com/incidentops/entity/Incident.java b/backend/src/main/java/com/incidentops/entity/Incident.java new file mode 100644 index 0000000..01d621e --- /dev/null +++ b/backend/src/main/java/com/incidentops/entity/Incident.java @@ -0,0 +1,73 @@ +package com.incidentops.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Table(name = "incidents") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Incident { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "service_id", nullable = false) + private Service service; + + @Column(nullable = false) + private String title; + + @Column(length = 2000) + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Severity severity; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private IncidentStatus status = IncidentStatus.OPEN; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "runbook_id") + private Runbook runbook; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "acknowledged_at") + private LocalDateTime acknowledgedAt; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @Column(name = "closed_at") + private LocalDateTime closedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public enum Severity { + LOW, + MEDIUM, + HIGH, + CRITICAL + } + + public enum IncidentStatus { + OPEN, + ACKNOWLEDGED, + INVESTIGATING, + RESOLVED, + CLOSED + } +} diff --git a/backend/src/main/java/com/incidentops/entity/Runbook.java b/backend/src/main/java/com/incidentops/entity/Runbook.java new file mode 100644 index 0000000..049a667 --- /dev/null +++ b/backend/src/main/java/com/incidentops/entity/Runbook.java @@ -0,0 +1,45 @@ +package com.incidentops.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Table(name = "runbooks") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Runbook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(length = 5000) + private String content; + + @Column + private String tags; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/incidentops/entity/Service.java b/backend/src/main/java/com/incidentops/entity/Service.java new file mode 100644 index 0000000..1d241ca --- /dev/null +++ b/backend/src/main/java/com/incidentops/entity/Service.java @@ -0,0 +1,59 @@ +package com.incidentops.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Table(name = "services") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Service { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String url; + + @Column + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ServiceStatus status = ServiceStatus.UNKNOWN; + + @Column(name = "check_interval") + private Integer checkInterval = 60; // seconds + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public enum ServiceStatus { + HEALTHY, + DEGRADED, + DOWN, + UNKNOWN + } +} diff --git a/backend/src/main/java/com/incidentops/repository/AlertRepository.java b/backend/src/main/java/com/incidentops/repository/AlertRepository.java new file mode 100644 index 0000000..68b772c --- /dev/null +++ b/backend/src/main/java/com/incidentops/repository/AlertRepository.java @@ -0,0 +1,14 @@ +package com.incidentops.repository; + +import com.incidentops.entity.Alert; +import com.incidentops.entity.Service; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface AlertRepository extends JpaRepository { + List findByStatus(Alert.AlertStatus status); + List findByServiceAndStatus(Service service, Alert.AlertStatus status); + List findByServiceOrderByCreatedAtDesc(Service service); +} diff --git a/backend/src/main/java/com/incidentops/repository/HealthCheckRepository.java b/backend/src/main/java/com/incidentops/repository/HealthCheckRepository.java new file mode 100644 index 0000000..6fb5334 --- /dev/null +++ b/backend/src/main/java/com/incidentops/repository/HealthCheckRepository.java @@ -0,0 +1,15 @@ +package com.incidentops.repository; + +import com.incidentops.entity.HealthCheck; +import com.incidentops.entity.Service; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface HealthCheckRepository extends JpaRepository { + List findByServiceOrderByCheckedAtDesc(Service service); + List findByServiceAndCheckedAtAfter(Service service, LocalDateTime after); + List findTop10ByServiceOrderByCheckedAtDesc(Service service); +} diff --git a/backend/src/main/java/com/incidentops/repository/IncidentRepository.java b/backend/src/main/java/com/incidentops/repository/IncidentRepository.java new file mode 100644 index 0000000..4ff6add --- /dev/null +++ b/backend/src/main/java/com/incidentops/repository/IncidentRepository.java @@ -0,0 +1,14 @@ +package com.incidentops.repository; + +import com.incidentops.entity.Incident; +import com.incidentops.entity.Service; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface IncidentRepository extends JpaRepository { + List findByStatus(Incident.IncidentStatus status); + List findByServiceOrderByCreatedAtDesc(Service service); + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/incidentops/repository/RunbookRepository.java b/backend/src/main/java/com/incidentops/repository/RunbookRepository.java new file mode 100644 index 0000000..f741563 --- /dev/null +++ b/backend/src/main/java/com/incidentops/repository/RunbookRepository.java @@ -0,0 +1,12 @@ +package com.incidentops.repository; + +import com.incidentops.entity.Runbook; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface RunbookRepository extends JpaRepository { + List findByTitleContainingIgnoreCase(String title); + List findByTagsContaining(String tag); +} diff --git a/backend/src/main/java/com/incidentops/repository/ServiceRepository.java b/backend/src/main/java/com/incidentops/repository/ServiceRepository.java new file mode 100644 index 0000000..5d00a8d --- /dev/null +++ b/backend/src/main/java/com/incidentops/repository/ServiceRepository.java @@ -0,0 +1,11 @@ +package com.incidentops.repository; + +import com.incidentops.entity.Service; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface ServiceRepository extends JpaRepository { + List findByStatus(Service.ServiceStatus status); +} diff --git a/backend/src/main/java/com/incidentops/scheduler/HealthCheckScheduler.java b/backend/src/main/java/com/incidentops/scheduler/HealthCheckScheduler.java new file mode 100644 index 0000000..46da47e --- /dev/null +++ b/backend/src/main/java/com/incidentops/scheduler/HealthCheckScheduler.java @@ -0,0 +1,42 @@ +package com.incidentops.scheduler; + +import com.incidentops.entity.Service; +import com.incidentops.service.HealthCheckService; +import com.incidentops.service.ServiceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.util.List; + +@Component +public class HealthCheckScheduler { + + private static final Logger logger = LoggerFactory.getLogger(HealthCheckScheduler.class); + + @Autowired + private ServiceService serviceService; + + @Autowired + private HealthCheckService healthCheckService; + + @Scheduled(fixedDelayString = "${incidentops.health-check.interval:60000}") + public void performScheduledHealthChecks() { + logger.info("Starting scheduled health checks..."); + + List services = serviceService.getAllServices(); + + for (Service service : services) { + try { + logger.debug("Performing health check for service: {}", service.getName()); + healthCheckService.performHealthCheck(service); + } catch (Exception e) { + logger.error("Error performing health check for service {}: {}", + service.getName(), e.getMessage()); + } + } + + logger.info("Completed scheduled health checks for {} services", services.size()); + } +} diff --git a/backend/src/main/java/com/incidentops/service/AlertService.java b/backend/src/main/java/com/incidentops/service/AlertService.java new file mode 100644 index 0000000..c42c335 --- /dev/null +++ b/backend/src/main/java/com/incidentops/service/AlertService.java @@ -0,0 +1,71 @@ +package com.incidentops.service; + +import com.incidentops.entity.Alert; +import com.incidentops.entity.Service; +import com.incidentops.repository.AlertRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; + +@org.springframework.stereotype.Service +public class AlertService { + + @Autowired + private AlertRepository alertRepository; + + public List getAllAlerts() { + return alertRepository.findAll(); + } + + public List getActiveAlerts() { + return alertRepository.findByStatus(Alert.AlertStatus.ACTIVE); + } + + public List getAlertsByService(Service service) { + return alertRepository.findByServiceOrderByCreatedAtDesc(service); + } + + @Transactional + public Alert createAlertForService(Service service, String message) { + // Check if there's already an active alert for this service + List activeAlerts = alertRepository.findByServiceAndStatus(service, Alert.AlertStatus.ACTIVE); + if (!activeAlerts.isEmpty()) { + return activeAlerts.get(0); // Return existing alert + } + + Alert alert = new Alert(); + alert.setService(service); + alert.setMessage(message); + alert.setSeverity(determineSeverity(service.getStatus())); + alert.setStatus(Alert.AlertStatus.ACTIVE); + + return alertRepository.save(alert); + } + + @Transactional + public Alert acknowledgeAlert(Long id) { + Alert alert = alertRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Alert not found")); + alert.setStatus(Alert.AlertStatus.ACKNOWLEDGED); + return alertRepository.save(alert); + } + + @Transactional + public Alert resolveAlert(Long id) { + Alert alert = alertRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Alert not found")); + alert.setStatus(Alert.AlertStatus.RESOLVED); + alert.setResolvedAt(LocalDateTime.now()); + return alertRepository.save(alert); + } + + private Alert.Severity determineSeverity(Service.ServiceStatus status) { + return switch (status) { + case DOWN -> Alert.Severity.CRITICAL; + case DEGRADED -> Alert.Severity.HIGH; + case HEALTHY -> Alert.Severity.LOW; + default -> Alert.Severity.MEDIUM; + }; + } +} diff --git a/backend/src/main/java/com/incidentops/service/HealthCheckService.java b/backend/src/main/java/com/incidentops/service/HealthCheckService.java new file mode 100644 index 0000000..524b278 --- /dev/null +++ b/backend/src/main/java/com/incidentops/service/HealthCheckService.java @@ -0,0 +1,79 @@ +package com.incidentops.service; + +import com.incidentops.entity.HealthCheck; +import com.incidentops.entity.Service; +import com.incidentops.repository.HealthCheckRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.LocalDateTime; +import java.util.List; + +@org.springframework.stereotype.Service +public class HealthCheckService { + + @Autowired + private HealthCheckRepository healthCheckRepository; + + @Autowired + private ServiceService serviceService; + + @Autowired + private AlertService alertService; + + @Value("${incidentops.health-check.timeout:5000}") + private int timeout; + + public List getHealthChecksByService(Long serviceId) { + Service service = serviceService.getServiceById(serviceId) + .orElseThrow(() -> new RuntimeException("Service not found")); + return healthCheckRepository.findTop10ByServiceOrderByCheckedAtDesc(service); + } + + @Transactional + public HealthCheck performHealthCheck(Service service) { + HealthCheck healthCheck = new HealthCheck(); + healthCheck.setService(service); + + long startTime = System.currentTimeMillis(); + + try { + URL url = new URL(service.getUrl()); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(timeout); + connection.setReadTimeout(timeout); + + int statusCode = connection.getResponseCode(); + long responseTime = System.currentTimeMillis() - startTime; + + healthCheck.setStatusCode(statusCode); + healthCheck.setResponseTime(responseTime); + + if (statusCode >= 200 && statusCode < 300) { + healthCheck.setStatus(HealthCheck.CheckStatus.SUCCESS); + serviceService.updateServiceStatus(service.getId(), Service.ServiceStatus.HEALTHY); + } else { + healthCheck.setStatus(HealthCheck.CheckStatus.FAILURE); + healthCheck.setErrorMessage("HTTP " + statusCode); + serviceService.updateServiceStatus(service.getId(), Service.ServiceStatus.DEGRADED); + alertService.createAlertForService(service, "Service returned status code: " + statusCode); + } + + connection.disconnect(); + + } catch (IOException e) { + long responseTime = System.currentTimeMillis() - startTime; + healthCheck.setStatus(HealthCheck.CheckStatus.FAILURE); + healthCheck.setResponseTime(responseTime); + healthCheck.setErrorMessage(e.getMessage()); + serviceService.updateServiceStatus(service.getId(), Service.ServiceStatus.DOWN); + alertService.createAlertForService(service, "Service health check failed: " + e.getMessage()); + } + + return healthCheckRepository.save(healthCheck); + } +} diff --git a/backend/src/main/java/com/incidentops/service/IncidentService.java b/backend/src/main/java/com/incidentops/service/IncidentService.java new file mode 100644 index 0000000..a3a06e8 --- /dev/null +++ b/backend/src/main/java/com/incidentops/service/IncidentService.java @@ -0,0 +1,77 @@ +package com.incidentops.service; + +import com.incidentops.entity.Incident; +import com.incidentops.entity.Runbook; +import com.incidentops.entity.Service; +import com.incidentops.repository.IncidentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; + +@org.springframework.stereotype.Service +public class IncidentService { + + @Autowired + private IncidentRepository incidentRepository; + + @Autowired + private ServiceService serviceService; + + public List getAllIncidents() { + return incidentRepository.findAllByOrderByCreatedAtDesc(); + } + + public List getOpenIncidents() { + return incidentRepository.findByStatus(Incident.IncidentStatus.OPEN); + } + + public Incident getIncidentById(Long id) { + return incidentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Incident not found")); + } + + @Transactional + public Incident createIncident(Long serviceId, String title, String description, Incident.Severity severity) { + Service service = serviceService.getServiceById(serviceId) + .orElseThrow(() -> new RuntimeException("Service not found")); + + Incident incident = new Incident(); + incident.setService(service); + incident.setTitle(title); + incident.setDescription(description); + incident.setSeverity(severity); + incident.setStatus(Incident.IncidentStatus.OPEN); + + return incidentRepository.save(incident); + } + + @Transactional + public Incident updateIncidentStatus(Long id, Incident.IncidentStatus status) { + Incident incident = getIncidentById(id); + incident.setStatus(status); + + switch (status) { + case ACKNOWLEDGED: + incident.setAcknowledgedAt(LocalDateTime.now()); + break; + case RESOLVED: + incident.setResolvedAt(LocalDateTime.now()); + break; + case CLOSED: + incident.setClosedAt(LocalDateTime.now()); + break; + } + + return incidentRepository.save(incident); + } + + @Transactional + public Incident assignRunbook(Long incidentId, Long runbookId) { + Incident incident = getIncidentById(incidentId); + Runbook runbook = new Runbook(); + runbook.setId(runbookId); + incident.setRunbook(runbook); + return incidentRepository.save(incident); + } +} diff --git a/backend/src/main/java/com/incidentops/service/RunbookService.java b/backend/src/main/java/com/incidentops/service/RunbookService.java new file mode 100644 index 0000000..397baf1 --- /dev/null +++ b/backend/src/main/java/com/incidentops/service/RunbookService.java @@ -0,0 +1,47 @@ +package com.incidentops.service; + +import com.incidentops.entity.Runbook; +import com.incidentops.repository.RunbookRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Service +public class RunbookService { + + @Autowired + private RunbookRepository runbookRepository; + + public List getAllRunbooks() { + return runbookRepository.findAll(); + } + + public Runbook getRunbookById(Long id) { + return runbookRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Runbook not found")); + } + + public List searchRunbooks(String query) { + return runbookRepository.findByTitleContainingIgnoreCase(query); + } + + @Transactional + public Runbook createRunbook(Runbook runbook) { + return runbookRepository.save(runbook); + } + + @Transactional + public Runbook updateRunbook(Long id, Runbook runbookDetails) { + Runbook runbook = getRunbookById(id); + runbook.setTitle(runbookDetails.getTitle()); + runbook.setContent(runbookDetails.getContent()); + runbook.setTags(runbookDetails.getTags()); + return runbookRepository.save(runbook); + } + + @Transactional + public void deleteRunbook(Long id) { + runbookRepository.deleteById(id); + } +} diff --git a/backend/src/main/java/com/incidentops/service/ServiceService.java b/backend/src/main/java/com/incidentops/service/ServiceService.java new file mode 100644 index 0000000..52f90c1 --- /dev/null +++ b/backend/src/main/java/com/incidentops/service/ServiceService.java @@ -0,0 +1,54 @@ +package com.incidentops.service; + +import com.incidentops.entity.Service; +import com.incidentops.repository.ServiceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; + +@org.springframework.stereotype.Service +public class ServiceService { + + @Autowired + private ServiceRepository serviceRepository; + + public List getAllServices() { + return serviceRepository.findAll(); + } + + public Optional getServiceById(Long id) { + return serviceRepository.findById(id); + } + + @Transactional + public Service createService(Service service) { + return serviceRepository.save(service); + } + + @Transactional + public Service updateService(Long id, Service serviceDetails) { + Service service = serviceRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Service not found")); + + service.setName(serviceDetails.getName()); + service.setUrl(serviceDetails.getUrl()); + service.setDescription(serviceDetails.getDescription()); + service.setCheckInterval(serviceDetails.getCheckInterval()); + + return serviceRepository.save(service); + } + + @Transactional + public void deleteService(Long id) { + serviceRepository.deleteById(id); + } + + @Transactional + public void updateServiceStatus(Long id, Service.ServiceStatus status) { + Service service = serviceRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Service not found")); + service.setStatus(status); + serviceRepository.save(service); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..5578a1a --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,37 @@ +spring: + application: + name: incidentops + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:incidentops} + username: ${DB_USER:postgres} + password: ${DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + task: + scheduling: + pool: + size: 5 + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +incidentops: + health-check: + interval: 60000 # 60 seconds + timeout: 5000 # 5 seconds diff --git a/backend/src/test/java/com/incidentops/IncidentOpsApplicationTests.java b/backend/src/test/java/com/incidentops/IncidentOpsApplicationTests.java new file mode 100644 index 0000000..0f4754e --- /dev/null +++ b/backend/src/test/java/com/incidentops/IncidentOpsApplicationTests.java @@ -0,0 +1,12 @@ +package com.incidentops; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class IncidentOpsApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/backend/src/test/java/com/incidentops/service/ServiceServiceTest.java b/backend/src/test/java/com/incidentops/service/ServiceServiceTest.java new file mode 100644 index 0000000..1dde355 --- /dev/null +++ b/backend/src/test/java/com/incidentops/service/ServiceServiceTest.java @@ -0,0 +1,108 @@ +package com.incidentops.service; + +import com.incidentops.entity.Service; +import com.incidentops.repository.ServiceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ServiceServiceTest { + + @Mock + private ServiceRepository serviceRepository; + + @InjectMocks + private ServiceService serviceService; + + private Service testService; + + @BeforeEach + void setUp() { + testService = new Service(); + testService.setId(1L); + testService.setName("Test Service"); + testService.setUrl("http://example.com"); + testService.setStatus(Service.ServiceStatus.UNKNOWN); + testService.setCheckInterval(60); + } + + @Test + void getAllServices_ReturnsAllServices() { + // Arrange + List services = Arrays.asList(testService); + when(serviceRepository.findAll()).thenReturn(services); + + // Act + List result = serviceService.getAllServices(); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(testService.getName(), result.get(0).getName()); + verify(serviceRepository).findAll(); + } + + @Test + void getServiceById_WhenExists_ReturnsService() { + // Arrange + when(serviceRepository.findById(1L)).thenReturn(Optional.of(testService)); + + // Act + Optional result = serviceService.getServiceById(1L); + + // Assert + assertTrue(result.isPresent()); + assertEquals(testService.getName(), result.get().getName()); + verify(serviceRepository).findById(1L); + } + + @Test + void createService_SavesAndReturnsService() { + // Arrange + when(serviceRepository.save(any(Service.class))).thenReturn(testService); + + // Act + Service result = serviceService.createService(testService); + + // Assert + assertNotNull(result); + assertEquals(testService.getName(), result.getName()); + verify(serviceRepository).save(testService); + } + + @Test + void updateServiceStatus_UpdatesStatus() { + // Arrange + when(serviceRepository.findById(1L)).thenReturn(Optional.of(testService)); + when(serviceRepository.save(any(Service.class))).thenReturn(testService); + + // Act + serviceService.updateServiceStatus(1L, Service.ServiceStatus.HEALTHY); + + // Assert + verify(serviceRepository).findById(1L); + verify(serviceRepository).save(testService); + assertEquals(Service.ServiceStatus.HEALTHY, testService.getStatus()); + } + + @Test + void deleteService_DeletesService() { + // Act + serviceService.deleteService(1L); + + // Assert + verify(serviceRepository).deleteById(1L); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..144cddd --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + +incidentops: + health-check: + interval: 60000 + timeout: 5000 diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..f60ed98 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,68 @@ +-- IncidentOps Database Schema + +-- Services table +CREATE TABLE IF NOT EXISTS services ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + url VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN', + check_interval INTEGER DEFAULT 60, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Health checks table +CREATE TABLE IF NOT EXISTS health_checks ( + id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL, + response_time BIGINT, + status_code INTEGER, + error_message VARCHAR(1000), + checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Alerts table +CREATE TABLE IF NOT EXISTS alerts ( + id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + message VARCHAR(1000) NOT NULL, + severity VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP +); + +-- Runbooks table +CREATE TABLE IF NOT EXISTS runbooks ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT, + tags VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Incidents table +CREATE TABLE IF NOT EXISTS incidents ( + id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + severity VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'OPEN', + runbook_id BIGINT REFERENCES runbooks(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + acknowledged_at TIMESTAMP, + resolved_at TIMESTAMP, + closed_at TIMESTAMP +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_health_checks_service_id ON health_checks(service_id); +CREATE INDEX IF NOT EXISTS idx_health_checks_checked_at ON health_checks(checked_at); +CREATE INDEX IF NOT EXISTS idx_alerts_service_id ON alerts(service_id); +CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status); +CREATE INDEX IF NOT EXISTS idx_incidents_service_id ON incidents(service_id); +CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..810aa7b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: incidentops-postgres + environment: + POSTGRES_DB: incidentops + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: incidentops-backend + depends_on: + postgres: + condition: service_healthy + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: incidentops + DB_USER: postgres + DB_PASSWORD: postgres + ports: + - "8080:8080" + volumes: + - ./backend:/app + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: incidentops-frontend + depends_on: + - backend + environment: + REACT_APP_API_URL: http://localhost:8080/api + ports: + - "3000:3000" + volumes: + - ./frontend:/app + - /app/node_modules + restart: unless-stopped + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3d36ccb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:18-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6ec86d0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "incidentops-frontend", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.2", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..146a45f --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + IncidentOps + + + +
+ + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..c737ac7 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,298 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f5f5f5; +} + +.App { + min-height: 100vh; +} + +.navbar { + background-color: #2c3e50; + color: white; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.nav-brand h1 { + font-size: 1.5rem; + font-weight: 600; +} + +.nav-links { + display: flex; + list-style: none; + gap: 2rem; +} + +.nav-links a { + color: white; + text-decoration: none; + font-weight: 500; + transition: color 0.3s; +} + +.nav-links a:hover { + color: #3498db; +} + +.main-content { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 2rem; +} + +.page-header h2 { + font-size: 2rem; + color: #2c3e50; + margin-bottom: 0.5rem; +} + +.page-header p { + color: #7f8c8d; +} + +.card { + background: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.card h3 { + margin-bottom: 1rem; + color: #2c3e50; +} + +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.3s; +} + +.btn-primary { + background-color: #3498db; + color: white; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.btn-success { + background-color: #2ecc71; + color: white; +} + +.btn-success:hover { + background-color: #27ae60; +} + +.btn-danger { + background-color: #e74c3c; + color: white; +} + +.btn-danger:hover { + background-color: #c0392b; +} + +.btn-warning { + background-color: #f39c12; + color: white; +} + +.btn-warning:hover { + background-color: #d68910; +} + +.status-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-healthy { + background-color: #d4edda; + color: #155724; +} + +.status-degraded { + background-color: #fff3cd; + color: #856404; +} + +.status-down { + background-color: #f8d7da; + color: #721c24; +} + +.status-unknown { + background-color: #e2e3e5; + color: #383d41; +} + +.severity-low { + background-color: #d1ecf1; + color: #0c5460; +} + +.severity-medium { + background-color: #fff3cd; + color: #856404; +} + +.severity-high { + background-color: #f8d7da; + color: #721c24; +} + +.severity-critical { + background-color: #f8d7da; + color: #721c24; + font-weight: 600; +} + +.grid { + display: grid; + gap: 1.5rem; +} + +.grid-2 { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.grid-3 { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #ecf0f1; +} + +.table th { + background-color: #f8f9fa; + font-weight: 600; + color: #2c3e50; +} + +.table tbody tr:hover { + background-color: #f8f9fa; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #2c3e50; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.form-group textarea { + min-height: 100px; + resize: vertical; +} + +.loading { + text-align: center; + padding: 2rem; + color: #7f8c8d; +} + +.error { + background-color: #f8d7da; + color: #721c24; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.modal-header h3 { + margin: 0; +} + +.close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #7f8c8d; +} + +.close-btn:hover { + color: #2c3e50; +} diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..d604ef7 --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import Dashboard from './pages/Dashboard'; +import Services from './pages/Services'; +import Incidents from './pages/Incidents'; +import Alerts from './pages/Alerts'; +import Runbooks from './pages/Runbooks'; +import './App.css'; + +function App() { + return ( + +
+ + +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} + +export default App; diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..593edf1 --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/frontend/src/pages/Alerts.js b/frontend/src/pages/Alerts.js new file mode 100644 index 0000000..713def9 --- /dev/null +++ b/frontend/src/pages/Alerts.js @@ -0,0 +1,134 @@ +import React, { useState, useEffect } from 'react'; +import { alertApi } from '../services/api'; + +function Alerts() { + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + + useEffect(() => { + loadAlerts(); + }, [filter]); + + const loadAlerts = async () => { + try { + const response = filter === 'active' + ? await alertApi.getActive() + : await alertApi.getAll(); + setAlerts(response.data); + } catch (error) { + console.error('Error loading alerts:', error); + } finally { + setLoading(false); + } + }; + + const handleAcknowledge = async (id) => { + try { + await alertApi.acknowledge(id); + loadAlerts(); + } catch (error) { + console.error('Error acknowledging alert:', error); + } + }; + + const handleResolve = async (id) => { + try { + await alertApi.resolve(id); + loadAlerts(); + } catch (error) { + console.error('Error resolving alert:', error); + } + }; + + if (loading) return
Loading...
; + + return ( +
+
+

Alerts

+

Monitor and manage service alerts

+
+ +
+ + +
+ +
+ + + + + + + + + + + + + {alerts.map(alert => ( + + + + + + + + + ))} + +
ServiceMessageSeverityStatusCreatedActions
{alert.service?.name || 'N/A'}{alert.message} + + {alert.severity} + + {alert.status}{new Date(alert.createdAt).toLocaleString()} + {alert.status === 'ACTIVE' && ( + <> + + + + )} + {alert.status === 'ACKNOWLEDGED' && ( + + )} +
+ {alerts.length === 0 && ( +

+ No alerts found +

+ )} +
+
+ ); +} + +export default Alerts; diff --git a/frontend/src/pages/Dashboard.js b/frontend/src/pages/Dashboard.js new file mode 100644 index 0000000..ae9288b --- /dev/null +++ b/frontend/src/pages/Dashboard.js @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from 'react'; +import { serviceApi, alertApi, incidentApi } from '../services/api'; + +function Dashboard() { + const [services, setServices] = useState([]); + const [alerts, setAlerts] = useState([]); + const [incidents, setIncidents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadDashboardData(); + }, []); + + const loadDashboardData = async () => { + try { + const [servicesRes, alertsRes, incidentsRes] = await Promise.all([ + serviceApi.getAll(), + alertApi.getActive(), + incidentApi.getOpen() + ]); + setServices(servicesRes.data); + setAlerts(alertsRes.data); + setIncidents(incidentsRes.data); + } catch (error) { + console.error('Error loading dashboard data:', error); + } finally { + setLoading(false); + } + }; + + if (loading) return
Loading...
; + + const healthyServices = services.filter(s => s.status === 'HEALTHY').length; + const degradedServices = services.filter(s => s.status === 'DEGRADED').length; + const downServices = services.filter(s => s.status === 'DOWN').length; + + return ( +
+
+

Dashboard

+

Service health and incident overview

+
+ +
+
+

Services Status

+
+
+ Healthy: {healthyServices} +
+
+ Degraded: {degradedServices} +
+
+ Down: {downServices} +
+
+
+ +
+

Active Alerts

+
0 ? '#e74c3c' : '#2ecc71', marginTop: '1rem' }}> + {alerts.length} +
+

+ {alerts.length === 0 ? 'All clear' : 'Alerts require attention'} +

+
+ +
+

Open Incidents

+
0 ? '#f39c12' : '#2ecc71', marginTop: '1rem' }}> + {incidents.length} +
+

+ {incidents.length === 0 ? 'No open incidents' : 'Incidents in progress'} +

+
+
+ +
+
+

Recent Alerts

+ {alerts.length === 0 ? ( +

No active alerts

+ ) : ( + + + + + + + + + + {alerts.slice(0, 5).map(alert => ( + + + + + + ))} + +
ServiceSeverityMessage
{alert.service?.name || 'N/A'} + + {alert.severity} + + {alert.message}
+ )} +
+ +
+

Open Incidents

+ {incidents.length === 0 ? ( +

No open incidents

+ ) : ( + + + + + + + + + + {incidents.slice(0, 5).map(incident => ( + + + + + + ))} + +
TitleSeverityStatus
{incident.title} + + {incident.severity} + + {incident.status}
+ )} +
+
+
+ ); +} + +export default Dashboard; diff --git a/frontend/src/pages/Incidents.js b/frontend/src/pages/Incidents.js new file mode 100644 index 0000000..dd9a1f2 --- /dev/null +++ b/frontend/src/pages/Incidents.js @@ -0,0 +1,204 @@ +import React, { useState, useEffect } from 'react'; +import { incidentApi, serviceApi, runbookApi } from '../services/api'; + +function Incidents() { + const [incidents, setIncidents] = useState([]); + const [services, setServices] = useState([]); + const [runbooks, setRunbooks] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [formData, setFormData] = useState({ + serviceId: '', + title: '', + description: '', + severity: 'MEDIUM' + }); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const [incidentsRes, servicesRes, runbooksRes] = await Promise.all([ + incidentApi.getAll(), + serviceApi.getAll(), + runbookApi.getAll() + ]); + setIncidents(incidentsRes.data); + setServices(servicesRes.data); + setRunbooks(runbooksRes.data); + } catch (error) { + console.error('Error loading data:', error); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + try { + await incidentApi.create(formData); + setFormData({ serviceId: '', title: '', description: '', severity: 'MEDIUM' }); + setShowModal(false); + loadData(); + } catch (error) { + console.error('Error creating incident:', error); + } + }; + + const updateStatus = async (id, status) => { + try { + await incidentApi.updateStatus(id, status); + loadData(); + } catch (error) { + console.error('Error updating incident status:', error); + } + }; + + if (loading) return
Loading...
; + + return ( +
+
+

Incidents

+

Track and manage incidents

+
+ + + +
+ + + + + + + + + + + + + {incidents.map(incident => ( + + + + + + + + + ))} + +
TitleServiceSeverityStatusCreatedActions
{incident.title}{incident.service?.name || 'N/A'} + + {incident.severity} + + {incident.status}{new Date(incident.createdAt).toLocaleString()} + {incident.status === 'OPEN' && ( + + )} + {(incident.status === 'OPEN' || incident.status === 'ACKNOWLEDGED') && ( + + )} + {incident.status === 'INVESTIGATING' && ( + + )} + {incident.status === 'RESOLVED' && ( + + )} +
+ {incidents.length === 0 && ( +

+ No incidents found +

+ )} +
+ + {showModal && ( +
+
+
+

Create New Incident

+ +
+
+
+ + +
+
+ + setFormData({ ...formData, title: e.target.value })} + /> +
+
+ +