Compare commits

..
Author SHA1 Message Date
gorkem-bwl 3b858de2b5 feat: add buffer lookup and location results update to services
## Changes
- Add findInBuffer() to BufferService for looking up checks still in the write buffer
- Add updateLocationResults() to IChecksRepository interface and MongoChecksRepository
- Update GlobalpingService to return GlobalpingCheckResult[] instead of MonitorStatusResponse[]

## Benefits
Non-destructive service additions that prepare the backfill pipeline.
Existing check-writing and Globalping flows are unchanged.
2026-02-20 01:31:37 -05:00
gorkem-bwl d25ce9e498 feat: add LocationResult type and locationResults to Check model
## Changes
- Add LocationResult interface to server check types
- Add locationResults optional field to Check interface
- Add GlobalpingCheckResult interface to network types
- Add locationResultSchema subdocument and locationResults field to Mongoose Check schema
- Fix formatting in pre-existing files (SettingsGlobalping, Settings index, UptimeMonitorsTable, monitorService)

## Benefits
Non-destructive addition of the data model needed to store per-location
Globalping results inside a single Check document. No existing behaviour
is changed.
2026-02-20 01:31:31 -05:00
gorkem-bwl b7c9e2710a fix: address Globalping code review findings
## Changes

### Critical fixes
- Fix Switch `isChecked` → `checked` prop in CreateMonitor Globalping toggle
- Merge double `getDBSettings()` calls in globalpingService into single
  `getClientAndLocations()` method to eliminate redundant DB reads per check cycle
- Add in-flight lock (Set) to prevent overlapping Globalping checks for the same monitor
- Add hostname validation before calling Globalping API to prevent invalid targets

### Dead code removal
- Remove unused `locations` field from server Monitor type, Mongoose model,
  Joi validation, and repository mappers
- Remove unused `locations` field from client Monitor type
- Remove dead `getLimits()` method from globalpingService
- Remove dead `locations` translation keys from en.json
- Remove dead `monitorType`, `groupedUpChecks`, `groupedDownChecks` fields
  from findLocationChecksByMonitorId return value

### Medium/low fixes
- Make globalping/locations API fetch conditional on `monitor.globalpingEnabled`
  in Details page to avoid unnecessary requests
- Add useEffect to reset selectedLocationTab when location keys change
  to prevent out-of-bounds tab index
- Add `globalpingEnabled: false` to default case in useMonitorForm
- Replace console.log with comment in CreateMonitor onError handler
- Add aria-label to Globe icon for accessibility
- Add warning log when Globalping buildCheck returns undefined
- Fix step numbering in SuperSimpleQueueHelper (was duplicated Step 4)

## Benefits
- Eliminates one DB round-trip per monitor check cycle
- Prevents race conditions with overlapping Globalping checks
- Validates hostnames before external API calls
- Removes ~30 lines of dead code across server and client
- Improves accessibility with proper aria labels
2026-02-19 13:28:38 -05:00
gorkem-bwl bfdf28a2cc fix: show globe icon based on globalpingEnabled instead of locations array
The globe icon in the monitor list was checking row.locations.length,
but locations are no longer stored per-monitor. Updated to check
row.globalpingEnabled and show a "Global monitoring enabled" tooltip.
2026-02-19 12:14:52 -05:00
gorkem-bwl 4ca24c794e feat: add per-monitor Globalping toggle
Add a globalpingEnabled boolean to the Monitor model so users can
opt-in to global location checks per monitor during creation/edit.

## Changes
- Add globalpingEnabled field to server types, Mongoose model,
  Joi validation, and repository mappers
- Add globalpingEnabled to client Monitor type, Zod schemas for
  HTTP and ping monitors, and form defaults
- Add "Global monitoring" ConfigBox with Switch toggle to the
  monitor creation/edit form, visible for HTTP and ping types
- Queue helper and monitorService now check globalpingEnabled
  before running Globalping checks or fetching location data
- Add translation keys for the new form section

## Benefits
- Users can selectively enable Globalping on monitors that need it
  rather than running location checks on every HTTP/ping monitor
- Reduces unnecessary API usage and keeps the UI clean for monitors
  that only need local checks
2026-02-19 12:11:56 -05:00
gorkem-bwl 7f9d2f275e fix: prevent settings save from deleting API keys
When saving settings, empty sensitive fields (pagespeedApiKey,
globalpingApiKey, systemEmailPassword) were sent as "" to the server,
which treated them as $unset operations — deleting the stored keys.

Now the form omits these fields from the payload when they're already
set and haven't been explicitly reset by the user.
2026-02-19 11:04:21 -05:00
gorkem-bwl 1966134647 refactor: switch Globalping to dynamic tier-based location lookup
## Changes
- Remove per-monitor location selection — locations are now determined
  globally by the tier setting in admin settings (3, 6, or 15 locations)
- GlobalpingService reads tier from settings on each check run, so
  changing the tier in settings immediately affects all HTTP/ping monitors
- Add resolveLocationId() to map Globalping probe results back to
  configured location IDs for proper grouping
- Export LOCATION_LABELS for human-readable location names on the client
- Remove unused locations field from Zod schemas and form defaults
- Remove per-monitor location ConfigBox from CreateMonitor page
- Fix settingsController.updateAppSettings missing try/catch
- Fix MongoSettingsRepository duplicate findOneAndUpdate call
- Use typed LocationCheckData instead of Record<string, any>
- Remove unused REVERSE_LOCATION_MAP from globalpingService

## Benefits
- Changing the location tier in settings takes effect immediately for
  all monitors without needing to edit each one individually
- Cleaner separation of concerns — location configuration lives in
  settings, not scattered across monitors
- Human-readable location labels shown in uptime details tabs
2026-02-19 10:58:18 -05:00
gorkem-bwl 7646d11ea0 fix: align radio buttons with label text and increase rate limits font size
- Remove default Radio padding and use explicit margin-right for
  consistent alignment with the first line of label text
- Use RadioGroup gap for consistent spacing between options
- Switch rate limits text from body2 to default (body1) to match
  other settings section descriptions
2026-02-19 10:34:22 -05:00
gorkem-bwl c10d9be0f0 fix: align radio buttons and fix rate limits box dark mode contrast
- Add top margin to radio buttons so they align with the first line
  of the multi-line labels
- Remove custom background color from rate limits box that was
  unreadable in dark mode
- Use default text color for bullet points instead of text.secondary
- Keep only the example note in text.secondary with italic style
2026-02-19 10:31:55 -05:00
gorkem-bwl 27c3a9e12b feat: add location tier presets (3, 6, 15) to Globalping settings
## Changes

### Server
- Expand LOCATION_MAP to support city/country-level locations for the
  15-location tier (US East/West, Toronto, London, Frankfurt, Amsterdam,
  Warsaw, Tokyo, Singapore, Mumbai, Sydney, Brazil, Santiago, Johannesburg,
  Dubai)
- Add globalpingLocationsTier field to Settings type, Mongoose model,
  repository mapper, and Joi validation (valid: 3, 6, 15, default: 6)
- Update getGlobalpingLocations endpoint to return locations based on
  the configured tier from settings
- Pass settingsService to MonitorController for tier lookup

### Client
- Add tier selector (radio buttons) to SettingsGlobalping component
  with descriptions for each option:
  - 3 locations: North America, Europe, Asia
  - 6 locations: all continents
  - 15 locations: city-level global coverage
- Add rate limits info box explaining credit usage per tier:
  - Without token: 250 tests/hour
  - With free token: 500 tests/hour
  - GitHub sponsors: higher limits
- Add all translation keys for tier labels, descriptions, and rate info

## Benefits
- Admins can choose the right balance of coverage vs API usage
- Rate limit info helps users decide whether they need authentication
2026-02-19 10:31:15 -05:00
gorkem-bwl 134c2ebbf4 fix: map globalpingApiKey in settings repository toEntity
The MongoSettingsRepository.toEntity method was missing the
globalpingApiKey field, causing GlobalpingService to always
report the API key as not configured.
2026-02-19 10:19:30 -05:00
gorkem-bwl e39b44db6c feat: add multi-location uptime monitoring via Globalping
## Changes

### Server
- Add `locations` field to Monitor model and types (up to 5 locations per monitor)
- Add `location` field to Check metadata for per-location tracking
- Add `globalpingApiKey` to AppSettings for API key management
- Create GlobalpingService that wraps the Globalping SDK to run distributed
  HTTP/ping checks from multiple global locations (NA, EU, AS, SA, OC, AF)
- Extend SuperSimpleQueueHelper with fire-and-forget Globalping checks that
  run alongside the existing local check without affecting the main pipeline
- Add location-aware check queries with aggregation pipeline grouping by
  location in MongoChecksRepository
- Filter location checks from main uptime aggregation to preserve local check
  as source of truth for status/incidents
- Extend monitor details endpoint to return per-location check data
- Add GET /globalping/locations endpoint for available location options
- Add globalpingKeySet to settings response
- Add Joi validation for locations and globalpingApiKey fields

### Client
- Add locations multi-select (Autocomplete with chips) to monitor creation
  form, shown only for HTTP and ping monitor types
- Add SettingsGlobalping component for API key management (matching existing
  PageSpeed pattern with set/reset flow)
- Add per-location breakdown section to uptime details page with tabs,
  avg response time, uptime percentage, and response time charts
- Add globe icon with tooltip to monitor table for monitors with locations
- Add Zod validation for locations field in HTTP and ping schemas
- Add all translation keys for globalping settings, locations form, and
  location details sections

## Benefits
- Users gain visibility into geographic availability and latency data
- Local check remains the source of truth, Globalping runs alongside
- Supports up to 5 locations per monitor with 6 continent-level options
- Graceful degradation when API key is not configured or rate limited
2026-02-19 00:45:14 -05:00
Alexander Holliday 1c1474cd6f Merge pull request #3302 from bluewave-labs/fix/settings-form
fix: settings form
2026-02-18 11:25:34 -08:00
Alex Holliday db800e2259 simplify form 2026-02-18 19:23:03 +00:00
Alex Holliday b6765830a5 fix client/server validation 2026-02-18 19:13:52 +00:00
Alex Holliday 8cbde4a705 initial commit 2026-02-18 18:47:58 +00:00
Alexander Holliday 6fcaabbdb6 Merge pull request #3301 from bluewave-labs/fix/settings-form
fix logical errors in settings form
2026-02-18 10:12:44 -08:00
Alex Holliday 7cfbc85d9b fix logical errors in settings form 2026-02-18 18:06:00 +00:00
Alexander Holliday ab834d400e Merge pull request #3300 from bluewave-labs/fix/remove-constraint
fix: remove unique constraint
2026-02-18 09:45:15 -08:00
Alex Holliday 1fcf06a415 remove unique constraint 2026-02-18 17:43:32 +00:00
Alexander Holliday dab465b8c5 Merge pull request #3288 from bluewave-labs/settings-zod-schema
Settings zod schema
2026-02-18 09:32:28 -08:00
Alex Holliday 59d245b424 remove pre tag 2026-02-18 17:27:59 +00:00
karenvicent e921b20fe3 fix zod patterns 2026-02-17 22:21:56 -05:00
Gorkem Cetin 0e6680366d Remove Owaise from the contributing section 2026-02-17 19:53:24 -05:00
Alexander Holliday 7f7a9f40df Merge pull request #3298 from bluewave-labs/fix/monitor-stats-duplication
fix monitor stats bug
2026-02-17 16:08:44 -08:00
Alex Holliday 31698bd002 fix monitor stats bug 2026-02-18 00:06:02 +00:00
Alexander Holliday c7ccdafec2 Merge pull request #3297 from bluewave-labs/fix/avg-response-time-calc
fix: increment before calculate
2026-02-17 15:42:22 -08:00
Alex Holliday c920191984 increment before calculate 2026-02-17 23:40:14 +00:00
Alexander Holliday 374b6aa6ae Merge pull request #3296 from bluewave-labs/feat/max-response-time-rolling-stats
feat: max response time rolling stats
2026-02-17 15:34:35 -08:00
Alex Holliday 3d5c81fe2f Add maxResponseTime to client-side MonitorStats type 2026-02-17 22:50:44 +00:00
Alex Holliday 3bd6ec5d28 max response time 2026-02-17 22:47:37 +00:00
Alexander Holliday 7bb47ef432 Merge pull request #3295 from bluewave-labs/feat/cleanup-job
feat: cleanup job
2026-02-17 14:34:42 -08:00
Alex Holliday 7856317e8f remvoe space 2026-02-17 22:29:30 +00:00
Alex Holliday edbd8f2abb fix check bug 2026-02-17 22:27:52 +00:00
Alex Holliday cddf8b32e6 cleanup incidents 2026-02-17 19:39:24 +00:00
Alex Holliday 0032765bd1 remove orphaned checks 2026-02-17 19:20:08 +00:00
Alex Holliday 6bc9194e8f initial commit 2026-02-17 19:03:17 +00:00
Alexander Holliday ef15e5d6e0 Merge pull request #3294 from bluewave-labs/fix/remove-incidents-on-delete-monitor
fix: remove incidents on delete
2026-02-17 10:32:16 -08:00
Alex Holliday 97750da889 remove incidents on delete 2026-02-17 18:30:22 +00:00
Alexander Holliday af8083bdaa Merge pull request #3293 from bluewave-labs/fix/active-incidents
fix: add missing pluralization strings
2026-02-17 10:16:45 -08:00
Alexander Holliday 54b33ec835 Merge pull request #3292 from bluewave-labs/fix/incident-reason
fix/incident reason
2026-02-17 10:15:41 -08:00
Alex Holliday 847904a274 add missing pluralization strings 2026-02-17 18:15:30 +00:00
Alex Holliday 7bc03c976b remove comment 2026-02-17 17:53:00 +00:00
Alex Holliday 73f05ab78e refactor to remove duplication 2026-02-17 17:49:56 +00:00
Alex Holliday 584e4633d3 incident details 2026-02-17 17:42:11 +00:00
Alexander Holliday 433c57b42e Merge pull request #3291 from bluewave-labs/feat/notification-refactor
feat: notification refactor
2026-02-17 09:31:47 -08:00
Alexander Holliday a2cf2afc09 Merge pull request #3279 from bluewave-labs/enhancement/uptime-graph-avg-max-response
enhancement: add average and max response time to histogram
2026-02-17 09:31:25 -08:00
Alex Holliday 232be7a405 cleanup 2026-02-17 17:28:43 +00:00
mannilakash 7d66bb5668 fix: unnecessary custom styling and originalResponseTime usage 2026-02-17 10:27:22 -05:00
mannilakash 566fded55c Merge branch 'develop' of github.com:bluewave-labs/Checkmate into enhancement/uptime-graph-avg-max-response 2026-02-17 10:20:37 -05:00
Alex Holliday fbb0e940c6 emails 2026-02-17 01:47:57 +00:00
Alex Holliday f983863c72 new template 2026-02-17 01:25:30 +00:00
Alex Holliday e0a75c78db refactor discord 2026-02-17 01:16:00 +00:00
Alex Holliday 2cd2a6d917 pagerduty 2026-02-16 21:58:58 +00:00
Alex Holliday 6f55e4934f matrix 2026-02-16 21:49:42 +00:00
Alex Holliday 0e1dc3e3fe slack 2026-02-16 21:48:03 +00:00
Alex Holliday e18681606c add webhook 2026-02-16 20:55:00 +00:00
Alex Holliday d2d75cd035 initial commit 2026-02-16 20:18:25 +00:00
Alex Holliday a65515fc08 logger type 2026-02-16 19:42:10 +00:00
Alex Holliday 302ac6f4f1 update gitignore 2026-02-16 19:32:28 +00:00
Alexander Holliday b51d40fe77 Merge pull request #3290 from bluewave-labs/feat/incident-on-threshold-exception
Feat/incident on threshold exception
2026-02-16 10:47:24 -08:00
Alex Holliday 27f3def9bd correct logger type 2026-02-16 18:43:03 +00:00
Alex Holliday e7a7c0e603 remove grouping 2026-02-16 18:39:41 +00:00
Alex Holliday 55885ea007 remove global decision 2026-02-16 18:25:35 +00:00
karenvicent 475801e91c fix traslation object 2026-02-16 00:47:23 -05:00
karenvicent cd501e46cd Merge branch 'develop' into settings-zod-schema 2026-02-16 00:12:08 -05:00
karenvicent 93c15588a4 implement schema zod and validation on settings 2026-02-16 00:09:08 -05:00
Alex Holliday a8ecd44abf inject settings service, extract client host 2026-02-16 00:39:34 +00:00
Alex Holliday 412a3b7cc9 remove redundant method 2026-02-16 00:17:51 +00:00
Alex Holliday be8580ee99 update providers 2026-02-15 18:35:54 +00:00
Alex Holliday 8020bfde21 refactor handleIncidents 2026-02-15 18:20:42 +00:00
Alex Holliday 7061eb8eb0 use decision object in incidentService 2026-02-15 18:09:21 +00:00
Alex Holliday 70ae15725e conditinally render breached stauts box 2026-02-14 23:09:27 +00:00
Alex Holliday 32a7bd6543 add breached status to summary header 2026-02-14 22:57:27 +00:00
Alex Holliday 68db4b8701 breached status 2026-02-14 22:52:14 +00:00
Alex Holliday b75c1a4fcb create incident on hardware breach 2026-02-14 22:29:06 +00:00
Alex Holliday e50240885d debug logging 2026-02-14 22:25:15 +00:00
Alex Holliday 22645d86f7 handle infra thresholds 2026-02-14 22:14:51 +00:00
Alex Holliday 62778886e1 use counters 2026-02-14 21:55:47 +00:00
Alex Holliday 9c467c763d add threshold counters 2026-02-14 21:49:32 +00:00
Alex Holliday 97ace1e058 remove unused checks 2026-02-14 21:38:00 +00:00
Alex Holliday 2fb6cb0b6f remove alertThreshold 2026-02-14 21:29:30 +00:00
Alex Holliday a7b0ce4d8f remove thresholds 2026-02-14 21:21:18 +00:00
Alex Holliday 4a73170583 initial commit 2026-02-14 21:05:31 +00:00
Alexander Holliday 41310cb552 Merge pull request #3286 from bluewave-labs/feat/infra-thresholds
feat: infra thresholds
2026-02-13 17:23:49 -08:00
Alex Holliday 111b68b8df revert slider size change 2026-02-14 01:21:02 +00:00
Alex Holliday 2430bbeebb remove slider hover 2026-02-14 01:15:12 +00:00
Alex Holliday f694917dd4 remove unused 2026-02-14 01:07:46 +00:00
Alex Holliday 47b18e1d9d use sliderwithlabels 2026-02-14 01:02:15 +00:00
Alexander Holliday 0d863aefe8 Merge pull request #3285 from bluewave-labs/fix/create-monitors-port
fix port input
2026-02-13 16:45:01 -08:00
Alex Holliday c319738048 add config box for thresholds 2026-02-14 00:40:59 +00:00
Alex Holliday 002a82bb9a fix port input 2026-02-14 00:19:01 +00:00
Alexander Holliday 8719efab5d Merge pull request #3284 from bluewave-labs/fix/routes
fix: routes
2026-02-13 16:17:57 -08:00
Alex Holliday 86fd535eaf routes to TS 2026-02-14 00:08:28 +00:00
Alex Holliday 2f6dbe9a3a update gitignore 2026-02-13 23:56:35 +00:00
Alexander Holliday 649eaae712 Merge pull request #3283 from bluewave-labs/fix/string-cleanup
clean up strings
2026-02-13 13:22:34 -08:00
Alex Holliday 1888ee0478 remove unused monitor import 2026-02-13 21:19:55 +00:00
Alex Holliday fd2c54d3d3 remove unused 2026-02-13 20:42:26 +00:00
Alex Holliday bd941db771 clean up strings 2026-02-13 20:41:27 +00:00
Alexander Holliday 88fceddbbf Merge pull request #3282 from bluewave-labs/fix/monitor-stats
fix: initializing stats
2026-02-13 11:18:54 -08:00
Alex Holliday 13251ca7c5 fix initializing stats 2026-02-13 19:04:19 +00:00
Alexander Holliday 6d6b597f99 Merge pull request #3281 from bluewave-labs/fix/cleanup
fix: cleanup
2026-02-13 11:01:43 -08:00
Alex Holliday dfc1733820 remove icons 2026-02-13 18:51:29 +00:00
mannilakash 39a449e594 fix: padding fix for stats text 2026-02-13 11:14:25 -05:00
mannilakash 974d33b3de enhancement: add average and max response time for histogram 2026-02-13 10:32:52 -05:00
Alex Holliday afe2b9d3bb remove unused images 2026-02-12 23:37:08 +00:00
Alex Holliday 5ab5cbe622 status box fix 2026-02-12 23:31:37 +00:00
Alex Holliday b6b1b8464c remove old DB references 2026-02-12 23:03:16 +00:00
Alex Holliday f337ca5ff0 initial commit 2026-02-12 22:37:40 +00:00
Alexander Holliday f5bc8e78e4 Merge pull request #3278 from bluewave-labs/fix/failed-check-warn
Fix/failed check warn
2026-02-12 11:44:48 -08:00
Alex Holliday 8089e71f96 warn instead of throw on failure to build check 2026-02-12 19:42:58 +00:00
karenvicent 6195650dc1 Merge branch 'develop' into settings-zod-schema 2026-02-12 14:25:05 -05:00
karenvicent f59d8bf1bf Merge branch 'develop' into settings-zod-schema 2026-02-12 12:18:05 -05:00
karenvicent 0941f3ce1a zod schema and hook 2026-02-12 12:13:36 -05:00
112 changed files with 4521 additions and 2788 deletions

No files matched your search

