diff --git a/Dockerfile b/Dockerfile index f3e40890c..951c608e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,8 @@ FROM node:${DEBIAN_VERSION_TAG} AS builder-debian RUN apt-get update && apt-get install -y \ build-essential=12.9 \ python3=3.11.2-1+b1 \ - sqlite3=3.40.1-2+deb12u1 \ - libsqlite3-dev=3.40.1-2+deb12u1 \ + sqlite3 \ + libsqlite3-dev \ make=4.3-4.1 \ node-gyp=9.3.0-2 \ g++=4:12.2.0-3 \ @@ -77,7 +77,7 @@ FROM node:${DEBIAN_VERSION_TAG} AS final-debian # TODO: Consider adding `--no-install-recommends`, but will need testing (may further help reduce final build size) RUN apt-get update && apt-get install -y \ iputils-ping=3:20221126-1+deb12u1 \ - sqlite3=3.40.1-2+deb12u1 \ + sqlite3 \ tzdata \ # TODO: Is it ok to change to `curl` here so that we don't have to maintain `wget` version mismatch between Debian architectures? (`curl` is only used for the container healthcheck and because there is an Alpine variant (best!) we probably don't care if the Debian image ends up building bigger due to `curl`.) curl && \ diff --git a/docker-compose.yml b/docker-compose.yml index a19bfabb4..3ad6a9fc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,12 +8,12 @@ version: '3.8' services: kener: - image: rajnandan1/kener:latest # Change to 'rajnandan1/kener:alpine' for an even smaller image! 😁🚀 + build: . container_name: kener - # env_file: custom.env # Uncomment this if you are needing to export environment variables from a custom environment file. By default, Docker will import any variables that exist in `.env` - environment: - TZ: Etc/UTC - KENER_SECRET_KEY: replace_me_with_a_random_string # Keep private!! - best to define in `.env` file or through Docker Secret + env_file: .env # Uncomment this if you are needing to export environment variables from a custom environment file. By default, Docker will import any variables that exist in `.env` + #environment: + # TZ: America/Sao_Paulo + # KENER_SECRET_KEY: # Keep private!! - best to define in `.env` file or through Docker Secret # DATABASE_URL: custom_db_url # By default, a SQLite database is used - you may override the database url/type here # RESEND_API_KEY: # RESEND_SENDER_EMAIL: @@ -28,7 +28,7 @@ services: - '3000:3000/tcp' volumes: - data:/app/database # We suggest using a Docker named volume, which is more performant for databases - - $(pwd)/uploads:/app/uploads + - ./uploads:/app/uploads # read_only: true # Uncommenting this fortifies security by marking the container's filesystem as read-only (aka no data can be written to the container's filesystem except for explicitly defined writable volumes and bind mounts, an exception has already been defined for `/database` and `/uploads`) restart: unless-stopped # depends_on: # <-- Uncomment if you would like to use PostgreSQL or MySQL @@ -36,25 +36,25 @@ services: # - mysql # # Only use below section if you would like to utilize PostgreSQL instead of Kener's default SQLite database. (Don't forget to set `DATABASE_URL` in `kener` service to be: `DATABASE_URL=postgresql://db_user:db_password@localhost:5432/kener_db`) - postgres: - image: postgres:alpine - container_name: postgres - environment: - POSTGRES_USER: user - POSTGRES_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!! - POSTGRES_DB: kener_db - restart: unless-stopped + # postgres: + # image: postgres:alpine + # container_name: postgres + # environment: + # POSTGRES_USER: user + # POSTGRES_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!! + # POSTGRES_DB: kener_db + # restart: unless-stopped # Only use below section if you would like to utilize MySQL instead of Kener's default SQLite database. (Don't forget to set `DATABASE_URL` in `kener` service to be: `DATABASE_URL=mysql://db_user:db_password@localhost:3306/kener_db`) - mysql: - image: mariadb:11 - container_name: mysql - environment: - MYSQL_USER: user - MYSQL_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!! - MYSQL_DATABASE: kener_db - MYSQL_RANDOM_ROOT_PASSWORD: true - restart: unless-stopped + #mysql: + # image: mariadb:11 + # container_name: mysql + # environment: + # MYSQL_USER: user + # MYSQL_PASSWORD: some_super_random_secure_password # Best to define this in `.env` or via Docker Secret!! + # MYSQL_DATABASE: kener_db + # MYSQL_RANDOM_ROOT_PASSWORD: true + # restart: unless-stopped volumes: data: diff --git a/openapi.json b/openapi.json index c880024fb..942ff2359 100644 --- a/openapi.json +++ b/openapi.json @@ -32,6 +32,10 @@ { "name": "Incidents", "description": "APIs to integrate incidents" + }, + { + "name": "Reports", + "description": "APIs to generate and retrieve reports" } ], "components": { @@ -292,6 +296,150 @@ "start_date_time": 1731901920, "title": "title of the incident" } + }, + "DowntimeReport": { + "type": "object", + "description": "Downtime report for a monitor", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "example": "earth" + }, + "name": { + "type": "string", + "example": "Earth API" + }, + "monitor_type": { + "type": "string", + "example": "HTTP" + } + } + }, + "period": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "Start timestamp in seconds", + "example": 1712448000 + }, + "end": { + "type": "integer", + "description": "End timestamp in seconds", + "example": 1712534400 + }, + "startFormatted": { + "type": "string", + "example": "2024-04-07 00:00:00" + }, + "endFormatted": { + "type": "string", + "example": "2024-04-08 00:00:00" + }, + "durationHours": { + "type": "number", + "example": 24 + } + } + }, + "summary": { + "type": "object", + "properties": { + "totalDowntimeMinutes": { + "type": "number", + "example": 75.5 + }, + "totalDowntimeHours": { + "type": "number", + "example": 1.26 + }, + "uptimePercentage": { + "type": "number", + "example": 99.4792 + }, + "downtimePercentage": { + "type": "number", + "example": 0.5208 + }, + "totalEvents": { + "type": "integer", + "example": 2 + }, + "alertsGenerated": { + "type": "integer", + "example": 1 + } + } + }, + "downtimes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startDateTime": { + "type": "string", + "example": "2024-04-07 14:30:00" + }, + "endDateTime": { + "type": "string", + "example": "2024-04-07 15:45:00" + }, + "startTimestamp": { + "type": "integer", + "example": 1712500200 + }, + "endTimestamp": { + "type": "integer", + "example": 1712504700 + }, + "durationMinutes": { + "type": "number", + "example": 75 + }, + "durationHours": { + "type": "number", + "example": 1.25 + }, + "status": { + "type": "string", + "enum": ["DOWN", "DEGRADED"], + "example": "DOWN" + }, + "alertGenerated": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "meta": { + "type": "object", + "properties": { + "requestedAt": { + "type": "integer", + "description": "Timestamp when report was requested", + "example": 1712600000 + }, + "processingTimeMs": { + "type": "integer", + "description": "Time taken to generate report in milliseconds", + "example": 145 + } + } + } + } } }, "responses": { @@ -1995,6 +2143,164 @@ } } ] + }, + "/api/reports/downtime": { + "get": { + "tags": ["Reports"], + "summary": "Get downtime report for a monitor", + "description": "Returns detailed downtime information for a specific monitor within a date range. Requires API key authentication.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "tag", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Monitor tag identifier", + "example": "earth" + }, + { + "name": "start", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Start timestamp in Unix seconds (UTC)", + "example": 1712448000 + }, + { + "name": "end", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "End timestamp in Unix seconds (UTC)", + "example": 1712534400 + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["json", "summary"] + }, + "description": "Response format - 'json' for full report or 'summary' for summary only", + "example": "json" + } + ], + "responses": { + "200": { + "description": "Successful response with downtime report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DowntimeReport" + } + } + } + }, + "400": { + "description": "Bad request - missing or invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": ["MISSING_PARAMETERS", "INVALID_PARAMETERS", "INVALID_DATE_RANGE"], + "example": "MISSING_PARAMETERS" + }, + "message": { + "type": "string", + "example": "Missing required parameters: tag, start, end" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Response401" + }, + "404": { + "description": "Monitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "MONITOR_NOT_FOUND" + }, + "message": { + "type": "string", + "example": "Monitor earth not found" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "INTERNAL_ERROR" + }, + "message": { + "type": "string", + "example": "Error generating report" + } + } + } + } + } + } + } + } + } + } } } } diff --git a/package-lock.json b/package-lock.json index 2fc774ee6..84a081672 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "node-cache": "^5.1.2", "nodemailer": "^6.10.0", "npm-run-all": "^4.1.5", + "papaparse": "^5.5.3", "pg": "^8.13.1", "pg-pool": "^3.7.0", "ping": "^0.4.4", @@ -6225,6 +6226,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", diff --git a/package.json b/package.json index ada1209fd..df95770a7 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "node-cache": "^5.1.2", "nodemailer": "^6.10.0", "npm-run-all": "^4.1.5", + "papaparse": "^5.5.3", "pg": "^8.13.1", "pg-pool": "^3.7.0", "ping": "^0.4.4", diff --git a/src/lib/components/manage/homePage.svelte b/src/lib/components/manage/homePage.svelte index 66169dbb5..2e8f83948 100644 --- a/src/lib/components/manage/homePage.svelte +++ b/src/lib/components/manage/homePage.svelte @@ -1,4 +1,5 @@ @@ -288,10 +331,10 @@ {#if rollerLoading} {/if} - {#if !isNaN(uptimesRollers[rolledAt].value)} + {#if uptimesRollers[rolledAt]?.value !== undefined && uptimesRollers[rolledAt]?.value !== '-'} {#each dayIncidentsFull as incident, index}
- +
{/each} diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 20a221636..2352040c5 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -13,6 +13,13 @@ "All Systems are Down": "All Systems are Down", "All Systems are Operational": "All Systems are Operational", "All Systems are in Maintenance": "All Systems are in Maintenance", + "Not all incidents are shown. Click to see more": "Not all incidents are shown. Click to see more", + "Auto-Refresh": "Auto-Refresh", + "auto-refresh-description": "Enable to periodically refresh the page data.", + "Interval": "Interval", + "Seconds": "Seconds", + "Enable": "Enable", + "Disable": "Disable", "Back": "Back", "Badge Copied": "Badge Copied", "Badge": "Badge", @@ -88,5 +95,35 @@ "Uptime": "Uptime", "View in detail": "View in detail", "We have sent a code to your email. Please enter it below to confirm your login": "We have sent a code to your email. Please enter it below to confirm your login", - "You are logged in as %email": "You are logged in as %email" + "You are logged in as %email": "You are logged in as %email", + "Reports": "Reports", + "Downtime Reports": "Downtime Reports", + "Generate Report": "Generate Report", + "Export CSV": "Export CSV", + "Select monitor": "Select monitor", + "Start Date": "Start Date", + "End Date": "End Date", + "Total Downtime": "Total Downtime", + "Downtime Events": "Downtime Events", + "Alerts Generated": "Alerts Generated", + "Generate detailed downtime reports for your monitors with customizable date ranges": "Generate detailed downtime reports for your monitors with customizable date ranges", + "Select monitor and date range to generate downtime report": "Select monitor and date range to generate downtime report", + "Monitor": "Monitor", + "Period": "Period", + "Summary": "Summary", + "Downtime": "Downtime", + "Start Date/Time": "Start Date/Time", + "End Date/Time": "End Date/Time", + "Duration (Minutes)": "Duration (Minutes)", + "Duration (Hours)": "Duration (Hours)", + "Alert Generated": "Alert Generated", + "Alert": "Alert", + "No downtime events found!": "No downtime events found!", + "This monitor had 100% uptime during the selected period.": "This monitor had 100% uptime during the selected period.", + "Please select a monitor": "Please select a monitor", + "Error generating report": "Error generating report", + "Error exporting CSV": "Error exporting CSV", + "event": "event", + "events": "events", + "found": "found" } diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index c340fd894..082897683 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -13,6 +13,8 @@ "All Systems are Down": "Todos os Sistemas estão Indisponíveis", "All Systems are Operational": "Todos os Sistemas estão Operacionais", "All Systems are in Maintenance": "Todos os Sistemas estão em Manutenção", + "Auto-Refresh": "Atualização Automática", + "auto-refresh-description": "Ative para atualizar os dados da página periodicamente.", "Back": "Voltar", "Badge Copied": "Emblema Copiado", "Badge": "Emblema", @@ -88,5 +90,35 @@ "Uptime": "Tempo de Atividade", "View in detail": "Ver em detalhes", "We have sent a code to your email. Please enter it below to confirm your login": "Enviamos um código para seu e-mail. Por favor, digite-o abaixo para confirmar seu login", - "You are logged in as %email": "Você está logado como %email" + "You are logged in as %email": "Você está logado como %email", + "Reports": "Relatórios", + "Downtime Reports": "Relatórios de Indisponibilidade", + "Generate Report": "Gerar Relatório", + "Export CSV": "Exportar CSV", + "Select monitor": "Selecionar monitor", + "Start Date": "Data Inicial", + "End Date": "Data Final", + "Total Downtime": "Tempo Total de Indisponibilidade", + "Downtime Events": "Eventos de Indisponibilidade", + "Alerts Generated": "Alertas Gerados", + "Generate detailed downtime reports for your monitors with customizable date ranges": "Gere relatórios detalhados de indisponibilidade para seus monitores com períodos personalizáveis", + "Select monitor and date range to generate downtime report": "Selecione o monitor e o período para gerar o relatório de indisponibilidade", + "Monitor": "Monitor", + "Period": "Período", + "Summary": "Resumo", + "Downtime": "Indisponibilidade", + "Start Date/Time": "Data/Hora Inicial", + "End Date/Time": "Data/Hora Final", + "Duration (Minutes)": "Duração (Minutos)", + "Duration (Hours)": "Duração (Horas)", + "Alert Generated": "Alerta Gerado", + "Alert": "Alerta", + "No downtime events found!": "Nenhum evento de indisponibilidade encontrado!", + "This monitor had 100% uptime during the selected period.": "Este monitor teve 100% de disponibilidade durante o período selecionado.", + "Please select a monitor": "Por favor, selecione um monitor", + "Error generating report": "Erro ao gerar relatório", + "Error exporting CSV": "Erro ao exportar CSV", + "event": "evento", + "events": "eventos", + "found": "encontrado" } diff --git a/src/lib/server/controllers/reports.js b/src/lib/server/controllers/reports.js new file mode 100644 index 000000000..682542c9e --- /dev/null +++ b/src/lib/server/controllers/reports.js @@ -0,0 +1,164 @@ +// @ts-nocheck +import db from "../db/db.js"; +import { InterpolateData, GetLastStatusBefore } from "./controller.js"; +import { format } from "date-fns"; + +/** + * Generate downtime report for a monitor within a date range + * @param {string} monitor_tag - Monitor tag + * @param {number} startTimestamp - Start timestamp in seconds + * @param {number} endTimestamp - End timestamp in seconds + * @returns {Promise} Report object with downtimes and summary + */ +export const GenerateDowntimeReport = async (monitor_tag, startTimestamp, endTimestamp) => { + // Get monitor info + const monitor = await db.getMonitorByTag(monitor_tag); + if (!monitor) { + throw new Error(`Monitor ${monitor_tag} not found`); + } + + // Get all monitoring data for the period + const rawData = await db.getMonitoringData(monitor_tag, startTimestamp, endTimestamp); + const anchorStatus = await GetLastStatusBefore(monitor_tag, startTimestamp); + const interpolatedData = InterpolateData(rawData, startTimestamp, anchorStatus, endTimestamp); + + // Get alerts for the period + const alerts = await db.getAlertsForPeriod(monitor_tag, startTimestamp, endTimestamp); + // Convert alert created_at to timestamps for comparison + const alertTimestamps = new Set( + alerts.map((a) => Math.floor(new Date(a.created_at).getTime() / 1000)) + ); + + // Calculate downtime periods + const downtimes = []; + let currentDowntime = null; + + for (let i = 0; i < interpolatedData.length; i++) { + const entry = interpolatedData[i]; + const isDown = entry.status === "DOWN" || entry.status === "DEGRADED"; + + if (isDown && !currentDowntime) { + // Start of downtime + currentDowntime = { + startTimestamp: entry.timestamp, + status: entry.status, + hasAlert: alertTimestamps.has(entry.timestamp), + }; + } else if (!isDown && currentDowntime) { + // End of downtime + const durationMinutes = (entry.timestamp - currentDowntime.startTimestamp) / 60; + downtimes.push({ + startDateTime: format(new Date(currentDowntime.startTimestamp * 1000), "yyyy-MM-dd HH:mm:ss"), + endDateTime: format(new Date(entry.timestamp * 1000), "yyyy-MM-dd HH:mm:ss"), + durationMinutes: parseFloat(durationMinutes.toFixed(2)), + durationHours: parseFloat((durationMinutes / 60).toFixed(2)), + status: currentDowntime.status, + alertGenerated: currentDowntime.hasAlert, + }); + currentDowntime = null; + } else if (isDown && currentDowntime) { + // Continue downtime, check for alerts + if (alertTimestamps.has(entry.timestamp)) { + currentDowntime.hasAlert = true; + } + // Update status if it changed (e.g., from DEGRADED to DOWN) + if (entry.status === "DOWN" && currentDowntime.status !== "DOWN") { + currentDowntime.status = "DOWN"; + } + } + } + + // If still in downtime at the end + if (currentDowntime) { + const durationMinutes = (endTimestamp - currentDowntime.startTimestamp) / 60; + downtimes.push({ + startDateTime: format(new Date(currentDowntime.startTimestamp * 1000), "yyyy-MM-dd HH:mm:ss"), + endDateTime: format(new Date(endTimestamp * 1000), "yyyy-MM-dd HH:mm:ss"), + durationMinutes: parseFloat(durationMinutes.toFixed(2)), + durationHours: parseFloat((durationMinutes / 60).toFixed(2)), + status: currentDowntime.status, + alertGenerated: currentDowntime.hasAlert, + }); + } + + // Calculate summary + const totalDowntimeMinutes = downtimes.reduce((sum, d) => sum + d.durationMinutes, 0); + const totalMinutesInPeriod = (endTimestamp - startTimestamp) / 60; + const uptimePercentage = + totalMinutesInPeriod > 0 + ? ((totalMinutesInPeriod - totalDowntimeMinutes) / totalMinutesInPeriod) * 100 + : 100; + const alertsGenerated = downtimes.filter((d) => d.alertGenerated).length; + + return { + monitor: { + tag: monitor.tag, + name: monitor.name, + }, + period: { + start: startTimestamp, + end: endTimestamp, + startFormatted: format(new Date(startTimestamp * 1000), "yyyy-MM-dd HH:mm:ss"), + endFormatted: format(new Date(endTimestamp * 1000), "yyyy-MM-dd HH:mm:ss"), + }, + downtimes: downtimes, + summary: { + totalDowntimeMinutes: parseFloat(totalDowntimeMinutes.toFixed(2)), + totalDowntimeHours: parseFloat((totalDowntimeMinutes / 60).toFixed(2)), + uptimePercentage: parseFloat(uptimePercentage.toFixed(4)), + downtimePercentage: parseFloat((100 - uptimePercentage).toFixed(4)), + alertsGenerated: alertsGenerated, + totalEvents: downtimes.length, + }, + }; +}; + +/** + * Export report data to CSV format + * @param {Object} report - Report object from GenerateDowntimeReport + * @returns {Array>} CSV data as array of arrays + */ +export const ExportReportToCSV = (report) => { + const rows = []; + + // Header with report info + rows.push(["Downtime Report"]); + rows.push(["Monitor", report.monitor.name]); + rows.push(["Tag", report.monitor.tag]); + rows.push(["Period", `${report.period.startFormatted} to ${report.period.endFormatted}`]); + rows.push([]); + + // Data header + rows.push([ + "Start Date/Time", + "End Date/Time", + "Duration (Minutes)", + "Duration (Hours)", + "Status", + "Alert Generated", + ]); + + // Data rows + for (const downtime of report.downtimes) { + rows.push([ + downtime.startDateTime, + downtime.endDateTime, + downtime.durationMinutes, + downtime.durationHours, + downtime.status, + downtime.alertGenerated ? "Yes" : "No", + ]); + } + + // Summary section + rows.push([]); + rows.push(["SUMMARY"]); + rows.push(["Total Downtime Events", report.summary.totalEvents]); + rows.push(["Total Downtime (Minutes)", report.summary.totalDowntimeMinutes]); + rows.push(["Total Downtime (Hours)", report.summary.totalDowntimeHours]); + rows.push(["Uptime Percentage", `${report.summary.uptimePercentage}%`]); + rows.push(["Downtime Percentage", `${report.summary.downtimePercentage}%`]); + rows.push(["Alerts Generated", report.summary.alertsGenerated]); + + return rows; +}; diff --git a/src/lib/server/db/dbimpl.js b/src/lib/server/db/dbimpl.js index 77e12fd2c..8caa6240e 100644 --- a/src/lib/server/db/dbimpl.js +++ b/src/lib/server/db/dbimpl.js @@ -1272,6 +1272,19 @@ class DbImpl { async deleteSubscriptionTriggerById(id) { return await this.knex("subscription_triggers").where({ id }).del(); } + + // Get alerts generated for a monitor within a date range + async getAlertsForPeriod(monitor_tag, startTimestamp, endTimestamp) { + // Convert timestamps to milliseconds for comparison with created_at + const startDate = new Date(startTimestamp * 1000); + const endDate = new Date(endTimestamp * 1000); + + return await this.knex("monitor_alerts") + .where("monitor_tag", monitor_tag) + .andWhere("created_at", ">=", startDate.toISOString()) + .andWhere("created_at", "<=", endDate.toISOString()) + .orderBy("created_at", "asc"); + } } export default DbImpl; diff --git a/src/lib/server/services/apiCall.js b/src/lib/server/services/apiCall.js index a6928bae9..ab9d76760 100644 --- a/src/lib/server/services/apiCall.js +++ b/src/lib/server/services/apiCall.js @@ -33,7 +33,7 @@ class ApiCall { } let method = this.monitor.type_data.method; - let timeout = this.monitor.type_data.timeout || 10000; + let timeout = parseInt(this.monitor.type_data.timeout) || 10000; let tag = this.monitor.tag; let monitorEval = !!this.monitor.type_data.eval ? this.monitor.type_data.eval : DefaultAPIEval; diff --git a/src/lib/server/services/gamedigCall.js b/src/lib/server/services/gamedigCall.js index be6e05b12..5ed06b805 100644 --- a/src/lib/server/services/gamedigCall.js +++ b/src/lib/server/services/gamedigCall.js @@ -14,7 +14,7 @@ class GamedigCall { const tag = this.monitor.tag; const host = this.monitor.type_data.host; const port = this.monitor.type_data.port; - const timeout = !!this.monitor.type_data.timeout ? this.monitor.type_data.timeout : GAMEDIG_TIMEOUT; + const timeout = !!this.monitor.type_data.timeout ? parseInt(this.monitor.type_data.timeout) : GAMEDIG_TIMEOUT; const gamedigEval = !!this.monitor.type_data.eval ? this.monitor.type_data.eval : DefaultGamedigEval; // Query diff --git a/src/lib/server/services/pingCall.js b/src/lib/server/services/pingCall.js index a37defd76..e8cb24dda 100644 --- a/src/lib/server/services/pingCall.js +++ b/src/lib/server/services/pingCall.js @@ -29,7 +29,7 @@ class PingCall { let arrayOfPings = []; for (let i = 0; i < hosts.length; i++) { const host = hosts[i]; - arrayOfPings.push(await Ping(host.type, host.host, host.timeout, host.count)); + arrayOfPings.push(await Ping(host.type, host.host, parseInt(host.timeout) || 3000, parseInt(host.count) || 4)); } let evalResp = undefined; diff --git a/src/lib/server/services/sqlCall.js b/src/lib/server/services/sqlCall.js index 7f01012ca..2c6d1a5a3 100644 --- a/src/lib/server/services/sqlCall.js +++ b/src/lib/server/services/sqlCall.js @@ -19,7 +19,7 @@ class SqlCall { connection = ReplaceAllOccurrences(connection, secret.find, secret.replace); } let query = this.monitor.type_data.query; - let timeout = this.monitor.type_data.timeout || 5000; + let timeout = parseInt(this.monitor.type_data.timeout) || 5000; const startTime = Date.now(); let knexInstance = null; diff --git a/src/lib/server/services/tcpCall.js b/src/lib/server/services/tcpCall.js index d4d9709d8..0e73ea6d4 100644 --- a/src/lib/server/services/tcpCall.js +++ b/src/lib/server/services/tcpCall.js @@ -30,7 +30,7 @@ class TcpCall { let arrayOfPings = []; for (let i = 0; i < hosts.length; i++) { const host = hosts[i]; - arrayOfPings.push(await TCP(host.type, host.host, host.port, host.timeout)); + arrayOfPings.push(await TCP(host.type, host.host, parseInt(host.port), parseInt(host.timeout) || 3000)); } let evalResp = undefined; diff --git a/src/lib/stores/refreshStore.js b/src/lib/stores/refreshStore.js new file mode 100644 index 000000000..bf49c3cd8 --- /dev/null +++ b/src/lib/stores/refreshStore.js @@ -0,0 +1,22 @@ +import { writable } from 'svelte/store'; + +// Store for global refresh configuration +function createRefreshStore() { + const { subscribe, set, update } = writable({ + enabled: false, + interval: 60, // seconds + lastRefresh: null + }); + + return { + subscribe, + enable: () => update(state => ({ ...state, enabled: true })), + disable: () => update(state => ({ ...state, enabled: false })), + toggle: () => update(state => ({ ...state, enabled: !state.enabled })), + setInterval: (interval) => update(state => ({ ...state, interval })), + updateLastRefresh: () => update(state => ({ ...state, lastRefresh: Date.now() })), + setState: (newState) => set(newState) + }; +} + +export const refreshStore = createRefreshStore(); diff --git a/src/routes/(kener)/+layout.svelte b/src/routes/(kener)/+layout.svelte index 73e09f3ce..7c5dd2447 100644 --- a/src/routes/(kener)/+layout.svelte +++ b/src/routes/(kener)/+layout.svelte @@ -3,7 +3,7 @@ import "../../kener.css"; import "../../theme.css"; import Nav from "$lib/components/nav.svelte"; - import { onMount } from "svelte"; + import { onMount, onDestroy } from "svelte"; import { Input } from "$lib/components/ui/input"; import { base } from "$app/paths"; import { Button } from "$lib/components/ui/button"; @@ -11,12 +11,15 @@ import Moon from "lucide-svelte/icons/moon"; import Languages from "lucide-svelte/icons/languages"; import Globe from "lucide-svelte/icons/globe"; + import Loader from "lucide-svelte/icons/loader"; + import RefreshCw from "lucide-svelte/icons/refresh-cw"; import * as Popover from "$lib/components/ui/popover"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import { analyticsEvent } from "$lib/boringOne"; import { setMode, mode, ModeWatcher } from "mode-watcher"; import { l } from "$lib/i18n/client"; - + import { refreshStore } from "$lib/stores/refreshStore.js"; + export let data; let defaultLocaleKey = data.selectedLang; let allTimezones = Intl.supportedValuesOf("timeZone"); @@ -67,7 +70,77 @@ } let myTimezone = data.localTz; + + // Global refresh configuration + let refreshInterval = 60; + let refreshIntervalId = null; + + // Load refresh settings from localStorage + function loadRefreshSettings() { + if (typeof window === 'undefined') return; + + const savedInterval = localStorage.getItem('kener-global-refresh-interval'); + if (savedInterval) { + refreshInterval = parseInt(savedInterval, 10); + refreshStore.setInterval(refreshInterval); + } + + const savedEnabled = localStorage.getItem('kener-global-refresh-enabled'); + if (savedEnabled === 'true') { + refreshStore.enable(); + startGlobalRefresh(); + } + } + + // Start global refresh + function startGlobalRefresh() { + if (refreshIntervalId) { + clearInterval(refreshIntervalId); + } + refreshIntervalId = setInterval(() => { + refreshStore.updateLastRefresh(); + }, refreshInterval * 1000); + } + + // Stop global refresh + function stopGlobalRefresh() { + if (refreshIntervalId) { + clearInterval(refreshIntervalId); + refreshIntervalId = null; + } + } + + // Toggle global refresh + function toggleGlobalRefresh() { + refreshStore.toggle(); + + if ($refreshStore.enabled) { + localStorage.setItem('kener-global-refresh-enabled', 'true'); + startGlobalRefresh(); + refreshStore.updateLastRefresh(); // Immediate refresh + } else { + localStorage.setItem('kener-global-refresh-enabled', 'false'); + stopGlobalRefresh(); + } + } + + // Save interval to localStorage and update store + $: if (typeof window !== 'undefined' && refreshInterval) { + localStorage.setItem('kener-global-refresh-interval', String(refreshInterval)); + refreshStore.setInterval(refreshInterval); + } + + // Restart interval if changed while active + $: if ($refreshStore.enabled && refreshIntervalId && refreshInterval) { + startGlobalRefresh(); + } + + onDestroy(() => { + stopGlobalRefresh(); + }); + onMount(async () => { + loadRefreshSettings(); myTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; if (data.localTz === "UTC") { if (data.isBot === false) { @@ -87,6 +160,7 @@ if (!!data.site.favicon && !data.site.favicon.startsWith("http")) data.site.favicon = `${base}${data.site.favicon}`; let kenerTheme = data.site.kenerTheme || "default"; + @@ -124,7 +198,42 @@ {@html data.site.footerHTML} {/if} -
+
+
+ + + + + +
+
+

