Page MenuHomeDevCentral

No OneTemporary

diff --git a/.env.example b/.env.example
index f64bfec..961d2f1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,20 +1,24 @@
# Database
POSTGRES_USER=servpulse
POSTGRES_PASSWORD=changeme
POSTGRES_DB=servpulse
POSTGRES_CONNECTION_STRING=postgresql://servpulse:changeme@db:5432/servpulse
# Backend
EXPRESS_PORT=3000
JWT_SECRET=change-this-to-a-random-string
+HEALTH_CHECK_INTERVAL=60000
# Frontend
VITE_API_URL=http://localhost:3000/api
+# App
+APP_URL=http://localhost:8080
+
# Notifications (optional)
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=ServPulse <noreply@servpulse.local>
diff --git a/README.md b/README.md
index 4e6d451..c4cb73e 100644
--- a/README.md
+++ b/README.md
@@ -1,260 +1,263 @@
# ServPulse
An open-source status page application for monitoring services, servers, and infrastructure. Built with simplicity, transparency, and self-hosting in mind.
## Features
- **Service monitoring** — Track component statuses (operational, degraded, partial outage, major outage)
- **Incident management** — Full lifecycle tracking (investigating → identified → monitoring → resolved)
- **Scheduled maintenance** — Plan and communicate maintenance windows
- **Service grouping** — Organize services by groups with customizable ordering
- **Metrics tracking** — Store and display uptime, response time, and error rate (30-day charts)
- **Notifications** — Email and webhook subscriber notifications on incidents
- **Monitoring integrations** — Inbound webhook for automated status updates from tools like Prometheus
- **Admin dashboard** — CRUD interface for managing services, incidents, and maintenance
- **Dark mode** — Automatic dark mode support via system preferences
- **Docker ready** — One-command setup with Docker Compose
## Architecture
```
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │────▶│ Backend │────▶│ PostgreSQL │
│ Vue.js 3 │ │ Express.js │ │ 16 │
│ Tailwind │ │ Node.js 20 │ │ │
│ Chart.js │ │ JWT Auth │ │ │
└─────────────┘ └──────────────┘ └──────────────┘
:8080 :3000 :5432
```
For detailed diagrams (component tree, backend MVC, database ER, status flows), see **[docs/architecture.md](docs/architecture.md)**.
- **Frontend**: Vue.js 3 + Vite + Tailwind CSS + Chart.js
- **Backend**: Node.js 20 + Express.js (MVC pattern) + JWT authentication
- **Database**: PostgreSQL 16
## Quick Start
### Prerequisites
- [Docker](https://www.docker.com/) and Docker Compose
- Or: Node.js 20+ and PostgreSQL 16+
### Using Docker Compose (recommended)
```bash
# Clone the repository
git clone http://devcentral.nasqueron.org/source/servpulse.git
cd servpulse
# Copy environment configuration
cp .env.example .env
# Edit .env with your settings (database credentials, JWT secret, SMTP config)
# Start all services
docker compose up
```
The application will be available at:
- **Status page**: http://localhost:8080
- **API**: http://localhost:3000/api
### Manual Setup
```bash
# Start PostgreSQL and create the database
psql -U postgres -c "CREATE DATABASE servpulse;"
psql -U postgres -d servpulse -f database/init.sql
# Backend
cd backend
cp .env.dist .env # Edit with your database connection string
npm install
npm run dev # Development mode with auto-reload
# Frontend (in another terminal)
cd frontend
npm install
npm run dev # Vite dev server at http://localhost:5173
```
### Production Deployment
```bash
# Build and run with production Docker Compose
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
See [Deployment Guide](#deployment) for detailed production setup.
## Project Structure
```
servpulse/
├── backend/ # Express.js API server
│ ├── config/ # App config (app.json) and database connection
│ ├── controllers/ # Request handlers (service, incident, maintenance, metric, subscriber, webhook)
│ ├── middleware/ # JWT authentication middleware
│ ├── models/ # Data access layer (raw pg queries)
│ ├── routes/ # API route definitions
│ ├── services/ # Business logic (notification dispatch)
│ └── __tests__/ # Jest unit tests
├── frontend/ # Vue.js 3 application
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── AppNavbar.vue
│ │ │ ├── AppFooter.vue
│ │ │ ├── StatusBadge.vue
│ │ │ ├── OverallStatus.vue
│ │ │ ├── ServiceGroup.vue
│ │ │ ├── IncidentTimeline.vue
│ │ │ ├── MaintenanceCard.vue
│ │ │ ├── UptimeChart.vue
│ │ │ ├── SubscribeForm.vue
│ │ │ └── __tests__/ # Vitest component tests
│ │ ├── composables/ # Vue 3 composables (useServices, useIncidents, etc.)
│ │ ├── views/ # Page views (StatusPage, AdminDashboard, AdminLogin)
│ │ ├── plugins/ # API client (axios)
│ │ ├── utils/ # Status helpers and formatters
│ │ └── router/ # Vue Router config with auth guards
│ └── public/ # Static assets
├── database/ # SQL schema (init.sql)
├── docker-compose.yml # Development Docker Compose
├── docker-compose.prod.yml # Production Docker Compose
└── .env.example # Environment variable template
```
## API Reference
### Public Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/services` | List all services (ordered by group) |
| GET | `/api/services/:id` | Get a single service |
| GET | `/api/incidents` | List all incidents (newest first) |
| GET | `/api/incidents/:id` | Get incident with updates and affected services |
| GET | `/api/maintenances` | List all scheduled maintenances |
| GET | `/api/maintenances/:id` | Get maintenance with affected services |
| GET | `/api/metrics` | Get latest metrics per service |
| GET | `/api/metrics/service/:id` | Get raw metrics for a service (query: `?days=30`) |
| GET | `/api/metrics/service/:id/daily` | Get daily metric summaries (query: `?days=30`) |
| GET | `/api/config/getAll` | Get app configuration (navbar, branding) |
| POST | `/api/subscribers` | Subscribe to notifications (email or webhook) |
| GET | `/api/subscribers/confirm/:token` | Confirm a subscription |
### Admin Endpoints (requires `Authorization: Bearer <JWT>`)
| Method | Endpoint | Description |
|--------|----------|-------------|
+| POST | `/api/auth/verify` | Verify a JWT token |
| POST | `/api/services` | Create a service |
| PUT | `/api/services/:id` | Update a service |
| DELETE | `/api/services/:id` | Delete a service |
| POST | `/api/incidents` | Create an incident (supports `service_ids`) |
| PUT | `/api/incidents/:id` | Update an incident (supports `service_ids`, `message`) |
| PUT | `/api/incidents/:id/resolve` | Resolve an incident |
| POST | `/api/maintenances` | Schedule maintenance (supports `service_ids`) |
| PUT | `/api/maintenances/:id` | Update maintenance |
| DELETE | `/api/maintenances/:id` | Delete maintenance |
| POST | `/api/metrics` | Record a metric data point |
| GET | `/api/subscribers` | List all subscribers |
| DELETE | `/api/subscribers/:id` | Remove a subscriber |
+| PUT | `/api/config` | Update app configuration (navbar, branding) |
| POST | `/api/webhooks/ingest` | Inbound monitoring webhook |
### Monitoring Webhook
External monitoring tools can push status updates via `POST /api/webhooks/ingest`:
```json
{
"service_name": "Web Server",
"status": "degraded",
"message": "High response times detected",
"impact": "minor"
}
```
This automatically updates the service status and creates an incident if the status is non-operational.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `POSTGRES_USER` | `servpulse` | Database username |
| `POSTGRES_PASSWORD` | — | Database password |
| `POSTGRES_DB` | `servpulse` | Database name |
| `POSTGRES_CONNECTION_STRING` | — | Full PostgreSQL connection string |
| `EXPRESS_PORT` | `3000` | Backend API port |
| `JWT_SECRET` | `servpulse-dev-secret` | Secret for JWT token signing |
+| `HEALTH_CHECK_INTERVAL` | `60000` | Health check interval in milliseconds |
| `VITE_API_URL` | `http://localhost:3000/api` | API URL for frontend |
| `SMTP_HOST` | `localhost` | SMTP server for email notifications |
| `SMTP_PORT` | `587` | SMTP port |
| `SMTP_SECURE` | `false` | Use TLS for SMTP |
| `SMTP_USER` | — | SMTP username |
| `SMTP_PASS` | — | SMTP password |
| `SMTP_FROM` | `ServPulse <noreply@servpulse.local>` | Sender address |
## Testing
```bash
# Backend tests (Jest)
cd backend
npm test
# Frontend tests (Vitest)
cd frontend
npm run test:unit
```
## Deployment
### Self-Hosting with Docker
1. Clone the repository on your server
2. Copy `.env.example` to `.env` and configure:
- Set a strong `JWT_SECRET`
- Set `POSTGRES_PASSWORD`
- Configure SMTP for email notifications (optional)
3. Use the production Docker Compose:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
4. Generate an admin JWT token:
```bash
docker compose exec backend node -e "const {generateToken} = require('./middleware/auth.js'); console.log(generateToken({role:'admin'}))"
```
5. Use the token to log into the admin dashboard at `/admin/login`
### Reverse Proxy (Nginx)
For production, place Nginx in front to serve the frontend and proxy API requests:
```nginx
server {
listen 80;
server_name status.example.com;
location / {
proxy_pass http://localhost:8080;
}
location /api/ {
proxy_pass http://localhost:3000;
}
}
```
## Contributing
This project uses [Phabricator](https://devcentral.nasqueron.org/) for issue tracking (callsign: **SP**).
Code conventions: https://agora.nasqueron.org/Code_conventions
- Single quotes for strings
- camelCase for variables and functions
- Vue 3 Composition API (`<script setup>`) for components
- Tailwind CSS utility classes for styling
## License
[MIT](LICENSE)
diff --git a/backend/README.md b/backend/README.md
index 14a0ca5..9ea663a 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,84 +1,126 @@
# ServPulse Backend
Express.js REST API server with MVC architecture, JWT authentication, and PostgreSQL.
## Structure
```
backend/
├── app.js # Application entry point
├── config/
│ ├── app.json # Navbar and branding configuration
│ └── database.js # PostgreSQL connection pool
├── controllers/
│ ├── configController.js
│ ├── incidentController.js
│ ├── maintenanceController.js
│ ├── metricController.js
│ ├── serviceController.js
│ ├── subscriberController.js
│ └── webhookController.js
├── middleware/
│ └── auth.js # JWT authentication (generateToken, authenticate)
├── models/
│ ├── configModel.js
│ ├── incidentModel.js
│ ├── incidentServiceModel.js
│ ├── incidentUpdateModel.js
│ ├── maintenanceModel.js
│ ├── maintenanceServiceModel.js
│ ├── metricModel.js
│ ├── serviceModel.js
│ └── subscriberModel.js
├── routes/
+│ ├── authRoutes.js
│ ├── configRoutes.js
│ ├── incidentRoutes.js
│ ├── maintenanceRoutes.js
│ ├── metricRoutes.js
│ ├── serviceRoutes.js
│ ├── subscriberRoutes.js
│ └── webhookRoutes.js
├── services/
+│ ├── healthCheckService.js # Periodic URL health monitoring
│ └── notificationService.js # Email (Nodemailer) and webhook dispatch
└── __tests__/ # Jest unit tests
```
## Development
```bash
npm install
npm run dev # Starts with --watch for auto-reload
```
## Testing
```bash
npm test # Runs Jest with verbose output
```
## Adding a New Resource
Follow the MVC pattern:
1. Add table to `database/init.sql`
2. Create `models/resourceModel.js` — raw `pg` queries
3. Create `controllers/resourceController.js` — req/res handlers
4. Create `routes/resourceRoutes.js` — Express router with auth where needed
5. Register routes in `app.js`
6. Add tests in `__tests__/controllers/resourceController.test.js`
## Authentication
-Admin endpoints require a JWT Bearer token. Generate one:
+Admin endpoints require a JWT Bearer token signed with `JWT_SECRET`.
-```js
-const { generateToken } = require('./middleware/auth.js');
-console.log(generateToken({ role: 'admin' }));
+### Generating a token
+
+**Using Docker (recommended):**
+
+```bash
+docker compose exec backend node -e "const {generateToken} = require('./middleware/auth.js'); console.log(generateToken({role:'admin'}))"
+```
+
+**Using Node.js directly:**
+
+```bash
+cd backend
+node -e "const {generateToken} = require('./middleware/auth.js'); console.log(generateToken({role:'admin'}))"
+```
+
+The token is valid for 24 hours by default.
+
+### Using the token
+
+1. Copy the generated token
+2. Navigate to `/admin/login` in your browser
+3. Paste the token and click Sign In
+4. The token is validated against the backend before granting access
+
+The token is stored in `localStorage` and automatically attached to all API requests. It is verified server-side on every admin page navigation and on every protected API call.
+
+### Token verification endpoint
+
+```
+POST /api/auth/verify
+Authorization: Bearer <token>
```
+Returns `{ "valid": true }` if the token is valid, or `401` if not.
+
+## Health Checks
+
+Services with a URL are automatically monitored. The health checker runs every 60 seconds (configurable via `HEALTH_CHECK_INTERVAL` environment variable) and:
+
+- Sends an HTTP GET request to the service URL (10s timeout)
+- Updates the service status to `operational` (HTTP < 400) or `major` (HTTP >= 400 or unreachable)
+- Records response time, uptime, and error rate as metrics
+
+Services without a URL retain manual status control from the admin dashboard.
+
## Code Conventions
- [Nasqueron conventions](https://agora.nasqueron.org/Code_conventions)
- Single quotes, camelCase naming
- Raw `pg` queries (no ORM) for simplicity
- Fire-and-forget notifications (non-blocking)
diff --git a/backend/admin-token.txt b/backend/admin-token.txt
new file mode 100644
index 0000000..28bb299
--- /dev/null
+++ b/backend/admin-token.txt
@@ -0,0 +1 @@
+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NzExNjAwNTAsImV4cCI6MTc3Mzc1MjA1MH0.P4iQ_cd7PFZsc7znFN7C7BWxORJfkhjymbywEvajugg
\ No newline at end of file
diff --git a/backend/app.js b/backend/app.js
index e355bae..ff53d68 100644
--- a/backend/app.js
+++ b/backend/app.js
@@ -1,42 +1,47 @@
// -------------------------------------------------------------
// ServPulse :: app
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Project: Nasqueron
// Description: Bootstrap the application
// License: BSD-2-Clause
// -------------------------------------------------------------
require('dotenv').config(); // Set up .env file
require('./config/database.js'); // Set up database connection
const express = require('express');
+const healthCheckService = require('./services/healthCheckService.js');
const cors = require('cors');
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Import routes
const serviceRoutes = require('./routes/serviceRoutes.js');
const incidentRoutes = require('./routes/incidentRoutes.js');
const configRoutes = require('./routes/configRoutes.js');
const maintenanceRoutes = require('./routes/maintenanceRoutes.js');
const webhookRoutes = require('./routes/webhookRoutes.js');
const metricRoutes = require('./routes/metricRoutes.js');
const subscriberRoutes = require('./routes/subscriberRoutes.js');
+const authRoutes = require('./routes/authRoutes.js');
// Use the routes
app.use('/api', serviceRoutes);
app.use('/api', incidentRoutes);
app.use('/api', configRoutes);
app.use('/api', maintenanceRoutes);
app.use('/api', webhookRoutes);
app.use('/api', metricRoutes);
app.use('/api', subscriberRoutes);
+app.use('/api', authRoutes);
+
+healthCheckService.start();
// Start the server
const PORT = process.env.EXPRESS_PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
diff --git a/backend/config/app.json b/backend/config/app.json
index 145cf43..7eb63be 100644
--- a/backend/config/app.json
+++ b/backend/config/app.json
@@ -1,15 +1,31 @@
{
"navbar": {
- "title": "ServPulse",
- "button_left": {
- "name": "DevCentral",
- "icon": "fa fa-play",
- "link": "https://devcentral.nasqueron.org"
+ "title": "Codrlabs Uptime",
+ "buttons_left": [
+ {
+ "name": "DevCentral",
+ "icon": "fa fa-play",
+ "link": "https://devcentral.nasqueron.org"
+ },
+ {
+ "name": "Agora",
+ "icon": "fa fa-book",
+ "link": "https://agora.nasqueron.org"
+ }
+ ]
+ },
+ "footer": {
+ "line1": {
+ "text": "Powered by %link1% — made with ❤️ by %link2%",
+ "link1_label": "ServPulse",
+ "link1_url": "https://devcentral.nasqueron.org/source/servpulse/",
+ "link2_label": "Nasqueron",
+ "link2_url": "https://nasqueron.org"
},
- "button_right": {
- "name": "Agora",
- "icon": "fa fa-book",
- "link": "https://agora.nasqueron.org"
+ "line2": {
+ "text": "Find this useful? %link%",
+ "link_label": "Contribute to Nasqueron",
+ "link_url": "https://devcentral.nasqueron.org/source/servpulse/"
}
}
-}
+}
\ No newline at end of file
diff --git a/backend/controllers/configController.js b/backend/controllers/configController.js
index 64701bf..86c4b64 100644
--- a/backend/controllers/configController.js
+++ b/backend/controllers/configController.js
@@ -1,12 +1,21 @@
const configModel = require('../models/configModel.js');
const getConfig = async (req, res) => {
try {
const config = await configModel.getConfig();
res.status(200).json(config);
} catch (error) {
res.status(500).json({ message: 'Error fetching the config', error: error.message });
}
};
-module.exports = { getConfig };
+const updateConfig = async (req, res) => {
+ try {
+ const config = await configModel.updateConfig(req.body);
+ res.status(200).json(config);
+ } catch (error) {
+ res.status(500).json({ message: 'Error updating the config', error: error.message });
+ }
+};
+
+module.exports = { getConfig, updateConfig };
diff --git a/backend/controllers/subscriberController.js b/backend/controllers/subscriberController.js
index db0578c..753f3c1 100644
--- a/backend/controllers/subscriberController.js
+++ b/backend/controllers/subscriberController.js
@@ -1,48 +1,94 @@
const subscriberModel = require('../models/subscriberModel.js');
+const { sendEmail } = require('../services/notificationService.js');
const subscribe = async (req, res) => {
try {
const result = await subscriberModel.createSubscriber(req.body);
- res.status(201).json(result.rows[0]);
+ const subscriber = result.rows[0];
+
+ if (subscriber.type === 'email' && subscriber.email) {
+ const baseUrl = process.env.APP_URL || 'http://localhost:8080';
+ const confirmUrl = `${baseUrl}/confirm/${subscriber.confirm_token}`;
+ await sendEmail(
+ subscriber.email,
+ '[ServPulse] Confirm your subscription',
+ `You've subscribed to ServPulse status updates.\n\nPlease confirm your subscription by visiting:\n${confirmUrl}\n\nIf you didn't request this, you can ignore this email.`
+ );
+ }
+
+ res.status(201).json({ message: 'Please check your email to confirm your subscription.' });
} catch (error) {
if (error.code === '23505') {
return res.status(409).json({ message: 'Already subscribed' });
}
res.status(500).json({ message: 'Error subscribing', error: error.message });
}
};
const confirm = async (req, res) => {
try {
const result = await subscriberModel.confirmSubscriber(req.params.token);
if (result.rows.length === 0) {
- return res.status(404).json({ message: 'Invalid or expired token' });
+ return res.status(404).json({ message: 'Invalid or expired confirmation link' });
}
- res.status(200).json({ message: 'Subscription confirmed', subscriber: result.rows[0] });
+ res.status(200).json({ message: 'Subscription confirmed' });
} catch (error) {
res.status(500).json({ message: 'Error confirming subscription', error: error.message });
}
};
+const unsubscribe = async (req, res) => {
+ try {
+ const result = await subscriberModel.unsubscribe(req.params.token);
+ if (result.rows.length === 0) {
+ return res.status(404).json({ message: 'Invalid or expired unsubscribe link' });
+ }
+ res.status(200).json({ message: 'You have been unsubscribed' });
+ } catch (error) {
+ res.status(500).json({ message: 'Error unsubscribing', error: error.message });
+ }
+};
+
+const requestUnsubscribe = async (req, res) => {
+ try {
+ const result = await subscriberModel.getByEmail(req.body.email);
+ const subscriber = result.rows[0];
+
+ if (subscriber && subscriber.unsubscribe_token) {
+ const baseUrl = process.env.APP_URL || 'http://localhost:8080';
+ const unsubscribeUrl = `${baseUrl}/unsubscribe/${subscriber.unsubscribe_token}`;
+ await sendEmail(
+ subscriber.email,
+ '[ServPulse] Unsubscribe link',
+ `You requested your unsubscribe link for ServPulse.\n\nTo unsubscribe, visit:\n${unsubscribeUrl}\n\nIf you didn't request this, you can ignore this email.`
+ );
+ }
+
+ res.status(200).json({ message: 'If that email is subscribed, an unsubscribe link has been sent.' });
+ } catch (error) {
+ res.status(500).json({ message: 'Error processing unsubscribe request', error: error.message });
+ }
+};
+
const getAll = async (req, res) => {
try {
const result = await subscriberModel.getAllSubscribers();
res.status(200).json(result.rows);
} catch (error) {
res.status(500).json({ message: 'Error fetching subscribers', error: error.message });
}
};
const remove = async (req, res) => {
try {
const result = await subscriberModel.deleteSubscriber(req.params.id);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Subscriber not found' });
}
res.status(200).json({ message: 'Unsubscribed' });
} catch (error) {
res.status(500).json({ message: 'Error unsubscribing', error: error.message });
}
};
-module.exports = { subscribe, confirm, getAll, remove };
+module.exports = { subscribe, confirm, unsubscribe, requestUnsubscribe, getAll, remove };
diff --git a/backend/models/configModel.js b/backend/models/configModel.js
index 56e2e73..f60704d 100644
--- a/backend/models/configModel.js
+++ b/backend/models/configModel.js
@@ -1,9 +1,16 @@
const fs = require('fs');
+const path = require('path');
+
+const CONFIG_PATH = path.join(__dirname, '../config/app.json');
const getConfig = async () => {
- const jsonString = fs.readFileSync('config/app.json');
- const jsonObject = JSON.parse(jsonString);
- return await jsonObject;
+ const jsonString = fs.readFileSync(CONFIG_PATH, 'utf-8');
+ return JSON.parse(jsonString);
+};
+
+const updateConfig = async (config) => {
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, '\t'), 'utf-8');
+ return config;
};
-module.exports = { getConfig };
+module.exports = { getConfig, updateConfig };
diff --git a/backend/models/serviceModel.js b/backend/models/serviceModel.js
index 2bc1cb6..45bf3cc 100644
--- a/backend/models/serviceModel.js
+++ b/backend/models/serviceModel.js
@@ -1,39 +1,45 @@
const pool = require('../config/database.js');
const addService = async (data) => {
return await pool.query(`
INSERT INTO service
- (name, "group", description, status, "order")
- VALUES ($1, $2, $3, $4, $5)
+ (name, "group", description, url, auto_status, status, "order")
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
- `, [data.name, data.group, data.description, data.status || 'operational', data.order || 0]);
+ `, [data.name, data.group, data.description, data.url || null, data.auto_status !== false, data.status || 'operational', data.order || 0]);
};
const getServices = async () => {
return await pool.query(`
SELECT * FROM service ORDER BY "order", "group", name;
`);
};
const getServiceById = async (id) => {
return await pool.query(`
SELECT * FROM service WHERE id = $1;
`, [id]);
};
const updateService = async (id, data) => {
return await pool.query(`
UPDATE service
- SET name = $1, "group" = $2, description = $3, status = $4, "order" = $5, updated_at = NOW()
- WHERE id = $6
+ SET name = $1, "group" = $2, description = $3, url = $4, auto_status = $5, status = $6, "order" = $7, updated_at = NOW()
+ WHERE id = $8
RETURNING *
- `, [data.name, data.group, data.description, data.status, data.order, id]);
+ `, [data.name, data.group, data.description, data.url || null, data.auto_status !== false, data.status, data.order, id]);
+};
+
+const getServicesWithUrl = async () => {
+ return await pool.query(`
+ SELECT * FROM service WHERE url IS NOT NULL ORDER BY id;
+ `);
};
const deleteService = async (id) => {
return await pool.query(`
DELETE FROM service WHERE id = $1 RETURNING *;
`, [id]);
};
-module.exports = { addService, getServices, getServiceById, updateService, deleteService };
+module.exports = { addService, getServices, getServiceById, getServicesWithUrl, updateService, deleteService };
diff --git a/backend/models/subscriberModel.js b/backend/models/subscriberModel.js
index c7bcb5d..82bcc9a 100644
--- a/backend/models/subscriberModel.js
+++ b/backend/models/subscriberModel.js
@@ -1,41 +1,55 @@
const pool = require('../config/database.js');
+const crypto = require('crypto');
const createSubscriber = async (data) => {
- const token = require('crypto').randomBytes(32).toString('hex');
+ const confirmToken = crypto.randomBytes(32).toString('hex');
return await pool.query(`
INSERT INTO subscriber (email, webhook_url, type, confirm_token)
VALUES ($1, $2, $3, $4)
- RETURNING id, email, webhook_url, type, confirmed, created_at
- `, [data.email || null, data.webhook_url || null, data.type || 'email', token]);
+ RETURNING id, email, webhook_url, type, confirmed, confirm_token, created_at
+ `, [data.email || null, data.webhook_url || null, data.type || 'email', confirmToken]);
};
const confirmSubscriber = async (token) => {
+ const unsubscribeToken = crypto.randomBytes(32).toString('hex');
return await pool.query(`
- UPDATE subscriber SET confirmed = true, confirm_token = NULL
- WHERE confirm_token = $1
+ UPDATE subscriber SET confirmed = true, confirm_token = NULL, unsubscribe_token = $1
+ WHERE confirm_token = $2
RETURNING id, email, webhook_url, type, confirmed
+ `, [unsubscribeToken, token]);
+};
+
+const unsubscribe = async (token) => {
+ return await pool.query(`
+ DELETE FROM subscriber WHERE unsubscribe_token = $1 RETURNING id, email, webhook_url, type
`, [token]);
};
const getConfirmedSubscribers = async () => {
return await pool.query(`
- SELECT id, email, webhook_url, type FROM subscriber
+ SELECT id, email, webhook_url, type, unsubscribe_token FROM subscriber
WHERE confirmed = true
ORDER BY created_at ASC
`);
};
+const getByEmail = async (email) => {
+ return await pool.query(`
+ SELECT id, email, unsubscribe_token FROM subscriber WHERE email = $1 AND confirmed = true
+ `, [email]);
+};
+
const getAllSubscribers = async () => {
return await pool.query(`
SELECT id, email, webhook_url, type, confirmed, created_at FROM subscriber
ORDER BY created_at DESC
`);
};
const deleteSubscriber = async (id) => {
return await pool.query(`
DELETE FROM subscriber WHERE id = $1 RETURNING *
`, [id]);
};
-module.exports = { createSubscriber, confirmSubscriber, getConfirmedSubscribers, getAllSubscribers, deleteSubscriber };
+module.exports = { createSubscriber, confirmSubscriber, unsubscribe, getConfirmedSubscribers, getByEmail, getAllSubscribers, deleteSubscriber };
diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js
new file mode 100644
index 0000000..ad0b789
--- /dev/null
+++ b/backend/routes/authRoutes.js
@@ -0,0 +1,9 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth.js');
+
+router.post('/auth/verify', authenticate, (req, res) => {
+ res.json({ valid: true });
+});
+
+module.exports = router;
diff --git a/backend/routes/configRoutes.js b/backend/routes/configRoutes.js
index 90d048c..8b74882 100644
--- a/backend/routes/configRoutes.js
+++ b/backend/routes/configRoutes.js
@@ -1,7 +1,9 @@
const express = require('express');
const router = express.Router();
const configController = require('../controllers/configController.js');
+const { authenticate } = require('../middleware/auth.js');
router.get('/config/getAll', configController.getConfig);
+router.put('/config', authenticate, configController.updateConfig);
module.exports = router;
diff --git a/backend/routes/subscriberRoutes.js b/backend/routes/subscriberRoutes.js
index b19b2fb..78e3d34 100644
--- a/backend/routes/subscriberRoutes.js
+++ b/backend/routes/subscriberRoutes.js
@@ -1,11 +1,13 @@
const express = require('express');
const router = express.Router();
const subscriberController = require('../controllers/subscriberController.js');
const { authenticate } = require('../middleware/auth.js');
router.post('/subscribers', subscriberController.subscribe);
router.get('/subscribers/confirm/:token', subscriberController.confirm);
+router.post('/subscribers/unsubscribe-request', subscriberController.requestUnsubscribe);
+router.get('/subscribers/unsubscribe/:token', subscriberController.unsubscribe);
router.get('/subscribers', authenticate, subscriberController.getAll);
router.delete('/subscribers/:id', authenticate, subscriberController.remove);
module.exports = router;
diff --git a/backend/services/healthCheckService.js b/backend/services/healthCheckService.js
new file mode 100644
index 0000000..a0a148b
--- /dev/null
+++ b/backend/services/healthCheckService.js
@@ -0,0 +1,76 @@
+const axios = require('axios');
+const serviceModel = require('../models/serviceModel.js');
+const metricModel = require('../models/metricModel.js');
+
+const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000;
+
+const checkService = async (service) => {
+ const start = Date.now();
+ try {
+ const response = await axios.get(service.url, {
+ timeout: 10000,
+ validateStatus: (status) => status < 500,
+ });
+ const responseTime = Date.now() - start;
+ const isUp = response.status < 400;
+
+ return {
+ status: isUp ? 'operational' : 'major',
+ response_time: responseTime,
+ uptime: isUp ? 100 : 0,
+ error_rate: isUp ? 0 : 100,
+ };
+ } catch {
+ return {
+ status: 'major',
+ response_time: Date.now() - start,
+ uptime: 0,
+ error_rate: 100,
+ };
+ }
+};
+
+const runChecks = async () => {
+ try {
+ const result = await serviceModel.getServicesWithUrl();
+ const services = result.rows;
+
+ for (const service of services) {
+ const check = await checkService(service);
+
+ if (service.auto_status && service.status !== check.status) {
+ await serviceModel.updateService(service.id, {
+ ...service,
+ status: check.status,
+ });
+ }
+
+ await metricModel.recordMetric({
+ service_id: service.id,
+ uptime: check.uptime,
+ response_time: check.response_time,
+ error_rate: check.error_rate,
+ });
+ }
+ } catch (error) {
+ console.error('Health check error:', error.message);
+ }
+};
+
+let intervalId = null;
+
+const start = () => {
+ if (intervalId) return;
+ console.log(`Health checks starting (interval: ${CHECK_INTERVAL / 1000}s)`);
+ runChecks();
+ intervalId = setInterval(runChecks, CHECK_INTERVAL);
+};
+
+const stop = () => {
+ if (intervalId) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+};
+
+module.exports = { start, stop, runChecks, checkService };
diff --git a/backend/services/notificationService.js b/backend/services/notificationService.js
index 38efd29..f7b8d8c 100644
--- a/backend/services/notificationService.js
+++ b/backend/services/notificationService.js
@@ -1,71 +1,76 @@
const nodemailer = require('nodemailer');
const axios = require('axios');
const subscriberModel = require('../models/subscriberModel.js');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
} : undefined,
});
const sendEmail = async (to, subject, text) => {
try {
await transporter.sendMail({
from: process.env.SMTP_FROM || 'ServPulse <noreply@servpulse.local>',
to,
subject,
text,
});
} catch (error) {
console.error(`Failed to send email to ${to}:`, error.message);
}
};
const sendWebhook = async (url, payload) => {
try {
await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
});
} catch (error) {
console.error(`Failed to send webhook to ${url}:`, error.message);
}
};
const notifyAll = async (event, data) => {
const result = await subscriberModel.getConfirmedSubscribers();
const subscribers = result.rows;
+ const baseUrl = process.env.APP_URL || 'http://localhost:8080';
const subject = `[ServPulse] ${event}`;
- const text = formatNotification(event, data);
const payload = { event, timestamp: new Date().toISOString(), ...data };
const promises = subscribers.map((sub) => {
if (sub.type === 'email' && sub.email) {
+ const unsubscribeUrl = `${baseUrl}/unsubscribe/${sub.unsubscribe_token}`;
+ const text = formatNotification(event, data, unsubscribeUrl);
return sendEmail(sub.email, subject, text);
} else if (sub.type === 'webhook' && sub.webhook_url) {
return sendWebhook(sub.webhook_url, payload);
}
});
await Promise.allSettled(promises);
};
-const formatNotification = (event, data) => {
+const formatNotification = (event, data, unsubscribeUrl) => {
let text = `ServPulse Notification\n${'='.repeat(40)}\n\n`;
text += `Event: ${event}\n`;
text += `Time: ${new Date().toISOString()}\n\n`;
if (data.title) text += `Title: ${data.title}\n`;
if (data.status) text += `Status: ${data.status}\n`;
if (data.impact) text += `Impact: ${data.impact}\n`;
if (data.message) text += `Message: ${data.message}\n`;
+ text += `\n${'—'.repeat(40)}\n`;
+ text += `Unsubscribe: ${unsubscribeUrl}\n`;
+
return text;
};
module.exports = { notifyAll, sendEmail, sendWebhook };
diff --git a/database/init.sql b/database/init.sql
index 8f3685e..56ce2c3 100644
--- a/database/init.sql
+++ b/database/init.sql
@@ -1,107 +1,110 @@
-- -------------------------------------------------------------
-- ServPulse :: database schema
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-- Project: Nasqueron
-- Description: Initialize database tables
-- License: MIT
-- -------------------------------------------------------------
--
-- Services
--
CREATE TABLE IF NOT EXISTS service (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
"group" VARCHAR(255),
description TEXT,
+ url TEXT,
+ auto_status BOOLEAN NOT NULL DEFAULT true,
status VARCHAR(50) NOT NULL DEFAULT 'operational',
"order" INTEGER DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
--
-- Incidents
--
CREATE TABLE IF NOT EXISTS incident (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
start_date TIMESTAMP NOT NULL DEFAULT NOW(),
update_date TIMESTAMP,
end_date TIMESTAMP,
type_id INTEGER DEFAULT 1,
status VARCHAR(50) NOT NULL DEFAULT 'investigating',
impact VARCHAR(50) NOT NULL DEFAULT 'none',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS incident_update (
id SERIAL PRIMARY KEY,
incident_id INTEGER NOT NULL REFERENCES incident(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS incident_service (
id SERIAL PRIMARY KEY,
incident_id INTEGER NOT NULL REFERENCES incident(id) ON DELETE CASCADE,
service_id INTEGER NOT NULL REFERENCES service(id) ON DELETE CASCADE,
UNIQUE(incident_id, service_id)
);
--
-- Scheduled maintenance
--
CREATE TABLE IF NOT EXISTS maintenance (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
scheduled_start TIMESTAMP NOT NULL,
scheduled_end TIMESTAMP NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS maintenance_service (
id SERIAL PRIMARY KEY,
maintenance_id INTEGER NOT NULL REFERENCES maintenance(id) ON DELETE CASCADE,
service_id INTEGER NOT NULL REFERENCES service(id) ON DELETE CASCADE,
UNIQUE(maintenance_id, service_id)
);
--
-- Subscribers
--
CREATE TABLE IF NOT EXISTS subscriber (
id SERIAL PRIMARY KEY,
email VARCHAR(255),
webhook_url TEXT,
type VARCHAR(20) NOT NULL DEFAULT 'email',
confirmed BOOLEAN NOT NULL DEFAULT false,
confirm_token VARCHAR(255),
+ unsubscribe_token VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT subscriber_contact CHECK (email IS NOT NULL OR webhook_url IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriber_email ON subscriber(email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriber_webhook ON subscriber(webhook_url) WHERE webhook_url IS NOT NULL;
--
-- Metrics
--
CREATE TABLE IF NOT EXISTS metric (
id SERIAL PRIMARY KEY,
service_id INTEGER NOT NULL REFERENCES service(id) ON DELETE CASCADE,
uptime DECIMAL(5,2) NOT NULL DEFAULT 100.00,
response_time INTEGER,
error_rate DECIMAL(5,2) DEFAULT 0.00,
recorded_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_metric_service_date ON metric(service_id, recorded_at DESC);
diff --git a/docker-compose.yml b/docker-compose.yml
index 4e6d324..d0df5c7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,47 +1,56 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-servpulse}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
POSTGRES_DB: ${POSTGRES_DB:-servpulse}
volumes:
- pgdata:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${EXPRESS_PORT:-3000}:3000"
environment:
POSTGRES_CONNECTION_STRING: postgresql://${POSTGRES_USER:-servpulse}:${POSTGRES_PASSWORD:-changeme}@db:5432/${POSTGRES_DB:-servpulse}
EXPRESS_PORT: 3000
+ JWT_SECRET: ${JWT_SECRET:-servpulse-dev-secret}
+ APP_URL: ${APP_URL:-http://localhost:8080}
+ SMTP_HOST: ${SMTP_HOST:-localhost}
+ SMTP_PORT: ${SMTP_PORT:-587}
+ SMTP_SECURE: ${SMTP_SECURE:-false}
+ SMTP_USER: ${SMTP_USER:-}
+ SMTP_PASS: ${SMTP_PASS:-}
+ SMTP_FROM: ${SMTP_FROM:-ServPulse <noreply@servpulse.local>}
+ HEALTH_CHECK_INTERVAL: ${HEALTH_CHECK_INTERVAL:-60000}
depends_on:
- db
volumes:
- ./backend:/app
- /app/node_modules
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8080:8080"
environment:
VITE_API_URL: http://localhost:${EXPRESS_PORT:-3000}/api
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
volumes:
pgdata:
diff --git a/docs/architecture.md b/docs/architecture.md
index 9aaf0d0..f48d402 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,269 +1,275 @@
# ServPulse Architecture
## System Overview
```mermaid
flowchart LR
U["👤 User"] -->|":8080"| FE["Frontend\nVue.js 3 + Tailwind"]
A["🔧 Admin"] -->|":8080/admin"| FE
M["📡 Monitoring\nPrometheus, etc."] -->|"POST /api/webhooks/ingest"| BE
FE -->|"HTTP /api/*"| BE["Backend\nExpress.js + JWT"]
BE -->|"SQL"| DB[("PostgreSQL 16")]
BE -->|"SMTP"| EMAIL["📧 Email\n(Nodemailer)"]
BE -->|"HTTP POST"| WH["🔗 Webhooks\n(Subscribers)"]
```
## Frontend Component Architecture
```mermaid
flowchart TB
subgraph Views["Views (Pages)"]
SP["StatusPage.vue\n(Public status page)"]
AL["AdminLogin.vue\n(Token-paste login)"]
AD["AdminDashboard.vue\n(CRUD: Services, Incidents, Maintenance)"]
end
subgraph Components["Reusable Components"]
AN["AppNavbar.vue"]
AF["AppFooter.vue"]
OS["OverallStatus.vue"]
SG["ServiceGroup.vue"]
SB["StatusBadge.vue"]
IT["IncidentTimeline.vue"]
MC["MaintenanceCard.vue"]
UC["UptimeChart.vue"]
SF["SubscribeForm.vue"]
end
subgraph Logic["Composables & Utils"]
US["useServices.js"]
UI["useIncidents.js"]
UM["useMaintenances.js"]
UMT["useMetrics.js"]
UA["useAuth.js"]
ST["status.js (utils)"]
end
subgraph API["API Layer"]
AC["api.js\n(axios client + all endpoints)"]
end
SP --> OS
SP --> SG
SP --> IT
SP --> MC
SP --> SF
SG --> SB
AD --> SB
SP --> US
SP --> UI
SP --> UM
AD --> US
AD --> UI
AD --> UM
AL --> UA
AN --> UA
UC --> UMT
US --> AC
UI --> AC
UM --> AC
UMT --> AC
AN --> AC
SF --> AC
SB --> ST
OS --> ST
IT --> ST
MC --> ST
AD --> ST
```
## Backend Architecture
```mermaid
flowchart TB
subgraph Routes["Routes (Express Router)"]
+ AR["authRoutes"]
SR["serviceRoutes"]
IR["incidentRoutes"]
MR["maintenanceRoutes"]
CR["configRoutes"]
MTR["metricRoutes"]
SUB["subscriberRoutes"]
WH["webhookRoutes"]
end
subgraph Controllers["Controllers"]
SC["serviceController"]
IC["incidentController"]
MC["maintenanceController"]
CC["configController"]
MTC["metricController"]
SBC["subscriberController"]
WHC["webhookController"]
end
subgraph Models["Models (pg queries)"]
SM["serviceModel"]
IM["incidentModel"]
IUM["incidentUpdateModel"]
ISM["incidentServiceModel"]
MM["maintenanceModel"]
MSM["maintenanceServiceModel"]
MTM["metricModel"]
SBM["subscriberModel"]
CFM["configModel"]
end
subgraph Services["Services"]
+ HC["healthCheckService\n(URL monitoring)"]
NS["notificationService\n(email + webhook)"]
end
subgraph Middleware["Middleware"]
AUTH["auth.js\n(JWT verify)"]
end
SR --> SC --> SM
IR --> IC --> IM
IC --> IUM
IC --> ISM
IC --> NS
MR --> MC --> MM
MC --> MSM
CR --> CC --> CFM
MTR --> MTC --> MTM
SUB --> SBC --> SBM
WH --> WHC --> SM
WHC --> IM
WHC --> ISM
WHC --> IUM
NS --> SBM
+ HC --> SM
+ HC --> MTM
+ AR --> AUTH
```
## Database Schema
```mermaid
erDiagram
service {
serial id PK
varchar name
varchar group
text description
+ text url
varchar status
integer order
timestamp created_at
timestamp updated_at
}
incident {
serial id PK
varchar title
timestamp start_date
timestamp update_date
timestamp end_date
integer type_id
varchar status
varchar impact
timestamp created_at
}
incident_update {
serial id PK
integer incident_id FK
varchar status
text message
timestamp created_at
}
incident_service {
serial id PK
integer incident_id FK
integer service_id FK
}
maintenance {
serial id PK
varchar title
text description
timestamp scheduled_start
timestamp scheduled_end
varchar status
timestamp created_at
}
maintenance_service {
serial id PK
integer maintenance_id FK
integer service_id FK
}
metric {
serial id PK
integer service_id FK
decimal uptime
integer response_time
decimal error_rate
timestamp recorded_at
}
subscriber {
serial id PK
varchar email
text webhook_url
varchar type
boolean confirmed
varchar confirm_token
timestamp created_at
}
incident ||--o{ incident_update : "has updates"
incident ||--o{ incident_service : "affects"
service ||--o{ incident_service : "affected by"
service ||--o{ metric : "measured by"
service ||--o{ maintenance_service : "maintained by"
maintenance ||--o{ maintenance_service : "affects"
```
## Status Flow
```mermaid
stateDiagram-v2
[*] --> Operational
Operational --> Degraded: Performance issues
Operational --> Partial: Partial outage
Operational --> Major: Full outage
Operational --> Maintenance: Scheduled work
Degraded --> Operational: Resolved
Degraded --> Partial: Worsened
Degraded --> Major: Worsened
Partial --> Operational: Resolved
Partial --> Major: Worsened
Major --> Operational: Resolved
Maintenance --> Operational: Completed
```
## Incident Lifecycle
```mermaid
stateDiagram-v2
[*] --> Investigating: Incident created
Investigating --> Identified: Root cause found
Investigating --> Monitoring: Seems resolved
Investigating --> Resolved: Quick fix
Identified --> Monitoring: Fix applied
Identified --> Resolved: Fix confirmed
Monitoring --> Resolved: Stable
Monitoring --> Investigating: Regression
Resolved --> [*]
```
diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue
index d883105..94d6338 100644
--- a/frontend/src/components/AppFooter.vue
+++ b/frontend/src/components/AppFooter.vue
@@ -1,5 +1,49 @@
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { configApi } from '@/plugins/api'
+
+const config = ref(null)
+
+const defaults = {
+ line1: {
+ text: 'Powered by %link1% — made with ❤️ by %link2%',
+ link1_label: 'ServPulse',
+ link1_url: 'https://devcentral.nasqueron.org/source/servpulse/',
+ link2_label: 'Nasqueron',
+ link2_url: 'https://nasqueron.org',
+ },
+ line2: {
+ text: 'Find this useful? %link%',
+ link_label: 'Contribute to Nasqueron',
+ link_url: 'https://devcentral.nasqueron.org/source/servpulse/',
+ },
+}
+
+const footer = computed(() => config.value?.footer || defaults)
+
+onMounted(async () => {
+ try {
+ config.value = await configApi.getAll()
+ } catch {
+ config.value = { footer: defaults }
+ }
+})
+</script>
+
<template>
- <footer class="mt-12 pb-8 text-center text-xs text-gray-400 dark:text-gray-500">
- <p>Powered by <a href="https://devcentral.nasqueron.org/source/servpulse" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">ServPulse</a></p>
+ <footer class="mt-12 pb-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
+ <p v-if="footer.line1">
+ <template v-for="(part, i) in footer.line1.text.split(/(%link1%|%link2%)/)" :key="i">
+ <a v-if="part === '%link1%' && footer.line1.link1_url" :href="footer.line1.link1_url" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">{{ footer.line1.link1_label }}</a>
+ <a v-else-if="part === '%link2%' && footer.line1.link2_url" :href="footer.line1.link2_url" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">{{ footer.line1.link2_label }}</a>
+ <template v-else>{{ part }}</template>
+ </template>
+ </p>
+ <p v-if="footer.line2">
+ <template v-for="(part, i) in footer.line2.text.split(/(%link%)/)" :key="i">
+ <a v-if="part === '%link%' && footer.line2.link_url" :href="footer.line2.link_url" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">{{ footer.line2.link_label }}</a>
+ <template v-else>{{ part }}</template>
+ </template>
+ </p>
</footer>
</template>
diff --git a/frontend/src/components/AppNavbar.vue b/frontend/src/components/AppNavbar.vue
index d5aebe6..f352efa 100644
--- a/frontend/src/components/AppNavbar.vue
+++ b/frontend/src/components/AppNavbar.vue
@@ -1,75 +1,70 @@
<script setup>
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { configApi } from '@/plugins/api'
import { useAuth } from '@/composables/useAuth'
const config = ref(null)
const { isAuthenticated, logout } = useAuth()
onMounted(async () => {
try {
config.value = await configApi.getAll()
} catch {
- config.value = { navbar: { title: 'ServPulse', button_left: null, button_right: null } }
+ config.value = { navbar: { title: 'ServPulse', buttons_left: [] } }
}
})
const handleLogout = () => {
logout()
window.location.href = '/'
}
</script>
<template>
<nav class="bg-gray-900 text-white shadow-lg">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-14">
<div class="flex items-center gap-4">
<a
- v-if="config?.navbar?.button_left"
- :href="config.navbar.button_left.link"
+ v-for="btn in config?.navbar?.buttons_left"
+ :key="btn.name"
+ :href="btn.link"
class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
- {{ config.navbar.button_left.name }}
+ {{ btn.name }}
</a>
</div>
<RouterLink to="/" class="text-sm font-bold tracking-widest uppercase hover:text-gray-300 transition-colors">
{{ config?.navbar?.title || 'ServPulse' }}
</RouterLink>
<div class="flex items-center gap-4">
<RouterLink
v-if="isAuthenticated"
to="/admin"
class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Dashboard
</RouterLink>
<button
v-if="isAuthenticated"
@click="handleLogout"
class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Logout
</button>
<RouterLink
v-else
to="/admin/login"
class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Admin
</RouterLink>
- <a
- v-if="config?.navbar?.button_right"
- :href="config.navbar.button_right.link"
- class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
- >
- {{ config.navbar.button_right.name }}
- </a>
+
</div>
</div>
</div>
</nav>
</template>
diff --git a/frontend/src/components/SubscribeForm.vue b/frontend/src/components/SubscribeForm.vue
index 616386f..7212687 100644
--- a/frontend/src/components/SubscribeForm.vue
+++ b/frontend/src/components/SubscribeForm.vue
@@ -1,71 +1,119 @@
<script setup>
import { ref } from 'vue'
import { subscribersApi } from '@/plugins/api'
const type = ref('email')
const email = ref('')
const webhookUrl = ref('')
const submitted = ref(false)
const error = ref('')
+const showUnsubscribe = ref(false)
+const unsubEmail = ref('')
+const unsubSubmitted = ref(false)
+const unsubError = ref('')
+
const handleSubmit = async () => {
error.value = ''
try {
const data = type.value === 'email'
? { type: 'email', email: email.value }
: { type: 'webhook', webhook_url: webhookUrl.value }
await subscribersApi.subscribe(data)
submitted.value = true
} catch (err) {
if (err.response?.status === 409) {
error.value = 'Already subscribed'
} else {
error.value = 'Failed to subscribe. Please try again.'
}
}
}
+
+const handleUnsubscribeRequest = async () => {
+ unsubError.value = ''
+ try {
+ await subscribersApi.requestUnsubscribe(unsubEmail.value)
+ unsubSubmitted.value = true
+ } catch {
+ unsubError.value = 'Something went wrong. Please try again.'
+ }
+}
</script>
<template>
<div class="card p-5">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Subscribe to Updates</h3>
<div v-if="submitted" class="text-sm text-green-600 dark:text-green-400">
- ✓ Subscribed! Check your email for confirmation.
+ <p v-if="type === 'email'">✓ Check your email to confirm your subscription.</p>
+ <p v-else>✓ Webhook registered successfully.</p>
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-3">
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input type="radio" v-model="type" value="email" class="text-brand-500" />
Email
</label>
<label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input type="radio" v-model="type" value="webhook" class="text-brand-500" />
Webhook
</label>
</div>
<input
v-if="type === 'email'"
v-model="email"
type="email"
placeholder="you@example.com"
class="input-field"
required
/>
<input
v-else
v-model="webhookUrl"
type="url"
placeholder="https://your-webhook-url.com/hook"
class="input-field"
required
/>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
<button type="submit" class="btn-primary">Subscribe</button>
</form>
</div>
+
+ <div class="mt-3 text-center">
+ <button
+ type="button"
+ class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 underline"
+ @click="showUnsubscribe = !showUnsubscribe"
+ >
+ Want to unsubscribe?
+ </button>
+
+ <div v-if="showUnsubscribe" class="card p-5 mt-3">
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Unsubscribe</h3>
+
+ <div v-if="unsubSubmitted" class="text-sm text-green-600 dark:text-green-400">
+ If that email is subscribed, we've sent you an unsubscribe link.
+ </div>
+
+ <form v-else @submit.prevent="handleUnsubscribeRequest" class="space-y-3">
+ <input
+ v-model="unsubEmail"
+ type="email"
+ placeholder="you@example.com"
+ class="input-field"
+ required
+ />
+
+ <p v-if="unsubError" class="text-sm text-red-500">{{ unsubError }}</p>
+
+ <button type="submit" class="btn-primary">Unsubscribe</button>
+ </form>
+ </div>
+ </div>
</template>
diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js
index be664ae..5431562 100644
--- a/frontend/src/plugins/api.js
+++ b/frontend/src/plugins/api.js
@@ -1,61 +1,70 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
headers: { 'Content-Type': 'application/json' },
})
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('servpulse_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
+export const authApi = {
+ verify: (token) => apiClient.post('/auth/verify', {}, {
+ headers: { Authorization: `Bearer ${token}` }
+ }).then((r) => r.data),
+}
+
export const configApi = {
getAll: () => apiClient.get('/config/getAll').then((r) => r.data),
+ update: (data) => apiClient.put('/config', data).then((r) => r.data),
}
export const servicesApi = {
getAll: () => apiClient.get('/services').then((r) => r.data),
getById: (id) => apiClient.get(`/services/${id}`).then((r) => r.data),
create: (data) => apiClient.post('/services', data).then((r) => r.data),
update: (id, data) => apiClient.put(`/services/${id}`, data).then((r) => r.data),
delete: (id) => apiClient.delete(`/services/${id}`).then((r) => r.data),
}
export const incidentsApi = {
getAll: () => apiClient.get('/incidents').then((r) => r.data),
getById: (id) => apiClient.get(`/incidents/${id}`).then((r) => r.data),
create: (data) => apiClient.post('/incidents', data).then((r) => r.data),
update: (id, data) => apiClient.put(`/incidents/${id}`, data).then((r) => r.data),
resolve: (id, message) =>
apiClient.put(`/incidents/${id}/resolve`, { message }).then((r) => r.data),
}
export const maintenancesApi = {
getAll: () => apiClient.get('/maintenances').then((r) => r.data),
getById: (id) => apiClient.get(`/maintenances/${id}`).then((r) => r.data),
create: (data) => apiClient.post('/maintenances', data).then((r) => r.data),
update: (id, data) => apiClient.put(`/maintenances/${id}`, data).then((r) => r.data),
delete: (id) => apiClient.delete(`/maintenances/${id}`).then((r) => r.data),
}
export const metricsApi = {
getLatest: () => apiClient.get('/metrics').then((r) => r.data),
getByService: (serviceId, days = 30) =>
apiClient.get(`/metrics/service/${serviceId}?days=${days}`).then((r) => r.data),
getDailySummary: (serviceId, days = 30) =>
apiClient.get(`/metrics/service/${serviceId}/daily?days=${days}`).then((r) => r.data),
record: (data) => apiClient.post('/metrics', data).then((r) => r.data),
}
export const subscribersApi = {
subscribe: (data) => apiClient.post('/subscribers', data).then((r) => r.data),
+ requestUnsubscribe: (email) => apiClient.post('/subscribers/unsubscribe-request', { email }).then((r) => r.data),
confirm: (token) => apiClient.get(`/subscribers/confirm/${token}`).then((r) => r.data),
+ unsubscribe: (token) => apiClient.get(`/subscribers/unsubscribe/${token}`).then((r) => r.data),
getAll: () => apiClient.get('/subscribers').then((r) => r.data),
delete: (id) => apiClient.delete(`/subscribers/${id}`).then((r) => r.data),
}
export default apiClient
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index 9785a30..a2f85f3 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -1,35 +1,52 @@
import { createRouter, createWebHistory } from 'vue-router'
import StatusPage from '@/views/StatusPage.vue'
+import { authApi } from '@/plugins/api'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'status',
component: StatusPage,
},
+ {
+ path: '/confirm/:token',
+ name: 'confirm-subscription',
+ component: () => import('@/views/ConfirmSubscription.vue'),
+ },
+ {
+ path: '/unsubscribe/:token',
+ name: 'unsubscribe',
+ component: () => import('@/views/Unsubscribe.vue'),
+ },
{
path: '/admin/login',
name: 'admin-login',
component: () => import('@/views/AdminLogin.vue'),
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminDashboard.vue'),
meta: { requiresAuth: true },
},
],
})
-router.beforeEach((to) => {
+router.beforeEach(async (to) => {
if (to.meta.requiresAuth) {
const token = localStorage.getItem('servpulse_token')
if (!token) {
return { name: 'admin-login' }
}
+ try {
+ await authApi.verify(token)
+ } catch {
+ localStorage.removeItem('servpulse_token')
+ return { name: 'admin-login' }
+ }
}
})
export default router
diff --git a/frontend/src/views/AdminDashboard.vue b/frontend/src/views/AdminDashboard.vue
index a7d4ff1..e55a44a 100644
--- a/frontend/src/views/AdminDashboard.vue
+++ b/frontend/src/views/AdminDashboard.vue
@@ -1,383 +1,545 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useServices } from '@/composables/useServices'
import { useIncidents } from '@/composables/useIncidents'
import { useMaintenances } from '@/composables/useMaintenances'
-import { servicesApi, incidentsApi, maintenancesApi } from '@/plugins/api'
+import { servicesApi, incidentsApi, maintenancesApi, configApi } from '@/plugins/api'
import StatusBadge from '@/components/StatusBadge.vue'
import { formatDate } from '@/utils/status'
const activeTab = ref('services')
const tabs = [
{ key: 'services', label: 'Services' },
{ key: 'incidents', label: 'Incidents' },
{ key: 'maintenance', label: 'Maintenance' },
+ { key: 'settings', label: 'Settings' },
]
// Services
const { services, fetchServices } = useServices()
const showServiceForm = ref(false)
const editingService = ref(null)
-const serviceForm = ref({ name: '', group: '', description: '', status: 'operational', order: 0 })
+const serviceForm = ref({ name: '', group: '', description: '', url: '', auto_status: true, status: 'operational', order: 0 })
const openServiceForm = (service = null) => {
if (service) {
editingService.value = service.id
serviceForm.value = { ...service }
} else {
editingService.value = null
- serviceForm.value = { name: '', group: '', description: '', status: 'operational', order: 0 }
+ serviceForm.value = { name: '', group: '', description: '', url: '', auto_status: true, status: 'operational', order: 0 }
}
showServiceForm.value = true
}
const saveService = async () => {
try {
if (editingService.value) {
await servicesApi.update(editingService.value, serviceForm.value)
} else {
await servicesApi.create(serviceForm.value)
}
showServiceForm.value = false
await fetchServices()
} catch (err) {
alert('Error saving service: ' + err.message)
}
}
const deleteService = async (id) => {
if (!confirm('Delete this service?')) return
try {
await servicesApi.delete(id)
await fetchServices()
} catch (err) {
alert('Error deleting service: ' + err.message)
}
}
// Incidents
const { incidents, fetchIncidents } = useIncidents()
const showIncidentForm = ref(false)
const editingIncident = ref(null)
const incidentForm = ref({ title: '', status: 'investigating', impact: 'none', message: '' })
const openIncidentForm = (incident = null) => {
if (incident) {
editingIncident.value = incident.id
incidentForm.value = { title: incident.title, status: incident.status, impact: incident.impact, message: '' }
} else {
editingIncident.value = null
incidentForm.value = { title: '', status: 'investigating', impact: 'none', message: '' }
}
showIncidentForm.value = true
}
const saveIncident = async () => {
try {
if (editingIncident.value) {
await incidentsApi.update(editingIncident.value, incidentForm.value)
} else {
await incidentsApi.create(incidentForm.value)
}
showIncidentForm.value = false
await fetchIncidents()
} catch (err) {
alert('Error saving incident: ' + err.message)
}
}
const resolveIncident = async (id) => {
const message = prompt('Resolution message:')
if (message === null) return
try {
await incidentsApi.resolve(id, message || 'Incident resolved')
await fetchIncidents()
} catch (err) {
alert('Error resolving incident: ' + err.message)
}
}
// Maintenance
const { maintenances, fetchMaintenances } = useMaintenances()
const showMaintenanceForm = ref(false)
const editingMaintenance = ref(null)
const maintenanceForm = ref({ title: '', description: '', scheduled_start: '', scheduled_end: '', status: 'scheduled' })
const openMaintenanceForm = (m = null) => {
if (m) {
editingMaintenance.value = m.id
maintenanceForm.value = {
title: m.title,
description: m.description || '',
scheduled_start: m.scheduled_start ? m.scheduled_start.slice(0, 16) : '',
scheduled_end: m.scheduled_end ? m.scheduled_end.slice(0, 16) : '',
status: m.status,
}
} else {
editingMaintenance.value = null
maintenanceForm.value = { title: '', description: '', scheduled_start: '', scheduled_end: '', status: 'scheduled' }
}
showMaintenanceForm.value = true
}
const saveMaintenance = async () => {
try {
if (editingMaintenance.value) {
await maintenancesApi.update(editingMaintenance.value, maintenanceForm.value)
} else {
await maintenancesApi.create(maintenanceForm.value)
}
showMaintenanceForm.value = false
await fetchMaintenances()
} catch (err) {
alert('Error saving maintenance: ' + err.message)
}
}
const deleteMaintenance = async (id) => {
if (!confirm('Delete this maintenance?')) return
try {
await maintenancesApi.delete(id)
await fetchMaintenances()
} catch (err) {
alert('Error deleting maintenance: ' + err.message)
}
}
+// Settings
+const settingsForm = ref({
+ navbar: { title: 'ServPulse', buttons_left: [] },
+ footer: {
+ line1: { text: '', link1_label: '', link1_url: '', link2_label: '', link2_url: '' },
+ line2: { text: '', link_label: '', link_url: '' },
+ },
+})
+const MAX_FOOTER_LENGTH = 100
+const settingsSaved = ref(false)
+
+const defaultFooter = {
+ line1: { text: 'Powered by %link1% — made with ❤️ by %link2%', link1_label: 'ServPulse', link1_url: 'https://devcentral.nasqueron.org/source/servpulse/', link2_label: 'Nasqueron', link2_url: 'https://nasqueron.org' },
+ line2: { text: 'Find this useful? %link%', link_label: 'Contribute to Nasqueron', link_url: 'https://devcentral.nasqueron.org/source/servpulse/' },
+}
+
+const fetchSettings = async () => {
+ try {
+ const data = await configApi.getAll()
+ if (!data.footer) data.footer = { ...defaultFooter }
+ if (!data.footer.line1) data.footer.line1 = { ...defaultFooter.line1 }
+ if (!data.footer.line2) data.footer.line2 = { ...defaultFooter.line2 }
+ settingsForm.value = data
+ } catch (err) {
+ alert('Error loading settings: ' + err.message)
+ }
+}
+
+const saveSettings = async () => {
+ const buttons = settingsForm.value.navbar.buttons_left || []
+ const hasEmpty = buttons.some((btn) => !btn.name.trim() || !btn.link.trim())
+ if (hasEmpty) {
+ alert('All navigation links must have a name and URL.')
+ return
+ }
+ try {
+ await configApi.update(settingsForm.value)
+ settingsSaved.value = true
+ setTimeout(() => { settingsSaved.value = false }, 3000)
+ } catch (err) {
+ alert('Error saving settings: ' + err.message)
+ }
+}
+
+const MAX_NAV_BUTTONS = 3
+
+const addNavButton = () => {
+ if (!settingsForm.value.navbar.buttons_left) {
+ settingsForm.value.navbar.buttons_left = []
+ }
+ if (settingsForm.value.navbar.buttons_left.length >= MAX_NAV_BUTTONS) return
+ settingsForm.value.navbar.buttons_left.push({ name: '', link: '' })
+}
+
+const removeNavButton = (index) => {
+ settingsForm.value.navbar.buttons_left.splice(index, 1)
+}
+
onMounted(() => {
fetchServices()
fetchIncidents()
fetchMaintenances()
+ fetchSettings()
})
const statusOptions = ['operational', 'degraded', 'partial', 'major', 'maintenance']
const incidentStatusOptions = ['investigating', 'identified', 'monitoring', 'resolved']
const impactOptions = ['none', 'minor', 'major', 'critical']
const maintenanceStatusOptions = ['scheduled', 'in_progress', 'completed']
</script>
<template>
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Admin Dashboard</h1>
<!-- Tabs -->
<div class="flex gap-1 mb-6 border-b border-gray-200 dark:border-gray-700">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px"
:class="activeTab === tab.key
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
>
{{ tab.label }}
</button>
</div>
<!-- Services Tab -->
<div v-show="activeTab === 'services'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Services</h2>
<button @click="openServiceForm()" class="btn-primary">+ Add Service</button>
</div>
<!-- Service Form -->
<div v-if="showServiceForm" class="card p-5 mb-4">
<h3 class="text-sm font-semibold mb-3">{{ editingService ? 'Edit' : 'New' }} Service</h3>
<form @submit.prevent="saveService" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input v-model="serviceForm.name" class="input-field" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group</label>
<input v-model="serviceForm.group" class="input-field" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input v-model="serviceForm.description" class="input-field" />
</div>
+ <div class="sm:col-span-2">
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">URL</label>
+ <input v-model="serviceForm.url" type="url" class="input-field" placeholder="https://example.com" />
+ </div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Status</label>
- <select v-model="serviceForm.status" class="input-field">
+ <select v-model="serviceForm.status" class="input-field" :disabled="serviceForm.url && serviceForm.auto_status">
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
</select>
</div>
+ <div class="flex items-end h-full">
+ <label class="flex items-center gap-2 h-[38px]">
+ <input v-model="serviceForm.auto_status" type="checkbox" class="rounded border-gray-300 dark:border-gray-600" />
+ <span class="text-xs font-medium text-gray-600 dark:text-gray-400">Auto-manage status via health checks</span>
+ </label>
+ </div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Order</label>
<input v-model.number="serviceForm.order" type="number" class="input-field" />
</div>
<div class="sm:col-span-2 flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" @click="showServiceForm = false" class="btn-secondary">Cancel</button>
</div>
</form>
</div>
<!-- Services List -->
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Name</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Group</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Status</th>
<th class="text-right px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<tr v-for="service in services" :key="service.id" class="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td class="px-4 py-2.5 text-gray-900 dark:text-gray-100">{{ service.name }}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400">{{ service.group || '—' }}</td>
<td class="px-4 py-2.5"><StatusBadge :status="service.status" /></td>
<td class="px-4 py-2.5 text-right space-x-2">
<button @click="openServiceForm(service)" class="text-brand-600 hover:text-brand-700 text-xs font-medium">Edit</button>
<button @click="deleteService(service.id)" class="text-red-600 hover:text-red-700 text-xs font-medium">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Incidents Tab -->
<div v-show="activeTab === 'incidents'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Incidents</h2>
<button @click="openIncidentForm()" class="btn-primary">+ New Incident</button>
</div>
<!-- Incident Form -->
<div v-if="showIncidentForm" class="card p-5 mb-4">
<h3 class="text-sm font-semibold mb-3">{{ editingIncident ? 'Update' : 'New' }} Incident</h3>
<form @submit.prevent="saveIncident" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Title</label>
<input v-model="incidentForm.title" class="input-field" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Status</label>
<select v-model="incidentForm.status" class="input-field">
<option v-for="s in incidentStatusOptions" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Impact</label>
<select v-model="incidentForm.impact" class="input-field">
<option v-for="i in impactOptions" :key="i" :value="i">{{ i }}</option>
</select>
</div>
<div v-if="editingIncident" class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Update Message</label>
<textarea v-model="incidentForm.message" class="input-field" rows="2" placeholder="Describe the update..."></textarea>
</div>
<div class="sm:col-span-2 flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" @click="showIncidentForm = false" class="btn-secondary">Cancel</button>
</div>
</form>
</div>
<!-- Incidents List -->
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Title</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Status</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Impact</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Started</th>
<th class="text-right px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<tr v-for="incident in incidents" :key="incident.id" class="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td class="px-4 py-2.5 text-gray-900 dark:text-gray-100">{{ incident.title }}</td>
<td class="px-4 py-2.5">
<span class="text-xs font-medium" :class="{
'text-red-500': incident.status === 'investigating',
'text-orange-500': incident.status === 'identified',
'text-yellow-500': incident.status === 'monitoring',
'text-green-500': incident.status === 'resolved',
}">{{ incident.status }}</span>
</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400">{{ incident.impact }}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs">{{ formatDate(incident.start_date) }}</td>
<td class="px-4 py-2.5 text-right space-x-2">
<button @click="openIncidentForm(incident)" class="text-brand-600 hover:text-brand-700 text-xs font-medium">Edit</button>
<button v-if="incident.status !== 'resolved'" @click="resolveIncident(incident.id)" class="text-green-600 hover:text-green-700 text-xs font-medium">Resolve</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Maintenance Tab -->
<div v-show="activeTab === 'maintenance'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Scheduled Maintenance</h2>
<button @click="openMaintenanceForm()" class="btn-primary">+ Schedule</button>
</div>
<!-- Maintenance Form -->
<div v-if="showMaintenanceForm" class="card p-5 mb-4">
<h3 class="text-sm font-semibold mb-3">{{ editingMaintenance ? 'Edit' : 'New' }} Maintenance</h3>
<form @submit.prevent="saveMaintenance" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Title</label>
<input v-model="maintenanceForm.title" class="input-field" required />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<textarea v-model="maintenanceForm.description" class="input-field" rows="2"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
<input v-model="maintenanceForm.scheduled_start" type="datetime-local" class="input-field" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
<input v-model="maintenanceForm.scheduled_end" type="datetime-local" class="input-field" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Status</label>
<select v-model="maintenanceForm.status" class="input-field">
<option v-for="s in maintenanceStatusOptions" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<div class="sm:col-span-2 flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" @click="showMaintenanceForm = false" class="btn-secondary">Cancel</button>
</div>
</form>
</div>
<!-- Maintenance List -->
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Title</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Status</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Scheduled</th>
<th class="text-right px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<tr v-for="m in maintenances" :key="m.id" class="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td class="px-4 py-2.5 text-gray-900 dark:text-gray-100">{{ m.title }}</td>
<td class="px-4 py-2.5 text-xs font-medium text-gray-500">{{ m.status }}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs">{{ formatDate(m.scheduled_start) }}</td>
<td class="px-4 py-2.5 text-right space-x-2">
<button @click="openMaintenanceForm(m)" class="text-brand-600 hover:text-brand-700 text-xs font-medium">Edit</button>
<button @click="deleteMaintenance(m.id)" class="text-red-600 hover:text-red-700 text-xs font-medium">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
+
+ <!-- Settings Tab -->
+ <div v-show="activeTab === 'settings'">
+ <div class="flex justify-between items-center mb-4">
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
+ </div>
+
+ <form @submit.prevent="saveSettings" class="space-y-6">
+ <!-- Site Title -->
+ <div class="card p-5">
+ <h3 class="text-sm font-semibold mb-3">Site Title</h3>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Navbar Title</label>
+ <input v-model="settingsForm.navbar.title" class="input-field max-w-sm" placeholder="ServPulse" />
+ </div>
+ </div>
+
+ <!-- Navigation Buttons -->
+ <div class="card p-5">
+ <div class="flex justify-between items-center mb-3">
+ <h3 class="text-sm font-semibold">Navigation Links</h3>
+ <button v-if="(settingsForm.navbar.buttons_left?.length || 0) < MAX_NAV_BUTTONS" type="button" @click="addNavButton" class="btn-secondary text-xs">+ Add Link</button>
+ </div>
+ <div v-if="settingsForm.navbar.buttons_left?.length" class="space-y-3">
+ <div v-for="(btn, index) in settingsForm.navbar.buttons_left" :key="index" class="flex gap-3 items-end">
+ <div class="flex-1">
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
+ <input v-model="btn.name" class="input-field" placeholder="DevCentral" required />
+ </div>
+ <div class="flex-1">
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">URL</label>
+ <input v-model="btn.link" type="url" class="input-field" placeholder="https://example.com" required />
+ </div>
+ <button type="button" @click="removeNavButton(index)" class="text-red-600 hover:text-red-700 text-xs font-medium h-[38px] shrink-0">Remove</button>
+ </div>
+ </div>
+ <p v-else class="text-sm text-gray-400">No navigation links configured.</p>
+ </div>
+
+ <!-- Footer -->
+ <div class="card p-5">
+ <h3 class="text-sm font-semibold mb-3">Footer</h3>
+ <div class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Line 1 — Text <span class="text-gray-400">(use %link1% and %link2% for links)</span></label>
+ <input v-model="settingsForm.footer.line1.text" class="input-field" :maxlength="MAX_FOOTER_LENGTH" placeholder="Powered by %link1% — made with ❤️ by %link2%" />
+ <p class="text-xs text-gray-400 mt-1">{{ settingsForm.footer.line1.text.length }}/{{ MAX_FOOTER_LENGTH }}</p>
+ </div>
+ <div class="grid grid-cols-2 gap-3">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 1 Label</label>
+ <input v-model="settingsForm.footer.line1.link1_label" class="input-field" maxlength="30" placeholder="ServPulse" />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 1 URL</label>
+ <input v-model="settingsForm.footer.line1.link1_url" type="url" class="input-field" placeholder="https://..." />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 2 Label</label>
+ <input v-model="settingsForm.footer.line1.link2_label" class="input-field" maxlength="30" placeholder="Nasqueron" />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 2 URL</label>
+ <input v-model="settingsForm.footer.line1.link2_url" type="url" class="input-field" placeholder="https://..." />
+ </div>
+ </div>
+ <hr class="border-gray-200 dark:border-gray-700" />
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Line 2 — Text <span class="text-gray-400">(use %link% for a link)</span></label>
+ <input v-model="settingsForm.footer.line2.text" class="input-field" :maxlength="MAX_FOOTER_LENGTH" placeholder="Find this useful? %link%" />
+ <p class="text-xs text-gray-400 mt-1">{{ settingsForm.footer.line2.text.length }}/{{ MAX_FOOTER_LENGTH }}</p>
+ </div>
+ <div class="grid grid-cols-2 gap-3">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link Label</label>
+ <input v-model="settingsForm.footer.line2.link_label" class="input-field" maxlength="30" placeholder="Contribute to Nasqueron" />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link URL</label>
+ <input v-model="settingsForm.footer.line2.link_url" type="url" class="input-field" placeholder="https://..." />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Save -->
+ <div class="flex items-center gap-3">
+ <button type="submit" class="btn-primary">Save Settings</button>
+ <span v-if="settingsSaved" class="text-sm text-green-500">Settings saved!</span>
+ </div>
+ </form>
+ </div>
</div>
</template>
diff --git a/frontend/src/views/AdminLogin.vue b/frontend/src/views/AdminLogin.vue
index be2040a..80ee4de 100644
--- a/frontend/src/views/AdminLogin.vue
+++ b/frontend/src/views/AdminLogin.vue
@@ -1,53 +1,59 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
+import { authApi } from '@/plugins/api'
const router = useRouter()
const { login } = useAuth()
const token = ref('')
const error = ref('')
-const handleLogin = () => {
+const handleLogin = async () => {
if (!token.value.trim()) {
error.value = 'Please enter a valid token'
return
}
- login(token.value.trim())
- router.push('/admin')
+ try {
+ await authApi.verify(token.value.trim())
+ login(token.value.trim())
+ router.push('/admin')
+ } catch {
+ error.value = 'Invalid or expired token'
+ }
}
</script>
<template>
<div class="min-h-[70vh] flex items-center justify-center px-4">
<div class="card p-8 w-full max-w-sm">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 text-center mb-6">Admin Login</h1>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Token
</label>
<input
id="token"
v-model="token"
type="password"
placeholder="Paste your JWT token"
class="input-field"
/>
</div>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
<button type="submit" class="btn-primary w-full justify-center">
Sign In
</button>
</form>
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
Generate a token using the admin CLI or environment configuration.
</p>
</div>
</div>
</template>
diff --git a/frontend/src/views/ConfirmSubscription.vue b/frontend/src/views/ConfirmSubscription.vue
new file mode 100644
index 0000000..d3f8f3e
--- /dev/null
+++ b/frontend/src/views/ConfirmSubscription.vue
@@ -0,0 +1,43 @@
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { subscribersApi } from '@/plugins/api'
+
+const route = useRoute()
+const status = ref('loading')
+const message = ref('')
+
+onMounted(async () => {
+ try {
+ const result = await subscribersApi.confirm(route.params.token)
+ status.value = 'success'
+ message.value = result.message
+ } catch {
+ status.value = 'error'
+ message.value = 'Invalid or expired confirmation link.'
+ }
+})
+</script>
+
+<template>
+ <div class="min-h-[70vh] flex items-center justify-center px-4">
+ <div class="card p-8 w-full max-w-sm text-center">
+ <div v-if="status === 'loading'" class="flex justify-center py-4">
+ <div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-500"></div>
+ </div>
+ <template v-else>
+ <div v-if="status === 'success'" class="space-y-3">
+ <p class="text-3xl">✓</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Subscription Confirmed</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ </div>
+ <div v-else class="space-y-3">
+ <p class="text-3xl">✗</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Confirmation Failed</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ </div>
+ <RouterLink to="/" class="inline-block mt-4 text-sm text-brand-600 hover:text-brand-700 underline">Back to status page</RouterLink>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/frontend/src/views/Unsubscribe.vue b/frontend/src/views/Unsubscribe.vue
new file mode 100644
index 0000000..80a69b0
--- /dev/null
+++ b/frontend/src/views/Unsubscribe.vue
@@ -0,0 +1,49 @@
+<script setup>
+import { ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { subscribersApi } from '@/plugins/api'
+
+const route = useRoute()
+const status = ref('confirm')
+const message = ref('')
+
+const handleUnsubscribe = async () => {
+ status.value = 'loading'
+ try {
+ const result = await subscribersApi.unsubscribe(route.params.token)
+ status.value = 'success'
+ message.value = result.message
+ } catch {
+ status.value = 'error'
+ message.value = 'Invalid or expired unsubscribe link.'
+ }
+}
+</script>
+
+<template>
+ <div class="min-h-[70vh] flex items-center justify-center px-4">
+ <div class="card p-8 w-full max-w-sm text-center">
+ <div v-if="status === 'loading'" class="flex justify-center py-4">
+ <div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-500"></div>
+ </div>
+ <div v-else-if="status === 'confirm'" class="space-y-4">
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Unsubscribe</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">Are you sure you want to unsubscribe from status updates?</p>
+ <button @click="handleUnsubscribe" class="btn-primary w-full justify-center">Yes, unsubscribe</button>
+ <RouterLink to="/" class="inline-block text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 underline">Cancel</RouterLink>
+ </div>
+ <div v-else-if="status === 'success'" class="space-y-3">
+ <p class="text-3xl">✓</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Unsubscribed</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ <RouterLink to="/" class="inline-block mt-4 text-sm text-brand-600 hover:text-brand-700 underline">Back to status page</RouterLink>
+ </div>
+ <div v-else class="space-y-3">
+ <p class="text-3xl">✗</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Error</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ <RouterLink to="/" class="inline-block mt-4 text-sm text-brand-600 hover:text-brand-700 underline">Back to status page</RouterLink>
+ </div>
+ </div>
+ </div>
+</template>

File Metadata

Mime Type
text/x-diff
Expires
Mon, Jun 8, 09:10 (19 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3792924
Default Alt Text
(92 KB)

Event Timeline