+3 -1
View File
@@ -3,4 +3,6 @@
.VSCodeCounter
*.sh
mongo
node_modules/
node_modules/
docs/architecture
docs/reviews
+1 -1
View File
@@ -147,7 +147,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
+5 -3
View File
@@ -2472,11 +2472,13 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -1,102 +1,155 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
height?: ResponsiveStyleValue<number | string>;
gap?: ResponsiveStyleValue<number | string>;
showStats?: boolean;
statsPosition?: "left" | "right";
}
interface ResponseTimeStats {
max: number | string;
avg: number | string;
}
const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) {
return { max: "-", avg: "-" };
}
const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) {
return { max: "-", avg: "-" };
}
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.max(...responseTimes);
const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
return { max, avg };
};
export const HistogramResponseTime = ({
checks,
height = DEFAULT_HEIGHT,
gap,
showStats = true,
statsPosition = "right",
}: HistogramResponseTimeProps) => {
const theme = useTheme();
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
let data = Array<any>();
const data =
checks.length !== 25
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
: checks;
if (!checks || checks.length === 0) {
data = [];
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill({
status: "placeholder",
});
data = [...checks, ...placeholders];
} else {
data = checks;
}
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
alignItems: "end",
height: chartHeight,
}}
const statsContent = showStats &&
(typeof stats.max === "number" || typeof stats.avg === "number") && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
minWidth={70}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor =
check.status === true
? theme.palette.success.light
: theme.palette.error.light;
const bar = (
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Typography variant="body2">
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography variant="body2">
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return (
<Stack
direction={statsPosition === "left" ? "row-reverse" : "row"}
alignItems="center"
sx={{ width: "100%" }}
>
<Box sx={{ flex: 1 }}>
<Box
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor =
check.status === true
? theme.palette.success.light
: theme.palette.error.light;
const bar = (
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
position: "relative",
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
/>
</Box>
);
>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
);
return isPlaceholder ? (
<Box
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
) : (
<HeatmapResponseTimeTooltip
key={index}
check={check}
>
{bar}
</HeatmapResponseTimeTooltip>
);
})}
return isPlaceholder ? (
<Box
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
) : (
<HeatmapResponseTimeTooltip
key={index}
check={check}
>
{bar}
</HeatmapResponseTimeTooltip>
);
})}
</Box>
</Box>
</Box>
{statsContent}
</Stack>
);
};
@@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/design-elements";
import Background from "@/assets/Images/background-grid.svg?react";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material";
@@ -18,6 +17,7 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
return (
<BaseBox
sx={{
backgroundColor: theme.palette.background.default,
overflow: "hidden",
position: "relative",
flex: 1,
@@ -27,11 +27,23 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
top={0}
left={0}
right={0}
bottom={0}
sx={{
pointerEvents: "none",
backgroundImage: `
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
"linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)",
WebkitMaskImage:
"linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)",
}}
/>
{children}
</BaseBox>
);
@@ -127,6 +139,17 @@ export const InitializingStatusBox = ({ n }: { n: number }) => {
/>
);
};
export const BreachedStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.breached")}
n={n}
color={theme.palette.warning.main}
/>
);
};
export const TotalChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
@@ -21,6 +21,8 @@ export const StatusLabel = ({ status, sx }: { status: MonitorStatus; sx?: SxProp
return t("pages.common.monitors.status.up");
} else if (status === "down") {
return t("pages.common.monitors.status.down");
} else if (status === "breached") {
return t("pages.common.monitors.status.breached");
} else if (status === "maintenance") {
return t("pages.common.monitors.status.maintenance");
} else if (status === "paused") {
@@ -38,11 +38,11 @@ export const ImageUpload = ({
const isValidType = accept.some((type) => file.type.includes(type));
const isValidSize = file.size <= maxSize;
if (!isValidType) {
setLocalError(t("common.errors.invalidFileFormat"));
setLocalError(t("components.imageUpload.errors.invalidFileFormat"));
return;
}
if (!isValidSize) {
setLocalError(t("common.errors.invalidFileSize"));
setLocalError(t("components.imageUpload.errors.invalidFileSize"));
return;
}
setLocalError(null);
@@ -137,15 +137,15 @@ export const ImageUpload = ({
color="primary"
fontWeight={500}
>
{t("common.imageUpload.clickToUpload")}
{t("components.imageUpload.clickToUpload")}
</Typography>{" "}
{t("common.imageUpload.orDragAndDrop")}
{t("components.imageUpload.orDragAndDrop")}
</Typography>
<Typography
variant="caption"
color="text.disabled"
>
{accept.join(", ").toUpperCase()} • {t("common.imageUpload.maxSize")}{" "}
{accept.join(", ").toUpperCase()} • {t("components.imageUpload.maxSize")}{" "}
{Math.round(maxSize / 1024 / 1024)}MB
</Typography>
</Stack>
+4 -1
View File
@@ -41,7 +41,10 @@ export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
"&:hover, &.Mui-focusVisible": {
boxShadow: `0 0 0 8px ${theme.palette.primary.main}20`,
boxShadow: "none",
},
"&:active": {
boxShadow: "none",
},
},
"& .MuiSlider-valueLabel": {
@@ -3,6 +3,7 @@ import {
DownStatusBox,
PausedStatusBox,
InitializingStatusBox,
BreachedStatusBox,
} from "@/Components/v2/design-elements";
import Stack from "@mui/material/Stack";
@@ -11,9 +12,13 @@ import { useTheme } from "@mui/material";
interface MonitorsSummaryProps {
summary: MonitorsSummary | null;
showBreached?: boolean;
}
export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => {
export const HeaderMonitorsSummary = ({
summary,
showBreached = false,
}: MonitorsSummaryProps) => {
const theme = useTheme();
return (
<Stack
@@ -22,6 +27,7 @@ export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => {
>
<UpStatusBox n={summary?.upMonitors || 0} />
<DownStatusBox n={summary?.downMonitors || 0} />
{showBreached && <BreachedStatusBox n={summary?.breachedMonitors || 0} />}
<PausedStatusBox n={summary?.pausedMonitors || 0} />
<InitializingStatusBox n={summary?.initializingMonitors || 0} />
</Stack>
+7 -4
View File
@@ -37,6 +37,7 @@ export const useMonitorForm = ({
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
globalpingEnabled: data?.globalpingEnabled || false,
};
break;
case "ping":
@@ -44,6 +45,7 @@ export const useMonitorForm = ({
...base,
type: "ping",
url: data?.url || "",
globalpingEnabled: data?.globalpingEnabled || false,
};
break;
case "port":
@@ -83,10 +85,10 @@ export const useMonitorForm = ({
type: "hardware",
url: data?.url || "",
secret: data?.secret || "",
cpuAlertThreshold: data?.cpuAlertThreshold ?? 80,
memoryAlertThreshold: data?.memoryAlertThreshold ?? 80,
diskAlertThreshold: data?.diskAlertThreshold ?? 80,
tempAlertThreshold: data?.tempAlertThreshold ?? 80,
cpuAlertThreshold: data?.cpuAlertThreshold ?? 100,
memoryAlertThreshold: data?.memoryAlertThreshold ?? 100,
diskAlertThreshold: data?.diskAlertThreshold ?? 100,
tempAlertThreshold: data?.tempAlertThreshold ?? 100,
selectedDisks: data?.selectedDisks || [],
};
break;
@@ -100,6 +102,7 @@ export const useMonitorForm = ({
matchMethod: "",
expectedValue: "",
jsonPath: "",
globalpingEnabled: false,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { useMemo } from "react";
import { settingsSchema } from "@/Validation/settings";
import type { Settings } from "@/Types/Settings";
import type { SettingsFormData } from "@/Validation/settings";
interface UseSettingsFormOptions {
data?: Settings | null;
}
export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => {
return useMemo(() => {
const defaults: SettingsFormData = {
systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false,
systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true,
systemEmailSecure: data?.systemEmailSecure ?? false,
systemEmailPool: data?.systemEmailPool ?? false,
showURL: data?.showURL ?? false,
systemEmailHost: data?.systemEmailHost || "",
systemEmailUser: data?.systemEmailUser || "",
systemEmailAddress: data?.systemEmailAddress || "",
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
globalThresholds: {
cpu: data?.globalThresholds?.cpu,
memory: data?.globalThresholds?.memory,
disk: data?.globalThresholds?.disk,
temperature: data?.globalThresholds?.temperature,
},
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
globalpingApiKey: "",
globalpingLocationsTier: data?.globalpingLocationsTier ?? 6,
systemEmailPassword: "",
};
return { schema: settingsSchema, defaults };
}, [data]);
};
@@ -97,27 +97,27 @@ const SetNewPasswordPage = () => {
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("auth.common.passwordRules.length")}
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("auth.common.passwordRules.special")}
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("auth.common.passwordRules.number")}
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("auth.common.passwordRules.uppercase")}
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("auth.common.passwordRules.lowercase")}
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("auth.common.passwordRules.match")}
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
@@ -127,7 +127,7 @@ const SetNewPasswordPage = () => {
fullWidth
loading={loading}
>
{t("auth.forgotPassword.buttons.resetPassword")}
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
+184 -23
View File
@@ -146,7 +146,6 @@ const CreateMonitorPage = () => {
const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: games } = useGet<GamesMap>("/monitors/games");
const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null,
defaultType,
@@ -223,8 +222,8 @@ const CreateMonitorPage = () => {
}
};
const onError = (errors: unknown) => {
console.log(errors);
const onError = (_errors: unknown) => {
// Validation errors are shown inline via react-hook-form field errors
};
return (
@@ -329,11 +328,16 @@ const CreateMonitorPage = () => {
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
onChange={(e) => field.onChange(Number(e.target.value) || 0)}
value={field.value === 0 ? "" : field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
type="number"
fieldLabel={t("portToMonitor")}
placeholder="5173"
fieldLabel={t("pages.createMonitor.form.general.option.port.label")}
placeholder={t(
"pages.createMonitor.form.general.option.port.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
@@ -351,10 +355,12 @@ const CreateMonitorPage = () => {
<Select
{...field}
value={field.value ?? ""}
fieldLabel={t("chooseGame")}
fieldLabel={t("pages.createMonitor.form.general.option.game.label")}
error={!!fieldState.error}
>
<MenuItem value="">Select a game</MenuItem>
<MenuItem value="">
{t("pages.createMonitor.form.general.option.game.placeholder")}{" "}
</MenuItem>
{games &&
Object.entries(games).map(([key, game]) => (
<MenuItem
@@ -426,22 +432,167 @@ const CreateMonitorPage = () => {
)}
error={!!fieldState.error}
>
<MenuItem value={15000}>{t("time.fifteenSeconds")}</MenuItem>
<MenuItem value={30000}>{t("time.thirtySeconds")}</MenuItem>
<MenuItem value={60000}>{t("time.oneMinute")}</MenuItem>
<MenuItem value={120000}>{t("time.twoMinutes")}</MenuItem>
<MenuItem value={180000}>{t("time.threeMinutes")}</MenuItem>
<MenuItem value={240000}>{t("time.fourMinutes")}</MenuItem>
<MenuItem value={300000}>{t("time.fiveMinutes")}</MenuItem>
<MenuItem value={600000}>{t("time.tenMinutes")}</MenuItem>
<MenuItem value={900000}>{t("time.fifteenMinutes")}</MenuItem>
<MenuItem value={1800000}>{t("time.thirtyMinutes")}</MenuItem>
<MenuItem value={15000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.fifteenSeconds"
)}
</MenuItem>
<MenuItem value={30000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.thirtySeconds"
)}
</MenuItem>
<MenuItem value={60000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.oneMinute"
)}
</MenuItem>
<MenuItem value={120000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.twoMinutes"
)}
</MenuItem>
<MenuItem value={180000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.threeMinutes"
)}
</MenuItem>
<MenuItem value={240000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.fourMinutes"
)}
</MenuItem>
<MenuItem value={300000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.fiveMinutes"
)}
</MenuItem>
<MenuItem value={600000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.tenMinutes"
)}
</MenuItem>
<MenuItem value={900000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.fifteenMinutes"
)}
</MenuItem>
<MenuItem value={1800000}>
{t(
"pages.createMonitor.form.frequency.option.frequency.value.thirtyMinutes"
)}
</MenuItem>
</Select>
)}
/>
}
/>
{/* Global monitoring - only for http and ping types */}
{(watchedType === "http" || watchedType === "ping") && (
<ConfigBox
title={t("pages.createMonitor.form.globalping.title")}
subtitle={t("pages.createMonitor.form.globalping.description")}
rightContent={
<Controller
name="globalpingEnabled"
control={control}
render={({ field }) => (
<Switch
id="globalpingEnabled"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
}
/>
)}
{/* Alert Thresholds - only for hardware type */}
{generalSettingsConfig.showSecret && (
<ConfigBox
title={t("pages.createMonitor.form.thresholds.title")}
subtitle={t("pages.createMonitor.form.thresholds.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="cpuAlertThreshold"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t(
"pages.createMonitor.form.thresholds.option.cpuThreshold.label"
)}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
/>
)}
/>
<Controller
name="memoryAlertThreshold"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t(
"pages.createMonitor.form.thresholds.option.memoryThreshold.label"
)}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
/>
)}
/>
<Controller
name="diskAlertThreshold"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t(
"pages.createMonitor.form.thresholds.option.diskThreshold.label"
)}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
/>
)}
/>
<Controller
name="tempAlertThreshold"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t(
"pages.createMonitor.form.thresholds.option.tempThreshold.label"
)}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}°C`}
/>
)}
/>
</Stack>
}
/>
)}
<ConfigBox
title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")}
@@ -617,11 +768,21 @@ const CreateMonitorPage = () => {
"pages.createMonitor.form.advanced.option.matchMethod.label"
)}
>
<MenuItem value="equal">{t("matchMethodOptions.equal")}</MenuItem>
<MenuItem value="include">
{t("matchMethodOptions.include")}
<MenuItem value="equal">
{t(
"pages.createMonitor.form.advanced.option.matchMethod.equal"
)}
</MenuItem>
<MenuItem value="include">
{t(
"pages.createMonitor.form.advanced.option.matchMethod.include"
)}
</MenuItem>
<MenuItem value="regex">
{t(
"pages.createMonitor.form.advanced.option.matchMethod.regex"
)}
</MenuItem>
<MenuItem value="regex">{t("matchMethodOptions.regex")}</MenuItem>
</Select>
)}
/>
@@ -46,10 +46,9 @@ export const DialogResolution = ({
return (
<Dialog
open={open}
title={t("incidentsPage.resolveIncidentDialogTitle")}
title={t("pages.incidents.dialog.resolveIncident.title")}
onCancel={handleCancel}
onConfirm={handleConfirm}
confirmText={t("incidentsPage.resolveIncidentDialogConfirm")}
confirmColor="error"
cancelColor="primary"
loading={isResolving}
@@ -58,8 +57,10 @@ export const DialogResolution = ({
>
<Box sx={{ mt: theme.spacing(4) }}>
<TextField
fieldLabel={t("incidentsPage.resolveIncidentDialogCommentLabel")}
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
placeholder={t(
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
)}
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
@@ -133,7 +133,10 @@ const InfrastructureMonitors = () => {
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary summary={summary} />
<HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
@@ -196,13 +196,13 @@ export const MaintenanceWindowTable = ({
/>
<DialogInput
open={deleteDialogOpen}
title={t("maintenanceTableActionMenuDialogTitle")}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onCancel={() => {
setDeleteDialogOpen(false);
setSelectedWindow(null);
}}
onConfirm={handleDelete}
confirmText={t("delete")}
loading={deleteLoading}
/>
</Box>
+2 -2
View File
@@ -9,8 +9,8 @@ import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
interface NotFoundProps {
title: string;
desc: string;
title?: string;
desc?: string;
}
const NotFoundPage = ({ title, desc }: NotFoundProps) => {
+2 -2
View File
@@ -16,7 +16,7 @@ const SettingsAbout = () => {
component="h1"
variant="h2"
>
{t("settingsPage.aboutSettings.title")}
{t("pages.settings.aboutSettings.title")}
</Typography>
</Box>
<Box>
@@ -24,7 +24,7 @@ const SettingsAbout = () => {
{t("common.appName")} {__APP_VERSION__}
</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}>
{t("settingsPage.aboutSettings.labelDevelopedBy")}
{t("pages.settings.aboutSettings.labelDevelopedBy")}
</Typography>
<Link
level="secondary"
@@ -8,8 +8,9 @@ import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import Dialog from "@/Components/v1/Dialog/index.jsx";
import { useState } from "react";
import { useDelete, usePost } from "@/Hooks/UseApi";
const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) => {
const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, isLoading }) => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
@@ -18,7 +19,8 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
if (!isAdmin) {
return null;
}
const { post: postDemoMonitors } = usePost();
const { deleteFn: deleteAllMonitorsFn } = useDelete();
return (
<>
<ConfigBox>
@@ -27,10 +29,10 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
component="h1"
variant="h2"
>
{t("settingsPage.demoMonitorsSettings.title")}
{t("pages.settings.demoMonitorsSettings.title")}
</Typography>
<Typography sx={HEADER_SX}>
{t("settingsPage.demoMonitorsSettings.description")}
{t("pages.settings.demoMonitorsSettings.description")}
</Typography>
</Box>
<Box>
@@ -38,17 +40,12 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
variant="contained"
color="accent"
loading={isLoading}
onClick={() => {
const syntheticEvent = {
target: {
name: "demo",
},
};
handleChange(syntheticEvent);
onClick={async () => {
await postDemoMonitors("/monitors/demo", {});
}}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsPage.demoMonitorsSettings.buttonAddMonitors")}
{t("pages.settings.demoMonitorsSettings.buttonAddMonitors")}
</Button>
</Box>
</ConfigBox>
@@ -58,10 +55,10 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
component="h1"
variant="h2"
>
{t("settingsPage.systemResetSettings.title")}
{t("pages.settings.systemResetSettings.title")}
</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsPage.systemResetSettings.description")}
{t("pages.settings.systemResetSettings.description")}
</Typography>
</Box>
<Box>
@@ -72,22 +69,17 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
onClick={() => setIsOpen(true)}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsPage.systemResetSettings.buttonRemoveAllMonitors")}
{t("pages.settings.systemResetSettings.buttonRemoveAllMonitors")}
</Button>
</Box>
<Dialog
open={isOpen}
theme={theme}
title={t("settingsPage.systemResetSettings.dialogTitle")}
title={t("pages.settings.systemResetSettings.dialogTitle")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("settingsPage.systemResetSettings.dialogConfirm")}
onConfirm={() => {
const syntheticEvent = {
target: {
name: "deleteMonitors",
},
};
handleChange(syntheticEvent);
confirmationButtonLabel={t("pages.settings.systemResetSettings.dialogConfirm")}
onConfirm={async () => {
await deleteAllMonitorsFn("/monitors/");
setIsOpen(false);
}}
isLoading={isLoading}
@@ -99,7 +91,6 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
SettingsDemoMonitors.propTypes = {
isAdmin: PropTypes.bool,
handleChange: PropTypes.func,
HEADER_SX: PropTypes.object,
};
+247 -216
View File
@@ -15,16 +15,18 @@ import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornment
import { usePost } from "@/Hooks/UseApi";
import { useSelector } from "react-redux";
import { createToast } from "@/Utils/toastUtils.jsx";
import { Controller } from "react-hook-form";
const SettingsEmail = ({
isAdmin,
HEADER_SX,
handleChange,
settingsData,
setSettingsData,
isEmailPasswordSet,
emailPasswordHasBeenReset,
setEmailPasswordHasBeenReset,
control,
defaults,
formValues,
setValue,
}) => {
// Setup
const { t } = useTranslation();
@@ -44,22 +46,13 @@ const SettingsEmail = ({
systemEmailIgnoreTLS = false,
systemEmailRequireTLS = false,
systemEmailRejectUnauthorized = true,
} = settingsData?.settings || {};
// Local state
const [password, setPassword] = useState("");
} = formValues || {};
// Network
const { post: sendTestEmailFn, loading: isSending } = usePost();
const user = useSelector((state) => state.auth.user);
// Handlers
const handlePasswordChange = (e) => {
setPassword(e.target.value);
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, systemEmailPassword: e.target.value },
});
};
/**
* Handle sending test email with current form values
@@ -70,10 +63,10 @@ const SettingsEmail = ({
!systemEmailHost ||
!systemEmailPort ||
!systemEmailAddress ||
!(password || systemEmailPassword)
!systemEmailPassword
) {
createToast({
body: t("settingsPage.emailSettings.toastEmailRequiredFieldsError"),
body: t("pages.settings.emailSettings.toastEmailRequiredFieldsError"),
variant: "error",
});
return;
@@ -85,7 +78,7 @@ const SettingsEmail = ({
systemEmailHost,
systemEmailPort,
systemEmailAddress,
systemEmailPassword: password || systemEmailPassword,
systemEmailPassword,
systemEmailSecure,
systemEmailPool,
systemEmailIgnoreTLS,
@@ -108,229 +101,267 @@ const SettingsEmail = ({
component="h1"
variant="h2"
>
{t("settingsPage.emailSettings.title")}
{t("pages.settings.emailSettings.title")}
</Typography>
<Typography sx={HEADER_SX}>
{t("settingsPage.emailSettings.description")}
{t("pages.settings.emailSettings.description")}
</Typography>
</Box>
<Box>
<Stack gap={theme.spacing(10)}>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelHost")}
name="systemEmailHost"
placeholder="smtp.gmail.com"
value={systemEmailHost}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelPort")}
name="systemEmailPort"
placeholder="425"
type="number"
value={systemEmailPort}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelUser")}
name="systemEmailUser"
placeholder={t("settingsPage.emailSettings.placeholderUser")}
value={systemEmailUser}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelAddress")}
name="systemEmailAddress"
placeholder="uptime@bluewavelabs.ca"
value={systemEmailAddress}
onChange={handleChange}
/>
</Box>
{(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && (
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelPassword")}
name="systemEmailPassword"
type="password"
placeholder="123 456 789 101112"
value={password}
onChange={handlePasswordChange}
endAdornment={<PasswordEndAdornment />}
/>
</Box>
)}
{isEmailPasswordSet === true && emailPasswordHasBeenReset === false && (
<Box>
<Typography>{t("settingsPage.emailSettings.labelPasswordSet")}</Typography>
<Button
onClick={() => {
setPassword("");
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, systemEmailPassword: "" },
});
setEmailPasswordHasBeenReset(true);
}}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
<Stack gap={theme.spacing(10)}>
<Box>
<Controller
name="systemEmailHost"
control={control}
defaultValue={defaults.systemEmailHost}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelHost")}
placeholder="smtp.gmail.com"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Box>
<Box>
<Controller
name="systemEmailPort"
control={control}
defaultValue={defaults.systemEmailPort}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelPort")}
placeholder="425"
type="number"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Box>
<Box>
<Controller
name="systemEmailUser"
control={control}
defaultValue={defaults.systemEmailUser}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelUser")}
placeholder={t("pages.settings.emailSettings.placeholderUser")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Box>
<Box>
<Controller
name="systemEmailAddress"
control={control}
defaultValue={defaults.systemEmailAddress}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelAddress")}
placeholder="uptime@bluewavelabs.ca"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Box>
{(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && (
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelTLSServername")}
name="systemEmailTLSServername"
placeholder="bluewavelabs.ca"
value={systemEmailTLSServername}
onChange={handleChange}
<Controller
name="systemEmailPassword"
control={control}
defaultValue={defaults.systemEmailPassword}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelPassword")}
type="password"
placeholder="123 456 789 101112"
endAdornment={<PasswordEndAdornment />}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Box>
)}
{isEmailPasswordSet === true && emailPasswordHasBeenReset === false && (
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelConnectionHost")}
name="systemEmailConnectionHost"
placeholder="bluewavelabs.ca"
value={systemEmailConnectionHost}
onChange={handleChange}
/>
<Typography>{t("pages.settings.emailSettings.labelPasswordSet")}</Typography>
<Button
onClick={() => {
setEmailPasswordHasBeenReset(true);
}}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
<Box>
<Controller
name="systemEmailTLSServername"
control={control}
defaultValue={defaults.systemEmailTLSServername}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelTLSServername")}
placeholder="bluewavelabs.ca"
/>
)}
/>
</Box>
<Box>
<Controller
name="systemEmailConnectionHost"
control={control}
defaultValue={defaults.systemEmailConnectionHost}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.emailSettings.labelConnectionHost")}
placeholder="bluewavelabs.ca"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(4),
}}
>
{[
[
"pages.settings.emailSettings.labelSecure",
"systemEmailSecure",
systemEmailSecure,
],
[
"pages.settings.emailSettings.labelPool",
"systemEmailPool",
systemEmailPool,
],
[
"pages.settings.emailSettings.labelIgnoreTLS",
"systemEmailIgnoreTLS",
systemEmailIgnoreTLS,
],
[
"pages.settings.emailSettings.labelRequireTLS",
"systemEmailRequireTLS",
systemEmailRequireTLS,
],
[
"pages.settings.emailSettings.labelRejectUnauthorized",
"systemEmailRejectUnauthorized",
systemEmailRejectUnauthorized,
],
].map(([labelKey, name, value]) => (
<Controller
key={name}
name={name}
control={control}
defaultValue={defaults[name]}
render={({ field }) => (
<Box
key={name}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography>{t(labelKey)}</Typography>
<Switch
{...field}
checked={field.value}
/>
</Box>
)}
/>
))}
<TextLink
text={t("pages.settings.emailSettings.descriptionTransport")}
linkText={t("pages.settings.emailSettings.linkTransport")}
href="https://nodemailer.com/smtp"
target="_blank"
/>
<Box
component={"pre"}
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(4),
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
}}
>
{[
[
"settingsPage.emailSettings.labelSecure",
"systemEmailSecure",
systemEmailSecure,
],
[
"settingsPage.emailSettings.labelPool",
"systemEmailPool",
systemEmailPool,
],
[
"settingsPage.emailSettings.labelIgnoreTLS",
"systemEmailIgnoreTLS",
systemEmailIgnoreTLS,
],
[
"settingsPage.emailSettings.labelRequireTLS",
"systemEmailRequireTLS",
systemEmailRequireTLS,
],
[
"settingsPage.emailSettings.labelRejectUnauthorized",
"systemEmailRejectUnauthorized",
systemEmailRejectUnauthorized,
],
].map(([labelKey, name, value]) => (
<Box
key={name}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography>{t(labelKey)}</Typography>
<Switch
name={name}
checked={value}
onChange={handleChange}
/>
</Box>
))}
<TextLink
text={t("settingsPage.emailSettings.descriptionTransport")}
linkText={t("settingsPage.emailSettings.linkTransport")}
href="https://nodemailer.com/smtp"
target="_blank"
/>
<Box
component={"pre"}
sx={{
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: systemEmailHost,
port: systemEmailPort,
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: "<your_password>",
},
name: systemEmailConnectionHost || "localhost",
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
<code>
{JSON.stringify(
{
host: systemEmailHost,
port: systemEmailPort,
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: "<your_password>",
},
null,
2
)}
</code>
</Box>
</Box>
<pre>
{JSON.stringify({
systemEmailHost,
systemEmailAddress,
systemEmailPassword,
})}
</pre>
<Box>
{systemEmailHost &&
systemEmailPort &&
systemEmailAddress &&
systemEmailPassword && (
<Button
variant="contained"
color="accent"
loading={isSending}
onClick={handleSendTestEmail}
>
{t("settingsPage.emailSettings.buttonSendTestEmail")}
</Button>
name: systemEmailConnectionHost || "localhost",
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
},
null,
2
)}
</code>
</Box>
</Stack>
</Box>
</Box>
<Box>
{systemEmailHost &&
systemEmailPort &&
systemEmailAddress &&
systemEmailPassword && (
<Button
variant="contained"
color="accent"
loading={isSending}
onClick={handleSendTestEmail}
>
{t("pages.settings.emailSettings.buttonSendTestEmail")}
</Button>
)}
</Box>
</Stack>
</ConfigBox>
);
};
SettingsEmail.propTypes = {
isAdmin: PropTypes.bool,
settingsData: PropTypes.object,
setSettingsData: PropTypes.func,
handleChange: PropTypes.func,
HEADER_SX: PropTypes.object,
isPasswordSet: PropTypes.bool,
};
+26 -10
View File
@@ -8,8 +8,9 @@ import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import Dialog from "@/Components/v1/Dialog/index.jsx";
import { useState } from "react";
import { useLazyGet } from "@/Hooks/UseApi";
const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) => {
const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, isLoading }) => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
@@ -18,6 +19,29 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
if (!isAdmin) {
return null;
}
const { get: fetchJson } = useLazyGet();
const handleExport = async () => {
const res = await fetchJson("/monitors/export/json");
const json = res?.data ?? [];
if (!json || json.length === 0) {
return;
}
const blob = new Blob([JSON.stringify(json, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "monitors.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return;
};
return (
<>
@@ -38,14 +62,7 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
variant="contained"
color="accent"
loading={isLoading}
onClick={() => {
const syntheticEvent = {
target: {
name: "export",
},
};
handleChange(syntheticEvent);
}}
onClick={handleExport}
sx={{ mt: theme.spacing(4) }}
>
Export Monitors to JSON
@@ -58,7 +75,6 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
SettingsDemoMonitors.propTypes = {
isAdmin: PropTypes.bool,
handleChange: PropTypes.func,
HEADER_SX: PropTypes.object,
};
@@ -7,39 +7,18 @@ import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { Controller } from "react-hook-form";
const SettingsGlobalThresholds = ({
isAdmin,
HEADING_SX,
settingsData,
setSettingsData,
control,
defaults,
}) => {
const { t } = useTranslation(); // For language translation
const theme = useTheme(); // MUI theme access
// Handles input change and updates parent state
const handleChange = (e, min, max) => {
const { name, value } = e.target;
const numValue = parseFloat(value);
const isValidNumber =
value === "" ||
(!isNaN(numValue) && isFinite(numValue) && numValue >= min && numValue <= max);
if (isValidNumber) {
setSettingsData((prev) => ({
...prev,
settings: {
...prev.settings,
globalThresholds: {
...prev.settings?.globalThresholds,
[name]: value,
},
},
}));
}
};
// Only render this section for admins
if (!isAdmin) return null;
@@ -51,11 +30,11 @@ const SettingsGlobalThresholds = ({
component="h1"
variant="h2"
>
{t("settingsPage.globalThresholds.title", "Global Thresholds")}
{t("pages.settings.globalThresholds.title", "Global Thresholds")}
</Typography>
<Typography sx={HEADING_SX}>
{t(
"settingsPage.globalThresholds.description",
"pages.settings.globalThresholds.description",
"Configure global CPU, Memory, Disk, and Temperature thresholds."
)}
</Typography>
@@ -69,14 +48,21 @@ const SettingsGlobalThresholds = ({
["Disk Threshold (%)", "disk", 1, 100],
["Temperature Threshold (°C)", "temperature", 1, 150],
].map(([label, name, min, max]) => (
<TextInput
<Controller
key={name}
name={name}
label={label}
placeholder={`${min} - ${max}`}
type="number"
value={settingsData?.settings?.globalThresholds?.[name] || ""}
onChange={(e) => handleChange(e, min, max)}
name={`globalThresholds.${name}`}
control={control}
defaultValue={defaults.globalThresholds?.[name]}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={label}
placeholder={`${min} - ${max}`}
type="number"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
))}
</Stack>
@@ -88,8 +74,6 @@ const SettingsGlobalThresholds = ({
SettingsGlobalThresholds.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
settingsData: PropTypes.object,
setSettingsData: PropTypes.func,
};
export default SettingsGlobalThresholds;
@@ -0,0 +1,197 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import Radio from "@mui/material/Radio";
import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { Controller } from "react-hook-form";
const SettingsGlobalping = ({
isAdmin,
HEADING_SX,
isGlobalpingKeySet,
globalpingKeyHasBeenReset,
setGlobalpingKeyHasBeenReset,
defaults,
control,
setValue,
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (!isAdmin) {
return null;
}
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("pages.settings.globalpingSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("pages.settings.globalpingSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
{(isGlobalpingKeySet === false || globalpingKeyHasBeenReset === true) && (
<Controller
name="globalpingApiKey"
control={control}
defaultValue={defaults.globalpingApiKey}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.globalpingSettings.labelApiKey")}
type={"password"}
optionalLabel={t("pages.settings.globalpingSettings.optionalLabel")}
endAdornment={<PasswordEndAdornment />}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
)}
{isGlobalpingKeySet === true && globalpingKeyHasBeenReset === false && (
<Box>
<Typography>
{t("pages.settings.globalpingSettings.labelApiKeySet")}
</Typography>
<Button
onClick={() => {
setValue("globalpingApiKey", "");
setGlobalpingKeyHasBeenReset(true);
}}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
<Box>
<Typography
variant="h2"
sx={{ mb: theme.spacing(2) }}
>
{t("pages.settings.globalpingSettings.locationsTierTitle")}
</Typography>
<Typography sx={{ mb: theme.spacing(8), color: theme.palette.text.secondary }}>
{t("pages.settings.globalpingSettings.locationsTierDescription")}
</Typography>
<Controller
name="globalpingLocationsTier"
control={control}
defaultValue={defaults.globalpingLocationsTier}
render={({ field }) => (
<RadioGroup
value={String(field.value ?? 6)}
onChange={(e) => field.onChange(Number(e.target.value))}
sx={{ gap: theme.spacing(4) }}
>
{[
{ value: "3", labelKey: "tier3Label", descKey: "tier3Description" },
{ value: "6", labelKey: "tier6Label", descKey: "tier6Description" },
{ value: "15", labelKey: "tier15Label", descKey: "tier15Description" },
].map((tier) => (
<FormControlLabel
key={tier.value}
value={tier.value}
control={<Radio sx={{ p: 0, mr: theme.spacing(4) }} />}
label={
<Box>
<Typography
fontWeight={500}
lineHeight={1.5}
>
{t(`pages.settings.globalpingSettings.${tier.labelKey}`)}
</Typography>
<Typography
variant="body2"
color="text.secondary"
>
{t(`pages.settings.globalpingSettings.${tier.descKey}`)}
</Typography>
</Box>
}
sx={{ alignItems: "flex-start", m: 0 }}
/>
))}
</RadioGroup>
)}
/>
</Box>
<Box
sx={{
p: theme.spacing(8),
borderRadius: theme.spacing(2),
border: `1px solid ${theme.palette.primary.lowContrast}`,
}}
>
<Typography
variant="h2"
sx={{ mb: theme.spacing(4) }}
>
{t("pages.settings.globalpingSettings.rateLimitsTitle")}
</Typography>
<Typography sx={{ mb: theme.spacing(4) }}>
{t("pages.settings.globalpingSettings.rateLimitsDescription")}
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: theme.spacing(8) }}
>
<Typography
component="li"
sx={{ mb: theme.spacing(2) }}
>
{t("pages.settings.globalpingSettings.rateLimitNoAuth")}
</Typography>
<Typography
component="li"
sx={{ mb: theme.spacing(2) }}
>
{t("pages.settings.globalpingSettings.rateLimitAuth")}
</Typography>
<Typography component="li">
{t("pages.settings.globalpingSettings.rateLimitSponsor")}
</Typography>
</Box>
<Typography
color="text.secondary"
sx={{ mt: theme.spacing(4), fontStyle: "italic" }}
>
{t("pages.settings.globalpingSettings.rateLimitsNote")}
</Typography>
</Box>
</Stack>
</ConfigBox>
);
};
SettingsGlobalping.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
isGlobalpingKeySet: PropTypes.bool,
globalpingKeyHasBeenReset: PropTypes.bool,
setGlobalpingKeyHasBeenReset: PropTypes.func,
defaults: PropTypes.object,
control: PropTypes.object,
setValue: PropTypes.func,
};
export default SettingsGlobalping;
+24 -28
View File
@@ -10,15 +10,17 @@ import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller } from "react-hook-form";
const SettingsPagespeed = ({
isAdmin,
HEADING_SX,
settingsData,
setSettingsData,
isApiKeySet,
apiKeyHasBeenReset,
setApiKeyHasBeenReset,
defaults,
control,
setValue,
}) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -26,15 +28,6 @@ const SettingsPagespeed = ({
// Local state
const [apiKey, setApiKey] = useState("");
// Handler
const handleChange = (e) => {
setApiKey(e.target.value);
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, pagespeedApiKey: e.target.value },
});
};
if (!isAdmin) {
return null;
}
@@ -46,35 +39,40 @@ const SettingsPagespeed = ({
component="h1"
variant="h2"
>
{t("settingsPage.pageSpeedSettings.title")}
{t("pages.settings.pageSpeedSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.pageSpeedSettings.description")}
{t("pages.settings.pageSpeedSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
{(isApiKeySet === false || apiKeyHasBeenReset === true) && (
<TextInput
<Controller
name="pagespeedApiKey"
label={t("settingsPage.pageSpeedSettings.labelApiKey")}
value={apiKey}
type={"password"}
onChange={handleChange}
optionalLabel="(Optional)"
endAdornment={<PasswordEndAdornment />}
control={control}
defaultValue={defaults.pagespeedApiKey}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.pageSpeedSettings.labelApiKey")}
type={"password"}
optionalLabel="(Optional)"
endAdornment={<PasswordEndAdornment />}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
)}
{isApiKeySet === true && apiKeyHasBeenReset === false && (
<Box>
<Typography>{t("settingsPage.pageSpeedSettings.labelApiKeySet")}</Typography>
<Typography>
{t("pages.settings.pageSpeedSettings.labelApiKeySet")}
</Typography>
<Button
onClick={() => {
setApiKey("");
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, pagespeedApiKey: "" },
});
setValue("pagespeedApiKey", "");
setApiKeyHasBeenReset(true);
}}
variant="contained"
@@ -93,8 +91,6 @@ const SettingsPagespeed = ({
SettingsPagespeed.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
settingsData: PropTypes.object,
setSettingsData: PropTypes.func,
isApiKeySet: PropTypes.bool,
setIsApiKeySet: PropTypes.func,
apiKeyHasBeenReset: PropTypes.bool,
+13 -18
View File
@@ -11,8 +11,9 @@ import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useDelete } from "@/Hooks/UseApi";
const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors }) => {
const SettingsStats = ({ isAdmin, HEADING_SX, errors }) => {
const theme = useTheme();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
@@ -20,6 +21,7 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
if (!isAdmin) {
return null;
}
const { deleteFn: deleteMonitorStatsFn } = useDelete();
return (
<ConfigBox>
@@ -29,16 +31,16 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
variant="h2"
sx={HEADING_SX}
>
{t("settingsPage.statsSettings.title")}
{t("pages.settings.statsSettings.title")}
</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsPage.statsSettings.description")}
{t("pages.settings.statsSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Typography>
{t("settingsPage.statsSettings.clearAllStatsDescription")}
{t("pages.settings.statsSettings.clearAllStatsDescription")}
</Typography>
<Button
variant="contained"
@@ -46,26 +48,22 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
onClick={() => setIsOpen(true)}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsPage.statsSettings.clearAllStatsButton")}
{t("pages.settings.statsSettings.clearAllStatsButton")}
</Button>
</Box>
</Stack>
<Dialog
open={isOpen}
theme={theme}
title={t("settingsPage.statsSettings.clearAllStatsDialogTitle")}
description={t("settingsPage.statsSettings.clearAllStatsDialogDescription")}
title={t("pages.settings.statsSettings.clearAllStatsDialogTitle")}
description={t("pages.settings.statsSettings.clearAllStatsDialogDescription")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t(
"settingsPage.statsSettings.clearAllStatsDialogConfirm"
"pages.settings.statsSettings.clearAllStatsDialogConfirm"
)}
onConfirm={() => {
const syntheticEvent = {
target: {
name: "deleteStats",
},
};
handleChange(syntheticEvent);
onConfirm={async () => {
await deleteMonitorStatsFn("/checks/team");
setIsOpen(false);
}}
isLoading={false}
@@ -77,9 +75,6 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
SettingsStats.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
settingsData: PropTypes.object,
errors: PropTypes.object,
};
export default SettingsStats;
+12 -17
View File
@@ -7,31 +7,28 @@ import timezones from "@/Utils/timezones.json";
// Utils
import { useTheme } from "@emotion/react";
import { useSelector, useDispatch } from "react-redux";
import { setTimezone } from "@/Features/UI/uiSlice.js";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
const SettingsTimeZone = ({ HEADING_SX }) => {
const theme = useTheme();
const { t } = useTranslation();
const [rawInput, setRawInput] = useState("");
const dispatch = useDispatch();
const { timezone } = useSelector((state) => state.ui);
const selectedTimezone = useMemo(
() => timezones.find((tz) => tz._id === timezone) ?? null,
[timezone]
);
const handleTimezoneChange = useCallback(
(newValue) => {
setRawInput("");
handleChange({
target: {
name: "timezone",
value: newValue?._id ?? "",
},
});
},
[handleChange]
);
const handleTimezoneChange = (newValue) => {
setRawInput("");
const newId = newValue?._id ?? "";
dispatch(setTimezone({ timezone: newId }));
};
return (
<ConfigBox>
@@ -40,11 +37,11 @@ const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
component="h1"
variant="h2"
>
{t("settingsPage.timezoneSettings.title")}
{t("pages.settings.timezoneSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
<Typography component="span">
{t("settingsPage.timezoneSettings.description")}
{t("pages.settings.timezoneSettings.description")}
</Typography>
</Typography>
</Box>
@@ -68,8 +65,6 @@ const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
SettingsTimeZone.propTypes = {
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
timezone: PropTypes.string,
};
export default SettingsTimeZone;
+19 -15
View File
@@ -11,11 +11,19 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme";
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setMode, setLanguage, setChartType } from "@/Features/UI/uiSlice.js";
const SettingsUI = ({ HEADING_SX, handleChange, mode, language, chartType }) => {
const SettingsUI = ({ HEADING_SX }) => {
const {
mode,
language = "en",
chartType = "histogram",
} = useSelector((state) => state.ui);
const { t, i18n } = useTranslation();
const theme = useTheme();
const languages = Object.keys(i18n.options.resources || {});
const dispatch = useDispatch();
const v2Theme = mode === "dark" ? darkTheme : lightTheme;
return (
<ConfigBox>
@@ -24,18 +32,18 @@ const SettingsUI = ({ HEADING_SX, handleChange, mode, language, chartType }) =>
component="h1"
variant="h2"
>
{t("settingsPage.uiSettings.title")}
{t("pages.settings.uiSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.uiSettings.description")}
{t("pages.settings.uiSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
name="mode"
label={t("settingsPage.uiSettings.labelTheme")}
label={t("pages.settings.uiSettings.labelTheme")}
value={mode}
onChange={handleChange}
onChange={(e) => dispatch(setMode(e.target.value))}
items={[
{ _id: "light", name: "Light" },
{ _id: "dark", name: "Dark" },
@@ -43,19 +51,19 @@ const SettingsUI = ({ HEADING_SX, handleChange, mode, language, chartType }) =>
></Select>
<Select
name="language"
label={t("settingsPage.uiSettings.labelLanguage")}
label={t("pages.settings.uiSettings.labelLanguage")}
value={language}
onChange={handleChange}
onChange={(e) => dispatch(setLanguage(e.target.value))}
items={languages.map((lang) => ({ _id: lang, name: lang.toUpperCase() }))}
></Select>
<Select
name="chartType"
label={t("settingsPage.uiSettings.labelChartType")}
label={t("pages.settings.uiSettings.labelChartType")}
value={chartType}
onChange={handleChange}
onChange={(e) => dispatch(setChartType(e.target.value))}
items={[
{ _id: "histogram", name: t("settingsPage.uiSettings.chartTypeHistogram") },
{ _id: "heatmap", name: t("settingsPage.uiSettings.chartTypeHeatmap") },
{ _id: "histogram", name: t("pages.settings.uiSettings.chartTypeHistogram") },
{ _id: "heatmap", name: t("pages.settings.uiSettings.chartTypeHeatmap") },
]}
></Select>
<ThemeProvider theme={v2Theme}>
@@ -68,10 +76,6 @@ const SettingsUI = ({ HEADING_SX, handleChange, mode, language, chartType }) =>
SettingsUI.propTypes = {
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
mode: PropTypes.string,
language: PropTypes.string,
chartType: PropTypes.string,
};
export default SettingsUI;
+20 -14
View File
@@ -3,13 +3,14 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
import Select from "@/Components/v1/Inputs/Select/index.jsx";
import { Controller } from "react-hook-form";
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => {
const SettingsURL = ({ HEADING_SX, control, defaults }) => {
const { t } = useTranslation();
const theme = useTheme();
return (
@@ -19,23 +20,30 @@ const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => {
component="h1"
variant="h2"
>
{t("settingsPage.urlSettings.title")}
{t("pages.settings.urlSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.urlSettings.description")}
{t("pages.settings.urlSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
<Controller
name="showURL"
label={t("settingsPage.urlSettings.label")}
value={showURL === true}
onChange={handleChange}
items={[
{ _id: true, name: t("settingsPage.urlSettings.selectEnabled") },
{ _id: false, name: t("settingsPage.urlSettings.selectDisabled") },
]}
></Select>
control={control}
defaultValue={defaults.showURL}
render={({ field, fieldState }) => (
<Select
{...field}
error={!!fieldState.error}
helperText={fieldState.error?.message}
label={t("pages.settings.urlSettings.label")}
items={[
{ _id: true, name: t("pages.settings.urlSettings.selectEnabled") },
{ _id: false, name: t("pages.settings.urlSettings.selectDisabled") },
]}
/>
)}
/>
</Stack>
</ConfigBox>
);
@@ -43,8 +51,6 @@ const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => {
SettingsURL.propTypes = {
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
showURL: PropTypes.bool,
};
export default SettingsURL;
+105 -186
View File
@@ -5,6 +5,7 @@ import SettingsTimeZone from "./SettingsTimeZone.jsx";
import SettingsUI from "./SettingsUI.jsx";
import SettingsURL from "./SettingsURL.jsx";
import SettingsPagespeed from "./SettingsPagespeed.jsx";
import SettingsGlobalping from "./SettingsGlobalping.jsx";
import SettingsDemoMonitors from "./SettingsDemoMonitors.jsx";
import SettingsAbout from "./SettingsAbout.jsx";
import SettingsEmail from "./SettingsEmail.jsx";
@@ -12,18 +13,14 @@ import SettingsGlobalThresholds from "./SettingsGlobalThresholds.jsx";
import SettingsExport from "./SettingsExport.jsx";
import Button from "@mui/material/Button";
// Utils
import { settingsValidation } from "@/Validation/validation.js";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSettingsForm } from "@/Hooks/useSettingsForm.js";
import { useState, useEffect } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import {
setTimezone,
setMode,
setLanguage,
setShowURL,
setChartType,
} from "@/Features/UI/uiSlice.js";
import SettingsStats from "./SettingsStats.jsx";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
@@ -32,32 +29,46 @@ import { useGet, usePost, useDelete, useLazyGet, usePatch } from "@/Hooks/UseApi
const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }];
const Settings = () => {
// Redux state
const {
mode,
language = "en",
timezone,
showURL,
chartType = "histogram",
} = useSelector((state) => state.ui);
const { data: fetchedSettings, isLoading: isSettingsLoading } = useGet("/settings");
// Local state
const [settingsData, setSettingsData] = useState({});
const [errors, setErrors] = useState({});
const [isApiKeySet, setIsApiKeySet] = useState(settingsData?.pagespeedKeySet ?? false);
const [isApiKeySet, setIsApiKeySet] = useState(
fetchedSettings?.pagespeedKeySet ?? false
);
const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false);
const [isGlobalpingKeySet, setIsGlobalpingKeySet] = useState(
fetchedSettings?.globalpingKeySet ?? false
);
const [globalpingKeyHasBeenReset, setGlobalpingKeyHasBeenReset] = useState(false);
const [isEmailPasswordSet, setIsEmailPasswordSet] = useState(
settingsData?.emailPasswordSet ?? false
fetchedSettings?.emailPasswordSet ?? false
);
const [emailPasswordHasBeenReset, setEmailPasswordHasBeenReset] = useState(false);
// Network
const { data: fetchedSettings, isLoading: isSettingsLoading } = useGet("/settings");
const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings });
const form = useForm({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const {
control,
watch,
reset,
handleSubmit,
clearErrors,
trigger,
getValues,
setValue,
} = form;
useEffect(() => {
reset(defaults, { keepDirtyValues: false });
}, [defaults, reset]);
useEffect(() => {
if (fetchedSettings) {
setSettingsData(fetchedSettings);
setIsApiKeySet(fetchedSettings?.pagespeedKeySet);
setIsGlobalpingKeySet(fetchedSettings?.globalpingKeySet);
setIsEmailPasswordSet(fetchedSettings?.emailPasswordSet);
}
}, [fetchedSettings]);
@@ -69,21 +80,61 @@ const Settings = () => {
if (response?.data) {
const data = response.data;
setIsApiKeySet(data.pagespeedKeySet);
setIsGlobalpingKeySet(data.globalpingKeySet);
setIsEmailPasswordSet(data.emailPasswordSet);
if (data.pagespeedKeySet === true) {
setApiKeyHasBeenReset(false);
}
if (data.globalpingKeySet === true) {
setGlobalpingKeyHasBeenReset(false);
}
if (data.emailPasswordSet === true) {
setEmailPasswordHasBeenReset(false);
}
setSettingsData(data);
}
};
const onSubmit = async (data) => {
// Convert undefined to empty strings for proper unsetting on backend
const toSubmit = Object.fromEntries(
Object.entries(data).map(([key, value]) => {
if (value === undefined) return [key, ""];
if (typeof value === "object" && value !== null) {
// Handle nested objects like globalThresholds
return [
key,
Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, v === undefined ? "" : v])
),
];
}
return [key, value];
})
);
// Don't send empty sensitive fields when the key is already set and
// hasn't been explicitly reset — otherwise the backend $unsets them
if (isApiKeySet && !apiKeyHasBeenReset && !toSubmit.pagespeedApiKey) {
delete toSubmit.pagespeedApiKey;
}
if (isGlobalpingKeySet && !globalpingKeyHasBeenReset && !toSubmit.globalpingApiKey) {
delete toSubmit.globalpingApiKey;
}
if (
isEmailPasswordSet &&
!emailPasswordHasBeenReset &&
!toSubmit.systemEmailPassword
) {
delete toSubmit.systemEmailPassword;
}
saveSettings(toSubmit);
};
// New API hooks to replace monitorHooks
const { post: postDemoMonitors, loading: isAddingDemoMonitors } = usePost();
const { deleteFn: deleteAllMonitorsFn, loading: isDeletingMonitors } = useDelete();
const { deleteFn: deleteMonitorStatsFn, loading: isDeletingMonitorStats } = useDelete();
const { loading: isAddingDemoMonitors } = usePost();
const { loading: isDeletingMonitors } = useDelete();
const { loading: isDeletingMonitorStats } = useDelete();
const { get: fetchJson, loading: isFetchingJson } = useLazyGet();
// Setup
@@ -92,184 +143,52 @@ const Settings = () => {
const HEADING_SX = { mt: theme.spacing(2), mb: theme.spacing(2) };
const { t } = useTranslation();
const dispatch = useDispatch();
// Handlers
const handleChange = async (e) => {
const { name, value, checked } = e.target;
let newValue;
if (
name === "systemEmailIgnoreTLS" ||
name === "systemEmailRequireTLS" ||
name === "systemEmailRejectUnauthorized" ||
name === "systemEmailSecure" ||
name === "systemEmailPool"
) {
newValue = checked;
}
// Ensure showURL is a proper boolean
if (name === "showURL") {
newValue = value === true || value === "true";
}
// Build next state early
const newSettingsData = {
...settingsData,
settings: { ...settingsData.settings, [name]: newValue ?? value },
};
if (name === "timezone") {
dispatch(setTimezone({ timezone: value }));
return;
}
if (name === "mode") {
dispatch(setMode(value));
return;
}
if (name === "language") {
dispatch(setLanguage(value));
return;
}
if (name === "chartType") {
dispatch(setChartType(value));
return;
}
if (name === "deleteStats") {
await deleteMonitorStatsFn("/checks/team");
return;
}
if (name === "demo") {
await postDemoMonitors("/monitors/demo", {});
return;
}
if (name === "deleteMonitors") {
await deleteAllMonitorsFn("/monitors/");
return;
}
if (name === "export") {
const res = await fetchJson("/monitors/export/json");
const json = res?.data ?? [];
if (!json || json.length === 0) {
return;
}
const blob = new Blob([JSON.stringify(json, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "monitors.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return;
}
setSettingsData(newSettingsData);
// Update Redux immediately for UI feedback
if (name === "showURL") {
dispatch(setShowURL(newValue));
}
};
const handleSave = () => {
// Validate]
const toSubmit = {
checkTTL: settingsData.settings.checkTTL,
pagespeedApiKey: settingsData.settings.pagespeedApiKey,
language: settingsData.settings.language,
timezone: settingsData.settings.timezone,
systemEmailHost: settingsData.settings.systemEmailHost,
systemEmailPort: settingsData.settings.systemEmailPort,
systemEmailSecure: settingsData.settings.systemEmailSecure,
systemEmailPool: settingsData.settings.systemEmailPool,
systemEmailAddress: settingsData.settings.systemEmailAddress,
systemEmailPassword: settingsData.settings.systemEmailPassword,
systemEmailUser: settingsData.settings.systemEmailUser,
systemEmailConnectionHost: settingsData.settings.systemEmailConnectionHost,
systemEmailTLSServername: settingsData.settings.systemEmailTLSServername,
systemEmailIgnoreTLS: settingsData.settings.systemEmailIgnoreTLS,
systemEmailRequireTLS: settingsData.settings.systemEmailRequireTLS,
systemEmailRejectUnauthorized: settingsData.settings.systemEmailRejectUnauthorized,
showURL: settingsData.settings.showURL,
globalThresholds: settingsData.settings.globalThresholds,
};
const { error } = settingsValidation.validate(toSubmit, {
abortEarly: false,
});
if (!error || error.details.length === 0) {
setErrors({});
saveSettings(toSubmit);
} else {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
}
};
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<Typography variant="h1">{t("settingsPage.title")}</Typography>
<SettingsTimeZone
HEADING_SX={HEADING_SX}
handleChange={handleChange}
timezone={timezone}
/>
<SettingsUI
HEADING_SX={HEADING_SX}
handleChange={handleChange}
mode={mode}
language={language}
chartType={chartType}
/>
<Typography variant="h1">{t("pages.settings.title")}</Typography>
<SettingsTimeZone HEADING_SX={HEADING_SX} />
<SettingsUI HEADING_SX={HEADING_SX} />
<SettingsPagespeed
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
settingsData={settingsData}
setSettingsData={setSettingsData}
isApiKeySet={isApiKeySet}
apiKeyHasBeenReset={apiKeyHasBeenReset}
setApiKeyHasBeenReset={setApiKeyHasBeenReset}
control={control}
defaults={defaults}
setValue={setValue}
/>
<SettingsGlobalping
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
isGlobalpingKeySet={isGlobalpingKeySet}
globalpingKeyHasBeenReset={globalpingKeyHasBeenReset}
setGlobalpingKeyHasBeenReset={setGlobalpingKeyHasBeenReset}
control={control}
defaults={defaults}
setValue={setValue}
/>
<SettingsURL
HEADING_SX={HEADING_SX}
handleChange={handleChange}
showURL={showURL}
control={control}
defaults={defaults}
/>
<SettingsStats
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
settingsData={settingsData}
handleChange={handleChange}
errors={errors}
/>
<SettingsGlobalThresholds
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
settingsData={settingsData}
setSettingsData={setSettingsData}
control={control}
defaults={defaults}
/>
<SettingsDemoMonitors
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
handleChange={handleChange}
isLoading={
isSettingsLoading || isSaving || isDeletingMonitorStats || isAddingDemoMonitors
}
@@ -277,18 +196,18 @@ const Settings = () => {
<SettingsEmail
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
handleChange={handleChange}
settingsData={settingsData}
setSettingsData={setSettingsData}
isEmailPasswordSet={isEmailPasswordSet}
emailPasswordHasBeenReset={emailPasswordHasBeenReset}
setEmailPasswordHasBeenReset={setEmailPasswordHasBeenReset}
control={control}
defaults={defaults}
formValues={watch()}
setValue={setValue}
/>
<SettingsExport
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
handleChange={handleChange}
isLoading={isSettingsLoading || isSaving || isFetchingJson}
/>
<SettingsAbout />
@@ -318,13 +237,13 @@ const Settings = () => {
loading={
isSaving || isDeletingMonitorStats || isSettingsLoading || isDeletingMonitors
}
disabled={Object.keys(errors).length > 0}
disabled={!form.formState.isValid}
variant="contained"
color="accent"
sx={{ px: theme.spacing(12), py: theme.spacing(8) }}
onClick={handleSave}
onClick={handleSubmit(onSubmit)}
>
{t("settingsPage.saveButtonLabel")}
{t("pages.settings.saveButtonLabel")}
</Button>
</Stack>
</Stack>
@@ -64,7 +64,7 @@ export const HeaderStatusPageControls = ({
},
}}
>
{t("publicLink")}
{t("components.headerStatusPageControls.publicLink")}
</Typography>
<Box>
<ExternalLink size={14} />
@@ -96,7 +96,6 @@ const StatusPageView = () => {
}}
/>
)}
<Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography>
<StatusBar monitors={monitors} />
<MonitorsList
statusPage={statusPage}
+88 -2
View File
@@ -1,6 +1,10 @@
import { BasePage } from "@/Components/v2/design-elements";
import { HeaderTimeRange } from "@/Components/v2/common";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import {
HistogramStatus,
RadialAvgResponse,
@@ -13,11 +17,11 @@ import { MonitorStatBoxes } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { MonitorDetailsResponse, LocationCheckData } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
@@ -38,6 +42,7 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocationTab, setSelectedLocationTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
@@ -108,6 +113,30 @@ const UptimeDetailsPage = () => {
const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0;
const globalpingLocationsUrl = monitor?.globalpingEnabled
? "/monitors/globalping/locations"
: null;
const { data: globalpingData } = useGet<{
locations: { id: string; label: string }[];
labels: Record<string, string>;
}>(globalpingLocationsUrl);
const locationLabels = globalpingData?.labels ?? {};
const locationChecks = monitorData?.locationChecks;
const locationKeys = useMemo(
() => (locationChecks ? Object.keys(locationChecks).sort() : []),
[locationChecks]
);
// Reset tab index when location keys change to prevent out-of-bounds
useEffect(() => {
setSelectedLocationTab(0);
}, [locationKeys.length]);
const selectedLocation = locationKeys[selectedLocationTab] ?? null;
const selectedLocationData: LocationCheckData | null =
selectedLocation && locationChecks ? locationChecks[selectedLocation] : null;
return (
<BasePage>
<HeaderMonitorControls
@@ -152,6 +181,63 @@ const UptimeDetailsPage = () => {
checks={monitorData?.groupedChecks || []}
range={dateRange}
/>
{locationKeys.length > 0 && (
<Box>
<Typography
variant="h2"
sx={{ mb: theme.spacing(4) }}
>
{t("pages.uptime.details.locations.title")}
</Typography>
<Tabs
value={selectedLocationTab}
onChange={(_e, newVal) => setSelectedLocationTab(newVal)}
sx={{ mb: theme.spacing(4) }}
>
{locationKeys.map((loc) => (
<Tab
key={loc}
label={locationLabels[loc] ?? loc}
/>
))}
</Tabs>
{selectedLocationData && (
<Stack gap={theme.spacing(4)}>
<Stack
direction="row"
gap={theme.spacing(8)}
>
<Box>
<Typography
variant="body2"
color="text.secondary"
>
{t("pages.uptime.details.locations.avgResponseTime")}
</Typography>
<Typography variant="h3">
{Math.round(selectedLocationData.avgResponseTime)} ms
</Typography>
</Box>
<Box>
<Typography
variant="body2"
color="text.secondary"
>
{t("pages.uptime.details.locations.uptime")}
</Typography>
<Typography variant="h3">
{(selectedLocationData.uptimePercentage * 100).toFixed(1)}%
</Typography>
</Box>
</Stack>
<HistogramDetails
checks={selectedLocationData.groupedChecks || []}
range={dateRange}
/>
</Stack>
)}
</Box>
)}
<ChecksTable
checks={checks}
count={checksCount}
@@ -9,7 +9,8 @@ import {
} from "@/Components/v2/design-elements";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common";
import { ActionsMenu } from "@/Components/v2/actions-menu";
import { ArrowDown, ArrowUp } from "lucide-react";
import { ArrowDown, ArrowUp, Globe } from "lucide-react";
import Tooltip from "@mui/material/Tooltip";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
@@ -167,7 +168,23 @@ export const MonitorTable = ({
),
render: (row) => {
return row?.name;
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
{row?.name}
{row.globalpingEnabled && (
<Tooltip title={t("pages.uptime.table.headers.globalpingEnabled")}>
<Globe
size={14}
aria-label={t("pages.uptime.table.headers.globalpingEnabled")}
/>
</Tooltip>
)}
</Stack>
);
},
},
{
@@ -3,6 +3,7 @@ import { ThemeProvider } from "@mui/material";
import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import { Navigate, Route, Routes as LibRoutes } from "react-router";
import RootLayout from "@/Components/v2/layout/RootLayout";
import NotFound from "@/Pages/NotFound";
@@ -59,7 +60,7 @@ import {
import CreateMonitor from "@/Pages/CreateMonitor";
const Routes = () => {
const mode = useSelector((state) => state.ui.mode);
const mode = useSelector((state: RootState) => state.ui.mode);
const v2theme = mode === "light" ? lightTheme : darkTheme;
return (
<LibRoutes>
@@ -390,12 +391,11 @@ const Routes = () => {
/>
<Route
exact
path="/register/:token"
element={
<>
<ThemeProvider theme={v2theme}>
<AuthRegister superAdminExists={true} />
<AuthRegister />
</ThemeProvider>
</>
}
+15 -9
View File
@@ -18,16 +18,10 @@ export const MonitorStatuses = [
"paused",
"initializing",
"maintenance",
"breached",
] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number];
export interface MonitorThresholds {
usage_cpu?: number;
usage_memory?: number;
usage_disk?: number;
usage_temperature?: number;
}
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export interface Monitor {
@@ -53,15 +47,18 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
secret?: string;
thresholds?: MonitorThresholds;
alertThreshold: number;
cpuAlertThreshold: number;
cpuAlertCounter: number;
memoryAlertThreshold: number;
memoryAlertCounter: number;
diskAlertThreshold: number;
diskAlertCounter: number;
tempAlertThreshold: number;
tempAlertCounter: number;
selectedDisks: string[];
gameId?: string;
group: string | null;
globalpingEnabled: boolean;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
@@ -76,6 +73,7 @@ export interface MonitorsSummary {
pausedMonitors: number;
initializingMonitors: number;
maintenanceMonitors: number;
breachedMonitors: number;
}
export interface MonitorsWithChecksResponse {
@@ -88,6 +86,7 @@ export interface MonitorStats {
id: string;
monitorId: string;
avgResponseTime: number;
maxResponseTime: number;
totalChecks: number;
totalUpChecks: number;
totalDownChecks: number;
@@ -99,6 +98,12 @@ export interface MonitorStats {
updatedAt: string;
}
export interface LocationCheckData {
groupedChecks: GroupedCheck[];
uptimePercentage: number;
avgResponseTime: number;
}
export interface MonitorData {
monitor: Monitor;
groupedChecks: GroupedCheck[];
@@ -106,6 +111,7 @@ export interface MonitorData {
groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number;
groupedUptimePercentage: number;
locationChecks?: Record<string, LocationCheckData>;
}
export interface MonitorDetailsResponse {
+2
View File
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean;
showURL: boolean;
globalpingLocationsTier?: number;
singleton: boolean;
globalThresholds?: SettingsThresholds;
createdAt: string;
@@ -29,6 +30,7 @@ export interface Settings {
export interface AppSettingsResponse {
pagespeedKeySet: boolean;
globalpingKeySet: boolean;
emailPasswordSet: boolean;
settings: Settings;
}
+4 -38
View File
@@ -1,4 +1,4 @@
import type { Monitor, MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
import type { ValueType } from "@/Components/v2/design-elements/StatusLabel";
@@ -23,6 +23,9 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
if (status === "down") {
return "error";
}
if (status === "breached") {
return "error";
}
return "warning";
};
@@ -81,40 +84,3 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
? `${strippedUrl.slice(0, maxLength)}…`
: strippedUrl;
};
export interface IStatusPageHeaderConfig {
paletteKey: PaletteKey;
message: string;
}
export const getStatusPageHeaderConfig = (
monitors: Monitor[],
t: any
): IStatusPageHeaderConfig => {
if (!monitors || monitors.length === 0) {
return { paletteKey: "error", message: "No monitors available" };
}
const allUp = monitors.every((monitor) => monitor.status === "up");
const anyDown = monitors.some((monitor) => monitor.status === "down");
const allDown = monitors.every((monitor) => monitor.status === "down");
if (allUp)
return {
paletteKey: "success",
message: t("statusPage.details.statusHeader.allUp"),
};
if (allDown)
return {
paletteKey: "error",
message: t("statusPage.details.statusHeader.allDown"),
};
if (anyDown)
return {
paletteKey: "warning",
message: t("statusPage.details.statusHeader.anyDown"),
};
return {
paletteKey: "warning",
message: t("statusPage.details.statusHeader.anyDown"),
};
};
+3 -4
View File
@@ -2,11 +2,10 @@ import { z } from "zod";
export const loginSchema = z.object({
email: z
.string()
.min(1, "auth.common.inputs.email.errors.empty")
.email("auth.common.inputs.email.errors.invalid")
.email("Please enter a valid email address")
.min(1, "Please enter your email address")
.transform((val) => val.toLowerCase().trim()),
password: z.string().min(1, "auth.common.inputs.password.errors.empty"),
password: z.string().min(1, "Please enter your password"),
});
export type LoginFormData = z.infer<typeof loginSchema>;
+2
View File
@@ -31,12 +31,14 @@ const httpSchema = baseSchema.extend({
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: z.string().optional(),
jsonPath: z.string().optional(),
globalpingEnabled: z.boolean(),
});
// Ping monitor schema
const pingSchema = baseSchema.extend({
type: z.literal("ping"),
url: z.string().min(1, "Host is required"),
globalpingEnabled: z.boolean(),
});
// Port monitor schema
+2 -3
View File
@@ -2,9 +2,8 @@ import { z } from "zod";
export const recoverySchema = z.object({
email: z
.string()
.min(1, "auth.common.inputs.email.errors.empty")
.email("auth.common.inputs.email.errors.invalid")
.email("Please enter a valid email address")
.min(1, "Please enter your email address")
.transform((val) => val.toLowerCase().trim()),
});
+73
View File
@@ -0,0 +1,73 @@
import { z } from "zod";
// Helper to normalize empty strings to undefined
const optionalString = () =>
z.preprocess(
(val) =>
val === "" || val === null || val === undefined ? undefined : String(val).trim(),
z.string().optional()
);
// Helper for threshold fields
const thresholdField = (min: number, max: number, unit: string) =>
z.preprocess(
(val) => (val === "" || val === null || val === undefined ? undefined : val),
z.coerce
.number()
.int()
.min(min, `Min ${min}${unit}`)
.max(max, `Max ${max}${unit}`)
.optional()
);
export const settingsSchema = z.object({
systemEmailIgnoreTLS: z.boolean(),
systemEmailRequireTLS: z.boolean(),
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: optionalString(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z.coerce
.number()
.int()
.min(1, "Please enter a value")
.max(365, "Maximum 365 days"),
pagespeedApiKey: optionalString(),
globalpingApiKey: optionalString(),
globalpingLocationsTier: z.coerce.number().optional(),
systemEmailHost: z.preprocess(
(val) =>
val === "" || val === null || val === undefined ? undefined : String(val).trim(),
z
.string()
.regex(/^[a-zA-Z0-9.-]+$/, "Invalid hostname or IP address")
.optional()
),
systemEmailPort: z.preprocess(
(val) => (val === "" || val === null || val === undefined ? undefined : val),
z.coerce
.number()
.int()
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535")
.optional()
),
systemEmailAddress: z.preprocess((val) => {
if (val === "" || val === null || val === undefined) return undefined;
return String(val).toLowerCase().trim();
}, z.string().email("Please enter a valid email address").optional()),
systemEmailUser: optionalString(),
systemEmailPassword: optionalString(),
systemEmailTLSServername: optionalString(),
globalThresholds: z
.object({
cpu: thresholdField(0, 100, "%"),
memory: thresholdField(0, 100, "%"),
disk: thresholdField(0, 100, "%"),
temperature: thresholdField(0, 150, "°C"),
})
.optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1,67 +0,0 @@
<svg width="768" height="768" viewBox="0 0 768 768" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_14_2)">
<mask id="mask0_14_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="768" height="768">
<path d="M768 0H0V768H768V0Z" fill="url(#paint0_radial_14_2)"/>
</mask>
<g mask="url(#mask0_14_2)">
<mask id="mask1_14_2" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="768" height="768">
<path d="M768 0H0V768H768V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_14_2)">
<mask id="mask2_14_2" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="768" height="768">
<path d="M768 0H0V768H768V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_14_2)">
<path d="M0.5 0V768" stroke="#b8b9bd"/>
<path d="M48.5 0V768" stroke="#b8b9bd"/>
<path d="M96.5 0V768" stroke="#b8b9bd"/>
<path d="M144.5 0V768" stroke="#b8b9bd"/>
<path d="M192.5 0V768" stroke="#b8b9bd"/>
<path d="M240.5 0V768" stroke="#b8b9bd"/>
<path d="M288.5 0V768" stroke="#b8b9bd"/>
<path d="M336.5 0V768" stroke="#b8b9bd"/>
<path d="M384.5 0V768" stroke="#b8b9bd"/>
<path d="M432.5 0V768" stroke="#b8b9bd"/>
<path d="M480.5 0V768" stroke="#b8b9bd"/>
<path d="M528.5 0V768" stroke="#b8b9bd"/>
<path d="M576.5 0V768" stroke="#b8b9bd"/>
<path d="M624.5 0V768" stroke="#b8b9bd"/>
<path d="M672.5 0V768" stroke="#b8b9bd"/>
<path d="M720.5 0V768" stroke="#b8b9bd"/>
</g>
<path d="M767.5 0.5H0.5V767.5H767.5V0.5Z" stroke="#b8b9bd"/>
<mask id="mask3_14_2" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="768" height="768">
<path d="M768 0H0V768H768V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_14_2)">
<path d="M0 47.5H768" stroke="#b8b9bd"/>
<path d="M0 95.5H768" stroke="#b8b9bd"/>
<path d="M0 143.5H768" stroke="#b8b9bd"/>
<path d="M0 191.5H768" stroke="#b8b9bd"/>
<path d="M0 239.5H768" stroke="#b8b9bd"/>
<path d="M0 287.5H768" stroke="#b8b9bd"/>
<path d="M0 335.5H768" stroke="#b8b9bd"/>
<path d="M0 383.5H768" stroke="#b8b9bd"/>
<path d="M0 431.5H768" stroke="#b8b9bd"/>
<path d="M0 479.5H768" stroke="#b8b9bd"/>
<path d="M0 527.5H768" stroke="#b8b9bd"/>
<path d="M0 575.5H768" stroke="#b8b9bd"/>
<path d="M0 623.5H768" stroke="#b8b9bd"/>
<path d="M0 671.5H768" stroke="#b8b9bd"/>
<path d="M0 719.5H768" stroke="#b8b9bd"/>
<path d="M0 767.5H768" stroke="#b8b9bd"/>
</g>
<path d="M767.5 0.5H0.5V767.5H767.5V0.5Z" stroke="#b8b9bd"/>
</g>
</g>
</g>
<defs>
<radialGradient id="paint0_radial_14_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(384 384) rotate(90) scale(384)">
<stop/>
<stop offset="1" stop-opacity="0"/>
</radialGradient>
<clipPath id="clip0_14_2">
<rect width="768" height="768" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,36 +0,0 @@
<svg width="256" height="170" viewBox="0 0 256 170" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_24_2)">
<path d="M248.196 0.390244H7.80489C3.7099 0.390244 0.390259 3.70989 0.390259 7.80487V161.561C0.390259 165.656 3.7099 168.975 7.80489 168.975H248.196C252.291 168.975 255.61 165.656 255.61 161.561V7.80487C255.61 3.70989 252.291 0.390244 248.196 0.390244Z" fill="#151518"/>
<path d="M248.196 0.390244H7.80489C3.7099 0.390244 0.390259 3.70989 0.390259 7.80487V161.561C0.390259 165.656 3.7099 168.975 7.80489 168.975H248.196C252.291 168.975 255.61 165.656 255.61 161.561V7.80487C255.61 3.70989 252.291 0.390244 248.196 0.390244Z" stroke="#27272A" stroke-width="0.780488"/>
<path d="M234.976 18H21.122C19.3977 18 18 19.3977 18 21.122V69.5122C18 71.2364 19.3977 72.6341 21.122 72.6341H234.976C236.7 72.6341 238.098 71.2364 238.098 69.5122V21.122C238.098 19.3977 236.7 18 234.976 18Z" fill="#27272A" fill-opacity="0.8"/>
<path d="M99.9025 78.8293H21.0732C19.3489 78.8293 17.9512 80.227 17.9512 81.9512V85.0732C17.9512 86.7974 19.3489 88.1952 21.0732 88.1952H99.9025C101.627 88.1952 103.024 86.7974 103.024 85.0732V81.9512C103.024 80.227 101.627 78.8293 99.9025 78.8293Z" fill="#27272A" fill-opacity="0.6"/>
<path d="M234.927 93.6584H21.0732C19.3489 93.6584 17.9512 95.0561 17.9512 96.7803V99.9023C17.9512 101.627 19.3489 103.024 21.0732 103.024H234.927C236.651 103.024 238.049 101.627 238.049 99.9023V96.7803C238.049 95.0561 236.651 93.6584 234.927 93.6584Z" fill="#27272A" fill-opacity="0.3"/>
<path d="M234.927 108.488H21.0732C19.3489 108.488 17.9512 109.886 17.9512 111.61V114.732C17.9512 116.456 19.3489 117.854 21.0732 117.854H234.927C236.651 117.854 238.049 116.456 238.049 114.732V111.61C238.049 109.886 236.651 108.488 234.927 108.488Z" fill="#27272A" fill-opacity="0.3"/>
<g filter="url(#filter0_d_24_2)">
<mask id="mask0_24_2" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="174" y="129" width="65" height="21">
<path d="M234.927 129.561H177.171C175.447 129.561 174.049 130.959 174.049 132.683V146.732C174.049 148.456 175.447 149.854 177.171 149.854H234.927C236.651 149.854 238.049 148.456 238.049 146.732V132.683C238.049 130.959 236.651 129.561 234.927 129.561Z" fill="white"/>
<path d="M177.171 130.061H234.927C236.375 130.061 237.549 131.235 237.549 132.683V146.732C237.549 148.18 236.375 149.354 234.927 149.354H177.171C175.723 149.354 174.549 148.18 174.549 146.732V132.683C174.549 131.235 175.723 130.061 177.171 130.061Z" stroke="white"/>
</mask>
<g mask="url(#mask0_24_2)">
<path d="M234.927 129.561H177.171C175.447 129.561 174.049 130.959 174.049 132.683V146.732C174.049 148.456 175.447 149.854 177.171 149.854H234.927C236.651 149.854 238.049 148.456 238.049 146.732V132.683C238.049 130.959 236.651 129.561 234.927 129.561Z" fill="#27272A" fill-opacity="0.2"/>
<path d="M177.171 130.061H234.927C236.375 130.061 237.549 131.235 237.549 132.683V146.732C237.549 148.18 236.375 149.354 234.927 149.354H177.171C175.723 149.354 174.549 148.18 174.549 146.732V132.683C174.549 131.235 175.723 130.061 177.171 130.061Z" stroke="#27272A" stroke-opacity="0.8"/>
</g>
<path d="M234.927 129.951H177.171C175.662 129.951 174.439 131.174 174.439 132.683V146.731C174.439 148.24 175.662 149.463 177.171 149.463H234.927C236.435 149.463 237.658 148.24 237.658 146.731V132.683C237.658 131.174 236.435 129.951 234.927 129.951Z" stroke="#27272A" stroke-opacity="0.8" stroke-width="0.780488"/>
</g>
</g>
<defs>
<filter id="filter0_d_24_2" x="172.049" y="128.561" width="68.0002" height="24.2929" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_24_2"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_24_2" result="shape"/>
</filter>
<clipPath id="clip0_24_2">
<rect width="256" height="170" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,29 +0,0 @@
<svg width="256" height="170" viewBox="0 0 256 170" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.390244" y="0.390244" width="255.22" height="168.585" rx="7.41463" fill="white"/>
<rect x="0.390244" y="0.390244" width="255.22" height="168.585" rx="7.41463" stroke="#EBEBEB" stroke-width="0.780488"/>
<rect x="17.9512" y="16.3901" width="220.098" height="54.6341" rx="3.12195" fill="#EFF2FF"/>
<rect x="17.9512" y="78.8293" width="85.0732" height="9.36585" rx="3.12195" fill="#D9D9D9"/>
<rect x="17.9512" y="93.6584" width="220.098" height="9.36585" rx="3.12195" fill="#F3F3F3"/>
<rect x="17.9512" y="108.488" width="220.098" height="9.36585" rx="3.12195" fill="#F3F3F3"/>
<g filter="url(#filter0_d_28_7483)">
<g clip-path="url(#clip0_28_7483)">
<rect x="174.049" y="129.561" width="64" height="20.2927" rx="3.12195" fill="#E0F2FE"/>
</g>
<rect x="174.439" y="129.951" width="63.2195" height="19.5122" rx="2.73171" stroke="#D5D9EB" stroke-width="0.780488"/>
</g>
<defs>
<filter id="filter0_d_28_7483" x="172.049" y="128.561" width="68" height="24.2927" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_28_7483"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_28_7483" result="shape"/>
</filter>
<clipPath id="clip0_28_7483">
<rect x="174.049" y="129.561" width="64" height="20.2927" rx="3.12195" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,19 +0,0 @@
<svg width="250" height="176" viewBox="0 0 250 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M246 72.5H4C2.067 72.5 0.5 74.067 0.5 76V100C0.5 101.933 2.067 103.5 4 103.5H246C247.933 103.5 249.5 101.933 249.5 100V76C249.5 74.067 247.933 72.5 246 72.5Z" fill="white" stroke="#E1E1E1"/>
<path d="M112.5 81H42.5C38.3579 81 35 84.3579 35 88.5C35 92.6421 38.3579 96 42.5 96H112.5C116.642 96 120 92.6421 120 88.5C120 84.3579 116.642 81 112.5 81Z" fill="#F1F1F1"/>
<path d="M25 88.5C25 84.3579 21.6421 81 17.5 81C13.3579 81 10 84.3579 10 88.5C10 92.6421 13.3579 96 17.5 96C21.6421 96 25 92.6421 25 88.5Z" fill="#F1F1F1"/>
<path d="M219 34H31C28.7909 34 27 35.7909 27 38V56C27 58.2091 28.7909 60 31 60H219C221.209 60 223 58.2091 223 56V38C223 35.7909 221.209 34 219 34Z" fill="white"/>
<path d="M219 34.5H31C29.067 34.5 27.5 36.067 27.5 38V56C27.5 57.933 29.067 59.5 31 59.5H219C220.933 59.5 222.5 57.933 222.5 56V38C222.5 36.067 220.933 34.5 219 34.5Z" stroke="#E1E1E1" stroke-opacity="0.71"/>
<path d="M156.5 42H60.5C57.4624 42 55 44.4624 55 47.5C55 50.5376 57.4624 53 60.5 53H156.5C159.538 53 162 50.5376 162 47.5C162 44.4624 159.538 42 156.5 42Z" fill="#F4F4F4"/>
<path d="M41.5 42H40.5C37.4624 42 35 44.4624 35 47.5C35 50.5376 37.4624 53 40.5 53H41.5C44.5376 53 47 50.5376 47 47.5C47 44.4624 44.5376 42 41.5 42Z" fill="#F4F4F4"/>
<path d="M219 116H31C28.7909 116 27 117.791 27 120V138C27 140.209 28.7909 142 31 142H219C221.209 142 223 140.209 223 138V120C223 117.791 221.209 116 219 116Z" fill="white"/>
<path d="M219 116.5H31C29.067 116.5 27.5 118.067 27.5 120V138C27.5 139.933 29.067 141.5 31 141.5H219C220.933 141.5 222.5 139.933 222.5 138V120C222.5 118.067 220.933 116.5 219 116.5Z" stroke="#E1E1E1" stroke-opacity="0.71"/>
<path d="M156.5 124H60.5C57.4624 124 55 126.462 55 129.5C55 132.538 57.4624 135 60.5 135H156.5C159.538 135 162 132.538 162 129.5C162 126.462 159.538 124 156.5 124Z" fill="#F4F4F4"/>
<path d="M41.5 124H40.5C37.4624 124 35 126.462 35 129.5C35 132.538 37.4624 135 40.5 135H41.5C44.5376 135 47 132.538 47 129.5C47 126.462 44.5376 124 41.5 124Z" fill="#F4F4F4"/>
<path d="M176 0.5H59C57.067 0.5 55.5 2.067 55.5 4V18C55.5 19.933 57.067 21.5 59 21.5H176C177.933 21.5 179.5 19.933 179.5 18V4C179.5 2.067 177.933 0.5 176 0.5Z" fill="white" stroke="#F6F6F6"/>
<path d="M136.5 7H77.5C75.0147 7 73 9.01472 73 11.5C73 13.9853 75.0147 16 77.5 16H136.5C138.985 16 141 13.9853 141 11.5C141 9.01472 138.985 7 136.5 7Z" fill="#F6F6F6"/>
<path d="M68 11C68 8.79086 66.2091 7 64 7C61.7909 7 60 8.79086 60 11V12C60 14.2091 61.7909 16 64 16C66.2091 16 68 14.2091 68 12V11Z" fill="#F6F6F6"/>
<path d="M176 154.5H59C57.067 154.5 55.5 156.067 55.5 158V172C55.5 173.933 57.067 175.5 59 175.5H176C177.933 175.5 179.5 173.933 179.5 172V158C179.5 156.067 177.933 154.5 176 154.5Z" fill="white" stroke="#F6F6F6"/>
<path d="M136.5 161H77.5C75.0147 161 73 163.015 73 165.5C73 167.985 75.0147 170 77.5 170H136.5C138.985 170 141 167.985 141 165.5C141 163.015 138.985 161 136.5 161Z" fill="#F6F6F6"/>
<path d="M68 165C68 162.791 66.2091 161 64 161C61.7909 161 60 162.791 60 165V166C60 168.209 61.7909 170 64 170C66.2091 170 68 168.209 68 166V165Z" fill="#F6F6F6"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,19 +0,0 @@
<svg width="250" height="176" viewBox="0 0 250 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M246 72.5H4C2.067 72.5 0.5 74.067 0.5 76V100C0.5 101.933 2.067 103.5 4 103.5H246C247.933 103.5 249.5 101.933 249.5 100V76C249.5 74.067 247.933 72.5 246 72.5Z" fill="#151518" stroke="#1F1F23"/>
<path d="M112.5 81H42.5C38.3579 81 35 84.3579 35 88.5C35 92.6421 38.3579 96 42.5 96H112.5C116.642 96 120 92.6421 120 88.5C120 84.3579 116.642 81 112.5 81Z" fill="#1F1F23"/>
<path d="M25 88.5C25 84.3579 21.6421 81 17.5 81C13.3579 81 10 84.3579 10 88.5C10 92.6421 13.3579 96 17.5 96C21.6421 96 25 92.6421 25 88.5Z" fill="#1F1F23"/>
<path d="M219 34H31C28.7909 34 27 35.7909 27 38V56C27 58.2091 28.7909 60 31 60H219C221.209 60 223 58.2091 223 56V38C223 35.7909 221.209 34 219 34Z" fill="#151518"/>
<path d="M219 34.5H31C29.067 34.5 27.5 36.067 27.5 38V56C27.5 57.933 29.067 59.5 31 59.5H219C220.933 59.5 222.5 57.933 222.5 56V38C222.5 36.067 220.933 34.5 219 34.5Z" stroke="#1F1F23" stroke-opacity="0.71"/>
<path d="M156.5 42H60.5C57.4624 42 55 44.4624 55 47.5C55 50.5376 57.4624 53 60.5 53H156.5C159.538 53 162 50.5376 162 47.5C162 44.4624 159.538 42 156.5 42Z" fill="#252529"/>
<path d="M41.5 42H40.5C37.4624 42 35 44.4624 35 47.5C35 50.5376 37.4624 53 40.5 53H41.5C44.5376 53 47 50.5376 47 47.5C47 44.4624 44.5376 42 41.5 42Z" fill="#252529"/>
<path d="M219 116H31C28.7909 116 27 117.791 27 120V138C27 140.209 28.7909 142 31 142H219C221.209 142 223 140.209 223 138V120C223 117.791 221.209 116 219 116Z" fill="#151518"/>
<path d="M219 116.5H31C29.067 116.5 27.5 118.067 27.5 120V138C27.5 139.933 29.067 141.5 31 141.5H219C220.933 141.5 222.5 139.933 222.5 138V120C222.5 118.067 220.933 116.5 219 116.5Z" stroke="#1F1F23" stroke-opacity="0.71"/>
<path d="M156.5 124H60.5C57.4624 124 55 126.462 55 129.5C55 132.538 57.4624 135 60.5 135H156.5C159.538 135 162 132.538 162 129.5C162 126.462 159.538 124 156.5 124Z" fill="#252529"/>
<path d="M41.5 124H40.5C37.4624 124 35 126.462 35 129.5C35 132.538 37.4624 135 40.5 135H41.5C44.5376 135 47 132.538 47 129.5C47 126.462 44.5376 124 41.5 124Z" fill="#252529"/>
<path d="M176 0.5H59C57.067 0.5 55.5 2.067 55.5 4V18C55.5 19.933 57.067 21.5 59 21.5H176C177.933 21.5 179.5 19.933 179.5 18V4C179.5 2.067 177.933 0.5 176 0.5Z" fill="#151518" stroke="#202023"/>
<path d="M136.5 7H77.5C75.0147 7 73 9.01472 73 11.5C73 13.9853 75.0147 16 77.5 16H136.5C138.985 16 141 13.9853 141 11.5C141 9.01472 138.985 7 136.5 7Z" fill="#202023"/>
<path d="M68 11C68 8.79086 66.2091 7 64 7C61.7909 7 60 8.79086 60 11V12C60 14.2091 61.7909 16 64 16C66.2091 16 68 14.2091 68 12V11Z" fill="#202023"/>
<path d="M176 154.5H59C57.067 154.5 55.5 156.067 55.5 158V172C55.5 173.933 57.067 175.5 59 175.5H176C177.933 175.5 179.5 173.933 179.5 172V158C179.5 156.067 177.933 154.5 176 154.5Z" fill="#151518" stroke="#202023"/>
<path d="M136.5 161H77.5C75.0147 161 73 163.015 73 165.5C73 167.985 75.0147 170 77.5 170H136.5C138.985 170 141 167.985 141 165.5C141 163.015 138.985 161 136.5 161Z" fill="#202023"/>
<path d="M68 165C68 162.791 66.2091 161 64 161C61.7909 161 60 162.791 60 165V166C60 168.209 61.7909 170 64 170C66.2091 170 68 168.209 68 166V165Z" fill="#202023"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,35 +0,0 @@
<svg width="104" height="32" viewBox="0 0 104 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.09074 25.1666C4.44267 27.0471 6.17683 28.6205 8.1795 29.7838C10.1822 30.947 12.4081 31.6738 14.7114 31.9165C13.5264 30.1333 11.8039 28.4928 9.65354 27.2438C7.50318 25.9948 5.22591 25.3125 3.09074 25.1666Z" fill="url(#paint0_linear_13278_146906)"/>
<path d="M12.543 22.2705C8.40015 19.8636 3.91612 19.2502 0.707662 20.3338C1.0174 21.3575 1.42589 22.3487 1.92738 23.2934C4.71498 23.2288 7.75856 23.9859 10.5906 25.6308C13.4227 27.2757 15.5888 29.5459 16.9143 32C17.9839 31.9672 19.0479 31.8309 20.0913 31.5932C19.4426 28.2698 16.6849 24.6779 12.543 22.2705Z" fill="url(#paint1_linear_13278_146906)"/>
<path d="M32.2852 12.5009C31.7585 10.3584 30.8054 8.34403 29.4829 6.57804C28.1604 4.81205 26.4956 3.33067 24.5879 2.22235C22.6802 1.11403 20.5687 0.401504 18.3796 0.12731C16.1904 -0.146884 13.9684 0.02288 11.8463 0.626466C15.3915 1.06033 19.3267 2.39122 23.1859 4.63324C27.0452 6.87525 30.1533 9.63411 32.2852 12.5009Z" fill="url(#paint2_linear_13278_146906)"/>
<path d="M27.1271 20.3583C25.3124 17.3446 22.2038 14.4588 18.3743 12.2342C14.5449 10.0095 10.4991 8.7388 6.98531 8.65474C3.894 8.58152 1.57389 9.48017 0.621548 11.1197C0.616124 11.1294 0.608531 11.1386 0.602566 11.1484C0.516877 11.4559 0.443119 11.7639 0.37587 12.0731C1.70568 11.5481 3.24645 11.2558 4.95969 11.2232C8.76959 11.1517 13.0334 12.3703 16.9681 14.6562C20.9027 16.9422 24.0759 20.0438 25.9003 23.3878C26.7182 24.8944 27.2285 26.3777 27.4308 27.7948C27.6662 27.5844 27.8972 27.3669 28.1212 27.1408C28.1272 27.1305 28.131 27.1196 28.1369 27.1088C29.0893 25.4677 28.721 23.0076 27.1271 20.3583Z" fill="url(#paint3_linear_13278_146906)"/>
<path d="M15.4609 17.2485C9.59662 13.8416 3.11626 13.3079 -5.33621e-07 15.6855C0.00612042 16.4297 0.0630161 17.1726 0.170292 17.9091C1.08699 17.6312 2.03177 17.4562 2.98718 17.3874C6.46951 17.1254 10.3087 18.0957 13.7927 20.1207C17.2766 22.1458 20.023 25.0018 21.5209 28.1543C21.935 29.018 22.2508 29.9254 22.4624 30.8595C23.1555 30.5878 23.8294 30.2694 24.4794 29.9066C25.0011 26.0213 21.3268 20.656 15.4609 17.2485Z" fill="url(#paint4_linear_13278_146906)"/>
<path d="M30.1434 15.3141C28.3082 12.3036 25.1724 9.40969 21.3158 7.17039C17.4593 4.93109 13.3977 3.64033 9.87257 3.53674C7.1853 3.45919 5.10382 4.11053 4.02457 5.34109C8.50588 4.58182 14.4168 5.85794 20.146 9.18625C25.8753 12.5146 29.9135 17.0181 31.4722 21.2868C32.0064 19.7406 31.5416 17.6098 30.1434 15.3141Z" fill="url(#paint5_linear_13278_146906)"/>
<path d="M46.6645 9.90909L49.4003 9.90909L49.4003 19.0369C49.4003 19.8807 49.2107 20.6136 48.8314 21.2358C48.4564 21.858 47.9344 22.3374 47.2654 22.674C46.5963 23.0107 45.8186 23.179 44.9323 23.179C44.1439 23.179 43.428 23.0405 42.7845 22.7635C42.1453 22.4822 41.6382 22.0561 41.2632 21.4851C40.8882 20.9098 40.7029 20.1875 40.7071 19.3182L43.4621 19.3182C43.4706 19.6634 43.5409 19.9595 43.673 20.2067C43.8094 20.4496 43.9948 20.6371 44.2291 20.7692C44.4678 20.897 44.749 20.9609 45.0729 20.9609C45.4138 20.9609 45.7014 20.8885 45.9358 20.7436C46.1745 20.5945 46.3556 20.3771 46.4791 20.0916C46.6027 19.8061 46.6645 19.4545 46.6645 19.0369L46.6645 9.90909ZM57.9337 18.8196L57.9337 13.1818L60.6567 13.1818L60.6567 23L58.0423 23L58.0423 21.2166L57.9401 21.2166C57.7185 21.7919 57.3499 22.2543 56.8343 22.6037C56.3229 22.9531 55.6986 23.1278 54.9614 23.1278C54.3051 23.1278 53.7277 22.9787 53.2291 22.6804C52.7306 22.3821 52.3406 21.9581 52.0594 21.4084C51.7824 20.8587 51.6418 20.2003 51.6375 19.4332L51.6375 13.1818L54.3605 13.1818L54.3605 18.9474C54.3648 19.527 54.5203 19.9851 54.8272 20.3217C55.134 20.6584 55.5452 20.8267 56.0608 20.8267C56.3889 20.8267 56.6958 20.7521 56.9813 20.603C57.2668 20.4496 57.4969 20.2237 57.6716 19.9254C57.8506 19.6271 57.9379 19.2585 57.9337 18.8196ZM62.8348 26.6818L62.8348 13.1818L65.5194 13.1818L65.5194 14.831L65.6409 14.831C65.7602 14.5668 65.9328 14.2983 66.1586 14.0256C66.3888 13.7486 66.6871 13.5185 67.0535 13.3352C67.4243 13.1477 67.8845 13.054 68.4342 13.054C69.1501 13.054 69.8106 13.2415 70.4158 13.6165C71.0209 13.9872 71.5045 14.5476 71.8667 15.2976C72.229 16.0433 72.4101 16.9787 72.4101 18.1037C72.4101 19.1989 72.2332 20.1236 71.8795 20.8778C71.5301 21.6278 71.0528 22.1967 70.4477 22.5845C69.8469 22.968 69.1736 23.1598 68.4278 23.1598C67.8994 23.1598 67.4498 23.0724 67.0791 22.8977C66.7126 22.723 66.4122 22.5036 66.1778 22.2393C65.9435 21.9709 65.7645 21.7003 65.6409 21.4276L65.5578 21.4276L65.5578 26.6818L62.8348 26.6818ZM65.5003 18.0909C65.5003 18.6747 65.5812 19.1839 65.7432 19.6186C65.9051 20.0533 66.1395 20.392 66.4463 20.6349C66.7531 20.8736 67.126 20.9929 67.5649 20.9929C68.0081 20.9929 68.3831 20.8714 68.6899 20.6286C68.9967 20.3814 69.229 20.0405 69.3866 19.6058C69.5486 19.1669 69.6295 18.6619 69.6295 18.0909C69.6295 17.5241 69.5507 17.0256 69.393 16.5952C69.2354 16.1648 69.0031 15.8281 68.6963 15.5852C68.3895 15.3423 68.0123 15.2209 67.5649 15.2209C67.1217 15.2209 66.7467 15.3381 66.4399 15.5724C66.1373 15.8068 65.9051 16.1392 65.7432 16.5696C65.5812 17 65.5003 17.5071 65.5003 18.0909ZM74.2254 23L74.2254 13.1818L76.9484 13.1818L76.9484 23L74.2254 23ZM75.5933 11.9162C75.1885 11.9162 74.8412 11.782 74.5514 11.5135C74.2659 11.2408 74.1231 10.9148 74.1231 10.5355C74.1231 10.1605 74.2659 9.83878 74.5514 9.57031C74.8412 9.29759 75.1885 9.16122 75.5933 9.16122C75.9981 9.16122 76.3433 9.29759 76.6288 9.57031C76.9186 9.83878 77.0635 10.1605 77.0635 10.5355C77.0635 10.9148 76.9186 11.2408 76.6288 11.5135C76.3433 11.782 75.9981 11.9162 75.5933 11.9162ZM84.352 13.1818L84.352 15.2273L78.4394 15.2273L78.4394 13.1818L84.352 13.1818ZM79.7817 10.8295L82.5047 10.8295L82.5047 19.983C82.5047 20.2344 82.5431 20.4304 82.6198 20.571C82.6965 20.7074 82.803 20.8033 82.9394 20.8587C83.08 20.9141 83.2419 20.9418 83.4252 20.9418C83.553 20.9418 83.6808 20.9311 83.8087 20.9098C83.9365 20.8842 84.0345 20.8651 84.1027 20.8523L84.531 22.8786C84.3946 22.9212 84.2029 22.9702 83.9557 23.0256C83.7085 23.0852 83.4081 23.1215 83.0544 23.1342C82.3982 23.1598 81.8229 23.0724 81.3286 22.8722C80.8385 22.6719 80.4571 22.3608 80.1844 21.9389C79.9117 21.517 79.7774 20.9844 79.7817 20.3409L79.7817 10.8295ZM90.4948 23.1918C89.4848 23.1918 88.6155 22.9872 87.8868 22.5781C87.1624 22.1648 86.6041 21.581 86.2121 20.8267C85.82 20.0682 85.624 19.1712 85.624 18.1357C85.624 17.1257 85.82 16.2393 86.2121 15.4766C86.6041 14.7138 87.156 14.1193 87.8676 13.6932C88.5835 13.267 89.423 13.054 90.3861 13.054C91.0338 13.054 91.6368 13.1584 92.195 13.3672C92.7575 13.5717 93.2476 13.8807 93.6652 14.294C94.0871 14.7074 94.4152 15.2273 94.6496 15.8537C94.884 16.4759 95.0012 17.2045 95.0012 18.0398L95.0012 18.7876L86.7107 18.7876L86.7107 17.1001L92.4379 17.1001C92.4379 16.7081 92.3527 16.3608 92.1823 16.0582C92.0118 15.7557 91.7753 15.5192 91.4727 15.3487C91.1745 15.174 90.8272 15.0866 90.4308 15.0866C90.0175 15.0866 89.651 15.1825 89.3314 15.3743C89.0161 15.5618 88.7689 15.8153 88.5899 16.1349C88.411 16.4503 88.3193 16.8018 88.3151 17.1896L88.3151 18.794C88.3151 19.2798 88.4046 19.6996 88.5835 20.0533C88.7668 20.407 89.0246 20.6797 89.357 20.8715C89.6894 21.0632 90.0835 21.1591 90.5395 21.1591C90.8421 21.1591 91.1191 21.1165 91.3705 21.0313C91.6219 20.946 91.8371 20.8182 92.0161 20.6477C92.195 20.4773 92.3314 20.2685 92.4252 20.0213L94.9436 20.1875C94.8158 20.7926 94.5537 21.321 94.1574 21.7727C93.7654 22.2202 93.2583 22.5696 92.6361 22.821C92.0182 23.0682 91.3044 23.1918 90.4948 23.1918ZM96.7781 23L96.7781 13.1818L99.4181 13.1818L99.4181Line truncated
<defs>
<linearGradient id="paint0_linear_13278_146906" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#C7F284"/>
<stop offset="1" stop-color="#00BEF0"/>
</linearGradient>
<linearGradient id="paint1_linear_13278_146906" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#C7F284"/>
<stop offset="1" stop-color="#00BEF0"/>
</linearGradient>
<linearGradient id="paint2_linear_13278_146906" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#C7F284"/>
<stop offset="1" stop-color="#00BEF0"/>
</linearGradient>
<linearGradient id="paint3_linear_13278_146906" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#C7F284"/>
<stop offset="1" stop-color="#00BEF0"/>
</linearGradient>
<linearGradient id="paint4_linear_13278_146906" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#C7F284"/>
<stop offset="1" stop-color="#00BEF0"/>
</linearGradient>
<linearGradient id="paint5_linear_13278_146906" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#C7F284"/>
<stop offset="1" stop-color="#00BEF0"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

@@ -1,35 +0,0 @@
<svg width="104" height="32" viewBox="0 0 104 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.09074 25.1666C4.44267 27.0471 6.17683 28.6205 8.1795 29.7838C10.1822 30.947 12.4081 31.6738 14.7114 31.9165C13.5264 30.1333 11.8039 28.4928 9.65354 27.2438C7.50318 25.9948 5.22591 25.3125 3.09074 25.1666Z" fill="url(#paint0_linear_13278_146916)"/>
<path d="M12.543 22.2705C8.40015 19.8636 3.91612 19.2502 0.707662 20.3338C1.0174 21.3575 1.42589 22.3487 1.92738 23.2934C4.71498 23.2288 7.75856 23.9859 10.5906 25.6308C13.4227 27.2757 15.5888 29.5459 16.9143 32C17.9839 31.9672 19.0479 31.8309 20.0913 31.5932C19.4426 28.2698 16.6849 24.6779 12.543 22.2705Z" fill="url(#paint1_linear_13278_146916)"/>
<path d="M32.2852 12.5009C31.7585 10.3584 30.8054 8.34403 29.4829 6.57804C28.1604 4.81205 26.4956 3.33067 24.5879 2.22235C22.6802 1.11403 20.5687 0.401504 18.3796 0.12731C16.1904 -0.146884 13.9684 0.02288 11.8463 0.626466C15.3915 1.06033 19.3267 2.39122 23.1859 4.63324C27.0452 6.87525 30.1533 9.63411 32.2852 12.5009Z" fill="url(#paint2_linear_13278_146916)"/>
<path d="M27.1271 20.3583C25.3124 17.3446 22.2038 14.4588 18.3743 12.2342C14.5449 10.0095 10.4991 8.7388 6.98531 8.65474C3.894 8.58152 1.57389 9.48017 0.621548 11.1197C0.616124 11.1294 0.608531 11.1386 0.602566 11.1484C0.516877 11.4559 0.443119 11.7639 0.37587 12.0731C1.70568 11.5481 3.24645 11.2558 4.95969 11.2232C8.76959 11.1517 13.0334 12.3703 16.9681 14.6562C20.9027 16.9422 24.0759 20.0438 25.9003 23.3878C26.7182 24.8944 27.2285 26.3777 27.4308 27.7948C27.6662 27.5844 27.8972 27.3669 28.1212 27.1408C28.1272 27.1305 28.131 27.1196 28.1369 27.1088C29.0893 25.4677 28.721 23.0076 27.1271 20.3583Z" fill="url(#paint3_linear_13278_146916)"/>
<path d="M15.4609 17.2485C9.59662 13.8416 3.11626 13.3079 -5.33621e-07 15.6855C0.00612042 16.4297 0.0630161 17.1726 0.170292 17.9091C1.08699 17.6312 2.03177 17.4562 2.98718 17.3874C6.46951 17.1254 10.3087 18.0957 13.7927 20.1207C17.2766 22.1458 20.023 25.0018 21.5209 28.1543C21.935 29.018 22.2508 29.9254 22.4624 30.8595C23.1555 30.5878 23.8294 30.2694 24.4794 29.9066C25.0011 26.0213 21.3268 20.656 15.4609 17.2485Z" fill="url(#paint4_linear_13278_146916)"/>
<path d="M30.1434 15.3141C28.3082 12.3036 25.1724 9.40969 21.3158 7.17039C17.4593 4.93109 13.3977 3.64033 9.87257 3.53674C7.1853 3.45919 5.10382 4.11053 4.02457 5.34109C8.50588 4.58182 14.4168 5.85794 20.146 9.18625C25.8753 12.5146 29.9135 17.0181 31.4722 21.2868C32.0064 19.7406 31.5416 17.6098 30.1434 15.3141Z" fill="url(#paint5_linear_13278_146916)"/>
<path d="M46.6645 9.90909L49.4003 9.90909L49.4003 19.0369C49.4003 19.8807 49.2106 20.6136 48.8314 21.2358C48.4564 21.858 47.9344 22.3374 47.2653 22.674C46.5963 23.0107 45.8186 23.179 44.9322 23.179C44.1439 23.179 43.428 23.0405 42.7845 22.7635C42.1453 22.4822 41.6382 22.0561 41.2632 21.4851C40.8882 20.9098 40.7028 20.1875 40.7071 19.3182L43.4621 19.3182C43.4706 19.6634 43.5409 19.9595 43.673 20.2067C43.8094 20.4496 43.9947 20.6371 44.2291 20.7692C44.4677 20.897 44.749 20.9609 45.0729 20.9609C45.4138 20.9609 45.7014 20.8885 45.9358 20.7436C46.1744 20.5945 46.3555 20.3771 46.4791 20.0916C46.6027 19.8061 46.6645 19.4545 46.6645 19.0369L46.6645 9.90909ZM57.9337 18.8196L57.9337 13.1818L60.6567 13.1818L60.6567 23L58.0423 23L58.0423 21.2166L57.94 21.2166C57.7185 21.7919 57.3498 22.2543 56.8342 22.6037C56.3229 22.9531 55.6986 23.1278 54.9614 23.1278C54.3051 23.1278 53.7277 22.9787 53.2291 22.6804C52.7305 22.3821 52.3406 21.9581 52.0594 21.4084C51.7824 20.8587 51.6418 20.2003 51.6375 19.4332L51.6375 13.1818L54.3605 13.1818L54.3605 18.9474C54.3648 19.527 54.5203 19.9851 54.8271 20.3217C55.1339 20.6584 55.5452 20.8267 56.0608 20.8267C56.3889 20.8267 56.6957 20.7521 56.9812 20.603C57.2668 20.4496 57.4969 20.2237 57.6716 19.9254C57.8506 19.6271 57.9379 19.2585 57.9337 18.8196ZM62.8348 26.6818L62.8348 13.1818L65.5194 13.1818L65.5194 14.831L65.6409 14.831C65.7602 14.5668 65.9328 14.2983 66.1586 14.0256C66.3887 13.7486 66.687 13.5185 67.0535 13.3352C67.4242 13.1477 67.8845 13.054 68.4342 13.054C69.1501 13.054 69.8106 13.2415 70.4157 13.6165C71.0208 13.9872 71.5045 14.5476 71.8667 15.2976C72.2289 16.0433 72.41 16.9787 72.41 18.1037C72.41 19.1989 72.2332 20.1236 71.8795 20.8778C71.5301 21.6278 71.0528 22.1967 70.4477 22.5845C69.8468 22.968 69.1735 23.1598 68.4278 23.1598C67.8994 23.1598 67.4498 23.0724 67.0791 22.8977C66.7126 22.723 66.4122 22.5036 66.1778 22.2393C65.9434 21.9709 65.7644 21.7003 65.6409 21.4276L65.5578 21.4276L65.5578 26.6818L62.8348 26.6818ZM65.5002 18.0909C65.5002 18.6747 65.5812 19.1839 65.7431 19.6186C65.9051 20.0533 66.1394 20.392 66.4463 20.6349C66.7531 20.8736 67.1259 20.9929 67.5649 20.9929C68.0081 20.9929 68.3831 20.8714 68.6899 20.6286C68.9967 20.3814 69.2289 20.0405 69.3866 19.6058C69.5485 19.1669 69.6295 18.6619 69.6295 18.0909C69.6295 17.5241 69.5507 17.0256 69.393 16.5952C69.2353 16.1648 69.0031 15.8281 68.6963 15.5852C68.3894 15.3423 68.0123 15.2209 67.5649 15.2209C67.1217 15.2209 66.7467 15.3381 66.4399 15.5724C66.1373 15.8068 65.9051 16.1392 65.7431 16.5696C65.5812 17 65.5002 17.5071 65.5002 18.0909ZM74.2254 23L74.2254 13.1818L76.9484 13.1818L76.9484 23L74.2254 23ZM75.5933 11.9162C75.1884 11.9162 74.8411 11.782 74.5514 11.5135C74.2659 11.2408 74.1231 10.9148 74.1231 10.5355C74.1231 10.1605 74.2659 9.83878 74.5514 9.57031C74.8411 9.29759 75.1884 9.16122 75.5933 9.16122C75.9981 9.16122 76.3433 9.29759 76.6288 9.57031C76.9186 9.83878 77.0634 10.1605 77.0634 10.5355C77.0634 10.9148 76.9186 11.2408 76.6288 11.5135C76.3433 11.782 75.9981 11.9162 75.5933 11.9162ZM84.352 13.1818L84.352 15.2273L78.4393 15.2273L78.4393 13.1818L84.352 13.1818ZM79.7817 10.8295L82.5047 10.8295L82.5047 19.983C82.5047 20.2344 82.543 20.4304 82.6197 20.571C82.6964 20.7074 82.803 20.8033 82.9393 20.8587C83.08 20.9141 83.2419 20.9418 83.4251 20.9418C83.553 20.9418 83.6808 20.9311 83.8087 20.9098C83.9365 20.8842 84.0345 20.8651 84.1027 20.8523L84.531 22.8786C84.3946 22.9212 84.2028 22.9702 83.9557 23.0256C83.7085 23.0852 83.4081 23.1215 83.0544 23.1342C82.3981 23.1598 81.8229 23.0724 81.3285 22.8722C80.8385 22.6719 80.4571 22.3608 80.1844 21.9389C79.9116 21.517 79.7774 20.9844 79.7817 20.3409L79.7817 10.8295ZM90.4947 23.1918C89.4848 23.1918 88.6155 22.9872 87.8868 22.5781C87.1623 22.1648 86.6041 21.581 86.2121 20.8267C85.82 20.0682 85.624 19.1712 85.624 18.1357C85.624 17.1257 85.82 16.2393 86.2121 15.4766C86.6041 14.7138 87.156 14.1193 87.8676 13.6932C88.5835 13.267 89.423 13.054 90.3861 13.054C91.0338 13.054 91.6368 13.1584 92.195 13.3672C92.7575 13.5717 93.2476 13.8807 93.6652 14.294C94.0871 14.7074 94.4152 15.2273 94.6496 15.8537C94.8839 16.4759 95.0011 17.2045 95.0011 18.0398L95.0011 18.7876L86.7106 18.7876L86.7106 17.1001L92.4379 17.1001C92.4379 16.7081 92.3527 16.3608 92.1822 16.0582C92.0118 15.7557 91.7753 15.5192 91.4727 15.3487C91.1744 15.174 90.8271 15.0866 90.4308 15.0866C90.0175 15.0866 89.651 15.1825 89.3314 15.3743C89.016 15.5618 88.7689 15.8153 88.5899 16.1349C88.4109 16.4503 88.3193 16.8018 88.315 17.1896L88.315 18.794C88.315 19.2798 88.4045 19.6996 88.5835 20.0533C88.7668 20.407 89.0246 20.6797 89.3569 20.8715C89.6893 21.0632 90.0835 21.1591 90.5395 21.1591C90.842 21.1591 91.119 21.1165 91.3704 21.0313C91.6219 20.946 91.8371 20.8182 92.016 20.6477C92.195 20.4773 92.3314 20.2685 92.4251 20.0213L94.9436 20.1875C94.8158 20.7926 94.5537 21.321 94.1574 21.7727C93.7653 22.2202 93.2582 22.5696 92.6361 22.821C92.0182 23.0682 91.3044 23.1918 90.4947 23.1918ZM96.7781 23L96.7781 13.1818L99.418 13.1818L99.418 14.8949L99.Line truncated
<defs>
<linearGradient id="paint0_linear_13278_146916" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#A4D756"/>
<stop offset="1" stop-color="#00B6E7"/>
</linearGradient>
<linearGradient id="paint1_linear_13278_146916" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#A4D756"/>
<stop offset="1" stop-color="#00B6E7"/>
</linearGradient>
<linearGradient id="paint2_linear_13278_146916" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#A4D756"/>
<stop offset="1" stop-color="#00B6E7"/>
</linearGradient>
<linearGradient id="paint3_linear_13278_146916" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#A4D756"/>
<stop offset="1" stop-color="#00B6E7"/>
</linearGradient>
<linearGradient id="paint4_linear_13278_146916" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#A4D756"/>
<stop offset="1" stop-color="#00B6E7"/>
</linearGradient>
<linearGradient id="paint5_linear_13278_146916" x1="21.5" y1="6.5" x2="6.66667" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#A4D756"/>
<stop offset="1" stop-color="#00B6E7"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100" viewBox="0 0 300 100">
<rect width="100%" height="100%" fill="#DDDDDD" />
<path fill="#999999"
d="m76.795 37.635-7.38 12.4v7.84h-3.75v-7.84l-7.38-12.4h3.32q.49 0 .78.23.29.24.48.61l3.7 6.76q.32.6.57 1.13.24.53.44 1.07.18-.54.42-1.07.23-.53.54-1.13l3.68-6.76q.16-.31.47-.58.3-.26.78-.26zm7.88 5.65q1.6 0 2.91.52t2.24 1.47 1.43 2.32q.51 1.38.51 3.07 0 1.71-.51 3.08-.5 1.37-1.43 2.34-.93.96-2.24 1.48t-2.91.52q-1.61 0-2.92-.52-1.32-.52-2.25-1.48-.93-.97-1.44-2.34t-.51-3.08q0-1.69.51-3.07.51-1.37 1.44-2.32t2.25-1.47q1.31-.52 2.92-.52m0 12.14q1.8 0 2.66-1.2.86-1.21.86-3.53 0-2.33-.86-3.54-.86-1.22-2.66-1.22-1.82 0-2.69 1.22-.88 1.23-.88 3.54t.88 3.52q.87 1.21 2.69 1.21m18.65-11.91h3.46v14.36h-2.11q-.69 0-.87-.63l-.24-1.15q-.88.9-1.95 1.45-1.06.55-2.5.55-1.18 0-2.08-.4-.9-.39-1.52-1.12t-.93-1.73q-.32-1-.32-2.21v-9.12h3.46v9.12q0 1.32.61 2.04t1.83.72q.89 0 1.68-.4.78-.4 1.48-1.1zm9.93.86.21 1.63q.67-1.29 1.59-2.02.93-.74 2.19-.74.99 0 1.59.43l-.22 2.59q-.07.26-.2.36-.14.11-.36.11-.21 0-.62-.07-.42-.07-.81-.07-.57 0-1.02.16-.45.17-.8.49-.36.31-.63.76-.28.45-.52 1.02v8.85h-3.45v-14.36h2.03q.53 0 .74.18.21.19.28.68m14.24-7.3h3.45v20.8h-3.45zm13.27 6.21q1.59 0 2.9.52t2.24 1.47 1.44 2.32q.5 1.38.5 3.07 0 1.71-.5 3.08-.51 1.37-1.44 2.34-.93.96-2.24 1.48t-2.9.52q-1.61 0-2.93-.52-1.31-.52-2.24-1.48-.94-.97-1.45-2.34t-.51-3.08q0-1.69.51-3.07.51-1.37 1.45-2.32.93-.95 2.24-1.47 1.32-.52 2.93-.52m0 12.14q1.79 0 2.65-1.2.86-1.21.86-3.53 0-2.33-.86-3.54-.86-1.22-2.65-1.22-1.82 0-2.7 1.22-.87 1.23-.87 3.54t.87 3.52q.88 1.21 2.7 1.21m14.98-5.08q.64 0 1.12-.18.47-.17.79-.48.31-.31.48-.74.16-.44.16-.95 0-1.07-.64-1.69t-1.91-.62q-1.28 0-1.91.62-.64.62-.64 1.69 0 .5.16.93.16.44.48.75.31.32.8.49.48.18 1.11.18m3.9 8.17q0-.42-.25-.68-.25-.27-.68-.42-.44-.14-1.02-.21t-1.23-.11l-1.34-.06q-.7-.03-1.36-.11-.57.32-.93.75-.35.44-.35 1.01 0 .38.19.71.18.33.6.57.41.23 1.07.37.66.13 1.61.13.96 0 1.66-.15.7-.14 1.16-.4.45-.26.66-.62t.21-.78m-.68-14.51h4.13v1.28q0 .62-.74.76l-1.29.24q.29.74.29 1.62 0 1.07-.42 1.93-.43.86-1.19 1.46-.75.6-1.78.93t-2.22.33q-.42 0-.81-.04-.4-.04-.77-.11-.68.4-.68.91 0 .43.4.63.4.21 1.06.29t1.5.1q.84.03 1.72.1t1.72.24q.84.18 1.5.55.66.38 1.06 1.03t.4 1.68q0 .95-.47 1.84-.47.9-1.36 1.6t-2.18 1.13q-1.3.42-2.95.42-1.63 0-2.83-.31-1.2-.32-2-.84-.8-.53-1.19-1.21-.39-.69-.39-1.43 0-1.01.61-1.69.6-.68 1.67-1.08-.58-.3-.91-.79-.34-.49-.34-1.28 0-.33.12-.67t.35-.68q.23-.33.58-.64.35-.3.83-.53-1.09-.59-1.72-1.57-.62-.98-.62-2.3 0-1.06.43-1.92.42-.86 1.19-1.47.76-.61 1.8-.93 1.05-.33 2.28-.33.92 0 1.73.19.82.19 1.49.56m12.67-.72q1.59 0 2.9.52t2.24 1.47 1.44 2.32q.5 1.38.5 3.07 0 1.71-.5 3.08-.51 1.37-1.44 2.34-.93.96-2.24 1.48t-2.9.52q-1.61 0-2.93-.52-1.31-.52-2.24-1.48-.94-.97-1.45-2.34t-.51-3.08q0-1.69.51-3.07.51-1.37 1.45-2.32.93-.95 2.24-1.47 1.32-.52 2.93-.52m0 12.14q1.79 0 2.65-1.2.86-1.21.86-3.53 0-2.33-.86-3.54-.86-1.22-2.65-1.22-1.82 0-2.7 1.22-.87 1.23-.87 3.54t.87 3.52q.88 1.21 2.7 1.21m19.99-18.35v7.98q.84-.79 1.85-1.28t2.36-.49q1.18 0 2.09.4t1.52 1.12.92 1.72q.32 1 .32 2.21v9.14h-3.46v-9.14q0-1.32-.6-2.04t-1.84-.72q-.89 0-1.68.41-.78.4-1.48 1.1v10.39h-3.46v-20.8zm15.06 11.97h6.53q0-.68-.19-1.27-.19-.6-.57-1.04-.38-.45-.96-.71t-1.35-.26q-1.5 0-2.36.85-.86.86-1.1 2.43m8.82 2.08h-8.89q.09 1.11.4 1.91.3.81.81 1.33.5.53 1.2.79.69.26 1.53.26t1.45-.2 1.06-.43q.46-.24.8-.44.34-.19.66-.19.44 0 .65.32l.99 1.26q-.57.67-1.29 1.12-.71.46-1.49.73-.77.28-1.58.39-.8.11-1.56.11-1.5 0-2.79-.5-1.28-.49-2.24-1.47-.95-.97-1.49-2.41-.55-1.43-.55-3.32 0-1.47.48-2.77.47-1.29 1.36-2.25t2.17-1.52 2.89-.56q1.36 0 2.51.44 1.15.43 1.97 1.26.83.84 1.3 2.05t.47 2.76q0 .79-.17 1.06t-.65.27m6.46-6.75.21 1.63q.67-1.29 1.59-2.02.93-.74 2.19-.74.99 0 1.59.43l-.22 2.59q-.07.26-.2.36-.14.11-.36.11-.21 0-.62-.07-.42-.07-.81-.07-.57 0-1.02.16-.45.17-.8.49-.36.31-.63.76-.28.45-.52 1.02v8.85h-3.45v-14.36h2.03q.53 0 .74.18.21.19.28.68m10.12 4.67h6.52q0-.68-.18-1.27-.19-.6-.57-1.04-.38-.45-.96-.71t-1.35-.26q-1.5 0-2.36.85-.86.86-1.1 2.43m8.82 2.08h-8.89q.08 1.11.39 1.91.31.81.81 1.33.51.53 1.2.79t1.53.26 1.45-.2 1.07-.43q.45-.24.8-.44.34-.19.66-.19.43 0 .64.32l1 1.26q-.58.67-1.29 1.12-.71.46-1.49.73-.78.28-1.58.39-.81.11-1.56.11-1.5 0-2.79-.5-1.29-.49-2.24-1.47-.95-.97-1.5-2.41-.54-1.43-.54-3.32 0-1.47.47-2.77.48-1.29 1.37-2.25t2.17-1.52 2.89-.56q1.36 0 2.5.44 1.15.43 1.98 1.26.82.84 1.29 2.05t.47 2.76q0 .79-.17 1.06-.16.27-.64.27" />
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,24 +0,0 @@
<svg width="646" height="96" viewBox="0 0 646 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1064_606)">
<path d="M108.53 75.6899L90.81 94.6899C90.4267 95.1026 89.9626 95.432 89.4464 95.6573C88.9303 95.8827 88.3732 95.9994 87.81 95.9999H3.81C3.40937 95.9997 3.01749 95.8827 2.68235 95.6631C2.34722 95.4436 2.08338 95.1311 1.92313 94.7639C1.76288 94.3967 1.71318 93.9908 1.78012 93.5958C1.84706 93.2008 2.02772 92.8338 2.3 92.5399L20 73.5399C20.3833 73.1273 20.8474 72.7979 21.3636 72.5725C21.8797 72.3472 22.4368 72.2305 23 72.2299H107C107.404 72.2216 107.802 72.333 108.143 72.5502C108.484 72.7674 108.754 73.0806 108.917 73.4504C109.081 73.8203 109.131 74.2303 109.062 74.6288C108.993 75.0273 108.808 75.3965 108.53 75.6899ZM90.81 37.4199C90.4253 37.0091 89.9608 36.6811 89.445 36.4558C88.9292 36.2306 88.3728 36.1129 87.81 36.11H3.81C3.40937 36.1102 3.01749 36.2272 2.68235 36.4468C2.34722 36.6663 2.08338 36.9788 1.92313 37.346C1.76288 37.7132 1.71318 38.1191 1.78012 38.5141C1.84706 38.9091 2.02772 39.2761 2.3 39.57L20 58.58C20.3847 58.9908 20.8492 59.3188 21.365 59.5441C21.8808 59.7693 22.4372 59.887 23 59.8899H107C107.4 59.8878 107.79 59.7693 108.124 59.5491C108.458 59.3288 108.72 59.0162 108.879 58.6494C109.038 58.2826 109.087 57.8774 109.019 57.4833C108.952 57.0892 108.772 56.7232 108.5 56.43L90.81 37.4199ZM3.81 23.7699H87.81C88.3732 23.7694 88.9303 23.6527 89.4464 23.4273C89.9626 23.202 90.4267 22.8726 90.81 22.4599L108.53 3.45995C108.808 3.16647 108.993 2.79726 109.062 2.39877C109.131 2.00028 109.081 1.59031 108.917 1.22045C108.754 0.850591 108.484 0.537368 108.143 0.320195C107.802 0.103021 107.404 -0.0084012 107 -5.10783e-05H23C22.4368 0.000541762 21.8797 0.117167 21.3636 0.342553C20.8474 0.567938 20.3833 0.897249 20 1.30995L2.3 20.3099C2.02772 20.6038 1.84706 20.9708 1.78012 21.3658C1.71318 21.7608 1.76288 22.1667 1.92313 22.5339C2.08338 22.9011 2.34722 23.2136 2.68235 23.4331C3.01749 23.6527 3.40937 23.7697 3.81 23.7699Z" fill="url(#paint0_linear_1064_606)"/>
<path d="M210.94 40.6002H166V25.8002H222.62V11.0002H165.85C163.91 10.9897 161.988 11.3613 160.192 12.0938C158.396 12.8264 156.761 13.9055 155.383 15.2696C154.004 16.6337 152.907 18.2561 152.155 20.044C151.403 21.832 151.01 23.7506 151 25.6902V40.6902C151.008 42.6315 151.398 44.5523 152.149 46.3425C152.9 48.1328 153.996 49.7575 155.375 51.1237C156.755 52.49 158.39 53.5709 160.187 54.3047C161.984 55.0385 163.909 55.4108 165.85 55.4002H210.85V70.2002H152.07V85.0002H210.94C212.88 85.0108 214.802 84.6391 216.598 83.9066C218.394 83.174 220.029 82.0949 221.407 80.7308C222.786 79.3667 223.883 77.7444 224.635 75.9564C225.387 74.1684 225.78 72.2498 225.79 70.3102V55.3102C225.782 53.3689 225.392 51.4482 224.641 49.6579C223.89 47.8676 222.794 46.2429 221.415 44.8767C220.035 43.5105 218.4 42.4296 216.603 41.6958C214.806 40.962 212.881 40.5897 210.94 40.6002Z" fill="white"/>
<path d="M298 11H252.89C250.947 10.9842 249.02 11.3519 247.219 12.0821C245.419 12.8123 243.78 13.8905 242.397 15.2552C241.013 16.6198 239.913 18.2439 239.159 20.0345C238.404 21.8251 238.01 23.747 238 25.69V70.31C238.01 72.253 238.404 74.1749 239.159 75.9655C239.913 77.7561 241.013 79.3802 242.397 80.7448C243.78 82.1095 245.419 83.1877 247.219 83.9179C249.02 84.6481 250.947 85.0158 252.89 85H298C299.94 85.0105 301.862 84.6389 303.658 83.9064C305.454 83.1738 307.089 82.0947 308.467 80.7306C309.846 79.3665 310.943 77.7441 311.695 75.9562C312.447 74.1682 312.84 72.2496 312.85 70.31V25.69C312.84 23.7504 312.447 21.8318 311.695 20.0438C310.943 18.2559 309.846 16.6335 308.467 15.2694C307.089 13.9053 305.454 12.8262 303.658 12.0936C301.862 11.3611 299.94 10.9895 298 11ZM297.89 70.2H253V25.8H297.87L297.89 70.2Z" fill="white"/>
<path d="M456 11.0001H412C410.06 10.9896 408.138 11.3612 406.342 12.0937C404.546 12.8263 402.911 13.9054 401.533 15.2695C400.154 16.6336 399.057 18.256 398.305 20.0439C397.553 21.8319 397.16 23.7505 397.15 25.6901V85.0001H412.15V60.6901H455.95V85.0001H470.95V25.6901C470.94 23.742 470.544 21.8152 469.786 20.0206C469.027 18.2261 467.922 16.5993 466.532 15.2338C465.143 13.8684 463.497 12.7914 461.689 12.0648C459.881 11.3382 457.948 10.9764 456 11.0001ZM455.89 45.8901H412.09V25.8001H455.89V45.8901Z" fill="white"/>
<path d="M631.15 11.0002H587.15C585.21 10.9897 583.288 11.3613 581.492 12.0938C579.696 12.8264 578.062 13.9055 576.683 15.2696C575.304 16.6337 574.207 18.2561 573.455 20.044C572.703 21.832 572.31 23.7506 572.3 25.6902V85.0002H587.3V60.6902H631V85.0002H646V25.6902C645.99 23.7506 645.597 21.832 644.845 20.044C644.093 18.2561 642.996 16.6337 641.617 15.2696C640.238 13.9055 638.604 12.8264 636.808 12.0938C635.012 11.3613 633.09 10.9897 631.15 11.0002ZM631 45.8902H587.2V25.8002H631V45.8902Z" fill="white"/>
<path d="M544 70.2001H538L516.55 17.2001C515.815 15.3716 514.55 13.8045 512.918 12.6999C511.286 11.5952 509.361 11.0033 507.39 11.0001H494.08C492.786 10.9935 491.504 11.2418 490.307 11.7307C489.109 12.2197 488.02 12.9397 487.1 13.8497C486.181 14.7598 485.45 15.8419 484.949 17.0345C484.448 18.227 484.187 19.5066 484.18 20.8001V85.0001H499.18V25.8001H505.18L526.62 78.8001C527.367 80.6251 528.642 82.1858 530.281 83.283C531.919 84.3803 533.848 84.9641 535.82 84.9601H549.13C550.424 84.9667 551.706 84.7185 552.903 84.2295C554.101 83.7406 555.19 83.0205 556.11 82.1105C557.029 81.2005 557.76 80.1183 558.261 78.9258C558.762 77.7332 559.023 76.4537 559.03 75.1601V11.0001H544V70.2001Z" fill="white"/>
<path d="M341.1 11H326.1V70.31C326.11 72.2539 326.505 74.1766 327.26 75.9678C328.015 77.7591 329.116 79.3836 330.5 80.7484C331.884 82.1132 333.525 83.1912 335.326 83.9208C337.128 84.6504 339.056 85.0171 341 85H386V70.2H341.1V11Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_1064_606" x1="10.81" y1="98.29" x2="98.89" y2="-1.01005" gradientUnits="userSpaceOnUse">
<stop offset="0.08" stop-color="#9945FF"/>
<stop offset="0.3" stop-color="#8752F3"/>
<stop offset="0.5" stop-color="#5497D5"/>
<stop offset="0.6" stop-color="#43B4CA"/>
<stop offset="0.72" stop-color="#28E0B9"/>
<stop offset="0.97" stop-color="#19FB9B"/>
</linearGradient>
<clipPath id="clip0_1064_606">
<rect width="646" height="96" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="646" height="96" viewBox="0 0 646 96" fill="none" version="1.1" id="svg36"
sodipodi:docname="solana_logo_banner_dark.svg" inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview id="namedview38" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25"
inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1"
showgrid="false" inkscape:zoom="2.0820433" inkscape:cx="324.68104" inkscape:cy="48.02974"
inkscape:window-width="1854" inkscape:window-height="1131" inkscape:window-x="0" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:current-layer="svg36" />
<g clip-path="url(#clip0_1064_606)" id="g16">
<path
d="M108.53 75.6899L90.81 94.6899C90.4267 95.1026 89.9626 95.432 89.4464 95.6573C88.9303 95.8827 88.3732 95.9994 87.81 95.9999H3.81C3.40937 95.9997 3.01749 95.8827 2.68235 95.6631C2.34722 95.4436 2.08338 95.1311 1.92313 94.7639C1.76288 94.3967 1.71318 93.9908 1.78012 93.5958C1.84706 93.2008 2.02772 92.8338 2.3 92.5399L20 73.5399C20.3833 73.1273 20.8474 72.7979 21.3636 72.5725C21.8797 72.3472 22.4368 72.2305 23 72.2299H107C107.404 72.2216 107.802 72.333 108.143 72.5502C108.484 72.7674 108.754 73.0806 108.917 73.4504C109.081 73.8203 109.131 74.2303 109.062 74.6288C108.993 75.0273 108.808 75.3965 108.53 75.6899ZM90.81 37.4199C90.4253 37.0091 89.9608 36.6811 89.445 36.4558C88.9292 36.2306 88.3728 36.1129 87.81 36.11H3.81C3.40937 36.1102 3.01749 36.2272 2.68235 36.4468C2.34722 36.6663 2.08338 36.9788 1.92313 37.346C1.76288 37.7132 1.71318 38.1191 1.78012 38.5141C1.84706 38.9091 2.02772 39.2761 2.3 39.57L20 58.58C20.3847 58.9908 20.8492 59.3188 21.365 59.5441C21.8808 59.7693 22.4372 59.887 23 59.8899H107C107.4 59.8878 107.79 59.7693 108.124 59.5491C108.458 59.3288 108.72 59.0162 108.879 58.6494C109.038 58.2826 109.087 57.8774 109.019 57.4833C108.952 57.0892 108.772 56.7232 108.5 56.43L90.81 37.4199ZM3.81 23.7699H87.81C88.3732 23.7694 88.9303 23.6527 89.4464 23.4273C89.9626 23.202 90.4267 22.8726 90.81 22.4599L108.53 3.45995C108.808 3.16647 108.993 2.79726 109.062 2.39877C109.131 2.00028 109.081 1.59031 108.917 1.22045C108.754 0.850591 108.484 0.537368 108.143 0.320195C107.802 0.103021 107.404 -0.0084012 107 -5.10783e-05H23C22.4368 0.000541762 21.8797 0.117167 21.3636 0.342553C20.8474 0.567938 20.3833 0.897249 20 1.30995L2.3 20.3099C2.02772 20.6038 1.84706 20.9708 1.78012 21.3658C1.71318 21.7608 1.76288 22.1667 1.92313 22.5339C2.08338 22.9011 2.34722 23.2136 2.68235 23.4331C3.01749 23.6527 3.40937 23.7697 3.81 23.7699Z"
fill="url(#paint0_linear_1064_606)" id="path2" />
<path
d="M210.94 40.6002H166V25.8002H222.62V11.0002H165.85C163.91 10.9897 161.988 11.3613 160.192 12.0938C158.396 12.8264 156.761 13.9055 155.383 15.2696C154.004 16.6337 152.907 18.2561 152.155 20.044C151.403 21.832 151.01 23.7506 151 25.6902V40.6902C151.008 42.6315 151.398 44.5523 152.149 46.3425C152.9 48.1328 153.996 49.7575 155.375 51.1237C156.755 52.49 158.39 53.5709 160.187 54.3047C161.984 55.0385 163.909 55.4108 165.85 55.4002H210.85V70.2002H152.07V85.0002H210.94C212.88 85.0108 214.802 84.6391 216.598 83.9066C218.394 83.174 220.029 82.0949 221.407 80.7308C222.786 79.3667 223.883 77.7444 224.635 75.9564C225.387 74.1684 225.78 72.2498 225.79 70.3102V55.3102C225.782 53.3689 225.392 51.4482 224.641 49.6579C223.89 47.8676 222.794 46.2429 221.415 44.8767C220.035 43.5105 218.4 42.4296 216.603 41.6958C214.806 40.962 212.881 40.5897 210.94 40.6002Z"
fill="white" id="path4" style="fill:#000000;fill-opacity:1" />
<path
d="M298 11H252.89C250.947 10.9842 249.02 11.3519 247.219 12.0821C245.419 12.8123 243.78 13.8905 242.397 15.2552C241.013 16.6198 239.913 18.2439 239.159 20.0345C238.404 21.8251 238.01 23.747 238 25.69V70.31C238.01 72.253 238.404 74.1749 239.159 75.9655C239.913 77.7561 241.013 79.3802 242.397 80.7448C243.78 82.1095 245.419 83.1877 247.219 83.9179C249.02 84.6481 250.947 85.0158 252.89 85H298C299.94 85.0105 301.862 84.6389 303.658 83.9064C305.454 83.1738 307.089 82.0947 308.467 80.7306C309.846 79.3665 310.943 77.7441 311.695 75.9562C312.447 74.1682 312.84 72.2496 312.85 70.31V25.69C312.84 23.7504 312.447 21.8318 311.695 20.0438C310.943 18.2559 309.846 16.6335 308.467 15.2694C307.089 13.9053 305.454 12.8262 303.658 12.0936C301.862 11.3611 299.94 10.9895 298 11ZM297.89 70.2H253V25.8H297.87L297.89 70.2Z"
fill="white" id="path6" style="fill:#000000" />
<path
d="M456 11.0001H412C410.06 10.9896 408.138 11.3612 406.342 12.0937C404.546 12.8263 402.911 13.9054 401.533 15.2695C400.154 16.6336 399.057 18.256 398.305 20.0439C397.553 21.8319 397.16 23.7505 397.15 25.6901V85.0001H412.15V60.6901H455.95V85.0001H470.95V25.6901C470.94 23.742 470.544 21.8152 469.786 20.0206C469.027 18.2261 467.922 16.5993 466.532 15.2338C465.143 13.8684 463.497 12.7914 461.689 12.0648C459.881 11.3382 457.948 10.9764 456 11.0001ZM455.89 45.8901H412.09V25.8001H455.89V45.8901Z"
fill="white" id="path8" style="fill:#000000" />
<path
d="M631.15 11.0002H587.15C585.21 10.9897 583.288 11.3613 581.492 12.0938C579.696 12.8264 578.062 13.9055 576.683 15.2696C575.304 16.6337 574.207 18.2561 573.455 20.044C572.703 21.832 572.31 23.7506 572.3 25.6902V85.0002H587.3V60.6902H631V85.0002H646V25.6902C645.99 23.7506 645.597 21.832 644.845 20.044C644.093 18.2561 642.996 16.6337 641.617 15.2696C640.238 13.9055 638.604 12.8264 636.808 12.0938C635.012 11.3613 633.09 10.9897 631.15 11.0002ZM631 45.8902H587.2V25.8002H631V45.8902Z"
fill="white" id="path10" style="fill:#000000" />
<path
d="M544 70.2001H538L516.55 17.2001C515.815 15.3716 514.55 13.8045 512.918 12.6999C511.286 11.5952 509.361 11.0033 507.39 11.0001H494.08C492.786 10.9935 491.504 11.2418 490.307 11.7307C489.109 12.2197 488.02 12.9397 487.1 13.8497C486.181 14.7598 485.45 15.8419 484.949 17.0345C484.448 18.227 484.187 19.5066 484.18 20.8001V85.0001H499.18V25.8001H505.18L526.62 78.8001C527.367 80.6251 528.642 82.1858 530.281 83.283C531.919 84.3803 533.848 84.9641 535.82 84.9601H549.13C550.424 84.9667 551.706 84.7185 552.903 84.2295C554.101 83.7406 555.19 83.0205 556.11 82.1105C557.029 81.2005 557.76 80.1183 558.261 78.9258C558.762 77.7332 559.023 76.4537 559.03 75.1601V11.0001H544V70.2001Z"
fill="white" id="path12" style="fill:#000000" />
<path
d="M341.1 11H326.1V70.31C326.11 72.2539 326.505 74.1766 327.26 75.9678C328.015 77.7591 329.116 79.3836 330.5 80.7484C331.884 82.1132 333.525 83.1912 335.326 83.9208C337.128 84.6504 339.056 85.0171 341 85H386V70.2H341.1V11Z"
fill="white" id="path14" style="fill:#000000" />
</g>
<defs id="defs34">
<linearGradient id="paint0_linear_1064_606" x1="10.81" y1="98.29" x2="98.89" y2="-1.01005"
gradientUnits="userSpaceOnUse">
<stop offset="0.08" stop-color="#9945FF" id="stop18" />
<stop offset="0.3" stop-color="#8752F3" id="stop20" />
<stop offset="0.5" stop-color="#5497D5" id="stop22" />
<stop offset="0.6" stop-color="#43B4CA" id="stop24" />
<stop offset="0.72" stop-color="#28E0B9" id="stop26" />
<stop offset="0.97" stop-color="#19FB9B" id="stop28" />
</linearGradient>
<clipPath id="clip0_1064_606">
<rect width="646" height="96" fill="white" id="rect31" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero">
␍</path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
␍<g fill-rule="evenodd" clip-rule="evenodd">
␍<path fill="#E01E5A" d="M2.471 11.318a1.474 1.474 0 001.47-1.471v-1.47h-1.47A1.474 1.474 0 001 9.846c.001.811.659 1.469 1.47 1.47zm3.682-2.942a1.474 1.474 0 00-1.47 1.471v3.683c.002.811.66 1.468 1.47 1.47a1.474 1.474 0 001.47-1.47V9.846a1.474 1.474 0 00-1.47-1.47z"/>
␍<path fill="#36C5F0" d="M4.683 2.471c.001.811.659 1.469 1.47 1.47h1.47v-1.47A1.474 1.474 0 006.154 1a1.474 1.474 0 00-1.47 1.47zm2.94 3.682a1.474 1.474 0 00-1.47-1.47H2.47A1.474 1.474 0 001 6.153c.002.812.66 1.469 1.47 1.47h3.684a1.474 1.474 0 001.47-1.47z"/>
␍<path fill="#2EB67D" d="M9.847 7.624a1.474 1.474 0 001.47-1.47V2.47A1.474 1.474 0 009.848 1a1.474 1.474 0 00-1.47 1.47v3.684c.002.81.659 1.468 1.47 1.47zm3.682-2.941a1.474 1.474 0 00-1.47 1.47v1.47h1.47A1.474 1.474 0 0015 6.154a1.474 1.474 0 00-1.47-1.47z"/>
␍<path fill="#ECB22E" d="M8.377 9.847c.002.811.659 1.469 1.47 1.47h3.683A1.474 1.474 0 0015 9.848a1.474 1.474 0 00-1.47-1.47H9.847a1.474 1.474 0 00-1.47 1.47zm2.94 3.682a1.474 1.474 0 00-1.47-1.47h-1.47v1.47c.002.812.659 1.469 1.47 1.47a1.474 1.474 0 001.47-1.47z"/>
␍</g>
␍</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g>
<path d="M128.080089,-0.000183105 C135.311053,0.0131003068 142.422517,0.624138494 149.335663,1.77979593 L149.335663,1.77979593 L149.335663,76.2997796 L202.166953,23.6044907 C208.002065,27.7488446 213.460883,32.3582023 218.507811,37.3926715 C223.557281,42.4271407 228.192318,47.8867213 232.346817,53.7047992 L232.346817,53.7047992 L179.512985,106.400063 L254.227854,106.400063 C255.387249,113.29414 256,120.36111 256,127.587243 L256,127.587243 L256,127.759881 C256,134.986013 255.387249,142.066204 254.227854,148.960282 L254.227854,148.960282 L179.500273,148.960282 L232.346817,201.642324 C228.192318,207.460402 223.557281,212.919983 218.523066,217.954452 L218.523066,217.954452 L218.507811,217.954452 C213.460883,222.988921 208.002065,227.6115 202.182208,231.742607 L202.182208,231.742607 L149.335663,179.04709 L149.335663,253.5672 C142.435229,254.723036 135.323765,255.333244 128.092802,255.348499 L128.092802,255.348499 L127.907197,255.348499 C120.673691,255.333244 113.590195,254.723036 106.677048,253.5672 L106.677048,253.5672 L106.677048,179.04709 L53.8457596,231.742607 C42.1780766,223.466917 31.977435,213.278734 23.6658953,201.642324 L23.6658953,201.642324 L76.4997269,148.960282 L1.78485803,148.960282 C0.612750404,142.052729 0,134.946095 0,127.719963 L0,127.719963 L0,127.349037 C0.0121454869,125.473817 0.134939797,123.182933 0.311311815,120.812834 L0.36577283,120.099764 C0.887996182,113.428547 1.78485803,106.400063 1.78485803,106.400063 L1.78485803,106.400063 L76.4997269,106.400063 L23.6658953,53.7047992 C27.8076812,47.8867213 32.4300059,42.4403618 37.4769335,37.4193681 L37.4769335,37.4193681 L37.5023588,37.3926715 C42.5391163,32.3582023 48.0106469,27.7488446 53.8457596,23.6044907 L53.8457596,23.6044907 L106.677048,76.2997796 L106.677048,1.77979593 C113.590195,0.624138494 120.688946,0.0131003068 127.932622,-0.000183105 L127.932622,-0.000183105 L128.080089,-0.000183105 Z M128.067377,95.7600714 L127.945335,95.7600714 C118.436262,95.7600714 109.32891,97.5001809 100.910584,100.661566 C97.7553011,109.043534 96.0085811,118.129275 95.9958684,127.613685 L95.9958684,127.733184 C96.0085811,137.217594 97.7553011,146.303589 100.923296,154.685303 C109.32891,157.846943 118.436262,159.587052 127.945335,159.587052 L128.067377,159.587052 C137.576449,159.587052 146.683802,157.846943 155.089415,154.685303 C158.257411,146.290368 160.004131,137.217594 160.004131,127.733184 L160.004131,127.613685 C160.004131,118.129275 158.257411,109.043534 155.089415,100.661566 C146.683802,97.5001809 137.576449,95.7600714 128.067377,95.7600714 Z" fill="#FF4A00" fill-rule="nonzero">
␍</path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

+227 -699
View File
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
function flattenKeys(obj, prefix = "", result = []) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (
typeof obj[key] === "object" &&
obj[key] !== null &&
!Array.isArray(obj[key])
) {
flattenKeys(obj[key], newKey, result);
} else {
result.push(newKey);
}
}
}
return result;
}
// Paths
const localesDir = path.join(__dirname, "../client/src/locales");
const inputPath = path.join(localesDir, "en.json");
const outputPath = path.join(localesDir, "keys.json");
try {
const jsonContent = fs.readFileSync(inputPath, "utf8");
const jsonData = JSON.parse(jsonContent);
const flattenedKeys = flattenKeys(jsonData);
flattenedKeys.sort();
fs.writeFileSync(outputPath, JSON.stringify(flattenedKeys, null, 2), "utf8");
console.log(`Keys written to: ${outputPath}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
+580 -17
View File
@@ -22,6 +22,7 @@
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"gamedig": "^5.3.1",
"globalping": "^0.2.0",
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
@@ -874,7 +875,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1504,7 +1504,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1549,7 +1548,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2261,6 +2259,132 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@hey-api/client-fetch": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.10.2.tgz",
"integrity": "sha512-AGiFYDx+y8VT1wlQ3EbzzZtfU8EfV+hLLRTtr8Y/tjYZaxIECwJagVZf24YzNbtEBXONFV50bwcU1wLVGXe1ow==",
"deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"@hey-api/openapi-ts": "< 2"
}
},
"node_modules/@hey-api/codegen-core": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.0.tgz",
"integrity": "sha512-HglL4B4QwpzocE+c8qDU6XK8zMf8W8Pcv0RpFDYxHuYALWLTnpDUuEsglC7NQ4vC1maoXsBpMbmwpco0N4QviA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/types": "0.1.3",
"ansi-colors": "4.1.3",
"c12": "3.3.3",
"color-support": "1.1.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@hey-api/json-schema-ref-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.0.tgz",
"integrity": "sha512-3tQJ8N2egHXZjQWUeceoWrl88APWjo7gRrQ/L4HWJKnh6HowczCv7yNNFeSusPoWGV6HGdoFiCvq6UsLkrwKhg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jsdevtools/ono": "7.1.3",
"@types/json-schema": "7.0.15",
"js-yaml": "4.1.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
}
},
"node_modules/@hey-api/openapi-ts": {
"version": "0.92.4",
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.92.4.tgz",
"integrity": "sha512-RA3wnL7Odr5xczuS3xpvnPClgJ/K8jivK3hvD8J0m5GBuvJFkZ1A1xp+6Ve1G0BV8p4LwxwgN1Qhb+4BFsLfMg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/codegen-core": "0.7.0",
"@hey-api/json-schema-ref-parser": "1.3.0",
"@hey-api/shared": "0.2.0",
"@hey-api/types": "0.1.3",
"ansi-colors": "4.1.3",
"color-support": "1.1.3",
"commander": "14.0.3"
},
"bin": {
"openapi-ts": "bin/run.js"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@hey-api/openapi-ts/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
}
},
"node_modules/@hey-api/shared": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.0.tgz",
"integrity": "sha512-t7C+65ES12OqAE5k6DB/y5nDuTjydtqdxf/Qe4zflVn2AzGs7hO/7KjXvGXZYnpNVF7QISAcj0LEObASU9I53Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/codegen-core": "0.7.0",
"@hey-api/json-schema-ref-parser": "1.3.0",
"@hey-api/types": "0.1.3",
"ansi-colors": "4.1.3",
"cross-spawn": "7.0.6",
"open": "11.0.0",
"semver": "7.7.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@hey-api/types": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.3.tgz",
"integrity": "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3223,6 +3347,13 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT",
"peer": true
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
@@ -4229,6 +4360,7 @@
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=10.13.0"
}
@@ -4405,7 +4537,6 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -4506,7 +4637,6 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@@ -4566,7 +4696,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -5053,7 +5182,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5632,7 +5760,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -5739,6 +5866,22 @@
"uuid": "^9.0.0"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"run-applescript": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -5759,6 +5902,78 @@
"node": ">= 0.8"
}
},
"node_modules/c12": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz",
"integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^5.0.0",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^17.2.3",
"exsolve": "^1.0.8",
"giget": "^2.0.0",
"jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.0.0",
"pkg-types": "^2.3.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "*"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/c12/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c12/node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/c12/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
@@ -6023,6 +6238,16 @@
"node": ">=8"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/cjs-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
@@ -6220,6 +6445,16 @@
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"peer": true,
"bin": {
"color-support": "bin.js"
}
},
"node_modules/colord": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
@@ -6357,6 +6592,23 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"license": "MIT",
"peer": true
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -6575,7 +6827,6 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssnano-preset-default": "^7.0.9",
"lilconfig": "^3.1.3"
@@ -6821,6 +7072,36 @@
"node": ">=0.10.0"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser-id": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -6830,6 +7111,26 @@
"node": ">=10"
}
},
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT",
"peer": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -6857,6 +7158,13 @@
"node": ">= 0.8"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT",
"peer": true
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -7330,7 +7638,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7692,7 +7999,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -7767,6 +8073,13 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT",
"peer": true
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8395,6 +8708,24 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"license": "MIT",
"peer": true,
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -8452,6 +8783,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globalping": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/globalping/-/globalping-0.2.0.tgz",
"integrity": "sha512-rxyvqXF/oz5yBGJHR58TEsCkcbc4WQVmdKC+uZ8LXfIt4KwTdTvbzkfwhhjuyUmuQr8U/b8m/ZicdrA8ZIroKw==",
"license": "MIT",
"dependencies": {
"@hey-api/client-fetch": "^0.10.0"
}
},
"node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
@@ -8947,6 +9287,22 @@
"node": ">=8"
}
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"license": "MIT",
"peer": true,
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -8987,6 +9343,38 @@
"node": ">=0.10.0"
}
},
"node_modules/is-in-ssh": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-docker": "^3.0.0"
},
"bin": {
"is-inside-container": "cli.js"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-json": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz",
@@ -9020,6 +9408,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-inside-container": "^1.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
@@ -9341,7 +9745,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -9929,6 +10332,16 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jmespath": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
@@ -11611,6 +12024,13 @@
}
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT",
"peer": true
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -11780,6 +12200,31 @@
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==",
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
"integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"citty": "^0.2.0",
"pathe": "^2.0.3",
"tinyexec": "^1.0.2"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": ">=18"
}
},
"node_modules/nypm/node_modules/citty": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
"license": "MIT",
"peer": true
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -11801,6 +12246,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"license": "MIT",
"peer": true
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11856,6 +12308,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
"license": "MIT",
"peer": true,
"dependencies": {
"default-browser": "^5.4.0",
"define-lazy-prop": "^3.0.0",
"is-in-ssh": "^1.0.0",
"is-inside-container": "^1.0.0",
"powershell-utils": "^0.1.0",
"wsl-utils": "^0.3.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -12080,6 +12553,20 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT",
"peer": true
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT",
"peer": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12199,6 +12686,18 @@
"node": ">=8"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"license": "MIT",
"peer": true,
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/plimit-lit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
@@ -12231,7 +12730,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -12825,6 +13323,19 @@
"node": ">=12"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -13088,6 +13599,17 @@
"node": ">= 0.8"
}
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -13280,6 +13802,19 @@
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"license": "MIT"
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -14179,6 +14714,7 @@
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
@@ -14205,6 +14741,7 @@
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -14215,6 +14752,7 @@
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
@@ -14228,7 +14766,8 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"license": "CC0-1.0",
"optional": true
"optional": true,
"peer": true
},
"node_modules/swagger-ui-dist": {
"version": "5.30.0",
@@ -14365,6 +14904,16 @@
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
@@ -14513,7 +15062,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14674,9 +15222,7 @@
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15306,6 +15852,23 @@
}
}
},
"node_modules/wsl-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
"integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-wsl": "^3.1.0",
"powershell-utils": "^0.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+1
View File
@@ -37,6 +37,7 @@
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"gamedig": "^5.3.1",
"globalping": "^0.2.0",
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
+2 -2
View File
@@ -29,8 +29,8 @@ export interface InitializedControllers {
export const initializeControllers = (services: InitializedServices): InitializedControllers => {
return {
authController: new AuthController(services.userService),
monitorController: new MonitorController(services.monitorService),
settingsController: new SettingsController(services.settingsService, services.emailService, services.db),
monitorController: new MonitorController(services.monitorService, services.settingsService),
settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
+19 -14
View File
@@ -14,6 +14,7 @@ import {
INotificationsService,
} from "@/service/index.js";
import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import GlobalpingService from "../service/infrastructure/globalpingService.js";
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js";
@@ -23,6 +24,7 @@ import MaintenanceWindowService from "../service/business/maintenanceWindowServi
import { MonitorService } from "@/service/index.js";
import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js";
import IncidentService from "../service/business/incidentService.js";
import { NotificationMessageBuilder, INotificationMessageBuilder } from "../service/infrastructure/notificationMessageBuilder.js";
import axios from "axios";
import got from "got";
import ping from "ping";
@@ -41,18 +43,6 @@ import crypto from "crypto";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
// DB Modules
import { GenerateAvatarImage } from "../utils/imageProcessing.js";
import { ParseBoolean } from "../utils/utils.js";
// Models
import InviteToken from "../db/models/Invite.js";
import Team from "../db/models/Team.js";
import MaintenanceWindow from "../db/models/MaintenanceWindow.js";
import MonitorStats from "../db/models/MonitorStats.js";
import NotificationModel from "../db/models/Notification.js";
import RecoveryToken from "../db/models/RecoveryToken.js";
// repositories
import {
MongoMonitorsRepository,
@@ -100,6 +90,7 @@ export type InitializedServices = {
logger: any;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
// Repositories
monitorsRepository: IMonitorsRepository;
@@ -161,11 +152,14 @@ export const initializeServices = async ({
});
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
const incidentService = new IncidentService({
logger,
incidentsRepository,
monitorsRepository,
usersRepository,
notificationMessageBuilder,
});
const checkService = new CheckService({
@@ -176,7 +170,7 @@ export const initializeServices = async ({
const bufferService = new BufferService({ logger, checkService, settingsService });
const statusService = new StatusService({ logger, buffer: bufferService, monitorsRepository });
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
const webhookProvider = new WebhookProvider(logger);
const slackProvider = new SlackProvider(logger);
@@ -194,9 +188,13 @@ export const initializeServices = async ({
discordProvider,
pagerDutyProvider,
matrixProvider,
logger
settingsService,
logger,
notificationMessageBuilder
);
const globalpingService = new GlobalpingService({ settingsService, logger });
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
logger,
networkService,
@@ -205,8 +203,13 @@ export const initializeServices = async ({
checkService,
buffer: bufferService,
incidentService,
globalpingService,
maintenanceWindowsRepository,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
});
const superSimpleQueue = await SuperSimpleQueue.create({
@@ -250,6 +253,7 @@ export const initializeServices = async ({
checksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
});
const statusPageService = new StatusPageService(statusPagesRepository);
@@ -273,6 +277,7 @@ export const initializeServices = async ({
logger,
notificationsService,
statusPageService,
notificationMessageBuilder,
// Repositories
monitorsRepository,
+20 -1
View File
@@ -26,15 +26,19 @@ import {
} from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js";
import { getGlobalpingLocationsByTier, LOCATION_LABELS } from "@/service/infrastructure/globalpingService.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService;
private settingsService: ISettingsService;
constructor(monitorService: IMonitorService) {
constructor(monitorService: IMonitorService, settingsService: ISettingsService) {
this.monitorService = monitorService;
this.settingsService = settingsService;
}
get serviceName() {
@@ -387,6 +391,21 @@ class MonitorController {
}
};
getGlobalpingLocations = async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await this.settingsService.getDBSettings();
const tier = settings.globalpingLocationsTier ?? 6;
const locations = getGlobalpingLocationsByTier(tier);
return res.status(200).json({
success: true,
msg: "Globalping locations retrieved successfully",
data: { locations, labels: LOCATION_LABELS },
});
} catch (error) {
next(error);
}
};
getGroupsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
+19 -12
View File
@@ -8,11 +8,9 @@ class SettingsController {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: any;
private emailService: any;
private db: any;
constructor(settingsService: any, emailService: any, db: any) {
constructor(settingsService: any, emailService: any) {
this.settingsService = settingsService;
this.emailService = emailService;
this.db = db;
}
get serviceName() {
@@ -23,8 +21,9 @@ class SettingsController {
const sanitizedSettings = { ...dbSettings };
delete sanitizedSettings.version;
delete sanitizedSettings.jwtSecret;
const returnSettings = {
const returnSettings: any = {
pagespeedKeySet: false,
globalpingKeySet: false,
emailPasswordSet: false,
settings: null,
};
@@ -33,6 +32,10 @@ class SettingsController {
returnSettings.pagespeedKeySet = true;
delete sanitizedSettings.pagespeedApiKey;
}
if (typeof sanitizedSettings.globalpingApiKey !== "undefined") {
returnSettings.globalpingKeySet = true;
delete sanitizedSettings.globalpingApiKey;
}
if (typeof sanitizedSettings.systemEmailPassword !== "undefined") {
returnSettings.emailPasswordSet = true;
delete sanitizedSettings.systemEmailPassword;
@@ -57,15 +60,19 @@ class SettingsController {
};
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
await updateAppSettingsBodyValidation.validateAsync(req.body);
try {
await updateAppSettingsBodyValidation.validateAsync(req.body);
const updatedSettings = await this.settingsService.updateDbSettings(req.body);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
msg: "App settings updated successfully",
data: returnSettings,
});
const updatedSettings = await this.settingsService.updateDbSettings(req.body);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
msg: "App settings updated successfully",
data: returnSettings,
});
} catch (error) {
next(error);
}
};
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
@@ -0,0 +1,81 @@
import { MonitorStatsModel } from "../models/MonitorStats.js";
import { logger } from "@/utils/logger.js";
/**
* Cleanup duplicate MonitorStats documents
* Keeps the most recent document (by updatedAt) for each monitorId
* and deletes all older duplicates
*/
export async function cleanupDuplicateMonitorStats(): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStats";
try {
logger.info({ service: SERVICE_NAME, message: "Starting cleanup of duplicate MonitorStats" });
// Find all duplicate monitorIds
const duplicates = await MonitorStatsModel.aggregate([
{
$group: {
_id: "$monitorId",
ids: { $push: "$_id" },
updatedAts: { $push: "$updatedAt" },
count: { $sum: 1 },
},
},
{ $match: { count: { $gt: 1 } } },
]);
if (duplicates.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${duplicates.length} monitors with duplicate stats`,
});
let totalDeleted = 0;
// For each set of duplicates, keep the newest and delete the rest
for (const duplicate of duplicates) {
const monitorId = duplicate._id;
const { ids, updatedAts } = duplicate;
type DocPair = { id: any; updatedAt: Date };
// Create array of {id, updatedAt} pairs and sort by updatedAt descending
const docs: DocPair[] = ids.map((id: any, index: number) => ({
id,
updatedAt: updatedAts[index],
}));
docs.sort((a: DocPair, b: DocPair) => b.updatedAt.getTime() - a.updatedAt.getTime());
// Keep the first (newest), delete the rest
const toDelete = docs.slice(1).map((doc: DocPair) => doc.id);
if (toDelete.length > 0) {
const result = await MonitorStatsModel.deleteMany({
_id: { $in: toDelete },
});
totalDeleted += result.deletedCount ?? 0;
logger.debug({
service: SERVICE_NAME,
message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`,
});
}
}
logger.info({
service: SERVICE_NAME,
message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats cleanup: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -1,5 +1,6 @@
import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js";
import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js";
import MigrationModel from "../models/Migration.js";
type MigrationEntry = {
@@ -10,6 +11,7 @@ type MigrationEntry = {
const migrations: MigrationEntry[] = [
{ name: "0001_migrateStatusWindowThreshold", execute: migrateStatusWindowThreshold },
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
];
const runMigrations = async (logger?: { info: Function; error: Function }) => {
+2
View File
@@ -23,6 +23,8 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
language: { type: String, default: "gb" },
jwtSecret: { type: String },
pagespeedApiKey: { type: String },
globalpingApiKey: { type: String },
globalpingLocationsTier: { type: Number, default: 6 },
systemEmailHost: { type: String },
systemEmailPort: { type: Number },
systemEmailAddress: { type: String },
+21
View File
@@ -13,12 +13,14 @@ import type {
CheckNetworkInterfaceInfo,
GotTimings,
ILighthouseAudit,
LocationResult,
} from "@/types/check.js";
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId;
teamId: Types.ObjectId;
type: MonitorType;
location?: string;
};
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
@@ -175,6 +177,17 @@ const auditsSchema = new Schema<CheckAudits>(
{ _id: false }
);
const locationResultSchema = new Schema<LocationResult>(
{
location: { type: String, required: true },
status: { type: Boolean, required: true },
responseTime: { type: Number, required: true },
statusCode: { type: Number, required: true },
message: { type: String, default: "" },
},
{ _id: false }
);
const metadataSchema = new Schema<CheckMetadataDocument>(
{
monitorId: {
@@ -197,6 +210,9 @@ const metadataSchema = new Schema<CheckMetadataDocument>(
required: true,
index: true,
},
location: {
type: String,
},
},
{ _id: false }
);
@@ -281,6 +297,10 @@ const CheckSchema = new Schema<CheckDocument>(
type: auditsSchema,
default: undefined,
},
locationResults: {
type: [locationResultSchema],
default: undefined,
},
},
{
timestamps: true,
@@ -299,6 +319,7 @@ CheckSchema.index({ createdAt: 1 });
CheckSchema.index({ "metadata.teamId": 1, createdAt: -1 });
CheckSchema.index({ "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 });
CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 });
CheckSchema.index({ "metadata.monitorId": 1, "metadata.location": 1, createdAt: -1 });
const CheckModel = model<CheckDocument>("Check", CheckSchema);
+24 -57
View File
@@ -1,5 +1,5 @@
import { Schema, model, Types, type UpdateQuery } from "mongoose";
import type { Monitor, MonitorMatchMethod, MonitorThresholds, CheckSnapshot } from "@/types/monitor.js";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date };
@@ -13,7 +13,6 @@ type MonitorDocumentBase = Omit<
notifications: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
thresholds?: MonitorThresholds;
};
interface MonitorDocument extends MonitorDocumentBase {
@@ -24,16 +23,6 @@ interface MonitorDocument extends MonitorDocumentBase {
updatedAt: Date;
}
const thresholdsSchema = new Schema<MonitorThresholds>(
{
usage_cpu: { type: Number },
usage_memory: { type: Number },
usage_disk: { type: Number },
usage_temperature: { type: Number },
},
{ _id: false }
);
const checkSnapshotSchema = new Schema<CheckSnapshotDocument>(
{
id: { type: String, required: true },
@@ -132,36 +121,37 @@ const MonitorSchema = new Schema<MonitorDocument>(
secret: {
type: String,
},
thresholds: {
type: thresholdsSchema,
cpuAlertThreshold: {
type: Number,
default: 100,
},
alertThreshold: {
cpuAlertCounter: {
type: Number,
default: 5,
},
cpuAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
memoryAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
default: 100,
},
memoryAlertCounter: {
type: Number,
default: 5,
},
diskAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
default: 100,
},
diskAlertCounter: {
type: Number,
default: 5,
},
tempAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
default: 100,
},
tempAlertCounter: {
type: Number,
default: 5,
},
selectedDisks: {
type: [String],
@@ -170,6 +160,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
gameId: {
type: String,
},
globalpingEnabled: {
type: Boolean,
default: false,
},
group: {
type: String,
trim: true,
@@ -189,33 +183,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
}
);
MonitorSchema.pre("save", function (next) {
if (!this.cpuAlertThreshold || this.isModified("alertThreshold")) {
this.cpuAlertThreshold = this.alertThreshold;
}
if (!this.memoryAlertThreshold || this.isModified("alertThreshold")) {
this.memoryAlertThreshold = this.alertThreshold;
}
if (!this.diskAlertThreshold || this.isModified("alertThreshold")) {
this.diskAlertThreshold = this.alertThreshold;
}
if (!this.tempAlertThreshold || this.isModified("alertThreshold")) {
this.tempAlertThreshold = this.alertThreshold;
}
next();
});
MonitorSchema.pre("findOneAndUpdate", function (next) {
const update = this.getUpdate() as UpdateQuery<MonitorDocument> | null;
if (update && !Array.isArray(update) && update.alertThreshold !== undefined) {
update.cpuAlertThreshold = update.alertThreshold;
update.memoryAlertThreshold = update.alertThreshold;
update.diskAlertThreshold = update.alertThreshold;
update.tempAlertThreshold = update.alertThreshold;
}
next();
});
MonitorSchema.index({ teamId: 1, type: 1 });
const MonitorModel = model<MonitorDocument>("Monitor", MonitorSchema);
+4 -1
View File
@@ -16,7 +16,6 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
immutable: true,
index: true,
required: true,
},
@@ -24,6 +23,10 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
type: Number,
default: 0,
},
maxResponseTime: {
type: Number,
default: 0,
},
totalChecks: {
type: Number,
default: 0,
@@ -2,6 +2,7 @@ import type {
Check,
ChecksQueryResult,
ChecksSummary,
LocationResult,
MonitorType,
PageSpeedChecksResult,
HardwareChecksResult,
@@ -11,6 +12,7 @@ import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory
export interface IChecksRepository {
// create
create(check: Check): Promise<Check>;
createChecks(checks: Check[]): Promise<Check[]>;
// single fetch
@@ -34,8 +36,11 @@ export interface IChecksRepository {
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>;
findLocationChecksByMonitorId(monitorId: string, startDate: Date, endDate: Date, dateString: string): Promise<Record<string, UptimeChecksResult>>;
// update
updateLocationResults(checkId: string, locationResults: LocationResult[]): Promise<void>;
//delete
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
}
@@ -11,9 +11,10 @@ import type {
CheckMetadata,
CheckNetworkInterfaceInfo,
GotTimings,
LocationResult,
MonitorType,
} from "@/types/index.js";
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
const SERVICE_NAME = "StatusService";
@@ -169,6 +170,7 @@ class MongoChecksRepository implements IChecksRepository {
monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId),
type: metadata.type,
...(metadata.location ? { location: metadata.location } : {}),
});
return {
@@ -210,23 +212,26 @@ class MongoChecksRepository implements IChecksRepository {
private toDocument = (check: Partial<Check>): CheckDocument => {
// Map id to _id for MongoDB storage
const { id, metadata, ...rest } = check;
if (!metadata || !metadata.monitorId || !metadata.teamId) {
throw new Error(`Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`);
}
return {
_id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(),
metadata: metadata
? {
monitorId: new mongoose.Types.ObjectId(metadata.monitorId),
teamId: new mongoose.Types.ObjectId(metadata.teamId),
type: metadata.type,
}
: {
monitorId: new mongoose.Types.ObjectId(),
teamId: new mongoose.Types.ObjectId(),
type: "http",
},
metadata: {
monitorId: new mongoose.Types.ObjectId(metadata.monitorId),
teamId: new mongoose.Types.ObjectId(metadata.teamId),
type: metadata.type,
...(metadata.location ? { location: metadata.location } : {}),
},
...rest,
} as unknown as CheckDocument;
};
create = async (check: Check) => {
const savedCheck = await CheckModel.create(check);
return this.toEntity(savedCheck);
};
createChecks = async (checks: Check[]) => {
const docs = checks.map((check) => this.toDocument(check));
const inserted = await CheckModel.insertMany(docs);
@@ -402,6 +407,10 @@ class MongoChecksRepository implements IChecksRepository {
};
};
updateLocationResults = async (checkId: string, locationResults: LocationResult[]): Promise<void> => {
await CheckModel.updateOne({ _id: new mongoose.Types.ObjectId(checkId) }, { $set: { locationResults } });
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) });
return result.deletedCount;
@@ -412,6 +421,65 @@ class MongoChecksRepository implements IChecksRepository {
return deleteResult.deletedCount;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await CheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount ?? 0;
};
findLocationChecksByMonitorId = async (monitorId: string, startDate: Date, endDate: Date, dateString: string): Promise<Record<string, any>> => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
const matchStage = {
"metadata.monitorId": monitorObjectId,
"metadata.location": { $exists: true, $ne: null },
createdAt: { $gte: startDate, $lte: endDate },
};
const results = await CheckModel.aggregate([
{ $match: matchStage },
{ $sort: { createdAt: 1 } },
{
$group: {
_id: {
location: "$metadata.location",
bucket: { $dateToString: { format: dateString, date: "$createdAt" } },
},
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
},
},
{ $sort: { "_id.bucket": 1 } },
{
$group: {
_id: "$_id.location",
groupedChecks: {
$push: {
bucketDate: "$_id.bucket",
avgResponseTime: "$avgResponseTime",
totalChecks: "$totalChecks",
},
},
totalUpChecks: { $sum: "$upChecks" },
totalAllChecks: { $sum: "$totalChecks" },
overallAvgResponseTime: { $avg: "$avgResponseTime" },
},
},
]);
const locationChecks: Record<string, any> = {};
for (const loc of results) {
const uptimePercentage = loc.totalAllChecks > 0 ? loc.totalUpChecks / loc.totalAllChecks : 0;
locationChecks[loc._id] = {
groupedChecks: loc.groupedChecks,
uptimePercentage,
avgResponseTime: loc.overallAvgResponseTime ?? 0,
};
}
return locationChecks;
};
private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
@@ -419,9 +487,10 @@ class MongoChecksRepository implements IChecksRepository {
endDate: Date,
dateString: string
) => {
const matchStage = {
const matchStage: Record<string, any> = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
"metadata.location": { $exists: false },
};
const [result] = await CheckModel.aggregate([
{ $match: matchStage },
@@ -23,5 +23,7 @@ export interface IIncidentsRepository {
// update
updateById(incidentId: string, teamId: string, updateData: Partial<Incident>): Promise<Incident>;
// delete
deleteByMonitorId(monitorId: string, teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
// other
}
@@ -273,5 +273,19 @@ class MongoIncidentRepository implements IIncidentsRepository {
})),
};
};
deleteByMonitorId = async (monitorId: string, teamId: string) => {
const result = await IncidentModel.deleteMany({
monitorId: new mongoose.Types.ObjectId(monitorId),
teamId: new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await IncidentModel.deleteMany({ monitorId: { $nin: objectIds } });
return result.deletedCount ?? 0;
};
}
export default MongoIncidentRepository;
@@ -1,10 +1,14 @@
import type { MonitorStats } from "@/types/index.js";
export interface IMonitorStatsRepository {
// create
create(data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// single fetch
findByMonitorId(monitorId: string): Promise<MonitorStats>;
// update
updateByMonitorId(monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// delete
deleteByMonitorId(monitorId: string): Promise<MonitorStats>;
deleteByMonitorIds(monitorIds: string[]): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
// other
}
@@ -20,6 +20,7 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
id: toStringId(doc._id),
monitorId: toStringId(doc.monitorId),
avgResponseTime: doc.avgResponseTime,
maxResponseTime: doc.maxResponseTime,
totalChecks: doc.totalChecks,
totalUpChecks: doc.totalUpChecks,
totalDownChecks: doc.totalDownChecks,
@@ -32,6 +33,11 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
};
};
create = async (data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const created = await MonitorStatsModel.create(data);
return this.toEntity(created);
};
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const monitorStats = await MonitorStatsModel.findOne({ monitorId: new mongoose.Types.ObjectId(monitorId) });
if (!monitorStats) {
@@ -40,6 +46,14 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
return this.toEntity(monitorStats);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true });
if (!updated) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(updated);
};
deleteByMonitorId = async (monitorId: string) => {
const deleted = await MonitorStatsModel.findOneAndDelete({ monitorId: new mongoose.Types.ObjectId(monitorId) });
if (!deleted) {
@@ -47,6 +61,18 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
}
return this.toEntity(deleted);
};
deleteByMonitorIds = async (monitorIds: string[]): Promise<number> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await MonitorStatsModel.deleteMany({ monitorId: { $in: objectIds } });
return result.deletedCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await MonitorStatsModel.deleteMany({ monitorId: { $nin: objectIds } });
return result.deletedCount ?? 0;
};
}
export default MongoMonitorStatsRepository;
@@ -41,4 +41,6 @@ export interface IMonitorsRepository {
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
findGroupsByTeamId(teamId: string): Promise<string[]>;
removeNotificationFromMonitors(notificationId: string): Promise<void>;
deleteByTeamIdsNotIn(teamIds: string[]): Promise<number>;
findAllMonitorIds(): Promise<string[]>;
}
@@ -252,13 +252,28 @@ class MongoMonitorsRepository implements IMonitorsRepository {
$cond: [{ $eq: ["$status", "maintenance"] }, 1, 0],
},
},
breachedMonitors: {
$sum: {
$cond: [{ $eq: ["$status", "breached"] }, 1, 0],
},
},
},
},
{ $project: { _id: 0 } },
];
const [summary] = await MonitorModel.aggregate(pipeline);
return summary ?? { totalMonitors: 0, upMonitors: 0, downMonitors: 0, pausedMonitors: 0, initializingMonitors: 0, maintenanceMonitors: 0 };
return (
summary ?? {
totalMonitors: 0,
upMonitors: 0,
downMonitors: 0,
pausedMonitors: 0,
initializingMonitors: 0,
maintenanceMonitors: 0,
breachedMonitors: 0,
}
);
};
findGroupsByTeamId = async (teamId: string): Promise<string[]> => {
@@ -317,15 +332,18 @@ class MongoMonitorsRepository implements IMonitorsRepository {
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
secret: doc.secret ?? undefined,
thresholds: doc.thresholds ?? undefined,
alertThreshold: doc.alertThreshold,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
memoryAlertThreshold: doc.memoryAlertThreshold,
memoryAlertCounter: doc.memoryAlertCounter,
diskAlertThreshold: doc.diskAlertThreshold,
diskAlertCounter: doc.diskAlertCounter,
tempAlertThreshold: doc.tempAlertThreshold,
tempAlertCounter: doc.tempAlertCounter,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
group: doc.group ?? null,
globalpingEnabled: doc.globalpingEnabled ?? false,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
@@ -370,15 +388,18 @@ class MongoMonitorsRepository implements IMonitorsRepository {
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
secret: doc.secret ?? undefined,
thresholds: doc.thresholds ?? undefined,
alertThreshold: doc.alertThreshold,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
memoryAlertThreshold: doc.memoryAlertThreshold,
memoryAlertCounter: doc.memoryAlertCounter,
diskAlertThreshold: doc.diskAlertThreshold,
diskAlertCounter: doc.diskAlertCounter,
tempAlertThreshold: doc.tempAlertThreshold,
tempAlertCounter: doc.tempAlertCounter,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
group: doc.group ?? null,
globalpingEnabled: doc.globalpingEnabled ?? false,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
@@ -412,6 +433,17 @@ class MongoMonitorsRepository implements IMonitorsRepository {
createdAt: toDateString(doc.createdAt),
};
};
deleteByTeamIdsNotIn = async (teamIds: string[]): Promise<number> => {
const objectIds = teamIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await MonitorModel.deleteMany({ teamId: { $nin: objectIds } });
return result.deletedCount ?? 0;
};
findAllMonitorIds = async (): Promise<string[]> => {
const monitors = await MonitorModel.find({}, { _id: 1 }).lean();
return monitors.map((doc) => doc._id.toString());
};
}
export default MongoMonitorsRepository;
@@ -2,7 +2,6 @@ import mongoose from "mongoose";
import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js";
import type { Settings } from "@/types/index.js";
import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js";
import { SERVFAIL } from "dns";
class MongoSettingsRepository implements ISettingsRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
@@ -39,6 +38,8 @@ class MongoSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true,
showURL: doc.showURL ?? false,
globalpingApiKey: doc.globalpingApiKey ?? undefined,
globalpingLocationsTier: doc.globalpingLocationsTier ?? 6,
singleton: doc.singleton,
version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined,
@@ -61,22 +62,27 @@ class MongoSettingsRepository implements ISettingsRepository {
};
update = async (settings: Partial<Settings>) => {
const update: Record<string, any> = { $set: { ...settings } };
const update: Record<string, any> = { $set: {}, $unset: {} };
if (settings.pagespeedApiKey === "") {
update.$unset = { pagespeedApiKey: "" };
delete update.$set.pagespeedApiKey;
}
if (settings.systemEmailPassword === "") {
update.$unset = { systemEmailPassword: "" };
delete update.$set.systemEmailPassword;
}
await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true,
// Iterate through settings and separate into $set and $unset
Object.entries(settings).forEach(([key, value]) => {
if (value === undefined || value === "") {
// Unset undefined or empty string values
update.$unset[key] = "";
} else {
// Set defined values
update.$set[key] = value;
}
});
// Clean up empty operators
if (Object.keys(update.$set).length === 0) {
delete update.$set;
}
if (Object.keys(update.$unset).length === 0) {
delete update.$unset;
}
const updatedSettings = await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true,
new: true,
@@ -6,4 +6,5 @@ export interface ITeamsRepository {
// update
// delete
// other
findAllTeamIds(): Promise<string[]>;
}
@@ -31,6 +31,11 @@ class MongoTeamsRepository implements ITeamsRepository {
const team = await TeamModel.create({ email });
return this.toEntity(team);
};
findAllTeamIds = async (): Promise<string[]> => {
const teams = await TeamModel.find({}, { _id: 1 }).lean();
return teams.map((team) => this.toStringId(team._id));
};
}
export default MongoTeamsRepository;
+3
View File
@@ -21,6 +21,9 @@ class MonitorRoutes {
this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId);
this.router.get("/team/groups", this.monitorController.getGroupsByTeamId);
// Globalping locations
this.router.get("/globalping/locations", this.monitorController.getGlobalpingLocations);
// Uptime routes
this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById);
+2 -1
View File
@@ -37,7 +37,7 @@ class CheckService {
};
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings, location } = statusResponse;
const check: Partial<Check> = {
id: new Types.ObjectId().toString(),
@@ -46,6 +46,7 @@ class CheckService {
monitorId,
teamId,
type,
...(location ? { location } : {}),
},
status,
statusCode: code,
+54 -11
View File
@@ -1,9 +1,12 @@
const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/types/monitor.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js";
import type { Incident } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
const dateRangeLookup: Record<string, Date | undefined> = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
@@ -21,56 +24,96 @@ class IncidentService {
private incidentsRepository: IIncidentsRepository;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
private notificationMessageBuilder: INotificationMessageBuilder;
constructor({
logger,
incidentsRepository,
monitorsRepository,
usersRepository,
notificationMessageBuilder,
}: {
logger: any;
incidentsRepository: IIncidentsRepository;
monitorsRepository: IMonitorsRepository;
usersRepository: IUsersRepository;
notificationMessageBuilder: INotificationMessageBuilder;
}) {
this.logger = logger;
this.incidentsRepository = incidentsRepository;
this.monitorsRepository = monitorsRepository;
this.usersRepository = usersRepository;
this.notificationMessageBuilder = notificationMessageBuilder;
}
get serviceName() {
return IncidentService.SERVICE_NAME;
}
handleIncident = async (monitor: Monitor, code: number): Promise<Incident | null> => {
handleIncident = async (
monitor: Monitor,
code: number,
decision: MonitorActionDecision,
monitorStatusResponse?: MonitorStatusResponse
): Promise<Incident | null> => {
if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) {
return null;
}
const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId);
// Monitor is down, create an incident
if (monitor.status === "down") {
if (decision.shouldCreateIncident) {
if (activeIncident) {
return activeIncident;
} else {
let statusCode = code;
let message: string | undefined;
// For threshold breaches, use 9999 status code and build descriptive message
if (decision.incidentReason === "threshold_breach") {
statusCode = 9999;
message = this.buildThresholdBreachMessage(monitor, monitorStatusResponse);
}
const incident = {
monitorId: monitor.id,
teamId: monitor.teamId,
startTime: Date.now().toString(),
status: true,
statusCode: code,
statusCode,
message,
};
return await this.incidentsRepository.create(incident);
}
}
// Monitor is up, resolve active incidents
if (!activeIncident) {
return null;
if (decision.shouldResolveIncident) {
if (!activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
return null;
};
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
if (!monitorStatusResponse) {
return "Threshold breach detected";
}
const breaches = this.notificationMessageBuilder.extractThresholdBreaches(monitor, monitorStatusResponse);
if (breaches.length === 0) {
return "Threshold breach detected";
}
return breaches.map((b) => `${b.metric.toUpperCase()}: ${b.formattedValue} (threshold: ${b.threshold}${b.unit})`).join(", ");
}
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
try {
if (!incidentId) {
+31 -1
View File
@@ -6,9 +6,16 @@ import type {
UptimeDetailsResult,
HardwareDetailsResult,
PageSpeedDetailsResult,
LocationCheckData,
GamesMap,
} from "@/types/monitor.js";
import type { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository, IStatusPagesRepository } from "@/repositories/index.js";
import type {
IChecksRepository,
IIncidentsRepository,
IMonitorsRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
} from "@/repositories/index.js";
import fs from "fs";
import { fileURLToPath } from "url";
import path from "path";
@@ -79,6 +86,7 @@ export class MonitorService implements IMonitorService {
private checksRepository: IChecksRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
constructor({
jobQueue,
@@ -89,6 +97,7 @@ export class MonitorService implements IMonitorService {
checksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
emailService: any;
@@ -98,6 +107,7 @@ export class MonitorService implements IMonitorService {
checksRepository: IChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
}) {
this.jobQueue = jobQueue;
this.emailService = emailService;
@@ -107,6 +117,7 @@ export class MonitorService implements IMonitorService {
this.checksRepository = checksRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
}
get serviceName(): string {
@@ -212,6 +223,15 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
// Fetch location checks if Globalping is enabled for this monitor
let locationChecks: Record<string, LocationCheckData> | undefined;
if (monitor.globalpingEnabled && (monitor.type === "http" || monitor.type === "ping")) {
const locData = await this.checksRepository.findLocationChecksByMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey));
if (Object.keys(locData).length > 0) {
locationChecks = locData;
}
}
return {
monitorData: {
monitor,
@@ -220,6 +240,7 @@ export class MonitorService implements IMonitorService {
groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage,
...(locationChecks ? { locationChecks } : {}),
},
monitorStats,
};
@@ -418,6 +439,15 @@ export class MonitorService implements IMonitorService {
stack: err.stack,
});
});
await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: any) => {
this.logger.warn({
message: `Error deleting incidents for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
});
});
await this.jobQueue.deleteJob(monitor);
return monitor;
};
@@ -97,6 +97,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
return true;
@@ -108,6 +109,8 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
}, randomOffset);
}
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true });
return true;
} catch (error) {
this.logger.error({
@@ -2,8 +2,31 @@ const SERVICE_NAME = "JobQueueHelper";
import type { Monitor } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js";
import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js";
import IncidentService from "@/service/business/incidentService.js";
import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js";
import type GlobalpingService from "@/service/infrastructure/globalpingService.js";
import {
IMaintenanceWindowsRepository,
IMonitorsRepository,
ITeamsRepository,
IMonitorStatsRepository,
IChecksRepository,
IIncidentsRepository,
} from "@/repositories/index.js";
export interface MonitorActionDecision {
shouldCreateIncident: boolean;
shouldResolveIncident: boolean;
shouldSendNotification: boolean;
incidentReason: "status_down" | "threshold_breach" | null;
notificationReason: "status_change" | "threshold_breach" | null;
thresholdBreaches?: {
cpu?: boolean;
memory?: boolean;
disk?: boolean;
temp?: boolean;
};
}
class SuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
@@ -15,8 +38,13 @@ class SuperSimpleQueueHelper {
private checkService: any;
private buffer: any;
private incidentService: IncidentService;
private globalpingService: GlobalpingService;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
private monitorsRepository: IMonitorsRepository;
private teamsRepository: ITeamsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
private incidentsRepository: IIncidentsRepository;
constructor({
logger,
@@ -26,8 +54,13 @@ class SuperSimpleQueueHelper {
checkService,
buffer,
incidentService,
globalpingService,
maintenanceWindowsRepository,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
}: {
logger: any;
networkService: INetworkService;
@@ -36,8 +69,13 @@ class SuperSimpleQueueHelper {
checkService: any;
buffer: any;
incidentService: IncidentService;
globalpingService: GlobalpingService;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
monitorsRepository: IMonitorsRepository;
teamsRepository: ITeamsRepository;
monitorStatsRepository: IMonitorStatsRepository;
checksRepository: IChecksRepository;
incidentsRepository: IIncidentsRepository;
}) {
this.logger = logger;
this.networkService = networkService;
@@ -46,8 +84,13 @@ class SuperSimpleQueueHelper {
this.buffer = buffer;
this.notificationsService = notificationsService;
this.incidentService = incidentService;
this.globalpingService = globalpingService;
this.maintenanceWindowsRepository = maintenanceWindowsRepository;
this.monitorsRepository = monitorsRepository;
this.teamsRepository = teamsRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
this.incidentsRepository = incidentsRepository;
}
get serviceName() {
@@ -84,18 +127,55 @@ class SuperSimpleQueueHelper {
throw new Error("No network response");
}
// Step 3. Build check
const check = await this.checkService.buildCheck(status);
// Step 2b. Run Globalping checks (fire-and-forget, doesn't affect local check pipeline)
if (monitor.globalpingEnabled && (monitor.type === "http" || monitor.type === "ping")) {
this.globalpingService
.runChecks(monitor)
.then((locationStatuses) => {
for (const locStatus of locationStatuses) {
const locCheck = this.checkService.buildCheck(locStatus);
if (locCheck) {
this.buffer.addToBuffer(locCheck);
} else {
this.logger.warn({
message: `Failed to build Globalping check for monitor ${monitorId}, location: ${locStatus.location}`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
}
}
})
.catch((err: any) => {
this.logger.warn({
message: `Globalping checks failed for monitor ${monitorId}: ${err.message}`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
});
}
// Step 4 Add check to buffer
this.buffer.addToBuffer({ check });
// Step 4. Update monitor status
// Step 3. Build check
const check = await this.checkService.buildCheck(status);
if (!check) {
this.logger.warn({
message: `No check could be built for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getMonitorJob",
details: { code: status.code, message: status.message },
});
return;
}
// Step 4. Add check to buffer
this.buffer.addToBuffer(check);
// Step 5. Update monitor status
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check);
// Step 5 handle notifications (best effort, continue even in event of failure, don't wait)
this.notificationsService
.handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged)
.catch((error: any) => {
// Step 6. Get decisions
const decision = this.evaluateMonitorAction(statusChangeResult);
// Step 7. Handle notifications (best effort, continue even in event of failure, don't wait)
if (decision.shouldSendNotification) {
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
@@ -104,19 +184,18 @@ class SuperSimpleQueueHelper {
stack: error.stack,
});
});
// Step 6. Handle incidents (best effort, don't wait)
if (statusChangeResult.statusChanged) {
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code).catch((error: any) => {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error handling incident for job ${monitor.id}: ${error.message}`,
stack: error.stack,
});
});
}
// Step 8. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error handling incident for job ${monitor.id}: ${error.message}`,
stack: error.stack,
});
});
} catch (error: any) {
this.logger.warn({
message: error.message,
@@ -129,6 +208,87 @@ class SuperSimpleQueueHelper {
};
};
getCleanupOrphanedJob = () => {
return async () => {
try {
this.logger.info({
message: "Starting cleanup of orphaned data",
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
// Get all valid team IDs
const validTeamIds = await this.teamsRepository.findAllTeamIds();
this.logger.debug({
message: `Found ${validTeamIds.length} valid teams`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
// Remove orphaned monitors (monitors without a valid team)
const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds);
if (deletedMonitorCount > 0) {
this.logger.info({
message: `Deleted ${deletedMonitorCount} orphaned monitors`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
}
// Remove orphaned monitorStats (stats without a valid monitor)
const allMonitorIds = await this.monitorsRepository.findAllMonitorIds();
this.logger.debug({
message: `Found ${allMonitorIds.length} valid monitors`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
if (deletedStatsCount > 0) {
this.logger.info({
message: `Deleted ${deletedStatsCount} orphaned monitor stats`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
}
// Remove orphaned checks
const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds);
if (deletedChecksCount > 0) {
this.logger.info({
message: `Deleted ${deletedChecksCount} orphaned checks`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
}
// Remove orphaned incidents
const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
if (deletedIncidentsCount > 0) {
this.logger.info({
message: `Deleted ${deletedIncidentsCount} orphaned incidents`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
}
this.logger.info({
message: "Cleanup of orphaned data completed",
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
} catch (error: any) {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
stack: error.stack,
});
throw error;
}
};
};
async isInMaintenanceWindow(monitorId: string, teamId: string) {
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
// Check for active maintenance window:
@@ -158,6 +318,44 @@ class SuperSimpleQueueHelper {
}, false);
return maintenanceWindowIsActive;
}
private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision {
const { monitor, statusChanged, prevStatus } = statusChangeResult;
// Initialize result
const decision: MonitorActionDecision = {
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
};
if (!statusChanged) {
return decision;
}
if (monitor.status === "down") {
// Monitor went down (unreachable)
decision.shouldCreateIncident = true;
decision.shouldSendNotification = true;
decision.incidentReason = "status_down";
decision.notificationReason = "status_change";
} else if (monitor.status === "breached") {
// Hardware monitor exceeded thresholds
decision.shouldCreateIncident = true;
decision.shouldSendNotification = true;
decision.incidentReason = "threshold_breach";
decision.notificationReason = "threshold_breach";
} else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) {
// Monitor recovered from down or breached state
decision.shouldResolveIncident = true;
decision.shouldSendNotification = true;
decision.notificationReason = "status_change";
}
return decision;
}
}
export default SuperSimpleQueueHelper;
@@ -2,7 +2,15 @@ import type { Check } from "@/types/index.js";
const SERVICE_NAME = "BufferService";
class BufferService {
export interface IBufferService {
addToBuffer(check: Check): void;
removeCheckFromBuffer(check: Check): boolean;
findInBuffer(checkId: string): Check | undefined;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
}
class BufferService implements IBufferService {
static SERVICE_NAME = SERVICE_NAME;
private BUFFER_TIMEOUT: number;
private logger: any;
@@ -13,7 +21,6 @@ class BufferService {
constructor({ logger, checkService, settingsService }: { logger: any; checkService: any; settingsService: any }) {
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute
console.log(this.BUFFER_TIMEOUT);
this.logger = logger;
this.checksService = checkService;
this.SERVICE_NAME = SERVICE_NAME;
@@ -30,7 +37,7 @@ class BufferService {
return BufferService.SERVICE_NAME;
}
addToBuffer({ check }: { check: Check }) {
addToBuffer(check: Check) {
try {
this.buffer.push(check);
} catch (error: any) {
@@ -43,6 +50,10 @@ class BufferService {
}
}
findInBuffer(checkId: string): Check | undefined {
return this.buffer.find((c: Check) => c.id?.toString() === checkId);
}
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
@@ -54,9 +65,9 @@ class BufferService {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.teamId?.toString() === checkToRemove.metadata.teamId &&
check.type === checkToRemove.metadata.type &&
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
check.metadata.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
@@ -60,11 +60,9 @@ class EmailService {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
serverIsDownTemplate: this.loadTemplate("serverIsDown"),
serverIsUpTemplate: this.loadTemplate("serverIsUp"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
};
};
@@ -0,0 +1,302 @@
import { Globalping } from "globalping";
import type { ISettingsService } from "@/service/system/settingsService.js";
import type { Monitor } from "@/types/monitor.js";
import type { GlobalpingCheckResult } from "@/types/network.js";
const SERVICE_NAME = "GlobalpingService";
export interface GlobalpingLocationOption {
id: string;
label: string;
}
// Maps location IDs to Globalping API location objects
const LOCATION_MAP: Record<string, Record<string, string>> = {
"north-america": { continent: "NA" },
europe: { continent: "EU" },
asia: { continent: "AS" },
"south-america": { continent: "SA" },
oceania: { continent: "OC" },
africa: { continent: "AF" },
"us-east": { country: "US", state: "VA" },
"us-west": { country: "US", state: "CA" },
canada: { country: "CA", city: "Toronto" },
uk: { country: "GB", city: "London" },
germany: { country: "DE", city: "Frankfurt" },
netherlands: { country: "NL", city: "Amsterdam" },
poland: { country: "PL", city: "Warsaw" },
japan: { country: "JP", city: "Tokyo" },
singapore: { country: "SG", city: "Singapore" },
india: { country: "IN", city: "Mumbai" },
australia: { country: "AU", city: "Sydney" },
brazil: { country: "BR" },
chile: { country: "CL", city: "Santiago" },
"south-africa": { country: "ZA", city: "Johannesburg" },
uae: { country: "AE", city: "Dubai" },
};
const LOCATIONS_TIER_3: GlobalpingLocationOption[] = [
{ id: "north-america", label: "North America" },
{ id: "europe", label: "Europe" },
{ id: "asia", label: "Asia" },
];
const LOCATIONS_TIER_6: GlobalpingLocationOption[] = [
...LOCATIONS_TIER_3,
{ id: "south-america", label: "South America" },
{ id: "oceania", label: "Oceania" },
{ id: "africa", label: "Africa" },
];
const LOCATIONS_TIER_15: GlobalpingLocationOption[] = [
{ id: "us-east", label: "US East (Virginia)" },
{ id: "us-west", label: "US West (California)" },
{ id: "canada", label: "Canada (Toronto)" },
{ id: "uk", label: "UK (London)" },
{ id: "germany", label: "Germany (Frankfurt)" },
{ id: "netherlands", label: "Netherlands (Amsterdam)" },
{ id: "poland", label: "Poland (Warsaw)" },
{ id: "japan", label: "Japan (Tokyo)" },
{ id: "singapore", label: "Singapore" },
{ id: "india", label: "India (Mumbai)" },
{ id: "australia", label: "Australia (Sydney)" },
{ id: "brazil", label: "Brazil" },
{ id: "chile", label: "Chile (Santiago)" },
{ id: "south-africa", label: "South Africa (Johannesburg)" },
{ id: "uae", label: "UAE (Dubai)" },
];
const LOCATIONS_BY_TIER: Record<number, GlobalpingLocationOption[]> = {
3: LOCATIONS_TIER_3,
6: LOCATIONS_TIER_6,
15: LOCATIONS_TIER_15,
};
// Build a lookup from location ID to human-readable label
const LOCATION_LABELS: Record<string, string> = {};
for (const tier of Object.values(LOCATIONS_BY_TIER)) {
for (const loc of tier) {
LOCATION_LABELS[loc.id] = loc.label;
}
}
const HOSTNAME_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
export const getGlobalpingLocationsByTier = (tier: number): GlobalpingLocationOption[] => {
return LOCATIONS_BY_TIER[tier] ?? LOCATIONS_TIER_6;
};
class GlobalpingService {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
private settingsService: ISettingsService;
private client: Globalping<false> | null = null;
private lastApiKey: string | undefined;
private inFlightChecks = new Set<string>();
constructor({ settingsService, logger }: { settingsService: ISettingsService; logger: any }) {
this.settingsService = settingsService;
this.logger = logger;
}
get serviceName() {
return GlobalpingService.SERVICE_NAME;
}
/**
* Reads settings once, returns both the SDK client and configured location IDs.
* Returns null client if the API key is not configured.
*/
private async getClientAndLocations(): Promise<{
client: Globalping<false> | null;
locationIds: string[];
}> {
const dbSettings = await this.settingsService.getDBSettings();
const apiKey = dbSettings.globalpingApiKey;
if (!apiKey) {
return { client: null, locationIds: [] };
}
if (!this.client || this.lastApiKey !== apiKey) {
this.client = new Globalping({ auth: apiKey, throwApiErrors: false, timeout: 30000 });
this.lastApiKey = apiKey;
}
const tier = dbSettings.globalpingLocationsTier ?? 6;
const locationIds = getGlobalpingLocationsByTier(tier).map((loc) => loc.id);
return { client: this.client, locationIds };
}
async runChecks(monitor: Monitor): Promise<GlobalpingCheckResult[]> {
if (monitor.type !== "http" && monitor.type !== "ping") {
return [];
}
// Prevent overlapping checks for the same monitor
if (this.inFlightChecks.has(monitor.id)) {
return [];
}
this.inFlightChecks.add(monitor.id);
try {
return await this.executeChecks(monitor);
} finally {
this.inFlightChecks.delete(monitor.id);
}
}
private async executeChecks(monitor: Monitor): Promise<GlobalpingCheckResult[]> {
const { client, locationIds } = await this.getClientAndLocations();
if (!client || locationIds.length === 0) {
return [];
}
const measurementType = monitor.type === "ping" ? "ping" : "http";
const monitorUrl = monitor.url ?? "";
let target: string;
try {
if (measurementType === "http") {
const url = new URL(monitorUrl);
target = url.hostname;
} else {
target = monitorUrl.replace(/^https?:\/\//, "").split("/")[0] ?? monitorUrl;
}
} catch {
target = monitorUrl;
}
if (!target || !HOSTNAME_REGEX.test(target)) {
this.logger.warn({
message: `Invalid target hostname for Globalping: "${target}"`,
service: SERVICE_NAME,
method: "executeChecks",
monitorId: monitor.id,
});
return [];
}
const locations = locationIds.map((loc) => LOCATION_MAP[loc]).filter(Boolean);
if (locations.length === 0) {
return [];
}
try {
const createResult =
measurementType === "http"
? await client.createMeasurement({
type: "http" as const,
target,
locations: locations as any,
measurementOptions: { request: { method: "HEAD" }, protocol: "HTTPS" },
})
: await client.createMeasurement({
type: "ping" as const,
target,
locations: locations as any,
});
if (!createResult.ok) {
this.logger.warn({
message: "Failed to create Globalping measurement",
service: SERVICE_NAME,
method: "executeChecks",
details: JSON.stringify(createResult.data),
});
return [];
}
const measurementId = (createResult.data as any).id;
const awaitResult = await client.awaitMeasurement(measurementId);
if (!awaitResult.ok) {
this.logger.warn({
message: "Failed to await Globalping measurement",
service: SERVICE_NAME,
method: "executeChecks",
details: JSON.stringify(awaitResult.data),
});
return [];
}
const measurement = awaitResult.data as any;
const results: GlobalpingCheckResult[] = [];
for (const item of measurement.results || []) {
const probe = item.probe;
const result = item.result;
const locationId = this.resolveLocationId(probe?.continent, probe?.country, locationIds);
if (measurementType === "http") {
results.push({
location: locationId,
status: result.statusCode >= 200 && result.statusCode < 400,
statusCode: result.statusCode || 0,
message: result.statusCodeName || result.rawOutput || "",
responseTime: result.timings?.total ?? 0,
});
} else {
// Ping: loss < 100% is considered up (consistent with local ping checks)
const stats = result.stats;
results.push({
location: locationId,
status: stats?.loss !== undefined ? stats.loss < 100 : false,
statusCode: stats?.loss === 0 ? 200 : 5000,
message: stats?.loss === 0 ? "Ping successful" : `Packet loss: ${stats?.loss}%`,
responseTime: stats?.avg ?? 0,
});
}
}
return results;
} catch (error: any) {
this.logger.warn({
message: `Globalping check failed: ${error.message}`,
service: SERVICE_NAME,
method: "executeChecks",
stack: error.stack,
});
return [];
}
}
/**
* Maps a probe's continent/country back to a configured location ID.
* For city-level tiers, matches by country code.
* For continent-level tiers, matches by continent code.
*/
private resolveLocationId(continent?: string, country?: string, configuredIds?: string[]): string {
if (!configuredIds || configuredIds.length === 0) {
return continent && country ? `${continent}-${country}` : continent || country || "unknown";
}
// Try country-level match first (for tier 15)
if (country) {
for (const id of configuredIds) {
const loc = LOCATION_MAP[id];
if (loc?.country === country) {
return id;
}
}
}
// Fall back to continent-level match (for tier 3/6)
if (continent) {
for (const id of configuredIds) {
const loc = LOCATION_MAP[id];
if (loc?.continent === continent) {
return id;
}
}
}
return continent && country ? `${continent}-${country}` : continent || country || "unknown";
}
}
export { LOCATION_LABELS };
export default GlobalpingService;
@@ -0,0 +1,285 @@
import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type {
NotificationMessage,
NotificationType,
NotificationSeverity,
ThresholdBreach,
NotificationContent,
} from "@/types/notificationMessage.js";
export interface INotificationMessageBuilder {
buildMessage(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationMessage;
extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[];
}
const SERVICE_NAME = "NotificationMessageBuilder";
export class NotificationMessageBuilder implements INotificationMessageBuilder {
static SERVICE_NAME = SERVICE_NAME;
buildMessage(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationMessage {
const type = this.determineNotificationType(decision, monitor);
const severity = this.determineSeverity(type, monitor.status);
const content = this.buildContent(type, monitor, monitorStatusResponse, decision, clientHost);
return {
type,
severity,
monitor: {
id: monitor.id,
name: monitor.name,
url: monitor.url,
type: monitor.type,
status: monitor.status,
},
content,
clientHost,
metadata: {
teamId: monitor.teamId,
notificationReason: decision.notificationReason || "status_change",
},
};
}
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType {
// Down status has highest priority (critical)
if (monitor.status === "down") {
return "monitor_down";
}
// Threshold breach (only if not down)
if (decision.notificationReason === "threshold_breach") {
return "threshold_breach";
}
// Recovery from threshold breach (only for hardware monitors)
if (decision.notificationReason === "status_change" && monitor.status === "up" && monitor.type === "hardware") {
return "threshold_resolved";
}
// Standard recovery (up)
if (monitor.status === "up") {
return "monitor_up";
}
// Default to monitor_up for any other case
return "monitor_up";
}
private determineSeverity(type: NotificationType, monitorStatus: string): NotificationSeverity {
switch (type) {
case "monitor_down":
return "critical";
case "threshold_breach":
return "warning";
case "monitor_up":
case "threshold_resolved":
return "success";
case "test":
return "info";
default:
return "info";
}
}
private buildContent(
type: NotificationType,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationContent {
switch (type) {
case "monitor_down":
return this.buildMonitorDownContent(monitor, monitorStatusResponse, clientHost);
case "monitor_up":
return this.buildMonitorUpContent(monitor, clientHost);
case "threshold_breach":
return this.buildThresholdBreachContent(monitor, monitorStatusResponse, decision, clientHost);
case "threshold_resolved":
return this.buildThresholdResolvedContent(monitor, clientHost);
default:
return this.buildDefaultContent(monitor, clientHost);
}
}
private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, clientHost: string): NotificationContent {
const title = `Monitor Down: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
// Add response code if available
if (monitorStatusResponse.code) {
details.push(`Response Code: ${monitorStatusResponse.code}`);
}
// Add error message if available
if (monitorStatusResponse.message) {
details.push(`Error: ${monitorStatusResponse.message}`);
}
return {
title,
summary,
details,
timestamp: new Date(),
};
}
private buildMonitorUpContent(monitor: Monitor, clientHost: string): NotificationContent {
const title = `Monitor Recovered: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is back up and operational.`;
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
return {
title,
summary,
details,
timestamp: new Date(),
};
}
private buildThresholdBreachContent(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationContent {
const title = `Threshold Breach: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" has breached one or more thresholds.`;
const details = [`URL: ${monitor.url}`, `Status: Breached`, `Type: ${monitor.type}`];
const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse);
return {
title,
summary,
details,
thresholds,
timestamp: new Date(),
};
}
private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent {
const title = `Thresholds Resolved: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" thresholds have returned to normal.`;
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
return {
title,
summary,
details,
timestamp: new Date(),
};
}
private buildDefaultContent(monitor: Monitor, clientHost: string): NotificationContent {
return {
title: `Monitor: ${monitor.name}`,
summary: `Status update for monitor "${monitor.name}".`,
details: [`URL: ${monitor.url}`, `Status: ${monitor.status}`, `Type: ${monitor.type}`],
timestamp: new Date(),
};
}
public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] {
const breaches: ThresholdBreach[] = [];
// Check if this is a hardware monitor with threshold data
if (monitor.type !== "hardware" || !monitorStatusResponse.payload) {
return breaches;
}
// Cast to HardwareStatusPayload type
const payload = monitorStatusResponse.payload as { data?: any };
const hardware = payload.data;
if (!hardware) {
return breaches;
}
// Note: usage_percent values in hardware payload are decimals (0-1)
if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) {
const cpuUsageDecimal = hardware.cpu.usage_percent;
const cpuPercent = cpuUsageDecimal * 100;
const threshold = monitor.cpuAlertThreshold;
if (cpuPercent > threshold) {
breaches.push({
metric: "cpu",
currentValue: cpuPercent,
threshold,
unit: "%",
formattedValue: `${cpuPercent.toFixed(1)}%`,
});
}
}
// Memory threshold breach
if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) {
const memoryUsageDecimal = hardware.memory.usage_percent;
const memoryPercent = memoryUsageDecimal * 100;
const threshold = monitor.memoryAlertThreshold;
if (memoryPercent > threshold) {
breaches.push({
metric: "memory",
currentValue: memoryPercent,
threshold,
unit: "%",
formattedValue: `${memoryPercent.toFixed(1)}%`,
});
}
}
// Disk threshold breach
if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) {
// Find the highest disk usage
let maxDiskUsageDecimal = 0;
for (const disk of hardware.disk) {
if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsageDecimal) {
maxDiskUsageDecimal = disk.usage_percent;
}
}
const maxDiskPercent = maxDiskUsageDecimal * 100;
const threshold = monitor.diskAlertThreshold;
if (maxDiskPercent > threshold) {
breaches.push({
metric: "disk",
currentValue: maxDiskPercent,
threshold,
unit: "%",
formattedValue: `${maxDiskPercent.toFixed(1)}%`,
});
}
}
// Temperature threshold breach
if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && hardware.cpu?.temperature) {
// Temperature is an array in cpu.temperature
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
const threshold = monitor.tempAlertThreshold;
if (maxTemp >= threshold) {
breaches.push({
metric: "temp",
currentValue: maxTemp,
threshold,
unit: "°C",
formattedValue: `${maxTemp.toFixed(1)}°C`,
});
}
}
return breaches;
}
}
@@ -1,6 +1,7 @@
import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js";
import type { Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
export interface INotificationProvider {
sendAlert: (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => Promise<boolean>;
sendMessage: (notification: Notification, message: NotificationMessage) => Promise<boolean>;
sendTestAlert(notification: Notification): Promise<boolean>;
}
@@ -1,7 +1,8 @@
const SERVICE_NAME = "DiscordProvider";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { buildHardwareAlerts, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import got from "got";
export class DiscordProvider implements INotificationProvider {
@@ -10,42 +11,7 @@ export class DiscordProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { discordPayload } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse);
return discordPayload;
};
sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(monitor, monitorStatusResponse);
} else {
body = buildDiscordBody(monitor, monitorStatusResponse);
}
if (!notification.address) {
return false;
}
try {
await got.post(notification.address, {
json: body,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Discord alert failed",
service: SERVICE_NAME,
method: "sendAlert",
stack: err?.stack,
});
return false;
}
};
sendTestAlert = async (notification: Notification) => {
if (!notification.address) {
return false;
@@ -69,4 +35,110 @@ export class DiscordProvider implements INotificationProvider {
return false;
}
};
/**
* New unified message format implementation
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
this.logger.warn({
message: "Discord notification missing webhook URL",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
const embed = this.buildDiscordEmbed(message);
try {
await got.post(notification.address, {
json: { embeds: [embed] },
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Discord notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
private buildDiscordEmbed(message: NotificationMessage): any {
// Map severity to Discord embed colors
const colorMap: Record<string, number> = {
critical: 0xdc2626, // red-600
warning: 0xf59e0b, // amber-500
info: 0x3b82f6, // blue-500
success: 0x10b981, // green-500
};
const color = colorMap[message.severity] || colorMap.info;
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
// Add monitor details
fields.push({
name: "Monitor",
value: message.monitor.name,
inline: true,
});
fields.push({
name: "Type",
value: message.monitor.type.toUpperCase(),
inline: true,
});
fields.push({
name: "Status",
value: message.monitor.status.charAt(0).toUpperCase() + message.monitor.status.slice(1),
inline: true,
});
// Add monitor URL
fields.push({
name: "URL",
value: message.monitor.url,
inline: false,
});
// Add threshold breaches if present
if (message.content.thresholds && message.content.thresholds.length > 0) {
const thresholdLines = message.content.thresholds
.map((t) => `• **${t.metric.toUpperCase()}**: ${t.formattedValue} (threshold: ${t.threshold}${t.unit})`)
.join("\n");
fields.push({
name: "Threshold Breaches",
value: thresholdLines,
inline: false,
});
}
// Add details if present
if (message.content.details && message.content.details.length > 0) {
const detailsText = message.content.details.join("\n");
fields.push({
name: "Details",
value: detailsText,
inline: false,
});
}
return {
title: message.content.title,
description: message.content.summary,
color,
fields,
timestamp: message.content.timestamp.toISOString(),
};
}
}
@@ -1,7 +1,9 @@
const SERVICE_NAME = "EmailProvider";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { buildHardwareAlerts, buildHardwareEmail, buildEmail, buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
export class EmailProvider implements INotificationProvider {
private emailService: any;
@@ -12,41 +14,6 @@ export class EmailProvider implements INotificationProvider {
this.logger = logger;
}
private buildHardwareEmail = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse);
const html = buildHardwareEmail(this.emailService, monitor, alertsToSend);
return html;
};
async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise<boolean> {
// For grouped notifications (identified by ":" in name), customize subject to indicate multiple services.
// Example: "2 services: Service A, Service B" becomes "Alert: 2 services are down"
const isGroupedNotification = monitor.name.includes(":");
const subject = isGroupedNotification ? `Alert: ${monitor.name} are down` : `Monitor ${monitor.name} is down`;
let html;
if (monitor.type === "hardware") {
html = this.buildHardwareEmail(monitor, monitorStatusResponse);
} else {
html = await buildEmail(this.emailService, monitor);
}
if (!notification.address) {
return false;
}
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
this.logger.warn({
message: "Email alert failed",
service: SERVICE_NAME,
method: "sendAlert",
});
return false;
}
return true;
}
async sendTestAlert(notification: Notification): Promise<boolean> {
const subject = "Test notification";
const html = await buildTestEmail(this.emailService);
@@ -62,4 +29,85 @@ export class EmailProvider implements INotificationProvider {
}
return true;
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
return false;
}
const subject = this.buildSubject(message);
const html = await this.buildEmailFromMessage(message);
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
this.logger.warn({
message: "Email notification failed",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
return true;
}
private buildSubject(message: NotificationMessage): string {
switch (message.type) {
case "monitor_down":
return `Monitor ${message.monitor.name} is down`;
case "monitor_up":
return `Monitor ${message.monitor.name} is back up`;
case "threshold_breach":
return `Monitor ${message.monitor.name} threshold breached`;
case "threshold_resolved":
return `Monitor ${message.monitor.name} thresholds resolved`;
default:
return `Alert: ${message.monitor.name}`;
}
}
private async buildEmailFromMessage(message: NotificationMessage): Promise<string> {
const context = {
title: message.content.title,
summary: message.content.summary,
monitorName: message.monitor.name,
monitorUrl: message.monitor.url,
monitorType: message.monitor.type,
monitorStatus: message.monitor.status,
headerColor: this.getColorForSeverity(message.severity),
thresholds: message.content.thresholds,
details: message.content.details,
incidentUrl: message.content.incident?.url,
};
this.logger.info({
message: "[DEBUG] Building email from message",
service: SERVICE_NAME,
method: "buildEmailFromMessage",
details: { context },
});
const html = await this.emailService.buildEmail("unifiedNotificationTemplate", context);
this.logger.info({
message: "[DEBUG] Email HTML generated",
service: SERVICE_NAME,
method: "buildEmailFromMessage",
details: {
htmlLength: html?.length || 0,
htmlPreview: html?.substring(0, 200),
},
});
return html;
}
private getColorForSeverity(severity: string): string {
const colorMap: Record<string, string> = {
critical: "red",
warning: "#f59e0b",
info: "#3b82f6",
success: "green",
};
return colorMap[severity] ?? "#3b82f6";
}
}
@@ -1,13 +1,9 @@
const SERVICE_NAME = "MatrixProvider";
import got from "got";
import type { INotificationProvider } from "@/service/index.js";
import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js";
import {
buildHardwareAlerts,
buildHardwareWebhookBody,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
export class MatrixProvider implements INotificationProvider {
private logger: any;
@@ -15,57 +11,6 @@ export class MatrixProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse);
const body = buildHardwareWebhookBody(alertsToSend, monitor);
return body;
};
private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const body = buildWebhookBody(monitor, monitorStatusResponse);
return body;
};
sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { homeserverUrl, accessToken, roomId } = notification;
let content;
if (monitor.type === "hardware") {
content = this.getHardwareContent(monitor, monitorStatusResponse);
} else {
content = this.getContent(monitor, monitorStatusResponse);
}
const title = `Checkmate status for ${monitor.name}`;
const formattedMessage = `## ${title}\n${content}`;
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: formattedMessage,
format: "org.matrix.custom.html",
formatted_body: formattedMessage,
};
try {
await got.post(url, {
json: body,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix alert failed",
service: SERVICE_NAME,
method: "sendAlert",
stack: err?.stack,
});
return false;
}
};
sendTestAlert = async (notification: Notification) => {
const { homeserverUrl, accessToken, roomId } = notification;
@@ -96,4 +41,146 @@ export class MatrixProvider implements INotificationProvider {
return false;
}
};
/**
* New unified message format - builds Matrix HTML message from NotificationMessage
*/
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) {
return false;
}
const { plainText, htmlText } = this.buildMatrixMessage(message);
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: plainText,
format: "org.matrix.custom.html",
formatted_body: htmlText,
};
try {
await got.post(url, {
json: body,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
};
/**
* Build Matrix message from NotificationMessage
* Returns both plain text and HTML formatted versions
* Matrix supports HTML subset for rich formatting
*/
private buildMatrixMessage(message: NotificationMessage): { plainText: string; htmlText: string } {
const plainLines: string[] = [];
const htmlLines: string[] = [];
// Determine color based on severity
const colorMap = {
critical: "#FF0000", // Red
warning: "#FFA500", // Orange
success: "#00FF00", // Green
info: "#0000FF", // Blue
};
const color = colorMap[message.severity] || "#808080";
// Title
plainLines.push(`# ${message.content.title}`);
htmlLines.push(`<h2 style="color: ${color};">${this.escapeHtml(message.content.title)}</h2>`);
// Summary
plainLines.push("");
plainLines.push(message.content.summary);
htmlLines.push(`<p>${this.escapeHtml(message.content.summary)}</p>`);
// Monitor details
plainLines.push("");
plainLines.push("## Monitor Details");
plainLines.push(`- Name: ${message.monitor.name}`);
plainLines.push(`- URL: ${message.monitor.url}`);
plainLines.push(`- Type: ${message.monitor.type}`);
plainLines.push(`- Status: ${message.monitor.status}`);
htmlLines.push(`<h3>Monitor Details</h3>`);
htmlLines.push(`<ul>`);
htmlLines.push(`<li><strong>Name:</strong> ${this.escapeHtml(message.monitor.name)}</li>`);
htmlLines.push(`<li><strong>URL:</strong> <a href="${this.escapeHtml(message.monitor.url)}">${this.escapeHtml(message.monitor.url)}</a></li>`);
htmlLines.push(`<li><strong>Type:</strong> ${this.escapeHtml(message.monitor.type)}</li>`);
htmlLines.push(`<li><strong>Status:</strong> ${this.escapeHtml(message.monitor.status)}</li>`);
htmlLines.push(`</ul>`);
// Threshold breaches (if any)
if (message.content.thresholds && message.content.thresholds.length > 0) {
plainLines.push("");
plainLines.push("## Threshold Breaches");
htmlLines.push(`<h3>Threshold Breaches</h3>`);
htmlLines.push(`<ul>`);
message.content.thresholds.forEach((breach) => {
plainLines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
htmlLines.push(
`<li><strong>${this.escapeHtml(breach.metric.toUpperCase())}:</strong> ${this.escapeHtml(breach.formattedValue)} (threshold: ${breach.threshold}${this.escapeHtml(breach.unit)})</li>`
);
});
htmlLines.push(`</ul>`);
}
// Additional details (if any)
if (message.content.details && message.content.details.length > 0) {
plainLines.push("");
plainLines.push("## Additional Information");
htmlLines.push(`<h3>Additional Information</h3>`);
htmlLines.push(`<ul>`);
message.content.details.forEach((detail) => {
plainLines.push(`- ${detail}`);
htmlLines.push(`<li>${this.escapeHtml(detail)}</li>`);
});
htmlLines.push(`</ul>`);
}
// Incident link (if incident exists)
if (message.content.incident) {
const incidentUrl = `${message.clientHost}/infrastructure/${message.monitor.id}`;
plainLines.push("");
plainLines.push(`View Incident: ${incidentUrl}`);
htmlLines.push(`<p><a href="${this.escapeHtml(incidentUrl)}">View Incident</a></p>`);
}
// Footer with timestamp
plainLines.push("");
plainLines.push(`Checkmate | ${new Date(message.content.timestamp).toUTCString()}`);
htmlLines.push(`<hr>`);
htmlLines.push(`<p><small>Checkmate | ${new Date(message.content.timestamp).toUTCString()}</small></p>`);
return {
plainText: plainLines.join("\n"),
htmlText: htmlLines.join(""),
};
}
/**
* Escape HTML special characters for safe rendering
*/
private escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
}
@@ -1,13 +1,10 @@
const SERVICE_NAME = "PagerDutyProvider";
import got from "got";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { INotificationProvider } from "@/service/index.js";
import {
buildHardwareAlerts,
buildHardwareWebhookBody,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
export class PagerDutyProvider implements INotificationProvider {
private logger: any;
@@ -15,51 +12,6 @@ export class PagerDutyProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse);
const body = buildHardwareWebhookBody(alertsToSend, monitor);
return body;
};
private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const body = buildWebhookBody(monitor, monitorStatusResponse);
return body;
};
async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise<boolean> {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(monitor, monitorStatusResponse);
} else {
body = this.getContent(monitor, monitorStatusResponse);
}
try {
await got.post("https://events.pagerduty.com/v2/enqueue", {
json: {
routing_key: notification.address,
event_action: "trigger",
payload: {
summary: body,
severity: "critical",
source: monitor.url,
timestamp: new Date().toISOString(),
},
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "PagerDuty alert failed",
service: SERVICE_NAME,
method: "sendAlert",
stack: err?.stack,
});
return false;
}
}
async sendTestAlert(notification: Notification): Promise<boolean> {
try {
@@ -88,4 +40,103 @@ export class PagerDutyProvider implements INotificationProvider {
return false;
}
}
/**
* New unified message format implementation
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
this.logger.warn({
message: "PagerDuty notification missing routing key",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
try {
const payload = this.buildPagerDutyPayload(notification, message);
await got.post("https://events.pagerduty.com/v2/enqueue", {
json: payload,
});
this.logger.info({
message: "[NEW] PagerDuty notification sent via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "PagerDuty notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): any {
// Map our notification type to PagerDuty event_action
const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger";
// Map severity to PagerDuty severity levels
const severityMap: Record<string, string> = {
critical: "critical",
warning: "warning",
info: "info",
success: "info",
};
const severity = severityMap[message.severity] || "error";
// Build deduplication key based on monitor ID for event grouping
const dedupKey = `checkmate-${message.monitor.id}`;
// Build summary
let summary = `${message.content.title} - ${message.content.summary}`;
// Add threshold details to summary if present
if (message.content.thresholds && message.content.thresholds.length > 0) {
const thresholdInfo = message.content.thresholds
.map((t) => `${t.metric.toUpperCase()}: ${t.formattedValue}/${t.threshold}${t.unit}`)
.join(", ");
summary += ` [${thresholdInfo}]`;
}
// Build custom details
const customDetails: any = {
monitor_name: message.monitor.name,
monitor_url: message.monitor.url,
monitor_type: message.monitor.type,
monitor_status: message.monitor.status,
notification_type: message.type,
};
if (message.content.thresholds && message.content.thresholds.length > 0) {
customDetails.threshold_breaches = message.content.thresholds.map((t) => ({
metric: t.metric,
current: t.formattedValue,
threshold: `${t.threshold}${t.unit}`,
}));
}
if (message.content.details && message.content.details.length > 0) {
customDetails.details = message.content.details;
}
return {
routing_key: notification.address,
dedup_key: dedupKey,
event_action: eventAction,
payload: {
summary,
severity,
source: message.monitor.url,
timestamp: message.content.timestamp.toISOString(),
custom_details: customDetails,
},
};
}
}
@@ -1,12 +1,9 @@
const SERVICE_NAME = "SlackProvider";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import {
buildHardwareAlerts,
buildHardwareWebhookBody,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got, { HTTPError } from "got";
export class SlackProvider implements INotificationProvider {
@@ -15,48 +12,6 @@ export class SlackProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse);
const body = buildHardwareWebhookBody(alertsToSend, monitor);
return body;
};
private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const body = buildWebhookBody(monitor, monitorStatusResponse);
return body;
};
async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise<boolean> {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(monitor, monitorStatusResponse);
} else {
body = this.getContent(monitor, monitorStatusResponse);
}
if (!notification.address) {
return false;
}
try {
await got.post(notification.address, {
json: { text: body },
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Slack alert failed",
service: SERVICE_NAME,
method: "sendAlert",
stack: err?.stack,
});
return false;
}
}
async sendTestAlert(notification: Notification): Promise<boolean> {
if (!notification.address) {
@@ -82,4 +37,162 @@ export class SlackProvider implements INotificationProvider {
return false;
}
}
/**
* New unified message format - builds Slack Block Kit payload from NotificationMessage
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
return false;
}
const payload = this.buildSlackPayload(message);
try {
await got.post(notification.address, {
json: payload,
headers: {
"Content-Type": "application/json",
},
});
this.logger.info({
message: "[NEW] Slack notification sent via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "[NEW] Slack alert failed via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
/**
* Build Slack Block Kit payload from NotificationMessage
* Uses Slack's rich formatting with sections, fields, and context blocks
*/
private buildSlackPayload(message: NotificationMessage): object {
const blocks: any[] = [];
// Determine color based on severity
const colorMap = {
critical: "#FF0000", // Red
warning: "#FFA500", // Orange
success: "#00FF00", // Green
info: "#0000FF", // Blue
};
const color = colorMap[message.severity] || "#808080";
// Header block with title
blocks.push({
type: "header",
text: {
type: "plain_text",
text: message.content.title,
emoji: true,
},
});
// Summary section
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: message.content.summary,
},
});
// Monitor details as fields
const monitorFields = [
{ type: "mrkdwn", text: `*Name:*\n${message.monitor.name}` },
{ type: "mrkdwn", text: `*Type:*\n${message.monitor.type}` },
{ type: "mrkdwn", text: `*Status:*\n${message.monitor.status}` },
{ type: "mrkdwn", text: `*URL:*\n${message.monitor.url}` },
];
blocks.push({
type: "section",
fields: monitorFields,
});
// Divider
blocks.push({ type: "divider" });
// Threshold breaches (if any)
if (message.content.thresholds && message.content.thresholds.length > 0) {
const thresholdText = message.content.thresholds
.map((breach) => `• *${breach.metric.toUpperCase()}:* ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`)
.join("\n");
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `*Threshold Breaches:*\n${thresholdText}`,
},
});
blocks.push({ type: "divider" });
}
// Additional details (if any)
if (message.content.details && message.content.details.length > 0) {
const detailsText = message.content.details.map((detail) => `• ${detail}`).join("\n");
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `*Additional Information:*\n${detailsText}`,
},
});
}
// Incident link button (if incident exists)
if (message.content.incident) {
blocks.push({
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Incident",
emoji: true,
},
url: `${message.clientHost}/infrastructure/${message.monitor.id}`,
style: "primary",
},
],
});
}
// Context footer with timestamp
blocks.push({
type: "context",
elements: [
{
type: "mrkdwn",
text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`,
},
],
});
// Return Slack payload with blocks and attachment color
return {
blocks,
attachments: [
{
color,
blocks: [], // Empty blocks in attachment for color bar
},
],
};
}
}
@@ -1,196 +1,5 @@
import { Monitor, HardwareStatusPayload, MonitorStatusResponse } from "@/types/index.js";
const getTime = (): { localTimeZone: string; localTime: string; utcTime: string } => {
const now = new Date();
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const localTime = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: localTimeZone,
timeZoneName: "short",
}).format(now);
const utcTime = now.toUTCString();
return { localTimeZone, localTime, utcTime };
};
export const buildHardwareAlerts = (
clientHost: string,
monitor: Monitor,
networkResponse: MonitorStatusResponse
): { alertsToSend: string[]; discordPayload: any } => {
const thresholds = monitor.thresholds || {};
const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds;
const payload = networkResponse?.payload as HardwareStatusPayload;
const metrics = payload.data || {};
const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics;
const alerts: Record<string, boolean> = {
cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false,
memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false,
disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false,
};
const alertsToSend = [];
const discordEmbeds = [];
const monitorInfoFields = [
{ name: "Monitor", value: monitor.name, inline: true },
{ name: "URL", value: monitor.url, inline: false },
];
const goToIncidentField = { name: `Go to incident`, value: `${clientHost}/infrastructure/${monitor.id}` };
const formatDiscordAlert = {
cpu: () => ({
title: "CPU alert",
description: `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`,
color: 15548997,
fields: [...monitorInfoFields, goToIncidentField],
footer: { text: "Checkmate" },
}),
memory: () => ({
title: "Memory alert",
description: `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`,
color: 15548997,
fields: [...monitorInfoFields, goToIncidentField],
footer: { text: "Checkmate" },
}),
disk: () => ({
title: "Disk alert",
description: `Your current disk usage is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`,
color: 15548997,
footer: { text: "Checkmate" },
fields: [
...monitorInfoFields,
...(Array.isArray(disk) ? disk : []).map((d: any, idx: number) => ({
name: `Disk ${idx}`,
value: `${(d?.usage_percent * 100).toFixed(0)}%`,
inline: true,
})),
goToIncidentField,
],
}),
};
const alertTypes = ["cpu", "memory", "disk"] as const;
const alertThresholdKeyMap: Record<(typeof alertTypes)[number], "cpuAlertThreshold" | "memoryAlertThreshold" | "diskAlertThreshold"> = {
cpu: "cpuAlertThreshold",
memory: "memoryAlertThreshold",
disk: "diskAlertThreshold",
};
for (const type of alertTypes) {
const thresholdKey = alertThresholdKeyMap[type];
// Iterate over each alert type to see if any need to be decremented
if (alerts[type] === true) {
const nextValue = ((monitor[thresholdKey] ?? monitor.alertThreshold) as number) - 1;
monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered
if (monitor[thresholdKey] <= 0) {
// If threshold drops below 0, reset and send notification
monitor[thresholdKey] = monitor.alertThreshold;
const formatAlert = {
cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`,
memory: () =>
`Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`,
disk: () =>
`Your current disk usage: ${disk
.map((d, idx) => `(Disk${idx}: ${(d?.usage_percent ?? 0 * 100).toFixed(0)}%)`)
.join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`,
};
alertsToSend.push(formatAlert[type]());
discordEmbeds.push(formatDiscordAlert[type]());
}
}
}
const discordPayload = discordEmbeds.length ? { embeds: discordEmbeds } : null;
return { alertsToSend, discordPayload };
};
export const buildHardwareNotificationMessage = (clientHost: string, alerts: any, monitor: Monitor) => {
const alertsHeader = [`Monitor: ${monitor.name}`, `URL: ${monitor.url}`];
const alertFooter = [`Go to incident: ${clientHost}/infrastructure/${monitor.id}`];
const alertText = alerts.length > 0 ? [...alertsHeader, ...alerts, ...alertFooter] : [];
return alertText.map((alert) => alert).join("\n");
};
export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): string => {
const content = alerts.map((alert) => alert).join("\n");
return content;
};
export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: MonitorStatusResponse): boolean => {
const thresholds = monitor.thresholds || {};
const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds;
const payload = networkResponse?.payload as HardwareStatusPayload;
const metrics = payload.data || {};
const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics;
const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold;
if (cpuBreach && ((monitor.cpuAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) {
return true;
}
const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold;
if (memoryBreach && ((monitor.memoryAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) {
return true;
}
const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold);
if (diskBreach && ((monitor.diskAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) {
return true;
}
return false;
};
export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { status, code } = monitorStatusResponse;
const { localTimeZone, localTime, utcTime } = getTime();
return `Monitor: ${monitor.name}\nLocal Time (${localTimeZone}): ${localTime}\nUTC Time: ${utcTime}\nStatus: ${status ? "UP" : "DOWN"}\nStatus Code: ${code}\n\u200B\n`;
};
export const buildHardwareEmail = async (emailService: any, monitor: Monitor, alerts: string[]) => {
const template = "hardwareIncidentTemplate";
const context = { monitor: monitor.name, url: monitor.url, alerts };
const html = await emailService.buildEmail(template, context);
return html;
};
export const buildEmail = async (emailService: any, monitor: Monitor): Promise<string> => {
const template = monitor.status === "up" ? "serverIsUpTemplate" : "serverIsDownTemplate";
const context = { monitor: monitor.name, url: monitor.url };
const html = await emailService.buildEmail(template, context);
return html;
};
export const buildDiscordBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { localTime, utcTime } = getTime();
let body = {
embeds: [
{
title: `Monitor name: ${monitor.name}`,
color: monitorStatusResponse.status ? 5763719 : 15548997,
fields: [
{ name: "Monitor", value: monitor.name, inline: false },
{ name: "Status", value: monitorStatusResponse.status ? "Up" : "Down", inline: false },
{ name: "Status Code", value: String(monitorStatusResponse.code), inline: false },
{ name: "Time", value: `${localTime} (Local) / ${utcTime} (UTC)`, inline: false },
{ name: "URL", value: monitor.url, inline: false },
],
footer: { text: "Checkmate" },
},
],
};
return body;
};
// Test notification helpers - used by all providers for test alerts
export const buildTestEmail = async (emailService: any) => {
const context = { testName: "Monitoring System" };
const html = await emailService.buildEmail("testEmailTemplate", context);
@@ -1,12 +1,9 @@
const SERVICE_NAME = "WebhookProvider";
import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import {
buildHardwareAlerts,
buildHardwareWebhookBody,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class WebhookProvider implements INotificationProvider {
@@ -15,49 +12,96 @@ export class WebhookProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse);
const body = buildHardwareWebhookBody(alertsToSend, monitor);
return body;
};
private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): string => {
const body = buildWebhookBody(monitor, monitorStatusResponse);
return body;
};
sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(monitor, monitorStatusResponse);
} else {
body = this.getContent(monitor, monitorStatusResponse);
}
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
if (!notification.address) {
return false;
}
// Build webhook payload from unified message
const payload = this.buildWebhookPayload(message);
try {
await got.post(notification.address, {
json: { text: body },
json: payload,
headers: {
"Content-Type": "application/json",
},
});
this.logger.info({
message: "Webhook notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Webhook alert failed",
service: SERVICE_NAME,
method: "sendAlert",
method: "sendMessage",
stack: err?.stack,
});
return false;
}
};
/**
* Build webhook payload from NotificationMessage
* Format: { text: string, severity: string, monitor: object, details: object }
*/
private buildWebhookPayload(message: NotificationMessage): object {
const lines: string[] = [];
// Title and summary
lines.push(`**${message.content.title}**`);
lines.push(message.content.summary);
lines.push("");
// Monitor information
lines.push("**Monitor Details:**");
lines.push(`- Name: ${message.monitor.name}`);
lines.push(`- URL: ${message.monitor.url}`);
lines.push(`- Type: ${message.monitor.type}`);
lines.push(`- Status: ${message.monitor.status}`);
lines.push("");
// Additional details
if (message.content.details && message.content.details.length > 0) {
lines.push("**Additional Information:**");
message.content.details.forEach((detail) => lines.push(`- ${detail}`));
lines.push("");
}
// Threshold breaches (for hardware monitors)
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("**Threshold Breaches:**");
message.content.thresholds.forEach((breach) => {
lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
lines.push("");
}
// Incident link
if (message.content.incident) {
lines.push(`[View Incident](${message.clientHost}/infrastructure/${message.monitor.id})`);
}
// Return webhook payload with both text and structured data
return {
text: lines.join("\n"),
severity: message.severity,
type: message.type,
monitor: {
id: message.monitor.id,
name: message.monitor.name,
url: message.monitor.url,
status: message.monitor.status,
},
timestamp: message.content.timestamp,
};
}
sendTestAlert = async (notification: Notification) => {
if (!notification.address) {
return false;
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more