{l(data.lang, 'Auto-Refresh')}

+

+ {l(data.lang, 'Configure automatic refresh for all monitors')} +

+
+
+
+ + +
+ +
+
+
+
+
{#if !!data.site.tzToggle && data.site.tzToggle === "YES"}
@@ -158,7 +267,7 @@ {#if tz.toLowerCase().includes(searchTzValue.toLowerCase())}
+ + {#if report} + + {/if} +
+ + {#if error} +
+ + {error} +
+ {/if} + + + + {#if report} + + + Summary - {report.monitor.name} + + Period: {report.period.startFormatted} to {report.period.endFormatted} + + + +
+
+
+ + Uptime +
+

+ {report.summary.uptimePercentage}% +

+
+
+
+ + Downtime +
+

+ {report.summary.downtimePercentage}% +

+
+
+

Total Downtime

+

+ {report.summary.totalDowntimeHours}h +

+

+ ({report.summary.totalDowntimeMinutes} min) +

+
+
+

Downtime Events

+

{report.summary.totalEvents}

+
+
+
+ + Alerts Generated +
+

{report.summary.alertsGenerated}

+
+
+
+
+ + + + Downtime Events + + {report.downtimes.length} event{report.downtimes.length !== 1 ? "s" : ""} found + + + + {#if report.downtimes.length === 0} +
+ +

No downtime events found!

+

This monitor had 100% uptime during the selected period.

+
+ {:else} +
+ + + + + + + + + + + + + {#each report.downtimes as downtime, index} + + + + + + + + + {/each} + +
Start Date/TimeEnd Date/TimeDuration (min)Duration (hrs)StatusAlert
{downtime.startDateTime}{downtime.endDateTime}{downtime.durationMinutes}{downtime.durationHours} + + {downtime.status} + + + {#if downtime.alertGenerated} + + {:else} + - + {/if} +
+
+ {/if} +
+
+ {/if} +
diff --git a/src/routes/(manage)/manage/(app)/app/reports/api/+server.js b/src/routes/(manage)/manage/(app)/app/reports/api/+server.js new file mode 100644 index 000000000..d8451fbed --- /dev/null +++ b/src/routes/(manage)/manage/(app)/app/reports/api/+server.js @@ -0,0 +1,36 @@ +// @ts-nocheck +import { json } from "@sveltejs/kit"; +import { GenerateDowntimeReport, ExportReportToCSV } from "$lib/server/controllers/reports.js"; +import { IsLoggedInSession } from "$lib/server/controllers/controller.js"; + +export async function POST({ request, cookies }) { + let isLoggedIn = await IsLoggedInSession(cookies); + if (!!isLoggedIn.error) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + const { monitor_tag, startTimestamp, endTimestamp, format } = await request.json(); + + if (!monitor_tag || !startTimestamp || !endTimestamp) { + return json({ error: "Missing required parameters" }, { status: 400 }); + } + + // Validate timestamps + if (startTimestamp >= endTimestamp) { + return json({ error: "Start timestamp must be before end timestamp" }, { status: 400 }); + } + + try { + const report = await GenerateDowntimeReport(monitor_tag, startTimestamp, endTimestamp); + + if (format === "csv") { + const csvData = ExportReportToCSV(report); + return json({ csvData, report }); + } + + return json({ report }); + } catch (error) { + console.error("Error generating report:", error); + return json({ error: error.message }, { status: 500 }); + } +}