diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml new file mode 100644 index 000000000..b98880545 --- /dev/null +++ b/.github/workflows/check-lockfile.yml @@ -0,0 +1,22 @@ +name: Lockfile Check (Client & Server) +on: + pull_request: + workflow_dispatch: + +jobs: + check-lockfiles: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Validate with the same node image the production build uses, so a lock + # that passes here cannot fail the image build's npm ci. + - name: Resolve node image from Dockerfile + id: node-image + run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT" + + - name: Check server lockfile + run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run + + - name: Check client lockfile + run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run diff --git a/.github/workflows/poeditor-sync.yml b/.github/workflows/poeditor-sync.yml deleted file mode 100644 index 6b6731b2d..000000000 --- a/.github/workflows/poeditor-sync.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: POEditor Translation Synchronization (disabled) - -# This workflow is intentionally disabled — kept for reference only. -# The full definition is preserved commented-out below. To re-enable, -# delete the stub job and uncomment the section starting at `on:`. - -on: - workflow_dispatch: - -jobs: - disabled: - if: false - runs-on: ubuntu-latest - steps: - - run: echo "This workflow is disabled. See comments below for the original definition." - -# --------------------------------------------------------------------------- -# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing. -# --------------------------------------------------------------------------- - -# on: -# # For manual triggering -# workflow_dispatch: -# inputs: -# languages: -# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)" -# required: false -# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th" -# format: -# description: "Export format (key_value_json or json)" -# required: false -# default: "key_value_json" - -# permissions: -# contents: write -# pull-requests: write - -# jobs: -# sync-translations: -# runs-on: ubuntu-latest -# permissions: -# contents: write -# issues: write -# pull-requests: write - -# steps: -# - name: Checkout code -# uses: actions/checkout@v3 -# with: -# token: ${{ secrets.GITHUB_TOKEN }} -# fetch-depth: 0 - -# - name: Setup Node.js -# uses: actions/setup-node@v3 -# with: -# node-version: "22" - -# - name: Create package.json for scripts -# run: | -# mkdir -p .github/scripts -# cat > .github/scripts/package.json << EOF -# { -# "name": "poeditor-scripts", -# "version": "1.0.0", -# "private": true, -# "type": "module", -# "dependencies": { -# "axios": "^1.6.0", -# "fs-extra": "^11.1.1" -# } -# } -# EOF - -# - name: Install dependencies -# run: | -# cd .github/scripts -# npm install - -# - name: Download translations from POEditor -# env: -# POEDITOR_API: ${{ secrets.POEDITOR_API }} -# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} -# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }} -# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }} -# run: | -# mkdir -p temp -# node .github/scripts/download-translations.js - -# - name: Verify translation files -# run: | -# echo "Verifying translation files..." -# for file in temp/*.json; do -# echo "Checking $file" -# if [ ! -s "$file" ]; then -# echo "Error: $file is empty or does not exist" -# exit 1 -# fi -# # Validate JSON format -# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; } -# done -# echo "All translation files are valid" - -# - name: Copy translations to project -# run: | -# mkdir -p client/src/locales -# cp -r temp/* client/src/locales/ -# echo "Translation files copied to client/src/locales/" - -# - name: Get current date -# id: date -# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT - -# - name: Format client code -# run: | -# cd client -# npm ci -# npm run format - -# - name: Create Pull Request -# uses: peter-evans/create-pull-request@v7 -# with: -# token: ${{ secrets.GITHUB_TOKEN }} -# commit-message: "feat: translations updated from POEditor" -# title: "🌐 Updated Translations from POEditor" -# body: | -# This PR contains the latest translations from POEditor. - -# 📅 Update Date: ${{ steps.date.outputs.date }} -# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }} - -# ⚠️ Please review the translations and approve the PR if everything looks correct. -# branch: translation-update-${{ github.run_number }} -# delete-branch: true -# base: develop -# add-paths: | -# client/src/locales/*.json -# committer: GitHub Action -# author: GitHub Action diff --git a/.github/workflows/upload-poeditor.yml b/.github/workflows/upload-poeditor.yml deleted file mode 100644 index f695c8bbc..000000000 --- a/.github/workflows/upload-poeditor.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Upload Translations to POEditor on PR Merge - -on: - pull_request: - types: [closed] - branches: - - develop - paths: - - "client/src/locales/**" - -jobs: - upload-translations: - # Only run if the PR was merged (not just closed) or manually triggered - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 # Fetch all history to get changed files - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "22" - - - name: Create package.json for scripts - run: | - mkdir -p .github/scripts - cat > .github/scripts/package.json << EOF - { - "name": "poeditor-scripts", - "version": "1.0.0", - "private": true, - "type": "module", - "dependencies": { - "axios": "^1.6.0", - "fs-extra": "^11.1.1", - "form-data": "^4.0.0" - } - } - EOF - - - name: Install dependencies - run: | - cd .github/scripts - npm install - - - name: Get changed locale files - id: changed-files - if: github.event_name == 'pull_request' - run: | - # Get base and head commits - BASE_SHA=${{ github.event.pull_request.base.sha }} - HEAD_SHA=${{ github.event.pull_request.head.sha }} - - echo "Base SHA: $BASE_SHA" - echo "Head SHA: $HEAD_SHA" - - # Get list of changed files in client/src/locales directory - CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json') - - if [ -z "$CHANGED_FILES" ]; then - echo "No changes detected in locale files" - echo "CHANGED_FILES=" >> $GITHUB_ENV - else - echo "Changed files:" - echo "$CHANGED_FILES" - echo "CHANGED_FILES<> $GITHUB_ENV - echo "$CHANGED_FILES" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - fi - - - name: Upload changed translations to POEditor - if: env.CHANGED_FILES != '' - env: - POEDITOR_API: ${{ secrets.POEDITOR_API }} - POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} - run: | - # Process each changed file - for FILE in $CHANGED_FILES; do - if [[ -f "$FILE" ]]; then - # Extract language code from filename (e.g., client/src/locales/en.json -> en) - FILENAME=$(basename "$FILE") - - # Special case: map gb.json to en language code - if [ "$FILENAME" == "gb.json" ]; then - LANG="en" - echo "Found gb.json, mapping to language code 'en'" - else - LANG=$(basename "$FILE" .json) - fi - - echo "Processing $FILE for language $LANG" - - # Upload to POEditor - LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js - fi - done - - - name: Notify on success - if: success() && env.CHANGED_FILES != '' - run: | - echo "Successfully uploaded translation files to POEditor." - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - echo "Manual trigger comment: ${{ github.event.inputs.comment }}" - fi - - - name: Notify on no changes - if: env.CHANGED_FILES == '' - run: | - echo "No translation files were found to upload." diff --git a/README.md b/README.md index bcbd557d4..bb5185378 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,13 @@

Checkmate

-

An open source uptime and infrastructure monitoring application

+

An open source uptime and infrastructure monitoring application [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) -image +image +


@@ -80,7 +81,9 @@ Usage instructions can be found [here](https://checkmate.so/docs). ## Installation -The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB: +The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service. + +> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance. ```bash curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml @@ -89,6 +92,10 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345. +There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate), +[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). + + ### Configuration The image is configured entirely through environment variables on the server container: @@ -151,7 +158,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! - Selective disk monitoring with mountpoint selection - Incidents at a glance - Status pages with 4 beautiful themes -- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications +- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - Scheduled maintenance - JSON query monitoring - Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese @@ -205,9 +212,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! ## Contributing -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 are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) 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. +We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe. Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us! diff --git a/client/package-lock.json b/client/package-lock.json index bca8463c0..6cee968e3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "3.10.0", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "3.10.0", + "version": "3.11.0", "dependencies": { "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", diff --git a/client/package.json b/client/package.json index ee290cae8..ce2e481ba 100644 --- a/client/package.json +++ b/client/package.json @@ -1,9 +1,10 @@ { "name": "client", "private": true, - "version": "3.10.0", + "version": "3.11.0", "type": "module", "scripts": { + "lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only", "dev": "vite", "build": "tsc -b && vite build", "build-dev": "tsc -b && vite build --mode development", diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx index 8bc651c8c..66c502f72 100644 --- a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import type { CheckSnapshot } from "@/Types/Check"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; @@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({ {t("common.labels.responseTime")}:{" "} - {check?.originalResponseTime?.toFixed() ?? "N/A"} ms + {check?.responseTime ? formatMs(check.responseTime) : "N/A"} Status:{" "} diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index d9485318a..e2924d29f 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -8,6 +8,8 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { computeBarHeights } from "@/Utils/DataUtils"; +import { formatMs } from "@/Utils/TimeUtils"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; @@ -18,25 +20,25 @@ interface HistogramResponseTimeProps { } interface ResponseTimeStats { - max: number | string; - avg: number | string; + max: string | null; + avg: string | null; } const DEFAULT_HEIGHT = 50; const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { if (!Array.isArray(checks) || checks.length === 0) { - return { max: "-", avg: "-" }; + return { max: null, avg: null }; } - const validChecks = checks.filter((check) => check.originalResponseTime != null); + const validChecks = checks.filter((check) => check.responseTime != null); if (validChecks.length === 0) { - return { max: "-", avg: "-" }; + return { max: null, avg: null }; } - const responseTimes = validChecks.map((check) => check.originalResponseTime); - const max = Math.round(Math.max(...responseTimes)); - const avg = Math.round( + const responseTimes = validChecks.map((check) => check.responseTime); + const max = formatMs(Math.max(...responseTimes)); + const avg = formatMs( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length ); @@ -54,7 +56,7 @@ export const HistogramResponseTime = ({ const { t } = useTranslation(); const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]); - + const barHeights = useMemo(() => computeBarHeights(checks), [checks]); if (!Array.isArray(checks) || checks.length === 0) return null; const data = @@ -68,29 +70,28 @@ export const HistogramResponseTime = ({ const chartHeight = typeof height === "number" ? `${height}px` : height; const gridGap = gap ?? theme.spacing(0.5); - const statsContent = showStats && - (typeof stats.max === "number" || typeof stats.avg === "number") && ( - + - - {t("common.charts.histogram.avg", { value: stats.avg })} - - - {t("common.charts.histogram.max", { value: stats.max })} - - - ); + {t("common.charts.histogram.avg", { value: stats.avg })} + + + {t("common.charts.histogram.max", { value: stats.max })} + + + ); return ( {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 heightPct = `${barHeights[index] ?? 0}%`; const barColor = check.status === true ? theme.palette.success.light diff --git a/client/src/Components/design-elements/Dot.tsx b/client/src/Components/design-elements/Dot.tsx index 541e90812..ff97de9b4 100644 --- a/client/src/Components/design-elements/Dot.tsx +++ b/client/src/Components/design-elements/Dot.tsx @@ -1,18 +1,28 @@ import type { CSSProperties } from "react"; +export const DotSizes = ["xs", "sm", "md", "lg"] as const; +export type DotSize = (typeof DotSizes)[number]; + +const DOT_SIZE_PX: Record = { + xs: "2px", + sm: "4px", + md: "8px", + lg: "12px", +}; + interface DotProps { color?: string; - size?: string; + size?: DotSize; style?: CSSProperties; } -export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => { +export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => { return ( { +const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => { return ( ); }; diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index 2d53470f0..cc0f8df5b 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography"; import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; -import prettyMilliseconds from "pretty-ms"; import { typographyLevels } from "@/Utils/Theme/Palette"; import useMediaQuery from "@mui/material/useMediaQuery"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { formatDuration } from "@/Utils/TimeUtils"; export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { <> - Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })} + Checking every {formatDuration(monitor?.interval, true)} )} diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index 4822a805f..75e8daf47 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { FieldLabel } from "./FieldLabel"; import { ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; type AutoCompleteInputProps = Omit< AutocompleteProps, @@ -15,6 +16,9 @@ type AutoCompleteInputProps = Omit< fieldLabel?: string; required?: boolean; renderOptionContent?: (option: any) => React.ReactNode; + placeholder?: string; + error?: boolean; + helperText?: string; }; export const AutoCompleteInput = ({ @@ -22,15 +26,21 @@ export const AutoCompleteInput = ({ required, renderInput, renderOptionContent, + placeholder, + error, + helperText, ...props }: AutoCompleteInputProps) => { const theme = useTheme(); + const { t } = useTranslation(); const multiple = props.multiple; const { renderOption: customRenderOption, ...autocompleteProps } = props; const defaultRenderInput = (params: any) => ( ); diff --git a/client/src/Components/inputs/Radio.tsx b/client/src/Components/inputs/Radio.tsx index 01e968188..bcb2288e2 100644 --- a/client/src/Components/inputs/Radio.tsx +++ b/client/src/Components/inputs/Radio.tsx @@ -42,6 +42,9 @@ export const RadioWithDescription = ({ "& .MuiButtonBase-root": { mr: theme.spacing(6), }, + "&:has(.Mui-focusVisible)": { + backgroundColor: theme.palette.action.hover, + }, }} /> ); diff --git a/client/src/Components/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx index 92a57860a..0077d2be6 100644 --- a/client/src/Components/inputs/Select.tsx +++ b/client/src/Components/inputs/Select.tsx @@ -11,10 +11,11 @@ interface SelectInputProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; placeholder?: string; + helperText?: string; } const SelectInputInner = ( - { fieldLabel, required, placeholder, ...props }: SelectInputProps, + { fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps, ref: React.ForwardedRef ) => { const theme = useTheme(); @@ -27,7 +28,7 @@ const SelectInputInner = ( : selected === undefined || selected === null || selected === ""; if (isEmpty && placeholder) { - return {placeholder}; + return {placeholder}; } if (isMultiple) { @@ -92,11 +93,21 @@ const SelectInputInner = ( /> ); - if (fieldLabel) { + const helper = helperText ? ( + + {helperText} + + ) : null; + + if (fieldLabel || helper) { return ( - {fieldLabel} + {fieldLabel && {fieldLabel}} {select} + {helper} ); } diff --git a/client/src/Components/inputs/TimePicker.tsx b/client/src/Components/inputs/TimePicker.tsx index 251332fe1..b82df1f15 100644 --- a/client/src/Components/inputs/TimePicker.tsx +++ b/client/src/Components/inputs/TimePicker.tsx @@ -3,6 +3,7 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker import type { Dayjs } from "dayjs"; import { useTheme } from "@mui/material/styles"; import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { FieldLabel } from "./FieldLabel"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; @@ -10,11 +11,15 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; interface TimePickerComponentProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; + error?: boolean; + helperText?: string; } export const TimePickerComponent = ({ fieldLabel, required, + error, + helperText, ...props }: TimePickerComponentProps) => { const theme = useTheme(); @@ -33,12 +38,12 @@ export const TimePickerComponent = ({ px: theme.spacing(5), }, "& fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, borderRadius: theme.shape.borderRadius, }, "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, }, }, }, @@ -48,14 +53,18 @@ export const TimePickerComponent = ({ ); - if (fieldLabel) { - return ( - - {fieldLabel} - {picker} - - ); - } - - return picker; + return ( + + {fieldLabel && {fieldLabel}} + {picker} + {helperText && ( + + {helperText} + + )} + + ); }; diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx new file mode 100644 index 000000000..1dbc65118 --- /dev/null +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -0,0 +1,52 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { Autocomplete } from "@/Components/inputs"; + +export interface AutocompleteOption { + id: string; + name: string; +} + +interface FormAutocompleteProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + placeholder?: string; +} + +export const FormAutocompleteField = < + T extends FieldValues, + O extends AutocompleteOption, +>({ + name, + options, + fieldLabel, + placeholder, +}: FormAutocompleteProps) => { + const { control } = useFormContext(); + return ( + ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={options.find((o) => o.id === field.value) ?? null} + onChange={(_: unknown, newValue: O | null) => { + field.onChange(newValue?.id ?? ""); + }} + fieldLabel={fieldLabel} + placeholder={placeholder} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormCheckboxField.tsx b/client/src/Components/inputs/forms/FormCheckboxField.tsx new file mode 100644 index 000000000..237191b68 --- /dev/null +++ b/client/src/Components/inputs/forms/FormCheckboxField.tsx @@ -0,0 +1,38 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { Checkbox } from "@/Components/inputs"; + +interface FormCheckboxFieldProps { + name: FieldPath; + label: string; +} + +export const FormCheckboxField = ({ + name, + label, +}: FormCheckboxFieldProps) => { + const { control } = useFormContext(); + + return ( + ( + field.onChange(e.target.checked)} + /> + } + label={label} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormColorField.tsx b/client/src/Components/inputs/forms/FormColorField.tsx new file mode 100644 index 000000000..4d8578318 --- /dev/null +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -0,0 +1,35 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { ColorInput } from "@/Components/inputs"; + +interface FormColorFieldProps { + name: FieldPath; + fieldLabel?: string; +} + +export const FormColorField = ({ + name, + fieldLabel, +}: FormColorFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormDateTimePickerField.tsx b/client/src/Components/inputs/forms/FormDateTimePickerField.tsx new file mode 100644 index 000000000..71a17afd0 --- /dev/null +++ b/client/src/Components/inputs/forms/FormDateTimePickerField.tsx @@ -0,0 +1,81 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs, { type Dayjs } from "dayjs"; +import { DatePicker, TimePicker } from "@/Components/inputs"; + +export interface FormPickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +interface DayjsPickerProps { + fieldLabel?: string; + value: Dayjs | null; + onChange: (value: Dayjs | null) => void; + error?: boolean; + helperText?: string; +} + +interface FormDayjsPickerFieldProps< + T extends FieldValues, +> extends FormPickerFieldProps { + picker: React.ComponentType; + format: string; +} + +const FormDayjsPickerField = ({ + name, + fieldLabel, + format, + onValueChange, + picker: Picker, +}: FormDayjsPickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = value ? value.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; + +export const FormDatePickerField = ({ + format = "YYYY-MM-DD", + ...rest +}: FormPickerFieldProps) => ( + +); + +export const FormTimePickerField = ({ + format = "HH:mm", + ...rest +}: FormPickerFieldProps) => ( + +); diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx new file mode 100644 index 000000000..60552c6f2 --- /dev/null +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -0,0 +1,187 @@ +import { useMemo } from "react"; +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import { useTheme } from "@mui/material/styles"; +import { Trash2, GripVertical } from "lucide-react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import type { DropResult } from "@hello-pangea/dnd"; +import { useTranslation } from "react-i18next"; +import { Autocomplete } from "@/Components/inputs"; +import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; + +export interface MultiSelectOption { + id: string | number; + name: string; +} + +interface FormMultiSelectFieldProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + placeholder?: string; + description?: string; + renderRow?: (option: O) => React.ReactNode; + renderOptionContent?: (option: O) => React.ReactNode; + sortable?: boolean; +} + +export const FormMultiSelectField = ({ + name, + options, + fieldLabel, + placeholder, + description, + renderRow, + renderOptionContent, + sortable = false, +}: FormMultiSelectFieldProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + const { t } = useTranslation(); + const optionsById = useMemo( + () => new Map(options.map((o) => [o.id, o])), + [options] + ); + return ( + { + const selectedIds: O["id"][] = field.value ?? []; + const selected = selectedIds + .map((id) => optionsById.get(id)) + .filter((o): o is O => o !== undefined); + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + const reordered = selected.map((o) => o.id); + const [moved] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, moved); + const unresolved = selectedIds.filter((id) => !optionsById.has(id)); + field.onChange([...reordered, ...unresolved]); + }; + + const rowContent = (option: O) => + renderRow ? ( + renderRow(option) + ) : ( + {option.name} + ); + + const removeButton = (option: O) => ( + { + field.onChange(selectedIds.filter((id) => id !== option.id)); + }} + aria-label={t("common.buttons.removeItem", { item: option.name })} + > + + + ); + + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_: unknown, newValue: O[]) => { + // The Autocomplete only sees resolved options, so ids missing + // from options must be re-appended or they'd be silently + // dropped (e.g. a role excluded from the selectable list). + const unresolved = selectedIds.filter((id) => !optionsById.has(id)); + field.onChange([...newValue.map((o) => o.id), ...unresolved]); + }} + fieldLabel={fieldLabel} + placeholder={selected.length > 0 ? "" : placeholder} + renderOptionContent={renderOptionContent} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + {description && ( + + {description} + + )} + {sortable + ? selected.length > 0 && ( + + + {(provided) => ( + + {selected.map((option, index) => ( + + {(provided) => ( + + + {rowContent(option)} + {removeButton(option)} + + )} + + ))} + {provided.placeholder} + + )} + + + ) + : selected.map((option) => ( + + {rowContent(option)} + {removeButton(option)} + + ))} + + ); + }} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormNumberField.tsx b/client/src/Components/inputs/forms/FormNumberField.tsx new file mode 100644 index 000000000..a78a92e46 --- /dev/null +++ b/client/src/Components/inputs/forms/FormNumberField.tsx @@ -0,0 +1,46 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { TextField } from "@/Components/inputs"; + +interface FormNumberFieldProps extends Omit< + React.ComponentProps, + "name" | "error" | "helperText" | "type" +> { + name: FieldPath; + emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel +} + +export const FormNumberField = ({ + name, + emptyValue = 0, + ...rest +}: FormNumberFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const val = e.target.value; + field.onChange(val === "" ? emptyValue : Number(val)); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + fullWidth + {...rest} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormRadioGroupField.tsx b/client/src/Components/inputs/forms/FormRadioGroupField.tsx new file mode 100644 index 000000000..ed97ae2ee --- /dev/null +++ b/client/src/Components/inputs/forms/FormRadioGroupField.tsx @@ -0,0 +1,54 @@ +import { RadioGroup, useTheme } from "@mui/material"; +import FormControl from "@mui/material/FormControl"; +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { RadioWithDescription } from "@/Components/inputs/Radio"; +import { FieldLabel } from "@/Components/inputs/FieldLabel"; +export interface FormRadioOption { + value: string; + label: string; + description: string; +} + +interface FormRadioGroupProps { + name: FieldPath; + options: FormRadioOption[]; + fieldLabel?: string; +} +export const FormRadioGroup = ({ + name, + options, + fieldLabel, +}: FormRadioGroupProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + return ( + ( + + {fieldLabel && {fieldLabel}} + + {options.map((option) => ( + + ))} + + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormSelectField.tsx b/client/src/Components/inputs/forms/FormSelectField.tsx new file mode 100644 index 000000000..faead399f --- /dev/null +++ b/client/src/Components/inputs/forms/FormSelectField.tsx @@ -0,0 +1,61 @@ +import { + Controller, + useFormContext, + type FieldValues, + type FieldPath, +} from "react-hook-form"; +import MenuItem from "@mui/material/MenuItem"; +import { Select } from "@/Components/inputs"; + +export interface FormSelectOption { + value: string | number; + label: string; +} + +interface FormSelectProps extends Omit< + React.ComponentProps, + "name" | "error" | "value" | "onChange" +> { + name: FieldPath; + options: FormSelectOption[]; + fallbackValue?: string | number; // display value while the field is undefined + onValueChange?: (value: string | number) => void; // side effects, see Notes +} + +export const FormSelectField = ({ + name, + options, + fallbackValue, + onValueChange, + ...rest +}: FormSelectProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormSliderField.tsx b/client/src/Components/inputs/forms/FormSliderField.tsx new file mode 100644 index 000000000..1d39d17ad --- /dev/null +++ b/client/src/Components/inputs/forms/FormSliderField.tsx @@ -0,0 +1,34 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { SliderWithLabel } from "@/Components/inputs"; + +interface FormSliderProps extends Omit< + React.ComponentProps, + "name" | "value" | "onChange" +> { + name: FieldPath; +} + +export const FormSliderField = ({ + name, + ...rest +}: FormSliderProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormSwitchField.tsx b/client/src/Components/inputs/forms/FormSwitchField.tsx new file mode 100644 index 000000000..ed65d99f2 --- /dev/null +++ b/client/src/Components/inputs/forms/FormSwitchField.tsx @@ -0,0 +1,59 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { SwitchComponent } from "@/Components/inputs"; +import { SPACING } from "@/Utils/Theme/constants"; + +interface FormSwitchProps { + name: FieldPath; + label: string; + labelPlacement?: "start" | "end"; // left | right +} + +export const FormSwitchField = ({ + name, + label, + labelPlacement = "end", +}: FormSwitchProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + + return ( + ( + + {labelPlacement === "start" ? ( + <> + {label} + field.onChange(e.target.checked)} + /> + + ) : ( + <> + field.onChange(e.target.checked)} + /> + {label} + + )} + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx new file mode 100644 index 000000000..3cda2a978 --- /dev/null +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -0,0 +1,40 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { TextField } from "@/Components/inputs"; + +interface FormTextFieldProps extends Omit< + React.ComponentProps, + "name" | "error" | "helperText" +> { + name: FieldPath; + helperText?: string; +} + +export const FormTextField = ({ + name, + ...rest +}: FormTextFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/layout/RootLayout.tsx b/client/src/Components/layout/RootLayout.tsx index c22bd7455..3312fc653 100644 --- a/client/src/Components/layout/RootLayout.tsx +++ b/client/src/Components/layout/RootLayout.tsx @@ -18,15 +18,13 @@ const RootLayout = () => { flex={1} padding={6} overflow={"hidden"} - sx={{ - backgroundColor: - theme.palette.mode === "dark" - ? "rgba(255, 255, 255, 0.01)" - : "rgba(0, 0, 0, 0.01)", - display: "flex", - alignItems: "center", - paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12, - }} + bgcolor={ + theme.palette.mode === "dark" + ? "rgba(255, 255, 255, 0.01)" + : "rgba(124, 116, 116, 0.01)" + } + alignItems="center" + paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12} > { return status ? theme.palette.success.main : theme.palette.error.main; }; - const formatResponseTime = (timing: number): string => { - return `${timing.toFixed(0)}ms`; - }; - return ( <> {mapPopupStyles} @@ -186,7 +183,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { > Response Time: {" "} - {formatResponseTime(selectedCheck.timings.total)} + {formatMs(selectedCheck.timings.total)} { const theme = useTheme(); const { t } = useTranslation(); @@ -31,14 +33,9 @@ export const MonitorStatBoxes = ({ const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0; - const options = { - secondsDecimalDigits: 0, - millisecondsDecimalDigits: 0, - }; + const streakTime = formatDuration(timeSinceLastFailure); - const streakTime = prettyMilliseconds(timeSinceLastFailure, options); - - const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options); + const lastCheckTime = formatDuration(timeSinceLastCheck); const isActive = monitor?.status === "up" || monitor?.status === "paused" || @@ -67,14 +64,20 @@ export const MonitorStatBoxes = ({ /> {monitor?.type === "http" && ( - + <> + + + )} ); diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts index fb9d1779c..0516eaac3 100644 --- a/client/src/Components/monitors/charts/ChartUtils.ts +++ b/client/src/Components/monitors/charts/ChartUtils.ts @@ -23,11 +23,12 @@ export const PHASE_COLOR_KEYS = { avgDownload: "download", } as const; -export const PHASE_LABELS: Record<(typeof PHASE_KEYS)[number], string> = { - avgDns: "DNS", - avgTcp: "TCP", - avgTls: "TLS", - avgRequest: "Request", - avgFirstByte: "First byte", - avgDownload: "Download", +// i18n keys, resolved with t() at render +export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = { + avgDns: "common.charts.phases.dns", + avgTcp: "common.charts.phases.tcp", + avgTls: "common.charts.phases.tls", + avgRequest: "common.charts.phases.request", + avgFirstByte: "common.charts.phases.firstByte", + avgDownload: "common.charts.phases.download", }; diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 0736abd6c..a9af6d117 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -14,10 +14,10 @@ import { import type { TooltipProps } from "recharts"; import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import Typography from "@mui/material/Typography"; -import prettyMilliseconds from "pretty-ms"; import { XTick } from "@/Components/monitors/charts/XTick"; import { useSelector } from "react-redux"; -import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; +import { useTranslation } from "react-i18next"; +import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; @@ -25,11 +25,11 @@ import type { RootState } from "@/Types/state"; import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; import { useMemo, useState } from "react"; -import { SPACING } from "@/Utils/Theme/constants"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { PHASE_COLOR_KEYS, PHASE_KEYS, - PHASE_LABELS, + PHASE_LABEL_KEYS, } from "@/Components/monitors/charts/ChartUtils"; type DetailsCheck = GroupedCheck & @@ -49,18 +49,20 @@ const ResponseTimeToolTip = ({ theme, uiTimezone, }: ResponseTimeToolTipProps) => { + const { t } = useTranslation(); if (!label) return null; if (!payload) return null; if (!active) return null; const format = tooltipDateFormatLookup(range); - const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0); + const responseTime = payload[0]?.payload?.avgResponseTime ?? 0; return ( {formatDateWithTz(String(label), format, uiTimezone)} - Response time:{" "} - {prettyMilliseconds(responseTime, { formatSubMilliseconds: true, compact: true })} + {t("common.charts.histogram.responseTime", { + value: formatMs(responseTime), + })} ); @@ -79,6 +81,7 @@ const TimingPhasesToolTip = ({ theme, uiTimezone, }: TimingPhaseToolTipProps) => { + const { t } = useTranslation(); if (!active || !payload?.length) return null; const bucket = payload[0].payload as GroupedUptimeCheck; const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0); @@ -86,7 +89,9 @@ const TimingPhasesToolTip = ({ return ( {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - Total: {prettyMilliseconds(total)} + + {t("common.charts.histogram.total", { value: formatMs(total) })} + {PHASE_KEYS.map((key) => ( - {PHASE_LABELS[key]}:{" "} - {prettyMilliseconds(bucket[key], { - formatSubMilliseconds: true, - compact: true, - })} + {t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])} ))} @@ -119,6 +120,9 @@ export const HistogramDetails = ({ range: string; }) => { const [showTimingPhases, setShowTimingPhases] = useState(false); + const [hoveredPhase, setHoveredPhase] = useState(null); + + const { t } = useTranslation(); const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const yMax = useMemo(() => { @@ -142,7 +146,11 @@ export const HistogramDetails = ({ strokeWidth={1.5} /> } - title={showTimingPhases ? "Timing Phases" : "Response Time"} + title={ + showTimingPhases + ? t("common.charts.labels.timingPhases") + : t("common.charts.labels.responseTime") + } onClick={() => { if (!hasTimingPhases) return; setShowTimingPhases((prev) => !prev); @@ -253,6 +261,7 @@ export const HistogramDetails = ({ {hasTimingPhases && showTimingPhases && PHASE_KEYS.map((key) => { + const dimmed = hoveredPhase !== null && hoveredPhase !== key; return ( ); })} + + {showTimingPhases ? ( + PHASE_KEYS.map((key) => { + return ( + setHoveredPhase(key)} + onMouseLeave={() => setHoveredPhase(null)} + > + + {t(PHASE_LABEL_KEYS[key])} + + ); + }) + ) : ( + setHoveredPhase("avgResponseTime")} + onMouseLeave={() => setHoveredPhase(null)} + > + + + {t("common.charts.labels.totalResponseTime")} + + + )} + ); }; diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx deleted file mode 100644 index 2ef74f56b..000000000 --- a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { GroupedUptimeCheck } from "@/Types/Check"; -import type { TooltipProps } from "recharts"; -import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; -import type { Theme } from "@mui/material/styles"; -import type { RootState } from "@/Types/state"; -import { Clock } from "lucide-react"; -import prettyMilliseconds from "pretty-ms"; - -import Stack from "@mui/material/Stack"; -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - YAxis, - XAxis, - Tooltip, -} from "recharts"; -import { BaseBox, BaseChart, Dot } from "@/Components/design-elements"; -import Typography from "@mui/material/Typography"; -import { XTick } from "@/Components/monitors/charts/XTick"; - -import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; -import { useSelector } from "react-redux"; -import { useMemo } from "react"; -import { useTheme } from "@mui/material"; -import { SPACING } from "@/Utils/Theme/constants"; -import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; - -export const PHASE_KEYS = [ - "avgDns", - "avgTcp", - "avgTls", - "avgRequest", - "avgFirstByte", - "avgDownload", -] as const; - -const PHASE_COLOR_KEYS = { - avgDns: "dns", - avgTcp: "tcp", - avgTls: "tls", - avgRequest: "request", - avgFirstByte: "firstByte", - avgDownload: "download", -} as const; - -const PHASE_LABELS: Record<(typeof PHASE_KEYS)[number], string> = { - avgDns: "DNS", - avgTcp: "TCP", - avgTls: "TLS", - avgRequest: "Request", - avgFirstByte: "First byte", - avgDownload: "Download", -}; - -type TimingPhaseToolTipProps = TooltipProps & { - range: string; - theme: Theme; - uiTimezone: string; -}; - -const TimingPhasesToolTip = ({ - active, - payload, - range, - theme, - uiTimezone, -}: TimingPhaseToolTipProps) => { - if (!active || !payload?.length) return null; - const bucket = payload[0].payload as GroupedUptimeCheck; - const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0); - const format = tooltipDateFormatLookup(range); - return ( - - {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - Total: {prettyMilliseconds(total)} - {PHASE_KEYS.map((key) => ( - - - - {PHASE_LABELS[key]}:{" "} - {prettyMilliseconds(bucket[key], { - formatSubMilliseconds: true, - compact: true, - })} - - - ))} - - ); -}; - -export const HistogramDetailsStacked = ({ - checks = [], - range, -}: { - checks: GroupedUptimeCheck[]; - range: string; -}) => { - const theme = useTheme(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - - // Cap the y domain at 2x the p95 of bucket totals, so typical stacks sit - // around mid-height with headroom above - const yMax = useMemo(() => { - const totals = checks.map((check) => - PHASE_KEYS.reduce((sum, key) => sum + check[key], 0) - ); - return computeYAxisCap(totals); - }, [checks]); - - return ( - - } - title="Timing phases" - > - - - - - {PHASE_KEYS.map((key) => ( - - - - - ))} - - - ( - - )} - /> - ( - - )} - /> - {PHASE_KEYS.map((key) => ( - - ))} - - - - ); -}; diff --git a/client/src/Components/monitors/charts/HistogramStatus.tsx b/client/src/Components/monitors/charts/HistogramStatus.tsx index 44f6bea97..846510e09 100644 --- a/client/src/Components/monitors/charts/HistogramStatus.tsx +++ b/client/src/Components/monitors/charts/HistogramStatus.tsx @@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography"; import { BaseChart } from "@/Components/design-elements"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; @@ -100,7 +100,7 @@ export const HistogramStatus = ({ ) : null} {titleText} - {Math.floor(avg)} ms + {formatMs(avg)} ); }; diff --git a/client/src/Components/monitors/charts/RadialAvgResponse.tsx b/client/src/Components/monitors/charts/RadialAvgResponse.tsx index dccb18984..98864b48e 100644 --- a/client/src/Components/monitors/charts/RadialAvgResponse.tsx +++ b/client/src/Components/monitors/charts/RadialAvgResponse.tsx @@ -7,6 +7,7 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts"; import { useTranslation } from "react-i18next"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; +import { formatMs } from "@/Utils/TimeUtils"; export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => { const { t } = useTranslation(); @@ -84,7 +85,9 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {`${avg?.toFixed()}ms`} + > + {formatMs(avg)} + diff --git a/client/src/Components/sidebar/Authfooter.tsx b/client/src/Components/sidebar/Authfooter.tsx index 34f76d7ce..1b68cecc7 100644 --- a/client/src/Components/sidebar/Authfooter.tsx +++ b/client/src/Components/sidebar/Authfooter.tsx @@ -10,10 +10,11 @@ import { useSelector, useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import { useNavigate } from "react-router"; -import { MoreVertical, LogOut } from "lucide-react"; +import { MoreVertical, LogOut, Moon, Sun } from "lucide-react"; import { Avatar, Icon } from "@/Components/design-elements"; import { clearAuthState } from "@/Features/Auth/authSlice.js"; import type { RootState } from "@/Types/state.js"; +import { setMode } from "@/Features/UI/uiSlice.js"; interface AuthFooterProps { collapsed: boolean; @@ -27,6 +28,7 @@ interface AuthFooterProps { export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => { const { t } = useTranslation(); const theme = useTheme(); + const mode = useSelector((state: RootState) => state.ui.mode); const user = useSelector((state: RootState) => state.auth.user); const navigate = useNavigate(); const dispatch = useDispatch(); @@ -105,13 +107,14 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {user?.firstName} {user?.lastName} {getRoleText()} @@ -154,15 +157,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => pb={2} sx={{ pointerEvents: "none" }} > - + {user?.firstName} {user?.lastName} {getRoleText()} @@ -178,6 +178,18 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {item.name} ))} + { + dispatch(setMode(mode === "light" ? "dark" : "light")); + handleMenuClose(); + }} + sx={menuItemSx} + > + + {mode === "light" + ? t("pages.settings.form.ui.option.theme.dark") + : t("pages.settings.form.ui.option.theme.light")} + { return [ { @@ -47,6 +47,11 @@ export const getMenu = (t: Function) => { path: "tags", icon: , }, + { + name: t("components.sidebar.menu.proxies"), + path: "proxies", + icon: , + }, { name: t("components.sidebar.menu.checks"), path: "checks", diff --git a/client/src/Components/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx index d2d759c6b..d92441b01 100644 --- a/client/src/Components/sidebar/index.tsx +++ b/client/src/Components/sidebar/index.tsx @@ -102,6 +102,7 @@ export const Sidebar = () => { })} + { + return useMemo(() => { + const defaults: ProxyFormData = { + name: data?.name ?? "", + protocol: data?.protocol ?? "http", + host: data?.host ?? "", + port: data?.port ?? 8080, + username: "", + password: "", + }; + + return { schema: proxySchema, defaults }; + }, [data]); +}; diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 643e9caca..95947928c 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => checkTTL: data?.checkTTL ?? 30, pagespeedApiKey: "", systemEmailPassword: "", + globalProxyEnabled: data?.globalProxyEnabled ?? false, + globalProxyId: data?.globalProxyId ?? null, }; return { schema: settingsSchema, defaults }; diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index 86a52bbbb..a36d18028 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -1,11 +1,6 @@ -import { Trash2 } from "lucide-react"; -import { TextField, Button, Autocomplete } from "@/Components/inputs"; +import { TextField, Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import Divider from "@mui/material/Divider"; -import FormHelperText from "@mui/material/FormHelperText"; -import { useForm, Controller } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { ConfigBox, BasePage } from "@/Components/design-elements"; import { UserRoles } from "@/Types/User"; @@ -22,6 +17,8 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; interface RoleOption { id: UserRole; @@ -43,10 +40,11 @@ const EditUserPage = () => { const { data: user, isLoading } = useGet(`/auth/users/${userId}`); - const { control, handleSubmit, reset, watch, setValue } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, }); + const { handleSubmit, reset } = form; useEffect(() => { if (user) { @@ -64,13 +62,6 @@ const EditUserPage = () => { name: t(`common.auth.roles.${role}`), })); - const watchedRoles = watch("role"); - const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id)); - const handleRemoveRole = (roleToRemove: UserRole) => { - const newRoles = watchedRoles.filter((role) => role !== roleToRemove); - setValue("role", newRoles, { shouldValidate: true }); - }; - const canDeleteUser = isAdmin && userId !== currentUser?.id && @@ -90,42 +81,31 @@ const EditUserPage = () => { }; return ( - -
+ + - ( - - )} + autoComplete="given-name" + fieldLabel={t("common.form.name.option.firstName.label")} + placeholder={t("common.form.name.option.firstName.placeholder")} /> - ( - - )} + autoComplete="family-name" + fieldLabel={t("common.form.name.option.lastName.label")} + placeholder={t("common.form.name.option.lastName.placeholder")} /> + { title={t("pages.editUser.form.roles.title")} subtitle={t("pages.editUser.form.roles.description")} rightContent={ - - ( - <> - option.name} - onChange={(_: unknown, newValue: RoleOption[]) => { - field.onChange(newValue.map((r) => r.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {fieldState.error && ( - {fieldState.error.message} - )} - - )} - /> - {selectedRoles.length > 0 && ( - - {selectedRoles.map((role, index) => ( - - {role.name} - handleRemoveRole(role.id)} - aria-label="Remove role" - > - - - {index < selectedRoles.length - 1 && } - - ))} - - )} - + } /> { - - setShowDeleteDialog(false)} - onConfirm={handleDeleteUser} - confirmText={t("common.buttons.removeUser")} - loading={isDeleting} - /> - + setShowDeleteDialog(false)} + onConfirm={handleDeleteUser} + confirmText={t("common.buttons.removeUser")} + loading={isDeleting} + /> +
+ ); }; diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index d09a9eeea..ff9598aac 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,13 +1,15 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/design-elements"; -import { TextField, Button } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { usePasswordForm } from "@/Hooks/usePasswordForm"; import { usePatch } from "@/Hooks/UseApi"; import type { PasswordFormData } from "@/Validation/password"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabPassword = () => { const { t } = useTranslation(); @@ -15,10 +17,11 @@ export const TabPassword = () => { const { resolver, defaults } = usePasswordForm(); const { patch, loading } = usePatch(); - const { control, handleSubmit, reset } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, }); + const { handleSubmit, reset } = form; const onSubmit = async (data: PasswordFormData) => { const fd = new FormData(); @@ -32,85 +35,61 @@ export const TabPassword = () => { }; return ( - - ( - - )} - /> - } - /> - - ( - - )} - /> - ( - - )} - /> - - } - /> - - + + } + /> + + + + + } + /> + + + ); }; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 01e4f14b1..e799855c8 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,11 +1,11 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/design-elements"; -import { TextField, Button } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { ImageUpload } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { useSelector, useDispatch } from "react-redux"; import { useProfileForm } from "@/Hooks/useProfileForm"; import { usePatch } from "@/Hooks/UseApi"; @@ -13,6 +13,8 @@ import { setUser } from "@/Features/Auth/authSlice"; import type { ProfileFormData } from "@/Validation/profile"; import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabProfile = () => { const theme = useTheme(); @@ -22,7 +24,7 @@ export const TabProfile = () => { const { resolver, defaults } = useProfileForm(); const { patch, loading: patchLoading } = usePatch(); - const { control, handleSubmit, setValue, watch } = useForm({ + const form = useForm({ resolver, defaultValues: { firstName: user?.firstName ?? defaults.firstName, @@ -31,7 +33,7 @@ export const TabProfile = () => { deleteProfileImage: defaults.deleteProfileImage, }, }); - + const { handleSubmit, setValue, watch } = form; const currentImage = watch("profileImage"); const deleteImage = watch("deleteProfileImage"); @@ -74,66 +76,52 @@ export const TabProfile = () => { }; return ( - - - ( - - )} - /> - ( - - )} - /> - - } - /> - - } - /> - - + + + + + } + /> + + } + /> + + + ); }; diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index aff0c3cf2..0e99af20f 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -1,15 +1,16 @@ import { Stack } from "@mui/material"; -import MenuItem from "@mui/material/MenuItem"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, TextField, Select } from "@/Components/inputs"; +import { Dialog } from "@/Components/inputs"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; -import type { UserRole, User } from "@/Types/User"; +import type { User } from "@/Types/User"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { RoleSelectField } from "./RoleSelectField"; interface AddTeamMemberDialogProps { open: boolean; @@ -35,17 +36,13 @@ export const AddTeamMemberDialog = ({ const { schema, defaults } = useAddTeamMemberForm(); const { post, loading } = usePost(); - const { control, handleSubmit, reset, setError } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, values: defaults, }); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; + const { handleSubmit, reset, setError } = form; const handleClose = () => { reset(); @@ -55,7 +52,7 @@ export const AddTeamMemberDialog = ({ const onSubmit = async (data: AddTeamMemberFormData) => { if (loading) return; - const { confirm, ...userData } = data; + const { confirm: _confirm, ...userData } = data; const payload: RegisterPayload = userData; const result = await post("/auth/users", payload); @@ -72,124 +69,64 @@ export const AddTeamMemberDialog = ({ }; return ( - - - - ( - - )} - /> - ( - - )} - /> - - ( - + + + + - )} - /> - ( - - )} - /> - - ( - - )} - /> - ( - - )} + + + + + + + + + + + - - + + ); }; diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index d41bf3ca3..498456f03 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -1,16 +1,16 @@ import { Stack } from "@mui/material"; -import MenuItem from "@mui/material/MenuItem"; -import { useTheme, FormHelperText, Typography } from "@mui/material"; +import { useTheme, Typography } from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; -import { Dialog, TextField, Select, Button } from "@/Components/inputs"; -import type { UserRole } from "@/Types/User"; +import { useForm, FormProvider } from "react-hook-form"; +import { Dialog, TextField, Button } from "@/Components/inputs"; import { useInviteForm } from "@/Hooks/useInviteForm"; import type { InviteFormData } from "@/Validation/invite"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; import { runtimeConfig } from "@/Utils/runtimeConfig"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { RoleSelectField } from "./RoleSelectField"; const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; @@ -31,11 +31,12 @@ export const InviteTeamMemberDialog = ({ const { t } = useTranslation(); const { resolver, defaults } = useInviteForm(); - const { control, handleSubmit, reset } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, values: defaults, }); + const { handleSubmit, reset } = form; const { post: generateToken, loading: generateLoading } = usePost< InviteFormData, @@ -47,12 +48,6 @@ export const InviteTeamMemberDialog = ({ >(); const [inviteLink, setInviteLink] = useState(null); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; - const handleGenerateToken = async (data: InviteFormData) => { const result = await generateToken("/invite", data); if (result?.data?.token) { @@ -76,88 +71,55 @@ export const InviteTeamMemberDialog = ({ }; return ( - - {t("common.buttons.generateToken")} - - } - > - - ( - - )} - /> - ( + + + {t("common.buttons.generateToken")} + + } + > + + + + + {inviteLink && ( <> - - {fieldState.error && ( - {fieldState.error.message} - )} + slotProps={{ + input: { + readOnly: true, + }, + }} + /> )} - /> - {inviteLink && ( - <> - - {t("pages.account.team.invite.linkLabel")} - - - - )} - - + + + ); }; diff --git a/client/src/Pages/Account/components/RoleSelectField.tsx b/client/src/Pages/Account/components/RoleSelectField.tsx new file mode 100644 index 000000000..a6bfcf15a --- /dev/null +++ b/client/src/Pages/Account/components/RoleSelectField.tsx @@ -0,0 +1,45 @@ +import MenuItem from "@mui/material/MenuItem"; +import FormHelperText from "@mui/material/FormHelperText"; +import { useController } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Select } from "@/Components/inputs"; +import { UserRoles } from "@/Types/User"; +import type { UserRole } from "@/Types/User"; + +export const RoleSelectField = () => { + const { t } = useTranslation(); + const { field, fieldState } = useController<{ role: UserRole[] }, "role">({ + name: "role", + }); + + const editableRoles = UserRoles.filter((role) => role !== "superadmin"); + const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({ + value: role, + label: t(`common.auth.roles.${role}`), + })); + + return ( + <> + + {fieldState.error && ( + {fieldState.error.message} + )} + + ); +}; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 9de5f1df5..0aeb17dd8 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,9 +1,9 @@ import { BaseAuthPage, TextLink } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useLoginForm } from "@/Hooks/useLoginForm"; import type { LoginFormData } from "@/Validation/login"; @@ -11,6 +11,7 @@ import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { setAuthState } from "@/Features/Auth/authSlice"; import { useLazyGet, usePost } from "@/Hooks/UseApi"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const LoginPage = () => { const { t } = useTranslation(); @@ -31,11 +32,13 @@ const LoginPage = () => { const { schema, defaults } = useLoginForm(); - const { control, handleSubmit } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit } = form; + if (isCheckingAdmin) return null; const onSubmit = async (data: LoginFormData) => { @@ -50,53 +53,39 @@ const LoginPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - + + + + + + ); }; diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index d0eeaef06..8680115af 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -1,12 +1,13 @@ -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { BaseAuthPage, TextLink } from "@/Components/design-elements"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; import type { RecoveryFormData } from "@/Validation/recovery"; import { usePost } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const ForgotPasswordPage = () => { const { t } = useTranslation(); @@ -14,10 +15,11 @@ const ForgotPasswordPage = () => { const { schema, defaults } = useRecoveryForm(); - const { control, handleSubmit } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit } = form; const onSubmit = async (data: RecoveryFormData) => { if (loading) return; @@ -30,39 +32,33 @@ const ForgotPasswordPage = () => { }; return ( - - ( - - )} - /> - - - + + + + + ); }; diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 92c712bda..af396e6c7 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -1,7 +1,7 @@ import { BaseAuthPage } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Alert from "@mui/material/Alert"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRegisterForm } from "@/Hooks/useRegisterForm"; import type { RegisterFormData } from "@/Validation/register"; @@ -12,6 +12,7 @@ import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import type { AuthResponse } from "@/Types/User"; import { useEffect, useRef, useState } from "react"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; interface RegisterPayload { user: Omit; @@ -45,10 +46,11 @@ const RegisterPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const { control, handleSubmit, setError, reset } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit, setError, reset } = form; useEffect(() => { if (!token || hasVerified.current) return; @@ -64,14 +66,14 @@ const RegisterPage = () => { navigate("/register", { replace: true }); } }); - }, [token]); + }, [token, defaults, navigate, reset, verifyToken]); if (isCheckingAdmin) return null; const onSubmit = async (data: RegisterFormData) => { if (loading) return; - const { confirm, ...userData } = data; + const { confirm: _confirm, ...userData } = data; const payload: RegisterPayload = { user: userData }; if (token) { payload.token = token; @@ -89,105 +91,70 @@ const RegisterPage = () => { }; return ( - - {!token && ( - ({ - fontSize: 13, - lineHeight: 1.55, - color: theme.palette.text.secondary, - backgroundColor: theme.palette.action.hover, - border: `1px solid ${theme.palette.divider}`, - borderRadius: 1, - "& .MuiAlert-message": { padding: 0 }, - })} - > - {t("pages.auth.register.setupNotice")} - - )} - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - + {!token && ( + ({ + fontSize: 13, + lineHeight: 1.55, + color: theme.palette.text.secondary, + backgroundColor: theme.palette.action.hover, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + "& .MuiAlert-message": { padding: 0 }, + })} + > + {t("pages.auth.register.setupNotice")} + + )} + + + + + + + + + + + ); }; diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index 5d70b4811..9ee2dee31 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -1,9 +1,9 @@ import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm"; import type { SetNewPasswordFormData } from "@/Validation/setNewPassword"; @@ -12,6 +12,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { usePost } from "@/Hooks/UseApi"; import { useDispatch } from "react-redux"; import { setAuthState } from "@/Features/Auth/authSlice"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const SetNewPasswordPage = () => { const theme = useTheme(); @@ -23,11 +24,11 @@ const SetNewPasswordPage = () => { const { schema, defaults } = useSetNewPasswordForm(); - const { control, handleSubmit, watch } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); - + const { handleSubmit, watch } = form; const password = watch("password"); const confirm = watch("confirm"); @@ -57,81 +58,68 @@ const SetNewPasswordPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - - - - - - - - - + + + + + + + + + + + + + + + ); }; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 96b05be71..5c2b693e0 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,39 +3,25 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; -import RadioGroup from "@mui/material/RadioGroup"; -import FormControl from "@mui/material/FormControl"; import { Trans, useTranslation } from "react-i18next"; -import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; -import Divider from "@mui/material/Divider"; -import IconButton from "@mui/material/IconButton"; -import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; +import Box from "@mui/material/Box"; import { BasePage, ColoredLabel, ConfigBox, StepProgress, } from "@/Components/design-elements"; -import { - RadioWithDescription, - Button, - TextField, - Select, - Autocomplete, - SwitchComponent as Switch, - SliderWithLabel, - Dialog, -} from "@/Components/inputs"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { Button, Dialog } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm, getMonitorDefaults } from "@/Hooks/useMonitorForm"; import { @@ -47,20 +33,45 @@ import { DefaultPageSpeedStrategy, DefaultHttpMethod, HttpMethods, + SelectableMonitorTypes, + monitorTypeLabelKey, + PageSpeedStrategies, + DnsRecordTypes, + MonitorIntervalOptions, + DefaultMonitorMatchMethod, + MonitorMatchMethods, + GeoCheckIntervalOptions, + ProxyModes, + type ProxyMode, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; +import type { AppSettingsResponse } from "@/Types/Settings"; import { stepFieldsFor, monitorStepCount, type MonitorFormData, } from "@/Validation/monitor"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import type { ProxyResponse } from "@/Types/Proxy"; + +const httpMethodOptions = HttpMethods.map((method) => ({ + value: method, + label: method, +})); interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; namePlaceholder: string; showUrl: boolean; + showProxy: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; @@ -81,6 +92,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: true, showPort: false, showGameSelect: false, showSecret: false, @@ -95,6 +107,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -109,6 +122,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -123,6 +137,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -137,6 +152,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: true, showSecret: false, @@ -151,6 +167,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -165,6 +182,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -179,6 +197,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: true, @@ -193,6 +212,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -207,6 +227,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -252,6 +273,8 @@ const CreateMonitorPage = () => { const { data: notifications } = useGet("/notifications/team"); const { data: games } = useGet("/monitors/games"); const { data: tags } = useGet("/tags/team"); + const { data: proxies } = useGet("/proxies/team"); + const { data: appSettings } = useGet("/settings"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, @@ -262,7 +285,7 @@ const CreateMonitorPage = () => { resolver: zodResolver(schema), defaultValues: defaults, }); - const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; + const { watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; useEffect(() => { reset(defaults); @@ -275,6 +298,7 @@ const CreateMonitorPage = () => { const watchedType = watch("type") as MonitorType; const watchedMethod = watch("method") as HttpMethod | undefined; + const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -287,6 +311,9 @@ const CreateMonitorPage = () => { setCurrentStep((step) => step + 1); } }; + + const isWizardStep = !isEditMode && currentStep < totalSteps - 1; + const handleBack = () => setCurrentStep((step) => step - 1); // Reset the form when the monitor type changes so stale type-specific fields @@ -356,1151 +383,641 @@ const CreateMonitorPage = () => { logger.debug("Monitor creation validation errors", errors); }; + const handleFormSubmit = isWizardStep + ? (event: React.FormEvent) => { + event.preventDefault(); + void handleNext(); + } + : handleSubmit(onSubmit, onError); + + const notificationOptions = useMemo(() => { + return (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + }, [notifications]); + + const locationOptions = useMemo( + () => + GeoContinents.map((continent) => ({ + id: continent, + name: t( + `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` + ), + })), + [t] + ); + + const typeOptions = useMemo( + () => + SelectableMonitorTypes.map((type) => ({ + value: type, + label: t(`pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}`), + description: t( + `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` + ), + })), + [t] + ); + + const strategyOptions = useMemo( + () => + PageSpeedStrategies.map((strategy) => ({ + value: strategy, + label: t(`pages.createMonitor.form.general.option.strategy.${strategy}`), + })), + [t] + ); + + const gameOptions = useMemo( + () => [ + { + value: "", + label: t("pages.createMonitor.form.general.option.game.placeholder"), + }, + ...Object.entries(games ?? {}).map(([key, game]) => ({ + value: key, + label: game.name, + })), + ], + [games, t] + ); + + const dnsRecordTypeOptions = useMemo( + () => + DnsRecordTypes.map((recordType) => ({ + value: recordType, + label: t( + `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` + ), + })), + [t] + ); + + const intervalOptions = useMemo( + () => + MonitorIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` + ), + })), + [t] + ); + + const matchMethodOptions = useMemo( + () => + MonitorMatchMethods.map((method) => ({ + value: method, + label: t(`pages.createMonitor.form.advanced.option.matchMethod.${method}`), + })), + [t] + ); + + const geoCheckIntervalOptions = useMemo( + () => + GeoCheckIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` + ), + })), + [t] + ); + const globalProxyName = appSettings?.globalProxy?.name; + + const proxyModeOptions = useMemo( + () => + ProxyModes.map((mode) => { + const key = + mode === "inherit" + ? globalProxyName + ? "inheritGlobal" + : "inheritDirect" + : mode; + return { + value: mode, + label: t(`pages.createMonitor.form.general.option.proxyMode.value.${key}`, { + name: globalProxyName, + }), + description: t( + `pages.createMonitor.form.general.option.proxyMode.description.${key}` + ), + }; + }), + [t, globalProxyName] + ); + + const proxyOptions = useMemo( + () => + (proxies ?? []).map((proxy) => ({ + value: proxy.id, + label: `${proxy.name} (${proxy.host}:${proxy.port})`, + })), + [proxies] + ); + return ( - - - {!isEditMode && ( - + + - )} - {/* Monitor Type Selection - only shown for uptime monitors */} - {showTypeSelector && showStep(0) && ( - ( - - - - - - - - - - - - - )} - /> - } - /> - )} - - {showStep(0) && ( - - ), - }} - /> - } - rightContent={ - - {/* URL/Host/Container field - not shown for hardware */} - {generalSettingsConfig.showUrl && ( - ( - - )} - /> - )} - - {/* Strategy field - only for pagespeed type */} - {generalSettingsConfig.showStrategy && ( - ( - - )} - /> - )} - - {/* Port field - only for port and game types */} - {generalSettingsConfig.showPort && ( - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" - 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 ?? ""} - /> - )} - /> - )} - - {/* Game select - only for game type */} - {generalSettingsConfig.showGameSelect && ( - ( - - )} - /> - )} - - {/* gRPC Service Name field - only for grpc type */} - {generalSettingsConfig.showGrpcServiceName && ( - ( - - )} - /> - )} - - {/* Secret field - only for hardware type */} - {generalSettingsConfig.showSecret && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsServer && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsRecordType && ( - ( - - )} - /> - )} - - ( - - )} - /> - - } - /> - )} - - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - - {/* Alert Thresholds - only for hardware type */} - {generalSettingsConfig.showSecret && showStep(1) && ( - - ( - `${value}%`} - /> - )} - /> - ( - `${value}%`} - /> - )} - /> - ( - `${value}%`} - /> - )} - /> - ( - `${value}°C`} - /> - )} - /> - - } - /> - )} - - {showStep(1) && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - - {showStep(1) && ( - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - )} - - {showStep(1) && ( - { - const tagOptions = tags ?? []; - const selectedTags = tagOptions.filter((tag) => - (field.value ?? []).includes(tag.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof tagOptions) => { - field.onChange(newValue.map((tag) => tag.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderOptionContent={(option) => ( - - )} - /> - {selectedTags.length > 0 && ( - - {selectedTags.map((tag, index) => ( - - -
- { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== tag.id - ) - ); - }} - aria-label="Remove tag" - > - - - {index < selectedTags.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - )} - - {showStep(2) && - (watchedType === "http" || - watchedType === "grpc" || - watchedType === "websocket") && ( + {!isEditMode && ( + + )} + {/* Monitor Type Selection - only shown for uptime monitors */} + {showTypeSelector && showStep(0) && ( ( - - field.onChange(e.target.checked)} + + } + /> + )} + + {showStep(0) && ( + - - {t("pages.createMonitor.form.ignoreTls.option.tls.label")} - - + ), + }} + /> + } + rightContent={ + + + {/* URL/Host/Container field - not shown for hardware */} + {generalSettingsConfig.showUrl && ( + + )} + {/* Proxy fields - only shown for HTTP */} + {generalSettingsConfig.showProxy && ( + <> + + {watchedProxyMode === "custom" && ( + + )} + + )} + + {/* Strategy field - only for pagespeed type */} + {generalSettingsConfig.showStrategy && ( + + )} + + {/* Port field - only for port and game types */} + {generalSettingsConfig.showPort && ( + + )} + + {/* Game select - only for game type */} + {generalSettingsConfig.showGameSelect && ( + + )} + + {/* gRPC Service Name field - only for grpc type */} + {generalSettingsConfig.showGrpcServiceName && ( + + )} + + {/* Secret field - only for hardware type */} + {generalSettingsConfig.showSecret && ( + + )} + {generalSettingsConfig.showDnsServer && ( + + )} + {generalSettingsConfig.showDnsRecordType && ( + + )} + + } + /> + )} + + {showStep(1) && ( + + } + /> + )} + + {/* Alert Thresholds - only for hardware type */} + {watchedType === "hardware" && showStep(1) && ( + + `${value}%`} + /> + `${value}%`} + /> + `${value}%`} + /> + `${value}°C`} + /> + + } + /> + )} + + {showStep(1) && ( + + + + + } + /> + )} + + {showStep(1) && ( + + } + /> + )} + + {showStep(1) && ( + ( + + + + )} + renderOptionContent={(option) => ( + )} /> } /> )} - {showStep(2) && watchedType === "http" && ( - - ( + {showStep(2) && + (watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( + + } + /> + )} + + {showStep(2) && watchedType === "http" && ( + + { + // HEAD has no response body, reset advanced matching fields if selected + if (value === "HEAD") { + setValue("useAdvancedMatching", false); + setValue("matchMethod", DefaultMonitorMatchMethod); + setValue("expectedValue", ""); + setValue("jsonPath", ""); + } + }} + options={httpMethodOptions} + /> + + {t( + `pages.createMonitor.form.advanced.option.method.description.${watchedMethod}` + )} + + + + + {watchedMethod !== "HEAD" && ( + + )} + {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - + fallbackValue={DefaultMonitorMatchMethod} + options={matchMethodOptions} + /> + + + + + - {t( - `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` - )} - - - )} - /> - { - const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => - field.value?.includes(option.id) - ); - return ( - - option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={( - _: unknown, - newValue: typeof ALL_HTTP_STATUS_CODES - ) => { - field.onChange(newValue?.map((v) => v.id) || []); + + ), }} - fieldLabel={t( - "pages.createMonitor.form.advanced.option.customUpCodes.label" - )} /> - - {t( - "pages.createMonitor.form.advanced.option.customUpCodes.description" - )} - - {selectedOptions.length > 0 && ( - - {selectedOptions.map((option, index) => ( - - {option.name} - { - field.onChange( - field.value?.filter((id) => id !== option.id) - ); - }} - aria-label={t( - "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" - )} - > - - - {index < selectedOptions.length - 1 && } - - ))} - - )} - - ); - }} - /> - {watchedMethod !== "HEAD" && ( - ( - - field.onChange(e.target.checked)} - /> - - {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" - )} - - - )} - /> - )} - {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - - ), - }} - /> - - - )} - - } - /> - )} - - {showStep(2) && supportsGeoCheck(watchedType) && ( - - ( - - field.onChange(e.target.checked)} - /> - - {t("pages.createMonitor.form.geoChecks.option.enabled.label")} )} - /> - {watchGeoCheckEnabled && ( - - { - // Map continents to have 'name' property for Autocomplete - const locationOptions = GeoContinents.map((continent) => ({ - id: continent, - name: t( - `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` - ), - })); - const selectedLocations = locationOptions.filter((loc) => - (field.value ?? []).includes(loc.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof locationOptions) => { - field.onChange(newValue.map((loc) => loc.id)); - }} - isOptionEqualToValue={(option, value) => - option.id === value.id - } - fieldLabel={t( - "pages.createMonitor.form.geoChecks.option.locations.label" - )} - /> - {selectedLocations.length > 0 && ( - - {selectedLocations.map((location, index) => ( - - {location.name} - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== location.id - ) - ); - }} - aria-label="Remove location" - > - - - {index < selectedLocations.length - 1 && } - - ))} - - )} - - ); - }} - /> - ( - - )} - /> - - )} - - } - /> - )} + + } + /> + )} - - {!isEditMode && ( - + {showStep(2) && supportsGeoCheck(watchedType) && ( + + + {watchGeoCheckEnabled && ( + + + + + )} + + } + /> )} - {!isEditMode && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + + {!isEditMode && ( + + )} + {!isEditMode && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + + + ); }; diff --git a/client/src/Pages/Incidents/utils.ts b/client/src/Pages/Incidents/utils.ts index 6ff518a18..3407c40b4 100644 --- a/client/src/Pages/Incidents/utils.ts +++ b/client/src/Pages/Incidents/utils.ts @@ -1,5 +1,5 @@ import type { Incident, IncidentSummaryItem } from "@/Types/Incident"; -import prettyMilliseconds from "pretty-ms"; +import { formatDuration } from "@/Utils/TimeUtils"; type IncidentLike = Pick; @@ -24,8 +24,5 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte return "-"; } - return prettyMilliseconds(durationMs, { - secondsDecimalDigits: 0, - millisecondsDecimalDigits: 0, - }); + return formatDuration(durationMs); }; diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx index ebfdad569..70f4325c6 100644 --- a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx @@ -11,7 +11,8 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { const { t } = useTranslation(); const theme = useTheme(); - const latestCheck = monitor?.recentChecks?.[0]; + const recentChecks = monitor?.recentChecks ?? []; + const latestCheck = recentChecks[recentChecks.length - 1]; // Get data from latest check const physicalCores = getCores(latestCheck?.cpu?.physical_core); const logicalCores = getCores(latestCheck?.cpu?.logical_core); @@ -21,13 +22,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; const diskTotalBytes = latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0; - const os = getOsAndPlatform(latestCheck?.host); - - const platform = latestCheck?.host?.platform ?? undefined; - const osPlatform = - typeof os === "undefined" && typeof platform === "undefined" - ? undefined - : `${os} ${platform}`; + const osPlatform = getOsAndPlatform(latestCheck?.host); return ( - + { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const cpuUsage = (check?.cpu?.usage_percent || 0) * 100; return ; }, @@ -257,7 +258,8 @@ export const InfraMonitorsTable = ({ id: "memory", content: t("pages.infrastructure.table.headers.memory"), render: (row) => { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const memoryUsage = (check?.memory?.usage_percent || 0) * 100; return ; }, @@ -266,7 +268,8 @@ export const InfraMonitorsTable = ({ id: "disk", content: t("pages.infrastructure.table.headers.disk"), render: (row) => { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const totalDiskUsage = check?.disk?.reduce( (acc, disk) => acc + (disk?.usage_percent || 0), diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 9fe04fa9d..d76aa7443 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -2,10 +2,10 @@ import Box from "@mui/material/Box"; import { StatBox } from "@/Components/design-elements"; import prettyBytes from "pretty-bytes"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; +import { formatDuration, formatMs } from "@/Utils/TimeUtils"; interface StatsProps { diagnostics: Diagnostics | null; @@ -18,13 +18,9 @@ export const Stats = ({ diagnostics }: StatsProps) => { const theme = useTheme(); const eventLoopDelay = diagnostics - ? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, { - millisecondsDecimalDigits: 2, - }) - : PLACEHOLDER; - const uptime = diagnostics - ? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true }) + ? formatMs(diagnostics.eventLoopDelayMs ?? 0) : PLACEHOLDER; + const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER; const usedHeap = diagnostics ? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0) : PLACEHOLDER; @@ -37,10 +33,10 @@ export const Stats = ({ diagnostics }: StatsProps) => { return ( *": { width: "100% !important" }, }} > diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index 26fe62bb3..c8c287a9e 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -1,7 +1,6 @@ import { Table, Pagination } from "@/Components/design-elements"; import { Typography, useTheme } from "@mui/material"; -import prettyMilliseconds from "pretty-ms"; -import { formatTimestamp } from "@/Utils/TimeUtils"; +import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; @@ -84,7 +83,7 @@ export const TableJobs = ({ id: "interval", content: t("common.table.headers.interval"), render: (row) => ( - {prettyMilliseconds(row.monitorInterval ?? 0)} + {formatDuration(row.monitorInterval ?? 0)} ), }, { diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx index 6dadb5a9c..7cd543007 100644 --- a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -4,7 +4,6 @@ import { Pagination } from "@/Components/design-elements/Table"; import { ActionsMenu } from "@/Components/actions-menu"; import { DialogInput } from "@/Components/inputs/Dialog"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import type { Header } from "@/Components/design-elements/Table"; import type { ActionMenuItem } from "@/Components/actions-menu"; @@ -18,6 +17,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice"; import dayjs from "dayjs"; import { useState } from "react"; import { useDelete, usePatch } from "@/Hooks/UseApi"; +import { formatDuration } from "@/Utils/TimeUtils"; interface MaintenanceWindowTableProps { maintenanceWindows: MaintenanceWindow[]; @@ -51,7 +51,7 @@ const getTimeToNextWindow = ( // Window is in the future if (start.isAfter(now)) { - return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true }); + return formatDuration(start.diff(now)); } return "N/A"; @@ -152,9 +152,7 @@ export const MaintenanceWindowTable = ({ id: "repeat", content: t("pages.maintenanceWindow.table.headers.repeat"), render: (row) => - row.repeat === 0 - ? t("common.labels.na") - : prettyMilliseconds(row.repeat, { verbose: true }), + row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat), }, { id: "actions", diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 20a0b461e..0372fe749 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -1,18 +1,8 @@ import Stack from "@mui/material/Stack"; -import MenuItem from "@mui/material/MenuItem"; import { logger } from "@/Utils/logger"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { - TextField, - Select, - DatePicker, - TimePicker, - Button, - Autocomplete, -} from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -25,10 +15,17 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { mutate } from "swr"; import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Trash2 } from "lucide-react"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { + FormDatePickerField, + FormTimePickerField, +} from "@/Components/inputs/forms/FormDateTimePickerField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); @@ -57,7 +54,7 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control, handleSubmit, trigger } = form; + const { handleSubmit, trigger } = form; useEffect(() => { if (existingMaintenanceWindow) { form.reset(defaults); @@ -114,255 +111,117 @@ const CreateMaintenanceWindowPage = () => { }; return ( - - - ( - - )} - /> - ( - - )} - /> - - } - /> - - ( - { - field.onChange(date ? date.format("YYYY-MM-DD") : ""); - trigger("startDate"); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - } - /> - - ( - { - field.onChange(time ? time.format("HH:mm") : ""); - trigger("startDate"); - }} - /> - )} - /> - - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} - sx={{ width: 120 }} - /> + + + + - ( - - )} + ({ + value: option.id, + label: option.name, + }))} /> - - } - /> - { - const monitorsList = monitors ?? []; - const selectedMonitors = field.value - .map((id: string) => monitorsList.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - {selectedMonitors.map((monitor) => ( - - {monitor.name} - { - field.onChange( - field.value.filter((id: string) => id !== monitor.id) - ); - }} - aria-label="Remove monitor" - > - - - - ))} - + } + /> + + trigger("startDate")} + /> + + } + /> + + trigger("startDate")} + /> + + - ); - }} - /> - } - /> - - - - + + + + ); }; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 84d264616..8063f16b6 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -1,14 +1,12 @@ import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { TextField, Select, Button } from "@/Components/inputs"; -import MenuItem from "@mui/material/MenuItem"; -import Typography from "@mui/material/Typography"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; -import { Controller, useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; @@ -16,6 +14,8 @@ import type { NotificationFormData } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; import { useTranslation } from "react-i18next"; import { NotificationChannels } from "@/Types/Notification"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; const NotificationsCreatePage = () => { const { t } = useTranslation(); @@ -39,7 +39,7 @@ const NotificationsCreatePage = () => { defaultValues: defaults, }); - const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; + const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; useEffect(() => { reset(defaults); @@ -93,384 +93,195 @@ const NotificationsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - {watchedType !== "matrix" && - watchedType !== "telegram" && - watchedType !== "pushover" && - watchedType !== "twilio" && - watchedType !== "ntfy" && ( + + + + } + /> + ({ + value: channel, + label: t(`pages.notifications.form.type.value.${channel}`), + }))} + /> + } + /> + {watchedType !== "matrix" && + watchedType !== "telegram" && + watchedType !== "pushover" && + watchedType !== "twilio" && + watchedType !== "ntfy" && ( + + } + /> + )} + {watchedType === "ntfy" && ( ( - - )} - /> + + + + } /> )} - {watchedType === "ntfy" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "telegram" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "pushover" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "twilio" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "matrix" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - - - - - + + + + + ); }; diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index 48a9ba99c..ea9da4a7f 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -45,6 +45,8 @@ const PageSpeedDetails = () => { const monitor = monitorData?.monitorData?.monitor; const groupedChecks = monitorData?.monitorData?.groupedChecks || []; const monitorStats = monitorData?.monitorStats || null; + const recentChecks = monitor?.recentChecks ?? []; + const latestCheck = recentChecks[recentChecks.length - 1]; return ( { direction={{ xs: "column", md: "row" }} gap={theme.spacing(10)} > - - + + ); diff --git a/client/src/Pages/Proxies/components/ProxiesTable.tsx b/client/src/Pages/Proxies/components/ProxiesTable.tsx new file mode 100644 index 000000000..f2ae148e3 --- /dev/null +++ b/client/src/Pages/Proxies/components/ProxiesTable.tsx @@ -0,0 +1,103 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/design-elements/Table"; +import { Table } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import { useClientPagination } from "@/Hooks/useClientPagination"; + +import type { ProxyResponse } from "@/Types/Proxy"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface ProxiesTableProps { + proxies: ProxyResponse[]; + setSelectedProxy: Function; +} + +export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + const { pagedRows, paginationProps } = useClientPagination(proxies); + + const getActions = (proxy: ProxyResponse): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/proxies/configure/${proxy.id}`); + }, + closeMenu: true, + }, + + { + id: 2, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedProxy(proxy); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.name}; + }, + }, + { + id: "protocol", + content: t("pages.proxies.table.headers.protocol"), + render: (row) => { + return {row?.protocol}; + }, + }, + { + id: "host", + content: t("pages.proxies.table.headers.host"), + render: (row) => { + return {row?.host}; + }, + }, + { + id: "port", + content: t("pages.proxies.table.headers.port"), + render: (row) => { + return {row?.port}; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + return ( + <> + { + navigate(`/proxies/configure/${row.id}`); + }} + /> + {proxies.length > 0 && } + + ); +}; diff --git a/client/src/Pages/Proxies/create/index.tsx b/client/src/Pages/Proxies/create/index.tsx new file mode 100644 index 000000000..2783c93a9 --- /dev/null +++ b/client/src/Pages/Proxies/create/index.tsx @@ -0,0 +1,213 @@ +import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Button, FieldLabel } from "@/Components/inputs"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; + +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { mutate } from "swr"; +import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import type { ProxyFormData } from "@/Validation/proxy"; +import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy"; +import { useTranslation } from "react-i18next"; +import { useProxiesForm } from "@/Hooks/useProxiesForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { useMemo } from "react"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +const ProxiesCreatePage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + const { proxyId } = useParams<{ proxyId: string }>(); + const isEditMode = Boolean(proxyId); + + const { data: existingProxy } = useGet( + isEditMode ? `/proxies/${proxyId}` : null + ); + + const { post, loading: isSubmitting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + + const { schema, defaults } = useProxiesForm({ data: existingProxy }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { reset, handleSubmit } = form; + + const isPasswordSet = existingProxy?.hasPassword ?? false; + const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false); + + const handleResetPassword = () => { + form.setValue("password", ""); + setPasswordHasBeenReset(true); + }; + + const isUsernameSet = Boolean(existingProxy?.username); + const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false); + + const handleResetUsername = () => { + form.setValue("username", ""); + setUsernameHasBeenReset(true); + }; + + const protocolOptions = useMemo( + () => + ProxyProtocols.map((protocol) => ({ + value: protocol, + label: protocol, + })), + [] + ); + + useEffect(() => { + reset(defaults); + }, [defaults, reset]); + + const onSubmit = async (data: ProxyFormData) => { + const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } = + { + ...data, + }; + // Don't send password/username if one is set and the user hasn't clicked reset + if (isPasswordSet && !passwordHasBeenReset) { + delete payload.password; + } else if (passwordHasBeenReset && !payload.password) { + payload.clearPassword = true; + } + if (isUsernameSet && !usernameHasBeenReset) { + delete payload.username; + } else if (usernameHasBeenReset && !payload.username) { + payload.clearUsername = true; + } + const result = isEditMode + ? await patch(`/proxies/${proxyId}`, payload) + : await post("/proxies", payload); + if (result?.success) { + await mutate((key) => typeof key === "string" && key.startsWith("/proxies")); + navigate("/proxies"); + } + }; + + return ( + + + + } + /> + + + + + + } + /> + + + {(isUsernameSet === false || usernameHasBeenReset === true) && ( + + )} + + {isUsernameSet === true && usernameHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.username.labelSet")} + + + + )} + + {(isPasswordSet === false || passwordHasBeenReset === true) && ( + + )} + + {isPasswordSet === true && passwordHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.password.labelSet")} + + + + )} + + } + /> + + + + + + + ); +}; + +export default ProxiesCreatePage; diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx new file mode 100644 index 000000000..5370543dc --- /dev/null +++ b/client/src/Pages/Proxies/index.tsx @@ -0,0 +1,72 @@ +import { BasePageWithStates } from "@/Components/design-elements"; +import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; + +import { useState } from "react"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; +import type { ProxyResponse } from "@/Types/Proxy"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +const ProxiesPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const [selectedProxy, setSelectedProxy] = useState(null); + const isDialogOpen = Boolean(selectedProxy); + + const { + data: proxies, + isLoading, + isValidating, + error, + refetch, + } = useGet("/proxies/team", {}, { keepPreviousData: true }); + + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedProxy) return; + await deleteFn(`/proxies/${selectedProxy.id}`); + setSelectedProxy(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedProxy(null); + }; + + return ( + + + + + + ); +}; + +export default ProxiesPage; diff --git a/client/src/Pages/Settings/DummyChart.tsx b/client/src/Pages/Settings/DummyChart.tsx index 47b3e45ea..78a05ed5f 100644 --- a/client/src/Pages/Settings/DummyChart.tsx +++ b/client/src/Pages/Settings/DummyChart.tsx @@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => { const checks: CheckSnapshot[] = []; for (let i = 0; i < MAX_RECENT_CHECKS; i++) { const isUp = Math.random() > 0.1; - const responseTime = Math.floor(Math.random() * 80) + 20; + const responseTime = Math.floor(Math.random() * 800) + 20; checks.push({ id: `dummy-${i}`, status: isUp, statusCode: isUp ? 200 : 500, responseTime: responseTime, - originalResponseTime: responseTime, message: "", createdAt: new Date(Date.now() - i * 60000).toISOString(), }); diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e14366e0f..70a8e1bb6 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; -import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs"; +import { Autocomplete, Select, Dialog } from "@/Components/inputs"; import { logger } from "@/Utils/logger"; import { LAYOUT } from "@/Utils/Theme/constants"; import { @@ -10,8 +10,8 @@ import { Alert, type SelectChangeEvent, } from "@mui/material"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useEffect, useMemo } from "react"; +import { FormProvider, useForm, type FieldPath } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; @@ -22,8 +22,7 @@ import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"; import { useState } from "react"; -import { Controller } from "react-hook-form"; -import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; +import { Button, FieldLabel } from "@/Components/inputs"; import { languageNames } from "@/Components/inputs/LanguageSelector"; import { Box, Typography } from "@mui/material"; import { useDelete } from "@/Hooks/UseApi"; @@ -36,20 +35,17 @@ import { type ThemeMode, type ChartType, } from "@/Features/UI/uiSlice.js"; -import timezones from "@/Utils/timezones.json"; +import { timezoneOptions } from "@/Utils/timezoneOptions"; +import type { TimezoneOption } from "@/Utils/timezoneOptions"; import type { RootState } from "@/Types/state"; import { CHECK_TTL_SENTINEL } from "@/Types/Check"; - -interface Timezone { - id: string; - name: string; -} - -interface SettingsResponse { - settings: any; - pagespeedKeySet: boolean; - emailPasswordSet: boolean; -} +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import type { ProxyResponse } from "@/Types/Proxy"; +import type { AppSettingsResponse } from "@/Types/Settings"; export const SettingsPage = () => { const theme = useTheme(); @@ -65,9 +61,20 @@ export const SettingsPage = () => { const { post: importMonitors, loading: isImportingMonitors } = usePost(); // Fetch settings data from API - const { data: fetchedSettings } = useGet("/settings"); + const { data: fetchedSettings } = useGet("/settings"); + // Get proxies + const { data: proxies } = useGet("/proxies"); + const proxyOptions = useMemo( + () => + (proxies ?? []).map((proxy) => ({ + value: proxy.id, + label: `${proxy.name} (${proxy.host}:${proxy.port})`, + })), + [proxies] + ); + // Form submission - const { patch, loading: isSaving } = usePatch(); + const { patch, loading: isSaving } = usePatch(); // Local state for API key reset const [isApiKeySet, setIsApiKeySet] = useState( @@ -90,12 +97,34 @@ export const SettingsPage = () => { // Initialize form with schema and defaults const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); + const emailSwitches: { name: FieldPath; label: string }[] = [ + { + name: "systemEmailSecure", + label: t("pages.settings.form.email.option.secure.label"), + }, + { name: "systemEmailPool", label: t("pages.settings.form.email.option.pool.label") }, + { + name: "systemEmailIgnoreTLS", + label: t("pages.settings.form.email.option.ignoreTLS.label"), + }, + { + name: "systemEmailRequireTLS", + label: t("pages.settings.form.email.option.requireTLS.label"), + }, + { + name: "systemEmailRejectUnauthorized", + label: t("pages.settings.form.email.option.rejectUnauthorized.label"), + }, + ]; + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, mode: "onChange", }); + const { watch } = form; + // Reset form when defaults change useEffect(() => { form.reset(defaults); @@ -118,16 +147,10 @@ export const SettingsPage = () => { const user = useSelector((state: RootState) => state.auth.user); - // Convert timezones to match AutoComplete format (id instead of _id) - const timezoneOptions: Timezone[] = timezones.map((tz) => ({ - id: tz._id, - name: tz.name, - })); - const selectedTimezone = timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; - const handleTimezoneChange = (newValue: Timezone | null) => { + const handleTimezoneChange = (newValue: TimezoneOption | null) => { if (!newValue?.id) return; dispatch(setTimezone({ timezone: newValue.id })); }; @@ -279,348 +302,281 @@ export const SettingsPage = () => { const languages = Object.keys(i18n.options.resources || {}); + const globalProxyEnabled = watch("globalProxyEnabled"); + + // Enabling the proxy with nothing selected preselects the first proxy + useEffect(() => { + if ( + globalProxyEnabled && + !form.getValues("globalProxyId") && + proxyOptions.length > 0 + ) { + form.setValue("globalProxyId", proxyOptions[0].value, { + shouldValidate: true, + }); + } + }, [globalProxyEnabled, proxyOptions, form]); + return ( - - - option.name} - isOptionEqualToValue={(option: Timezone, value: Timezone) => - option.id === value.id - } - onChange={(_, newValue) => { - handleTimezoneChange(newValue as Timezone | null); - }} - fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} - /> - } - /> - - - - - - - } - /> - {isAdmin && ( + + + - {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( - ( - - )} - /> - )} - - {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - - {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} - - - - )} - - } - /> - )} - - {/* URL Settings */} - ( - - )} - /> - } - /> - - {/* Clear All Stats */} - {isAdmin && ( - setIsStatsDialogOpen(true)} - > - {t("common.buttons.clear")} - - } - /> - )} - - {/* Check Retention */} - {isAdmin && ( - ( - field.onChange(value)} - valueLabelDisplay="auto" - valueLabelFormat={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - formatDisplayValue={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - /> - )} + option.name} + isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) => + option.id === value.id + } + onChange={(_, newValue: TimezoneOption | null) => { + handleTimezoneChange(newValue); + }} + fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} /> } /> - )} - - {/* Global Thresholds */} - {isAdmin && ( - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> + + + + + } /> - )} - - - {/* Email Settings - Admin Only */} - {isAdmin && ( - - - {(() => { - const address = form.watch("systemEmailAddress") || ""; - const displayName = form.watch("systemEmailDisplayName")?.trim(); - return ( - <> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", - }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 + {isAdmin && ( + 0 ? ( + + + {globalProxyEnabled && ( + + name="globalProxyId" + options={proxyOptions} + /> + )} + + ) : ( + + ) + } + /> + )} + {isAdmin && ( + + {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( + + )} + + {isApiKeySet === true && apiKeyHasBeenReset === false && ( + + + {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} + + - {address && ( + )} + + } + /> + )} + + {/* URL Settings */} + + } + /> + + {/* Clear All Stats */} + {isAdmin && ( + setIsStatsDialogOpen(true)} + > + {t("common.buttons.clear")} + + } + /> + )} + + {/* Check Retention */} + {isAdmin && ( + + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + formatDisplayValue={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + /> + } + /> + )} + + {/* Global Thresholds */} + {isAdmin && ( + + + + + + + + } + /> + )} + + + {/* Email Settings - Admin Only */} + {isAdmin && ( + + + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + return ( + <> { }} > - {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: + form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} - )} - - ); - })()} - - } - rightContent={ - - {/* Email Host */} - ( - - )} - /> - - {/* Email Port */} - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - fieldLabel={t("pages.settings.form.email.option.port.label")} - type="number" - inputProps={{ min: 0 }} - placeholder={t("pages.settings.form.email.option.port.placeholder")} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - {/* Email Address */} - ( - - )} - /> - - {/* Email Display Name (Optional) */} - ( - - )} - /> - - {/* Email User (Optional) */} - ( - - )} - /> - - {/* Email Password with Reset Pattern */} - {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( - - - {t("pages.settings.form.email.option.password.labelSet")} - - - - - - ) : ( - ( - + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + )} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> + + ); + })()} + + } + rightContent={ + + {/* Email Host */} + + + {/* Email Port */} + + + {/* Email Address */} + + + {/* Email Display Name (Optional) */} + - )} - {/* TLS Servername (Optional) */} - ( - - )} - /> + {/* Email User (Optional) */} + - {/* Connection Host (Optional) */} - ( - - )} - /> - - {/* Boolean Switches */} - - {[ - { - name: "systemEmailSecure", - label: t("pages.settings.form.email.option.secure.label"), - }, - { - name: "systemEmailPool", - label: t("pages.settings.form.email.option.pool.label"), - }, - { - name: "systemEmailIgnoreTLS", - label: t("pages.settings.form.email.option.ignoreTLS.label"), - }, - { - name: "systemEmailRequireTLS", - label: t("pages.settings.form.email.option.requireTLS.label"), - }, - { - name: "systemEmailRejectUnauthorized", - label: t("pages.settings.form.email.option.rejectUnauthorized.label"), - }, - ].map(({ name, label }) => ( - ( - + + {t("pages.settings.form.email.option.password.labelSet")} + + + + + + ) : ( + - ))} - + )} - {/* Test Email Button */} + {/* TLS Servername (Optional) */} + + + {/* Connection Host (Optional) */} + + + {/* Boolean Switches */} + + {emailSwitches.map(({ name, label }) => ( + + ))} + + + {/* Test Email Button */} + + + + + } + /> + )} + + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + - - } - /> - )} - - {/* Demo Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Export Monitors - Admin Only */} - {isAdmin && ( - - - - - - } - /> - )} - - {/* About */} - - - {t("common.appName")} {__APP_VERSION__} - - - {t("pages.settings.form.about.developedBy")} - - - https://github.com/bluewave-labs/checkmate - - - } - /> - - {/* Clear Stats Confirmation Dialog */} - setIsStatsDialogOpen(false)} - onConfirm={handleClearStats} - loading={isDeletingStats} - /> - - {/* Delete All Monitors Confirmation Dialog */} - setIsDemoMonitorsDialogOpen(false)} - onConfirm={async () => { - await deleteAllMonitors("/monitors/"); - setIsDemoMonitorsDialogOpen(false); - }} - loading={isDeletingAllMonitors} - confirmColor="error" - confirmText={t("common.buttons.removeMonitors")} - > - - {t("pages.settings.form.removeMonitors.dialog.paragraph")} - - - - - {/* Validation Error Display */} - {Object.keys(form.formState.errors).length > 0 && ( - - - {t("pages.settings.form.validation.errorMessage")} - - - {Object.entries(form.formState.errors).map(([field, error]) => { - const message = - typeof error === "object" && error?.message - ? error.message - : "Invalid value"; - return ( -
  • - - {field}: {message} - -
  • - ); - })} -
    -
    + } + /> )} - + + } + /> + )} + + {/* Export Monitors - Admin Only */} + {isAdmin && ( + + + + +
    + } + /> + )} + + {/* About */} + + + {t("common.appName")} {__APP_VERSION__} + + + {t("pages.settings.form.about.developedBy")} + + + https://github.com/bluewave-labs/checkmate + + + } + /> + + {/* Clear Stats Confirmation Dialog */} + setIsStatsDialogOpen(false)} + onConfirm={handleClearStats} + loading={isDeletingStats} + /> + + {/* Delete All Monitors Confirmation Dialog */} + setIsDemoMonitorsDialogOpen(false)} + onConfirm={async () => { + await deleteAllMonitors("/monitors/"); + setIsDemoMonitorsDialogOpen(false); + }} + loading={isDeletingAllMonitors} + confirmColor="error" + confirmText={t("common.buttons.removeMonitors")} > - {t("common.buttons.save")} - - -
    + + {t("pages.settings.form.removeMonitors.dialog.paragraph")} + + + + + {/* Validation Error Display */} + {Object.keys(form.formState.errors).length > 0 && ( + + + {t("pages.settings.form.validation.errorMessage")} + + + {Object.entries(form.formState.errors).map(([field, error]) => { + const message = + typeof error === "object" && error?.message + ? error.message + : "Invalid value"; + return ( +
  • + + {field}: {message} + +
  • + ); + })} +
    +
    + )} + + +
    +
    + ); }; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8dd119b72..e3b82f554 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,56 +1,81 @@ -import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements"; +import { + BasePage, + ColoredLabel, + ConfigBox, + StepProgress, +} from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import { logger } from "@/Utils/logger"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import { Trash2, GripVertical } from "lucide-react"; -import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import type { DropResult } from "@hello-pangea/dnd"; -import { - ImageUpload, - SwitchComponent, - Button, - TextField, - Autocomplete, - Checkbox, - Dialog, - ColorInput, -} from "@/Components/inputs"; +import { ImageUpload, Button, Dialog } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider, useController } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; +import { SelectableMonitorTypes } from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import timezones from "@/Utils/timezones.json"; +import { timezoneOptions } from "@/Utils/timezoneOptions"; import { useNavigate, useParams } from "react-router-dom"; import { mutate } from "swr"; -import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { ThemePicker } from "./Components/ThemePicker"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField"; +import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; +const ThemePickerField = () => { + const { field: themeField } = useController({ + name: "theme", + }); + const { field: modeField } = useController({ + name: "themeMode", + }); + return ( + + ); +}; +const LogoUploadField = () => { + const { field } = useController({ name: "logo" }); + return ( + { + field.onChange( + file ? { data: file.src, contentType: file.file.type, file: file.file } : null + ); + }} + /> + ); +}; + +// Derived from SelectableMonitorTypes so a newly supported monitor type is +// offered here automatically; the previous hardcoded list silently omitted DNS. +// "hardware" is appended because infrastructure monitors are created on their +// own page and so are absent from the selectable set. const monitorsUrl = (() => { const params = new URLSearchParams(); - ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach( - (type) => params.append("type", type) - ); + [...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type)); return `/monitors/team?${params.toString()}`; })(); -interface TimezoneOption { - _id: string; - name: string; -} - const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; @@ -67,7 +92,9 @@ const CreateStatusPage = () => { useGet(isCreate ? null : buildStatusPageKey(url)); const { data: monitorsResponse } = useGet(monitorsUrl); - const monitors = monitorsResponse ?? []; + const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]); + const { data: tagsResponse } = useGet("/tags/team"); + const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]); const { post, loading: isSubmittingPost } = usePost(); const { put, loading: isSubmittingPut } = usePut(); @@ -87,7 +114,7 @@ const CreateStatusPage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit, trigger } = form; + const { reset, handleSubmit, trigger } = form; // Reset form when defaults change (from fetched data) useEffect(() => { @@ -108,7 +135,7 @@ const CreateStatusPage = () => { }; const handleBack = () => setCurrentStep((step) => step - 1); - const watchedMonitorIds: string[] = form.watch("monitors") ?? []; + const watchedMonitorIds: string[] = form.watch("monitors"); const computedTypes: MonitorDisplayType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) .map((id) => monitors.find((m) => m.id === id)) @@ -124,7 +151,7 @@ const CreateStatusPage = () => { useEffect(() => { form.setValue("type", computedTypes); - }, [computedTypes]); + }, [computedTypes, form]); const onError = (errors: any) => { logger.debug("Status page validation errors", errors); @@ -178,18 +205,8 @@ const CreateStatusPage = () => { if (data.logo === null) { // Signal to remove the logo fd.append("removeLogo", "true"); - } else if (data.logo?.data && data.logo.data !== "") { - if (data.logo.data.startsWith("blob:")) { - try { - const imageResult = await axios.get(data.logo.data, { - responseType: "blob", - }); - fd.append("logo", imageResult.data); - URL.revokeObjectURL(data.logo.data); - } catch (e) { - logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined); - } - } + } else if (data.logo?.file) { + fd.append("logo", data.logo.file); } let result; @@ -216,470 +233,249 @@ const CreateStatusPage = () => { return Loading...; } return ( - - {!isCreate && } - {isCreate && ( - - )} - {showStep(0) && ( - - + + {!isCreate && } + {isCreate && ( + + )} + {showStep(0) && ( + ( - field.onChange(e.target.checked)} - /> - )} + label={t("pages.statusPages.form.access.option.published.name")} /> - - {t("pages.statusPages.form.access.option.published.name")} - - - } - /> - )} - {showStep(0) && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {showStep(0) && ( - { - const selectedMonitors = field.value - .map((id: string) => monitors.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - const handleDragEnd = (result: DropResult) => { - if (!result.destination) return; - const reordered = Array.from(field.value); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); - }; - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.statusPages.form.monitors.option.monitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - - {(provided) => ( - - {selectedMonitors.map((monitor, index) => ( - - {(provided) => ( - - - {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} - { - field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) - ); - }} - aria-label="Remove monitor" - > - - - - )} - - ))} - {provided.placeholder} - - )} - - - )} - - ); - }} - /> - } - /> - )} - {showStep(0) && ( - ( - option.name} - value={ - timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null - } - onChange={(_, newValue: TimezoneOption | null) => { - field.onChange(newValue?._id ?? ""); - }} - fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} - renderInput={(params) => ( - + } + /> + )} + {showStep(0) && ( + + - )} - /> - } - /> - )} - {showStep(1) && ( - - - ( - { - if (file) { - field.onChange({ - data: file.src, - contentType: file.file.type, - }); - } else { - field.onChange(null); - } - }} - /> + + + - ( - + } + /> + )} + {showStep(0) && ( + ( + + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + + {monitor.tags.map((tagId) => { + const tag = tags.find(({ id }) => id === tagId); + return tag ? ( + + ) : null; + })} + + )} + renderRow={(monitor) => ( + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + )} /> - - } - /> - )} - {showStep(1) && ( - ( - ( - + } + /> + )} + {showStep(0) && ( + + } + /> + )} + {showStep(1) && ( + + + + + + + } + /> + )} + {showStep(1) && ( + } + /> + )} + {showStep(1) && ( + + } + /> + )} + {showStep(1) && ( + + + - )} - /> - } - /> - )} - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - {showStep(1) && ( - - ( - - } - label={t("pages.statusPages.form.features.option.showCharts.label")} - /> - )} - /> - ( - - } - label={t( - "pages.statusPages.form.features.option.showInfrastructure.label" - )} - /> - )} - /> - {/* ( - - } + {/* + - )} - /> - ( - - } + + - )} - /> */} - - } + /> */} + + } + /> + )} + + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + - )} - - {isCreate && ( - - )} - {isCreate && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + ); }; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 3c0408628..935cfd2b3 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,6 +1,6 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; @@ -9,7 +9,11 @@ import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; -import { resolveStatusPageTheme } from "@/Types/StatusPage"; +import { + isStatusPageRange, + resolveStatusPageTheme, + type StatusPageRange, +} from "@/Types/StatusPage"; import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPageResponse, @@ -80,21 +84,37 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const onRangeChange = (next: StatusPageRange) => { + const updated = new URLSearchParams(searchParams); + if (next === "latest") { + updated.delete("range"); + } else { + updated.set("range", next); + } + setSearchParams(updated, { replace: true }); + }; const onCustomDomainHost = isCustomDomainHost(); const isPublic = onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); + const rawRange = searchParams.get("range"); + const range = isStatusPageRange(rawRange) ? rawRange : "latest"; + const apiUrl = buildStatusPageApiPath({ url, useCustomDomain: onCustomDomainHost, + range, }); const { data, isLoading, error } = useGet( apiUrl, {}, { - refreshInterval: 10000, + keepPreviousData: true, + refreshInterval: range === "latest" ? 10000 : 60000, } ); @@ -150,6 +170,10 @@ const StatusPageView = () => { statusPage={statusPage} monitors={monitors} config={themeConfig} + range={range} + onRangeChange={onRangeChange} + bucketTimezone={data.bucketTimezone ?? "Etc/UTC"} + checkTTLDays={data.checkTTLDays} /> ); diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index c4df4e207..86208d41b 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -4,8 +4,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type BoldBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type BoldHeatCell = HeatCellKind; +export type BoldBarKind = BarKind; export type BoldGaugeFill = "ok" | "warm" | "hot"; export interface BoldStyles { @@ -55,6 +57,7 @@ export const boldStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; @@ -62,12 +65,14 @@ export const boldStyles = ( fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`, + degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`, down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`, empty: "none", }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index 868ebf186..9de2c96fb 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -8,8 +8,10 @@ import { } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type EditorialBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type EditorialHeatCell = HeatCellKind; +export type EditorialBarKind = BarKind; export type EditorialGaugeFill = "ok" | "warm" | "hot"; export interface EditorialStyles { @@ -59,12 +61,14 @@ export const editorialStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index 2f730ee63..7bb108f51 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -5,8 +5,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type ModernBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type ModernHeatCell = HeatCellKind; +export type ModernBarKind = BarKind; export type ModernGaugeFill = "ok" | "warm" | "hot"; export interface ModernStyles { @@ -86,12 +88,14 @@ export const modernStyles = ( fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, + degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`, down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index 457b86ede..38f972c95 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -3,8 +3,10 @@ import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; -export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type RefinedBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type RefinedHeatCell = HeatCellKind; +export type RefinedBarKind = BarKind; export type RefinedGaugeFill = "ok" | "warm" | "hot"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; @@ -71,12 +73,14 @@ export const refinedStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 1dfc0bd1b..cf08ca62f 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -1,20 +1,19 @@ -import { useMemo, useState } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; +import Typography from "@mui/material/Typography"; +import { TriangleAlert } from "lucide-react"; + import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import type { StatusPage, StatusPageRange } from "@/Types/StatusPage"; +import { + getMonitorTypeLabel, + STATUS_PAGE_RANGE_DAYS, + STATUS_PAGE_RANGES, +} from "@/Types/StatusPage"; import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; -import { - ThemedHeatmap, - type HeatCellKind, -} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; -import { - ThemedHistogram, - type BarKind, -} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; +import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; +import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; import { ThemedInfrastructure, type GaugeFillLevel, @@ -26,10 +25,22 @@ import { resolveOverallStatus, statusBadgeKey, } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; -import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { formatPercentage } from "@/Utils/FormatUtils"; +import { + checksToCells, + dailyBucketsToCells, + type BarKind, + type HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Icon } from "@/Components/design-elements"; -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +type StatusPageMonitor = Monitor; export interface BaseStyles { page: SxProps; @@ -82,9 +93,22 @@ interface Props { statusPage: StatusPage; monitors: StatusPageMonitor[]; config: ThemeConfig; + range: StatusPageRange; + onRangeChange: (range: StatusPageRange) => void; + bucketTimezone: string; + checkTTLDays?: number; } -export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { +export const BaseStatusPage = ({ + statusPage, + monitors, + config, + range, + onRangeChange, + bucketTimezone, + checkTTLDays, +}: Props) => { + const theme = useTheme(); const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); const styles = useMemo( @@ -126,33 +150,75 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { /> {statusPage.showCharts && ( - - + + setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} + sx={styles.chartSwitch} + role="radiogroup" > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + - + + + {STATUS_PAGE_RANGES.map((value) => ( + onRangeChange(value)} + sx={styles.chartSwitchButton(range === value)} + > + {t(`pages.statusPages.range.${value}`)} + + ))} + + + {range !== "latest" && + checkTTLDays && + STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && ( + + + + {t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })} + + + )} + )} { const badgeTone = monitorBadgeTone(monitor.status); const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0); + const cells = + range === "latest" + ? checksToCells(monitor.recentChecks ?? []) + : dailyBucketsToCells( + monitor.dailyChecks ?? [], + STATUS_PAGE_RANGE_DAYS[range], + bucketTimezone, + t + ); + return ( { {showChart && (chartMode === "heatmap" ? ( ) : ( { + if (bucket.totalChecks === 0) return "empty"; + const upRatio = bucket.upChecks / bucket.totalChecks; + if (upRatio >= 1) return "up"; + if (upRatio < DAY_DOWN_THRESHOLD) return "down"; + return "degraded"; +}; + +export const classifyResponseTime = ( + status: boolean, + responseTime: number | undefined +): Exclude => { + if (!status) return "down"; + const rt = responseTime ?? 0; + if (rt > 500) return "slow"; + if (rt > 250) return "med"; + return "fast"; +}; + +const emptyCell = (key: string): ChartCell => ({ + key, + barKind: "empty", + heatKind: "empty", + heightPct: MIN_HEIGHT_PCT, + responseTime: 0, + tooltip: null, + ariaLabel: "", +}); + +const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ + ...cells, + ...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) => + emptyCell(`empty-${cells.length + i}`) + ), +]; + +export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => { + const source = checks.slice(-MAX_RECENT_CHECKS); + const heights = computeBarHeights(source); + const cells = source.map( + (check, i): ChartCell => ({ + key: check.id ?? String(i), + barKind: check.status ? "up" : "down", + heatKind: classifyResponseTime(check.status, check.responseTime), + heightPct: heights[i] ?? MIN_HEIGHT_PCT, + responseTime: check.responseTime ?? 0, + tooltip: , + ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`, + }) + ); + return padCells(cells, MAX_RECENT_CHECKS); +}; + +export const dailyBucketsToCells = ( + buckets: DailyCheckBucket[], + days: number, + bucketTimezone: string, + t: TFunction +): ChartCell[] => { + const byDate = new Map(buckets.map((b) => [b.date, b])); + const heights = computeDayBarHeights(buckets); + const enumerated = enumerateDays(days, bucketTimezone); + return enumerated.map((date) => { + const bucket = byDate.get(date); + if (!bucket) return emptyCell(`day-${date}`); + const barKind = classifyDay(bucket); + return { + key: `day-${date}`, + barKind, + heatKind: + barKind === "up" + ? classifyResponseTime(true, bucket.avgResponseTime ?? 0) + : barKind, + heightPct: heights.get(date) ?? MIN_HEIGHT_PCT, + responseTime: bucket.avgResponseTime ?? 0, + tooltip: , + ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", { + date, + uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), + }), + }; + }); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx new file mode 100644 index 000000000..8f6550460 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -0,0 +1,64 @@ +import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; +import { formatPercentage } from "@/Utils/FormatUtils"; +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { useTranslation } from "react-i18next"; + +export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { + const { t } = useTranslation(); + const { timezone } = useStatusPageTheme(); + return ( + + + {check.status + ? formatMs(check.responseTime) + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} + + + ); +}; + +export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => { + const { t } = useTranslation(); + return ( + + + {bucket.date} + + + {t("pages.statusPages.monitorsList.chart.dayUptime", { + uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), + upChecks: bucket.upChecks.toLocaleString(), + totalChecks: bucket.totalChecks.toLocaleString(), + })} + + {bucket.avgResponseTime !== null && ( + + {t("pages.statusPages.monitorsList.chart.dayAvgResponse", { + value: formatMs(bucket.avgResponseTime), + })} + + )} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index fe56155c3..390f70667 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -1,86 +1,49 @@ import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import type { CheckSnapshot } from "@/Types/Check"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; - -const CELLS = MAX_RECENT_CHECKS; - -export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty"; +import type { + ChartCell, + HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; interface Props { - checks: CheckSnapshot[]; + cells: ChartCell[]; containerSx: SxProps; cellSx: (kind: HeatCellKind) => SxProps; } -const classify = (check: CheckSnapshot): Exclude => { - if (!check.status) return "down"; - const rt = check.responseTime ?? 0; - if (rt > 500) return "slow"; - if (rt > 250) return "med"; - return "fast"; -}; - -export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { +export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const { timezone } = useStatusPageTheme(); - - const source = checks.slice(-CELLS); - const padded: (CheckSnapshot | null)[] = [ - ...source, - ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), - ]; return ( - {padded.map((check, i) => { - if (!check) { + {cells.map((cell) => { + if (cell.heatKind === "empty") { return ( ); } - const kind = classify(check); - const tooltipContent = ( - - - {check.status - ? `${check.responseTime} ms` - : t("pages.statusPages.monitorsList.chart.downTooltip")} - - - {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} - - - ); return ( ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 9d06a99ac..3643d901c 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -2,101 +2,67 @@ import { useMemo } from "react"; import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import type { CheckSnapshot } from "@/Types/Check"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -const CELLS = MAX_RECENT_CHECKS; -const MIN_HEIGHT_PCT = 6; - -export type BarKind = "up" | "down" | "empty"; +import { formatMs } from "@/Utils/TimeUtils"; +import type { + BarKind, + ChartCell, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; interface Props { - checks: CheckSnapshot[]; + cells: ChartCell[]; containerSx: SxProps; barSx: (kind: BarKind, heightPct: number) => SxProps; statsSx: SxProps; statsGap?: number; } -const tone = (check: CheckSnapshot): Exclude => - check.status ? "up" : "down"; - export const ThemedHistogram = ({ - checks, + cells, containerSx, barSx, statsSx, statsGap = 1, }: Props) => { const { t } = useTranslation(); - const { timezone } = useStatusPageTheme(); - const { padded, max, avg, peak } = useMemo(() => { - const source = checks.slice(-CELLS); - const out: (CheckSnapshot | null)[] = [ - ...source, - ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), - ]; - const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); - const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; + const { avg, peak } = useMemo(() => { + const valid = cells.filter((cell) => cell.responseTime > 0); + const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0; const avgRt = valid.length - ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) + ? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length : 0; - return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 }; - }, [checks]); + return { avg: formatMs(avgRt), peak: formatMs(maxRt) }; + }, [cells]); return ( - - {padded.map((check, i) => { - if (!check) { + + {cells.map((cell) => { + if (cell.barKind === "empty") { return ( ); } - const height = Math.max( - MIN_HEIGHT_PCT, - Math.round((check.responseTime / max) * 100) - ); - const tooltipContent = ( - - - {check.status - ? `${check.responseTime} ms` - : t("pages.statusPages.monitorsList.chart.downTooltip")} - - - {formatDateWithTz( - check.createdAt, - "ddd, MMMM D, YYYY, HH:mm A", - timezone - )} - - - ); return ( ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx index 01804a322..3c92e6b0a 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx @@ -18,7 +18,7 @@ export interface InfraSx { } interface Props { - monitor: Monitor & { checks?: Monitor["recentChecks"] }; + monitor: Monitor; sxApi: InfraSx; } @@ -36,7 +36,8 @@ const heatLevel = (value: number): GaugeFillLevel => export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { const { t } = useTranslation(); - const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; + const recentChecks = monitor.recentChecks ?? []; + const latest = recentChecks[recentChecks.length - 1]; const renderEmpty = () => ( {t("pages.statusPages.monitorsList.noData")} diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index 5e29096d0..484630c17 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -1,12 +1,12 @@ import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { TextField, Button, ColorInput } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useEffect } from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; -import { Controller, useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { mutate } from "swr"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; @@ -14,6 +14,8 @@ import type { TagFormData } from "@/Validation/tag"; import type { Tag } from "@/Types/Tag"; import { useTranslation } from "react-i18next"; import { useTagsForm } from "@/Hooks/useTagsForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; const TagsCreatePage = () => { const { t } = useTranslation(); @@ -34,7 +36,7 @@ const TagsCreatePage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit } = form; + const { reset, handleSubmit } = form; useEffect(() => { reset(defaults); @@ -51,67 +53,49 @@ const TagsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - - + - - - + + + + ); }; diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 373e09da2..2144c8769 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -8,10 +8,9 @@ import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -import prettyMilliseconds from "pretty-ms"; export const GeoChecksTable = ({ geoChecks, @@ -69,7 +68,7 @@ export const GeoChecksTable = ({ content: t("common.table.headers.responseTime"), render: (row) => { if (!row.timings?.total) return "N/A"; - return prettyMilliseconds(row.timings.total, { compact: true }); + return formatMs(row.timings.total); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 56724259a..bcf7177ac 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -38,6 +38,11 @@ interface CertificateResponse { certificateDate: string; } +interface DomainResponse { + domain: string; + expiryDate: string; +} + const UptimeDetailsPage = () => { const theme = useTheme(); const isAdmin = useIsAdmin(); @@ -102,6 +107,29 @@ const UptimeDetailsPage = () => { ); }, [certificateData, uiTimezone]); + // Domain expiry fetch - only for HTTP monitors + const domainUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http") { + return null; + } + return `/monitors/domain/${monitorId}`; + }, [monitorId, monitor?.type]); + + const { data: domainData } = useGet( + domainUrl, + {}, + { revalidateOnFocus: false } + ); + + const domainExpiry = useMemo(() => { + if (!domainData?.expiryDate) { + return undefined; + } + return ( + formatDateWithTz(domainData.expiryDate, certificateDateFormat, uiTimezone) ?? "N/A" + ); + }, [domainData, uiTimezone]); + const checksUrl = useMemo(() => { if (!monitorId || !monitor?.type) { return null; @@ -193,6 +221,7 @@ const UptimeDetailsPage = () => { monitor={monitor} monitorStats={monitorStats} certificateExpiry={certificateExpiry} + domainExpiry={domainExpiry} /> { if (isCustomDomainHost()) { return ( @@ -188,12 +189,24 @@ const Routes = () => { path="tags/create" element={} /> - } /> + } + /> + } + /> + } + /> + } diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index cfd072944..78e970f04 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -250,14 +250,53 @@ export interface ChecksSummary { downChecks: number; } -export type CheckSnapshot = Omit & { - originalResponseTime: number; -}; +export type SnapshotCpuInfo = Pick< + CheckCpuInfo, + | "physical_core" + | "logical_core" + | "frequency" + | "current_frequency" + | "temperature" + | "usage_percent" +>; +export type SnapshotMemoryInfo = Pick< + CheckMemoryInfo, + "total_bytes" | "used_bytes" | "usage_percent" +>; +export type SnapshotDiskInfo = Pick< + CheckDiskInfo, + "device" | "total_bytes" | "used_bytes" | "usage_percent" +>; +export type SnapshotHostInfo = Pick; +export type CheckSnapshot = Pick< + Check, + | "id" + | "status" + | "responseTime" + | "statusCode" + | "message" + | "createdAt" + | "accessibility" + | "bestPractices" + | "seo" + | "performance" + | "audits" +> & { + cpu?: SnapshotCpuInfo; + memory?: SnapshotMemoryInfo; + disk?: SnapshotDiskInfo[]; + host?: SnapshotHostInfo; +}; export interface HasResponseTime { responseTime: number; } -export type NormalizedCheck = T & { - originalResponseTime: number; -}; +export interface DailyCheckBucket { + monitorId: string; + date: string; + totalChecks: number; + upChecks: number; + downChecks: number; + avgResponseTime: number | null; // null when no check that day recorded a response time +} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index b9aaf417a..a5ed14c01 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,4 +1,9 @@ -import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check"; +import type { + GroupedCheck, + CheckSnapshot, + GroupedUptimeCheck, + DailyCheckBucket, +} from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; export type { GeoContinent } from "@/Types/GeoCheck"; @@ -20,6 +25,56 @@ export const MonitorTypes = [ ] as const; export type MonitorType = (typeof MonitorTypes)[number]; +// The subset offered by the CreateMonitor type selector, in display order. +// pagespeed/hardware are set by their own pages; unknown is never selectable. +export const SelectableMonitorTypes = [ + "http", + "ping", + "docker", + "port", + "game", + "grpc", + "websocket", + "dns", +] as const; +export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number]; + +// Irregular i18n key fragments shared by the type selector's label +// (pages.common.monitors.monitorTypes.), its description +// (pages.createMonitor.form.type.Description), and status page +// type labels (via getMonitorTypeLabel). Covers every type with a label; +// only selectable types have description keys. +export const monitorTypeLabelKey: Record & + Partial> = { + http: "optionHttp", + ping: "optionPing", + docker: "optionDocker", + port: "optionPort", + game: "optionGame", + grpc: "optionGrpc", + websocket: "optionWebSocket", + dns: "optionDns", + hardware: "optionHardware", + pagespeed: "optionPagespeed", +}; + +export const MonitorIntervalOptions = [ + { value: 15000, labelKey: "fifteenSeconds" }, + { value: 30000, labelKey: "thirtySeconds" }, + { value: 60000, labelKey: "oneMinute" }, + { value: 120000, labelKey: "twoMinutes" }, + { value: 180000, labelKey: "threeMinutes" }, + { value: 240000, labelKey: "fourMinutes" }, + { value: 300000, labelKey: "fiveMinutes" }, + { value: 600000, labelKey: "tenMinutes" }, + { value: 900000, labelKey: "fifteenMinutes" }, + { value: 1800000, labelKey: "thirtyMinutes" }, +] as const; + +export const GeoCheckIntervalOptions = MonitorIntervalOptions.filter( + ({ value }) => value >= 300000 +); + export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export type DnsRecordType = (typeof DnsRecordTypes)[number]; @@ -42,7 +97,9 @@ export const MonitorStatuses = [ ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; +export const MonitorMatchMethods = ["equal", "include", "regex"] as const; +export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; +export const DefaultMonitorMatchMethod: MonitorMatchMethod = "equal"; export const PageSpeedStrategies = ["desktop", "mobile"] as const; export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; @@ -52,6 +109,10 @@ export const HttpMethods = ["GET", "HEAD"] as const; export type HttpMethod = (typeof HttpMethods)[number]; export const DefaultHttpMethod: HttpMethod = "GET"; +export const ProxyModes = ["inherit", "none", "custom"] as const; +export type ProxyMode = (typeof ProxyModes)[number]; +export const DefaultProxyMode: ProxyMode = "inherit"; + export interface Monitor { id: string; userId: string; @@ -64,6 +125,8 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + proxyMode: ProxyMode; + proxyId?: string; useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; @@ -97,6 +160,7 @@ export interface Monitor { dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; + dailyChecks?: DailyCheckBucket[]; createdAt: string; updatedAt: string; } diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 0e389f9e2..a118c4a3d 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/client/src/Types/Proxy.ts b/client/src/Types/Proxy.ts new file mode 100644 index 000000000..dd098dee3 --- /dev/null +++ b/client/src/Types/Proxy.ts @@ -0,0 +1,17 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} + +export type ProxyResponse = Omit & { hasPassword: boolean }; diff --git a/client/src/Types/Settings.ts b/client/src/Types/Settings.ts index 1338d49cb..01d34aead 100644 --- a/client/src/Types/Settings.ts +++ b/client/src/Types/Settings.ts @@ -24,6 +24,8 @@ export interface Settings { showURL: boolean; singleton: boolean; globalThresholds?: SettingsThresholds; + globalProxyEnabled: boolean; + globalProxyId?: string | null; createdAt: string; updatedAt: string; } @@ -31,5 +33,11 @@ export interface Settings { export interface AppSettingsResponse { pagespeedKeySet: boolean; emailPasswordSet: boolean; + globalProxy: { + id: string; + name: string; + host: string; + port: number; + } | null; settings: Settings; } diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index bbe1f41f2..a9adb9271 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,24 +1,13 @@ +import { monitorTypeLabelKey } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; export type MonitorDisplayType = "uptime" | "infrastructure"; -export const MONITOR_TYPE_KEYS: Partial> = { - http: "pages.common.monitors.monitorTypes.optionHttp", - ping: "pages.common.monitors.monitorTypes.optionPing", - docker: "pages.common.monitors.monitorTypes.optionDocker", - port: "pages.common.monitors.monitorTypes.optionPort", - game: "pages.common.monitors.monitorTypes.optionGame", - grpc: "pages.common.monitors.monitorTypes.optionGrpc", - websocket: "pages.common.monitors.monitorTypes.optionWebSocket", - hardware: "pages.common.monitors.monitorTypes.optionHardware", - pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed", -}; - export const getMonitorTypeLabel = ( type: MonitorType, t: (key: string) => string ): string => { - const key = MONITOR_TYPE_KEYS[type]; - return key ? t(key) : type; + const fragment = monitorTypeLabelKey[type]; + return fragment ? t(`pages.common.monitors.monitorTypes.${fragment}`) : type; }; export const STATUS_PAGE_THEMES = [ @@ -35,6 +24,20 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; +export const STATUS_PAGE_DAY_RANGES = ["30d", "60d", "90d"] as const; +export type StatusPageDayRange = (typeof STATUS_PAGE_DAY_RANGES)[number]; +export const STATUS_PAGE_RANGES = ["latest", ...STATUS_PAGE_DAY_RANGES] as const; +export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number]; + +export const STATUS_PAGE_RANGE_DAYS: Record = { + "30d": 30, + "60d": 60, + "90d": 90, +}; + +export const isStatusPageRange = (value: string | null): value is StatusPageRange => + STATUS_PAGE_RANGES.some((range) => range === value); + export const resolveStatusPageTheme = ( value: string | null | undefined ): StatusPageTheme => @@ -78,4 +81,7 @@ export interface StatusPage { export interface StatusPageResponse { statusPage: StatusPage; monitors: Monitor[]; + range?: StatusPageDayRange; // present only when range !== "latest" + bucketTimezone?: string; + checkTTLDays?: number; } diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/DataUtils.ts index 40bbd1b8b..3562d86ed 100644 --- a/client/src/Utils/DataUtils.ts +++ b/client/src/Utils/DataUtils.ts @@ -1,47 +1,3 @@ -const MIN_OUT = 10; -const MAX_OUT = 100; - -export const normalizeResponseTimes = ( - checks: T[], - key: K -): (T & { normalResponseTime: number })[] => { - if (!Array.isArray(checks) || checks.length === 0) - return checks as (T & { - normalResponseTime: number; - })[]; - - if (checks.length === 1) { - return [ - { - ...checks[0], - normalResponseTime: 50, - }, - ]; - } - - const getVal = (check: T): number => { - const v = check[key] as unknown; - return typeof v === "number" && Number.isFinite(v) ? v : 0; - }; - - const { min, max } = checks.reduce( - (acc, check) => { - const v = getVal(check); - if (v > acc.max) acc.max = v; - if (v < acc.min) acc.min = v; - return acc; - }, - { max: -Infinity, min: Infinity } - ); - - const range = max - min || 1; - - return checks.map((check) => ({ - ...check, - normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range, - })); -}; - // Interpolate color between three theme colors over 0-100 range. // 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light) export const getResponseColor = ( @@ -129,3 +85,53 @@ export const getResponseColor = ( } return normalizeToHex(safe.end); }; + +export const MIN_HEIGHT_PCT = 8; +const CAP_PERCENTILE = 95; + +const percentile = (sortedValues: number[], p: number): number => { + const index = (p / 100) * (sortedValues.length - 1); + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + if (upper >= sortedValues.length) return sortedValues[lower]; + return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight; +}; + +export const computeBarHeights = ( + checks: { responseTime?: number | null }[] +): number[] => { + const values = checks.map((check) => { + return typeof check.responseTime === "number" && Number.isFinite(check.responseTime) + ? Math.max(0, check.responseTime) + : 0; + }); + + const sorted = values.slice().sort((a, b) => a - b); + const cap = percentile(sorted, CAP_PERCENTILE); + if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT); + return values.map((value) => { + return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100)); + }); +}; + +export const computeDayBarHeights = ( + buckets: { date: string; avgResponseTime: number | null }[] +): Map => { + const values = buckets.map((bucket) => + typeof bucket.avgResponseTime === "number" && Number.isFinite(bucket.avgResponseTime) + ? Math.max(0, bucket.avgResponseTime) + : 0 + ); + + const sorted = values.slice().sort((a, b) => a - b); + const cap = percentile(sorted, CAP_PERCENTILE); + return new Map( + buckets.map((bucket, i) => [ + bucket.date, + cap <= 0 + ? MIN_HEIGHT_PCT + : Math.max(MIN_HEIGHT_PCT, Math.min(100, (values[i] / cap) * 100)), + ]) + ); +}; diff --git a/client/src/Utils/FormatUtils.ts b/client/src/Utils/FormatUtils.ts index 35182b797..52c1e9076 100644 --- a/client/src/Utils/FormatUtils.ts +++ b/client/src/Utils/FormatUtils.ts @@ -1,25 +1,31 @@ /** * Intl.NumberFormat instance for percentage formatting. * Reused across all formatting calls for performance. + * + * Two decimals rather than one: at a single decimal every value above 99.95% + * renders as "100.0%", so a period containing real downtime is reported as + * flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially + * different SLA commitments — into the same figure. */ const percentageFormatter = new Intl.NumberFormat("en-US", { style: "percent", - minimumFractionDigits: 1, - maximumFractionDigits: 1, + minimumFractionDigits: 2, + maximumFractionDigits: 2, }); /** * Formats a decimal value as a percentage string. * @param value - Decimal value (e.g., 0.75 for 75%) - * @returns Formatted percentage string (e.g., "75.0%") + * @returns Formatted percentage string (e.g., "75.00%") * @example - * formatPercentage(0.75) // "75.0%" - * formatPercentage(1) // "100.0%" - * formatPercentage(0.5432) // "54.3%" + * formatPercentage(0.75) // "75.00%" + * formatPercentage(1) // "100.00%" + * formatPercentage(0.5432) // "54.32%" + * formatPercentage(0.9999) // "99.99%" */ export const formatPercentage = (value: number): string => { if (typeof value !== "number" || Number.isNaN(value)) { - return "0.0%"; + return "0.00%"; } return percentageFormatter.format(value); }; @@ -27,11 +33,11 @@ export const formatPercentage = (value: number): string => { /** * Formats a whole number percentage value as a percentage string. * @param value - Whole number percentage (e.g., 75 for 75%) - * @returns Formatted percentage string (e.g., "75.0%") + * @returns Formatted percentage string (e.g., "75.00%") * @example - * formatPercentageFromWhole(75) // "75.0%" - * formatPercentageFromWhole(100) // "100.0%" - * formatPercentageFromWhole(54.32) // "54.3%" + * formatPercentageFromWhole(75) // "75.00%" + * formatPercentageFromWhole(100) // "100.00%" + * formatPercentageFromWhole(54.32) // "54.32%" */ export const formatPercentageFromWhole = (value: number): string => { return formatPercentage(value / 100); diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index 33ba66945..b589be3a3 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -124,6 +124,14 @@ export const theme = (mode: string, palette: any) => disableTouchRipple: true, disableFocusRipple: true, }, + styleOverrides: { + root: ({ theme }) => ({ + "&.Mui-focusVisible": { + outline: `1px solid ${theme.palette.primary.main}`, + outlineOffset: 2, + }, + }), + }, }, MuiIconButton: { defaultProps: { diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 5b63de87e..9b809e1da 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -3,6 +3,7 @@ import duration from "dayjs/plugin/duration"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import customParseFormat from "dayjs/plugin/customParseFormat"; +import prettyMilliseconds from "pretty-ms"; dayjs.extend(utc); dayjs.extend(timezone); @@ -77,3 +78,60 @@ export const formatTimestamp = (timestamp: string | number | null): string => { const date = new Date(timestamp); return date.toLocaleString(); }; + +const DISPLAY_UNITS = [MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND]; + +// pretty-ms floors seconds-and-above and truncates fractional ms, so values +// must be rounded to their display unit first to land on the nearest whole +// unit (53.6 → "54 ms", 1937 → "2 s", 59500 → "1 m"). +const roundToDisplayUnit = (ms: number): number => { + for (const unit of DISPLAY_UNITS) { + if (ms >= unit) return Math.round(ms / unit) * unit; + } + return ms >= 1 ? Math.round(ms) : ms; +}; + +export const formatMs = (ms: number, hasSpace: boolean = true): string => { + const formatted = prettyMilliseconds(roundToDisplayUnit(ms), { + compact: true, + formatSubMilliseconds: true, + secondsDecimalDigits: 0, + }); + + if (hasSpace) { + return formatted.replace(/^(\d+)(\D+)$/, "$1 $2"); + } + return formatted; +}; + +export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => { + if (verbose) { + return prettyMilliseconds(ms, { verbose: true }); + } + + const formatted = prettyMilliseconds(ms, { + unitCount: 2, + secondsDecimalDigits: 0, + millisecondsDecimalDigits: 0, + }); + + if (hasSpace) { + return formatted.replace(/^(\d+)(\D+)$/, "$1 $2"); + } + return formatted; +}; + +export const enumerateDays = (days: number, timeZone: string): string[] => { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const [year, month, day] = formatter.format(new Date()).split("-").map(Number); + const todayUtc = Date.UTC(year, month - 1, day); + const DAY_MS = 24 * 60 * 60 * 1000; + return Array.from({ length: days }, (_, i) => + new Date(todayUtc - (days - 1 - i) * DAY_MS).toISOString().slice(0, 10) + ); +}; diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index 74be87918..1b7d7a7a5 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -16,11 +16,7 @@ interface ErrorLogData { timestamp: string; level: LogLevel; message: string; - error?: { - message: string; - name: string; - stack?: string; - }; + error?: Record; context?: LogContext; url: string; userAgent: string; @@ -34,14 +30,127 @@ const shouldLog = (level: LogLevel): boolean => { return LOG_LEVEL_PRIORITY[level] >= configuredPriority; }; -const formatError = (error?: Error) => { - if (!error) return undefined; +/** + * Keys whose values must never reach the console. Matched case-insensitively + * against a substring of the key, so `newPassword` and `X-Api-Key` are covered + * by `password` and `apikey` respectively. + */ +const SENSITIVE_KEY_PATTERNS = [ + "password", + "passwd", + "pwd", + "secret", + "token", + "authorization", + "apikey", + "credential", + "passphrase", + "cookie", + "jwt", + "privatekey", + "sessionid", + "csrf", +] as const; - return { - message: error.message, +const REDACTED = "[REDACTED]"; + +const isSensitiveKey = (key: string): boolean => { + const normalized = key.toLowerCase().replace(/[-_]/g, ""); + return SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern)); +}; + +/** + * Opaque browser types that must not be walked: they carry no useful log data + * and may hold large buffers. Resolved defensively because these globals are + * absent in SSR and some test environments. + */ +const isOpaqueObject = (value: object): boolean => { + const opaqueTypes = ["FormData", "Blob", "File", "ArrayBuffer"] as const; + return opaqueTypes.some((name) => { + const ctor = (globalThis as Record)[name]; + return typeof ctor === "function" && value instanceof (ctor as new () => object); + }); +}; + +/** + * Deep-copies a value with sensitive fields replaced by a placeholder. Walks + * class instances as well as object literals, so a credential on a DTO is + * redacted too. Opaque types (FormData, Blob) render as a type marker. + * + * `ancestors` tracks only the current path, so the same object appearing twice + * as a sibling is still rendered in full — only a genuine cycle is cut. + */ +const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unknown => { + if (value === null || typeof value !== "object") return value; + + if (ancestors.includes(value)) return "[CIRCULAR]"; + + if (isOpaqueObject(value)) return `[${value.constructor?.name ?? "Object"}]`; + + // Dates and RegExps stringify usefully on their own; walking them yields nothing. + if (value instanceof Date || value instanceof RegExp) return value; + + const path = [...ancestors, value]; + + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, path)); + } + + // Walk own enumerable properties of ANY object, including class instances — + // a credential on a DTO must be redacted just as it is on an object literal. + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(entry, path); + } + return result; +}; + +/** + * Parses a serialized request body so its fields can be redacted by key. A body + * that is not JSON is replaced wholesale rather than logged raw, since an + * unparseable payload (e.g. form-encoded) may still carry credentials. + */ +const safeJsonParse = (raw: string): unknown => { + try { + const parsed = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null ? parsed : "[UNPARSED BODY]"; + } catch { + return "[UNPARSED BODY]"; + } +}; + +/** + * Errors are logged as-is by the debug branches, but an AxiosError carries the + * outgoing request on `config` — including the serialized body and the + * Authorization header — so the raw object cannot be handed to the console. + * Returns a redacted plain object safe to print. + */ +const toSafeError = (error: Error): Record => { + const candidate = error as unknown as { + config?: { url?: string; method?: string; data?: unknown; headers?: unknown }; + response?: { status?: number }; + }; + + const safe: Record = { name: error.name, + message: error.message, stack: error.stack, }; + + if (candidate.config) { + const { url, method, data, headers } = candidate.config; + safe.config = { + url, + method, + // Axios serializes the request body to a string, so key-based + // redaction cannot reach into it — parse it back first. + data: redactSensitive(typeof data === "string" ? safeJsonParse(data) : data), + headers: redactSensitive(headers), + }; + safe.status = candidate.response?.status; + } + + return redactSensitive(safe) as Record; }; const createLogData = ( @@ -54,8 +163,8 @@ const createLogData = ( timestamp: new Date().toISOString(), level, message, - error: formatError(error), - context, + error: error === undefined ? undefined : toSafeError(error), + context: context === undefined ? undefined : (redactSensitive(context) as LogContext), url: window.location.href, userAgent: navigator.userAgent, }; @@ -69,10 +178,10 @@ const error = (message: string, error?: Error, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`ERROR: ${message}`); if (error) { - console.error("Error:", error); + console.error("Error:", toSafeError(error)); } if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.log("URL:", logData.url); console.log("Timestamp:", logData.timestamp); @@ -90,7 +199,7 @@ const warn = (message: string, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`WARN: ${message}`); if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.groupEnd(); } else { @@ -103,7 +212,7 @@ const debug = (message: string, data?: any): void => { console.group(`DEBUG: ${message}`); if (data !== undefined) { - console.log(data); + console.log(redactSensitive(data)); } console.groupEnd(); }; @@ -111,14 +220,16 @@ const debug = (message: string, data?: any): void => { const info = (message: string, data?: any): void => { if (!shouldLog("info")) return; + const safeData = data === undefined ? undefined : redactSensitive(data); + if (configuredLevel === "debug") { - console.log(`INFO: ${message}`, data); + console.log(`INFO: ${message}`, safeData); } else { const logData = { timestamp: new Date().toISOString(), level: "info" as LogLevel, message, - data, + data: safeData, }; console.log(JSON.stringify(logData)); } diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts index 65109c198..d015edf8b 100644 --- a/client/src/Utils/statusPageUrl.ts +++ b/client/src/Utils/statusPageUrl.ts @@ -1,4 +1,8 @@ -import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { + PUBLIC_STATUS_PAGE_PREFIX, + type StatusPage, + type StatusPageRange, +} from "@/Types/StatusPage"; import { runtimeConfig } from "@/Utils/runtimeConfig"; const CLIENT_HOST = @@ -86,8 +90,12 @@ export const getStatusPagePreviewUrl = (statusPage: Pick): st export const buildStatusPageApiPath = (options: { url?: string; useCustomDomain?: boolean; + range?: StatusPageRange; }): string | null => { - const query = "type=uptime&type=infrastructure"; + let query = "type=uptime&type=infrastructure"; + if (options.range && options.range !== "latest") { + query += `&range=${options.range}`; + } if (options.useCustomDomain && typeof window !== "undefined") { const domain = encodeURIComponent(window.location.hostname); diff --git a/client/src/Utils/timezoneOptions.ts b/client/src/Utils/timezoneOptions.ts new file mode 100644 index 000000000..c937bdc14 --- /dev/null +++ b/client/src/Utils/timezoneOptions.ts @@ -0,0 +1,13 @@ +import timezones from "@/Utils/timezones.json"; + +export interface TimezoneOption { + id: string; + name: string; +} + +// timezones.json uses Mongo-style _id; remap once at module load to the +// { id, name } shape AutoComplete expects. +export const timezoneOptions: TimezoneOption[] = timezones.map((tz) => ({ + id: tz._id, + name: tz.name, +})); diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index a31cdb919..5b53ff3a5 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -5,6 +5,7 @@ import { DnsRecordTypes, HttpMethods, PageSpeedStrategies, + ProxyModes, type MonitorType, } from "@/Types/Monitor"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; @@ -67,6 +68,8 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, + proxyMode: z.enum(ProxyModes), + proxyId: z.string().optional(), method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }), ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), @@ -195,7 +198,7 @@ const dnsSchema = baseSchema.extend({ }); // Discriminated union of all monitor types -export const monitorSchema = z.discriminatedUnion("type", [ +const monitorSchemaUnion = z.discriminatedUnion("type", [ httpSchema, pingSchema, portSchema, @@ -208,10 +211,21 @@ export const monitorSchema = z.discriminatedUnion("type", [ dnsSchema, ]); +// Mirrors the server's refineProxySelection (monitorValidation.ts) +export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { + if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}); + export type MonitorFormData = z.infer; const optionForType = (type: MonitorType) => - monitorSchema.options.find((o) => o.shape.type.value === type); + monitorSchemaUnion.options.find((o) => o.shape.type.value === type); // The step each field of a type is validated on, from the per-field // `monitorStepRegistry` metadata (unannotated fields default to step 0). diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 6526fa0dd..f7490d526 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -30,6 +30,14 @@ const webhookSchema = baseSchema.extend({ address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), }); +const rocketChatSchema = baseSchema.extend({ + type: z.literal("rocket_chat"), + address: z + .string() + .min(1, "Webhook URL is required") + .url({ protocol: /^https?$/, message: "Please enter a valid URL" }), +}); + const pagerDutySchema = baseSchema.extend({ type: z.literal("pager_duty"), address: z.string().min(1, "Integration key is required"), @@ -81,6 +89,7 @@ export const notificationSchema = z.discriminatedUnion("type", [ slackSchema, discordSchema, webhookSchema, + rocketChatSchema, pagerDutySchema, matrixSchema, teamsSchema, diff --git a/client/src/Validation/proxy.ts b/client/src/Validation/proxy.ts new file mode 100644 index 000000000..22582e92a --- /dev/null +++ b/client/src/Validation/proxy.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { ProxyProtocols } from "@/Types/Proxy"; + +export const proxySchema = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z + .number() + .int() + .min(1, "Proxy port is required") + .max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export type ProxyFormData = z.infer; diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index edacd9e4d..cd1f5a3c3 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -1,61 +1,73 @@ import { CHECK_TTL_SENTINEL } from "@/Types/Check"; import { z } from "zod"; -export const settingsSchema = z.object({ - systemEmailIgnoreTLS: z.boolean(), - systemEmailRequireTLS: z.boolean(), - systemEmailRejectUnauthorized: z.boolean(), - systemEmailConnectionHost: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailSecure: z.boolean().optional(), - systemEmailPool: z.boolean().optional(), - showURL: z.boolean().optional(), - checkTTL: z - .number() - .int() - .min(1, "Please enter a value") - .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), - pagespeedApiKey: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailHost: z - .string() - .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailPort: z.number().int().min(1).max(65535).optional(), - systemEmailAddress: z - .email("Please enter a valid email address") - .or(z.literal("")) - .transform((val) => (val === "" ? null : val.toLowerCase().trim())) - .optional(), - systemEmailDisplayName: z - .string() - .max(100, "Display name must be 100 characters or fewer") - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailUser: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailPassword: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailTLSServername: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - globalThresholds: z.object({ - cpu: z.number().int().min(1).max(100), - memory: z.number().int().min(1).max(100), - disk: z.number().int().min(1).max(100), - temperature: z.number().int().min(1).max(150), - }), -}); +export const settingsSchema = z + .object({ + systemEmailIgnoreTLS: z.boolean(), + systemEmailRequireTLS: z.boolean(), + systemEmailRejectUnauthorized: z.boolean(), + systemEmailConnectionHost: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + showURL: z.boolean().optional(), + checkTTL: z + .number() + .int() + .min(1, "Please enter a value") + .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), + pagespeedApiKey: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailHost: z + .string() + .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailPort: z.number().int().min(1).max(65535).optional(), + systemEmailAddress: z + .email("Please enter a valid email address") + .or(z.literal("")) + .transform((val) => (val === "" ? null : val.toLowerCase().trim())) + .optional(), + systemEmailDisplayName: z + .string() + .max(100, "Display name must be 100 characters or fewer") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailUser: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailPassword: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailTLSServername: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + globalThresholds: z.object({ + cpu: z.number().int().min(1).max(100), + memory: z.number().int().min(1).max(100), + disk: z.number().int().min(1).max(100), + temperature: z.number().int().min(1).max(150), + }), + globalProxyEnabled: z.boolean(), + globalProxyId: z.string().nullable().optional(), + }) + .superRefine((body, ctx) => { + if (body.globalProxyEnabled === true && !body.globalProxyId) { + ctx.addIssue({ + code: "custom", + path: ["globalProxyId"], + message: "A proxy must be selected to enable the global proxy", + }); + } + }); export type SettingsFormInput = z.input; export type SettingsFormData = z.infer; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index eddde8ab7..08d12cdde 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -79,6 +79,7 @@ export const statusPageSchema = z.object({ .object({ data: z.string(), contentType: z.string(), + file: z.instanceof(File).optional(), }) .nullable() .optional() diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 6e315bd9a..55f34650d 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -58,8 +58,8 @@ "uptime": "وقت التشغيل" }, "histogram": { - "avg": "المتوسط: {{value}} مللي ثانية", - "max": "الحد الأقصى: {{value}} مللي ثانية" + "avg": "المتوسط: {{value}}", + "max": "الحد الأقصى: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "وقت الاستجابة" }, "chart": { - "avg": "متوسط {{value}} مللي ثانية", + "avg": "متوسط {{value}}", "downTooltip": "متوقف", "heatmapAria": "خريطة حرارية لزمن الاستجابة", - "max": "أقصى {{value}} مللي ثانية" + "max": "أقصى {{value}}" }, "status": { "breached": "تم تجاوز الحد", diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 3c1a29613..7e39eca86 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -58,8 +58,8 @@ "uptime": "Temps en línia" }, "histogram": { - "avg": "Mitjana: {{value}} ms", - "max": "Màx: {{value}} ms" + "avg": "Mitjana: {{value}}", + "max": "Màx: {{value}}" } }, "dialogs": { @@ -1346,8 +1346,8 @@ "initializing": "Inicialitzant" }, "chart": { - "avg": "mitj {{value}}ms", - "max": "màx {{value}}ms", + "avg": "mitj {{value}}", + "max": "màx {{value}}", "downTooltip": "Caigut", "heatmapAria": "Mapa de calor del temps de resposta" }, diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index ae683aefe..d04b9b23b 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -58,8 +58,8 @@ "uptime": "Dostupnost" }, "histogram": { - "avg": "Prům.: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Prům.: {{value}}", + "max": "Max: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Doba odezvy" }, "chart": { - "avg": "průměr {{value}}ms", + "avg": "průměr {{value}}", "downTooltip": "Mimo provoz", "heatmapAria": "Tepelná mapa doby odezvy", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Překročen limit", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 7b2e64945..d43d5d52a 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -58,8 +58,8 @@ "uptime": "Verfügbarkeit" }, "histogram": { - "avg": "Durchschn.: {{value}} ms", - "max": "Max.: {{value}} ms" + "avg": "Durchschn.: {{value}}", + "max": "Max.: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Antwortzeit" }, "chart": { - "avg": "Ø {{value}}ms", + "avg": "Ø {{value}}", "downTooltip": "Ausgefallen", "heatmapAria": "Heatmap der Antwortzeit", - "max": "max. {{value}}ms" + "max": "max. {{value}}" }, "status": { "breached": "Schwellenwert überschritten", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d940f95fc..e82f84949 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -60,7 +60,8 @@ "exportToJSON": "Export to JSON", "importFromJSON": "Import from JSON", "clearFilters": "Clear filters", - "removeUser": "Remove user" + "removeUser": "Remove user", + "removeItem": "Remove {{item}}" }, "charts": { "labels": { @@ -68,11 +69,24 @@ "downtime": "Downtime", "high": "High", "low": "Low", + "responseTime": "Response time", + "timingPhases": "Timing phases", + "totalResponseTime": "Total response time", "uptime": "Uptime" }, "histogram": { - "avg": "Avg: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Avg: {{value}}", + "max": "Max: {{value}}", + "responseTime": "Response time: {{value}}", + "total": "Total: {{value}}" + }, + "phases": { + "dns": "DNS", + "tcp": "TCP", + "tls": "TLS", + "request": "Request", + "firstByte": "First byte", + "download": "Download" } }, "dialogs": { @@ -91,6 +105,9 @@ "responseTime": "Response time" }, "form": { + "autocomplete": { + "placeholder": "Type to search" + }, "role": { "option": { "role": { @@ -188,6 +205,7 @@ "infrastructure": "Infrastructure", "notifications": "Notifications", "tags": "Tags", + "proxies": "Proxies", "checks": "Checks", "incidents": "Incidents", "statusPages": "Status pages", @@ -227,6 +245,63 @@ } }, "pages": { + "proxies": { + "header": { + "title": "Proxies", + "description": "Manage your proxy servers for routing HTTP monitoring traffic." + }, + "fallback": { + "title": "No proxies yet", + "description": "Route HTTP monitor checks through a proxy server. Once you create a proxy, you can select it on individual monitors or set it as the global default in Settings.", + "actionButton": "Create a proxy" + }, + "form": { + "name": { + "title": "Proxy name", + "description": "A friendly name to identify this proxy", + "optionName": "Proxy name", + "placeholder": "My proxy" + }, + "server": { + "title": "Proxy server", + "description": "Where the proxy server is reachable. Set the protocol Checkmate uses to connect to it, its hostname or IP address, and the port it listens on.", + "option": { + "protocol": { + "label": "Protocol" + }, + "host": { + "label": "Hostname / IP Address", + "placeholder": "proxy.example.com" + }, + "port": { + "label": "Port" + } + } + }, + "auth": { + "title": "Authentication", + "description": "Optional. Only needed if the proxy server requires basic authentication.", + "option": { + "username": { + "label": "Username", + "placeholder": "Proxy username", + "labelSet": "Username is set. Click Reset to change it." + }, + "password": { + "label": "Password", + "labelSet": "Password is set. Click Reset to change it." + } + } + } + }, + "table": { + "headers": { + "protocol": "Protocol", + "host": "Host", + "port": "Port" + } + } + }, "notFound": { "title": "Oh no! You dropped your sushi!", "subtitle": "Either the URL doesn’t exist, or you don’t have access to it." @@ -458,6 +533,7 @@ "statBoxes": { "activeFor": "Active for", "certificateExpiry": "Certificate expiry", + "domainExpiry": "Domain expiry", "lastCheck": "Last check", "lastResponseTime": "Last response time", "serviceIsDown": "Service is down" @@ -515,8 +591,7 @@ }, "customUpCodes": { "label": "Accepted status codes", - "description": "Add any non-2xx codes that should also be considered successful.", - "removeAriaLabel": "Remove status code" + "description": "Add any non-2xx codes that should also be considered successful." } }, "title": "Advanced settings" @@ -564,13 +639,31 @@ "label": "URL", "placeholder": "https://www.google.com" }, + "proxyMode": { + "label": "Proxy", + "value": { + "inheritDirect": "Default (direct connection)", + "inheritGlobal": "Default ({{name}})", + "none": "Never use a proxy", + "custom": "Custom proxy" + }, + "description": { + "inheritDirect": "No global proxy is configured, connect directly. If a global proxy is later configured, this monitor will use it.", + "inheritGlobal": "Use the global proxy setting", + "none": "Always connects directly even if a global proxy is enabled later.", + "custom": "Route this monitor's checks through a specific proxy. Overrides global proxy" + } + }, + "proxy": { + "label": "Proxy server" + }, "game": { "label": "Choose game", "placeholder": "Select a game" }, "port": { "label": "Port to monitor", - "placeholder": 80 + "placeholder": "80" }, "grpcServiceName": { "label": "Service name", @@ -607,7 +700,7 @@ }, "title": "General settings", "description": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", @@ -708,17 +801,6 @@ } } }, - "url": { - "title": "Monitor IP/URL on Status Page", - "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", - "option": { - "showURL": { - "label": "Display IP/URL on status page", - "enabled": "Enabled", - "disabled": "Disabled" - } - } - }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", @@ -1151,7 +1233,21 @@ "type": { "description": "Select the type of notification channel to create.", "optionType": "Type", - "title": "Channel type" + "title": "Channel type", + "value": { + "email": "Email", + "slack": "Slack", + "discord": "Discord", + "webhook": "Webhook", + "rocket_chat": "Rocket.Chat", + "pager_duty": "PagerDuty", + "matrix": "Matrix", + "teams": "Teams", + "telegram": "Telegram", + "pushover": "Pushover", + "twilio": "Twilio", + "ntfy": "ntfy" + } }, "telegram": { "title": "Telegram configuration", @@ -1266,6 +1362,22 @@ } } }, + "globalProxy": { + "title": "Global proxy", + "description": "Route monitoring checks through a proxy by default. Applies to all HTTP monitors whose proxy setting is \"Default\"; monitors set to \"Never use a proxy\" or a custom proxy are unaffected.", + "option": { + "enabled": { + "label": "Use global proxy" + }, + "proxy": { + "label": "Choose a global proxy" + }, + "empty": { + "text": "No proxies configured yet.", + "link": "Create one first" + } + } + }, "pagespeed": { "title": "Google PageSpeed API key", "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", @@ -1282,9 +1394,7 @@ "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", "option": { "showURL": { - "label": "Display IP/URL on status page", - "enabled": "Enabled", - "disabled": "Disabled" + "label": "Display IP/URL on status page" } } }, @@ -1424,6 +1534,14 @@ }, "statusPages": { "deleteSuccess": "Status page deleted successfully", + "rangeSelectorAria": "History range", + "rangeExceedsRetention": "Check history retention is set to {{days}} days. Increase retention for more data.", + "range": { + "latest": "Latest", + "30d": "30 days", + "60d": "60 days", + "90d": "90 days" + }, "fallback": { "title": "No status pages yet", "description": "Publish a public page that shows real-time uptime and incident history to your customers and stakeholders.", @@ -1442,10 +1560,13 @@ "initializing": "Initializing" }, "chart": { - "avg": "avg {{value}}ms", - "max": "max {{value}}ms", + "avg": "avg {{value}}", + "max": "max {{value}}", "downTooltip": "Down", - "heatmapAria": "Response time heatmap" + "heatmapAria": "Response time heatmap", + "dayAria": "{{date}}, {{uptime}} uptime", + "dayUptime": "Uptime: {{uptime}} ({{upChecks}}/{{totalChecks}} checks)", + "dayAvgResponse": "Avg response: {{value}}" }, "infrastructure": { "title": "Infrastructure", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 1c45ecad4..589399a83 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -58,8 +58,8 @@ "uptime": "Tiempo de actividad" }, "histogram": { - "avg": "Prom: {{value}} ms", - "max": "Máx: {{value}} ms" + "avg": "Prom: {{value}}", + "max": "Máx: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Tiempo de respuesta" }, "chart": { - "avg": "prom {{value}}ms", + "avg": "prom {{value}}", "downTooltip": "Caído", "heatmapAria": "Mapa de calor del tiempo de respuesta", - "max": "máx {{value}}ms" + "max": "máx {{value}}" }, "status": { "breached": "Umbral superado", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 3ca1de82a..030698d44 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -58,8 +58,8 @@ "uptime": "Käytettävyys" }, "histogram": { - "avg": "Keskim.: {{value}} ms", - "max": "Maks.: {{value}} ms" + "avg": "Keskim.: {{value}}", + "max": "Maks.: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Vasteaika" }, "chart": { - "avg": "ka {{value}}ms", + "avg": "ka {{value}}", "downTooltip": "Alhaalla", "heatmapAria": "Vasteajan lämpökartta", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Kynnys ylittyi", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 9b3e983a6..1e0a6b096 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -58,8 +58,8 @@ "uptime": "Disponibilité" }, "histogram": { - "avg": "Moy : {{value}} ms", - "max": "Max : {{value}} ms" + "avg": "Moy : {{value}}", + "max": "Max : {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Temps de réponse" }, "chart": { - "avg": "moy {{value}}ms", + "avg": "moy {{value}}", "downTooltip": "Hors service", "heatmapAria": "Carte thermique des temps de réponse", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Seuil dépassé", diff --git a/client/src/locales/it.json b/client/src/locales/it.json index cabeb94ef..3d6d1ea16 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -71,8 +71,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Media: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Media: {{value}}", + "max": "Max: {{value}}" } }, "dialogs": { @@ -1442,8 +1442,8 @@ "initializing": "Inizializzazione" }, "chart": { - "avg": "media {{value}}ms", - "max": "max {{value}}ms", + "avg": "media {{value}}", + "max": "max {{value}}", "downTooltip": "Non attivo", "heatmapAria": "Heatmap del tempo di risposta" }, diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 031350b20..16e2ec641 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -58,8 +58,8 @@ "uptime": "稼働時間" }, "histogram": { - "avg": "平均: {{value}} ms", - "max": "最大: {{value}} ms" + "avg": "平均: {{value}}", + "max": "最大: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "応答時間" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "ダウン", "heatmapAria": "応答時間のヒートマップ", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "しきい値超過", diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 9a74b6cdb..81509ee6f 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -53,8 +53,8 @@ }, "charts": { "histogram": { - "avg": "Średnia: {{value}} ms", - "max": "Maks: {{value}} ms" + "avg": "Średnia: {{value}}", + "max": "Maks: {{value}}" }, "labels": { "averageResponseTime": "Średni czas odpowiedzi", @@ -1542,10 +1542,10 @@ }, "monitorsList": { "chart": { - "avg": "śr. {{value}}ms", + "avg": "śr. {{value}}", "downTooltip": "Niedostępny", "heatmapAria": "Heatmapa czasu odpowiedzi", - "max": "maks. {{value}}ms" + "max": "maks. {{value}}" }, "chartTypeHeatmap": "Heatmapa", "chartTypeHistogram": "Histogram", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 0286f2857..c75ead3ef 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -58,8 +58,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Média: {{value}} ms", - "max": "Máx: {{value}} ms" + "avg": "Média: {{value}}", + "max": "Máx: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Tempo de resposta" }, "chart": { - "avg": "méd {{value}}ms", + "avg": "méd {{value}}", "downTooltip": "Fora do ar", "heatmapAria": "Mapa de calor do tempo de resposta", - "max": "máx {{value}}ms" + "max": "máx {{value}}" }, "status": { "breached": "Limite excedido", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 50226037a..cf5e486f7 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -58,8 +58,8 @@ "uptime": "Аптайм" }, "histogram": { - "avg": "Сред: {{value}} мс", - "max": "Макс: {{value}} мс" + "avg": "Сред: {{value}}", + "max": "Макс: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Время ответа" }, "chart": { - "avg": "ср {{value}}мс", + "avg": "ср {{value}}", "downTooltip": "Не работает", "heatmapAria": "Тепловая карта времени отклика", - "max": "макс {{value}}мс" + "max": "макс {{value}}" }, "status": { "breached": "Превышен порог", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index c0889a72f..66d354adc 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -58,8 +58,8 @@ "uptime": "อัปไทม์" }, "histogram": { - "avg": "เฉลี่ย: {{value}} ms", - "max": "สูงสุด: {{value}} ms" + "avg": "เฉลี่ย: {{value}}", + "max": "สูงสุด: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "เวลาตอบสนอง" }, "chart": { - "avg": "เฉลี่ย {{value}}ms", + "avg": "เฉลี่ย {{value}}", "downTooltip": "ไม่พร้อมใช้งาน", "heatmapAria": "แผนที่ความร้อนของเวลาตอบสนอง", - "max": "สูงสุด {{value}}ms" + "max": "สูงสุด {{value}}" }, "status": { "breached": "เกินขีดจำกัด", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index c82091a83..26f8aace0 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -58,8 +58,8 @@ "uptime": "Çalışma süresi" }, "histogram": { - "avg": "Ort: {{value}} ms", - "max": "Maks: {{value}} ms" + "avg": "Ort: {{value}}", + "max": "Maks: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Yanıt süresi" }, "chart": { - "avg": "ort {{value}}ms", + "avg": "ort {{value}}", "downTooltip": "Çalışmıyor", "heatmapAria": "Yanıt süresi ısı haritası", - "max": "maks {{value}}ms" + "max": "maks {{value}}" }, "status": { "breached": "Eşik aşıldı", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 6c3b47b66..d1cb983d1 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -58,8 +58,8 @@ "uptime": "Час роботи" }, "histogram": { - "avg": "Сер: {{value}} мс", - "max": "Макс: {{value}} мс" + "avg": "Сер: {{value}}", + "max": "Макс: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Час відповіді" }, "chart": { - "avg": "сер {{value}}мс", + "avg": "сер {{value}}", "downTooltip": "Не працює", "heatmapAria": "Теплова карта часу відгуку", - "max": "макс {{value}}мс" + "max": "макс {{value}}" }, "status": { "breached": "Перевищено поріг", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 72c933601..81da1c6fc 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -58,8 +58,8 @@ "uptime": "Thời gian hoạt động" }, "histogram": { - "avg": "TB: {{value}} ms", - "max": "Tối đa: {{value}} ms" + "avg": "TB: {{value}}", + "max": "Tối đa: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Thời gian phản hồi" }, "chart": { - "avg": "TB {{value}}ms", + "avg": "TB {{value}}", "downTooltip": "Ngừng hoạt động", "heatmapAria": "Bản đồ nhiệt thời gian phản hồi", - "max": "tối đa {{value}}ms" + "max": "tối đa {{value}}" }, "status": { "breached": "Vượt ngưỡng", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 4499dd5fb..1a455d41c 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -58,8 +58,8 @@ "uptime": "正常运行时间" }, "histogram": { - "avg": "平均: {{value}} ms", - "max": "最大: {{value}} ms" + "avg": "平均: {{value}}", + "max": "最大: {{value}}" } }, "dialogs": { @@ -443,6 +443,7 @@ "statBoxes": { "activeFor": "已活跃", "certificateExpiry": "证书到期", + "domainExpiry": "域名到期", "lastCheck": "上次检查", "lastResponseTime": "上次响应时间", "serviceIsDown": "服务已停止" @@ -1352,10 +1353,10 @@ "responseTime": "响应时间" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "宕机", "heatmapAria": "响应时间热图", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "超出阈值", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 7b560ae7d..a15a2a2bb 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -58,8 +58,8 @@ "uptime": "正常運行時間" }, "histogram": { - "avg": "平均:{{value}} ms", - "max": "最大:{{value}} ms" + "avg": "平均:{{value}}", + "max": "最大:{{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "回應時間" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "停機", "heatmapAria": "回應時間熱圖", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "超過閾值", diff --git a/server/openapi.json b/server/openapi.json index 4b710b639..c2408b580 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -34,7 +34,7 @@ }, { "name": "notifications", - "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts." + "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts." }, { "name": "maintenance-window", @@ -48,6 +48,10 @@ "name": "settings", "description": "Global application settings (admin/superadmin)." }, + { + "name": "proxies", + "description": "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." + }, { "name": "invite", "description": "Team invitations and accepting them." @@ -373,6 +377,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -486,6 +497,7 @@ "statusWindowSize", "statusWindowThreshold", "ignoreTlsErrors", + "proxyMode", "useAdvancedMatching", "method", "notifications", @@ -536,6 +548,349 @@ "updatedAt": "2026-04-15T14:30:00.000Z" } }, + "UptimeDetails": { + "type": "object", + "properties": { + "monitorData": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "statusWindowSize": { + "type": "number" + }, + "statusWindowThreshold": { + "type": "number" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, + "secret": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number" + }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "teamId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "lastEvaluatedAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "proxyMode", + "useAdvancedMatching", + "method", + "notifications", + "tags", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt", + "lastEvaluatedAt", + "id" + ], + "additionalProperties": { + "nullable": true + } + }, + "groupedChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucketDate": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + }, + "avgDns": { + "type": "number" + }, + "avgTcp": { + "type": "number" + }, + "avgTls": { + "type": "number" + }, + "avgRequest": { + "type": "number" + }, + "avgFirstByte": { + "type": "number" + }, + "avgDownload": { + "type": "number" + } + }, + "required": [ + "bucketDate", + "avgResponseTime", + "totalChecks", + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "avgDownload" + ] + } + }, + "groupedUpChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucketDate": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + } + }, + "required": ["bucketDate", "avgResponseTime", "totalChecks"] + } + }, + "groupedDownChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucketDate": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + } + }, + "required": ["bucketDate", "avgResponseTime", "totalChecks"] + } + }, + "groupedAvgResponseTime": { + "type": "number" + }, + "groupedUptimePercentage": { + "type": "number" + } + }, + "required": ["monitor", "groupedChecks", "groupedUpChecks", "groupedDownChecks", "groupedAvgResponseTime", "groupedUptimePercentage"] + }, + "monitorStats": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "monitorId": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "maxResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + }, + "totalUpChecks": { + "type": "number" + }, + "totalDownChecks": { + "type": "number" + }, + "uptimePercentage": { + "type": "number" + }, + "lastCheckTimestamp": { + "type": "number" + }, + "lastResponseTime": { + "type": "number" + }, + "timeOfLastFailure": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "monitorId", + "avgResponseTime", + "maxResponseTime", + "totalChecks", + "totalUpChecks", + "totalDownChecks", + "uptimePercentage", + "lastCheckTimestamp", + "lastResponseTime", + "createdAt", + "updatedAt" + ] + } + }, + "required": ["monitorData", "monitorStats"] + }, "NotificationChannelBody": { "oneOf": [ { @@ -544,6 +899,9 @@ { "$ref": "#/components/schemas/WebhookNotification" }, + { + "$ref": "#/components/schemas/RocketChatNotification" + }, { "$ref": "#/components/schemas/SlackNotification" }, @@ -577,6 +935,7 @@ "mapping": { "email": "#/components/schemas/EmailNotification", "webhook": "#/components/schemas/WebhookNotification", + "rocket_chat": "#/components/schemas/RocketChatNotification", "slack": "#/components/schemas/SlackNotification", "discord": "#/components/schemas/DiscordNotification", "pager_duty": "#/components/schemas/PagerDutyNotification", @@ -701,6 +1060,29 @@ "address": "https://example.com/hooks/checkmate" } }, + "RocketChatNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["rocket_chat"] + }, + "address": { + "type": "string", + "format": "uri" + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Rocket.Chat alerts", + "type": "rocket_chat", + "address": "https://chat.example.com/hooks/integration-id/token" + } + }, "SlackNotification": { "type": "object", "properties": { @@ -1048,6 +1430,32 @@ "topic": { "type": "string", "minLength": 1 + }, + "ntfyAuthType": { + "type": "string", + "enum": ["none", "token", "basic"] + }, + "ntfyUsername": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] } }, "required": ["notificationName", "type", "address", "topic"], @@ -1055,7 +1463,296 @@ "notificationName": "ntfy topic", "type": "ntfy", "address": "https://ntfy.sh", - "topic": "checkmate-alerts" + "topic": "checkmate-alerts", + "ntfyAuthType": "token", + "accessToken": "tk_your-ntfy-access-token" + } + }, + "PublicStatusPagePayload": { + "type": "object", + "properties": { + "statusPage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "type": { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + }, + "companyName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "customDomain": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "originalMonitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "logo": { + "type": "object", + "nullable": true, + "properties": { + "data": { + "type": "string" + }, + "contentType": { + "type": "string" + } + }, + "required": ["data", "contentType"] + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "customCSS": { + "type": "string" + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial", "minimal"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "teamId", + "type", + "companyName", + "url", + "color", + "monitors", + "subMonitors", + "isPublished", + "showCharts", + "showUptimePercentage", + "showAdminLoginLink", + "showInfrastructure", + "customCSS", + "theme", + "themeMode", + "createdAt", + "updatedAt" + ] + }, + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "uptimePercentage": { + "type": "number" + }, + "recentChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "boolean" + }, + "responseTime": { + "type": "number" + }, + "statusCode": { + "type": "number" + }, + "message": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "accessibility": { + "type": "number" + }, + "bestPractices": { + "type": "number" + }, + "seo": { + "type": "number" + }, + "performance": { + "type": "number" + } + }, + "required": ["id", "status", "responseTime", "statusCode", "message", "createdAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "dailyChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "monitorId": { + "type": "string" + }, + "date": { + "type": "string" + }, + "totalChecks": { + "type": "number" + }, + "upChecks": { + "type": "number" + }, + "downChecks": { + "type": "number" + }, + "avgResponseTime": { + "type": "number", + "nullable": true + } + }, + "required": ["monitorId", "date", "totalChecks", "upChecks", "downChecks", "avgResponseTime"] + } + } + }, + "required": ["id", "name", "type", "status", "recentChecks"] + } + }, + "range": { + "type": "string", + "enum": ["30d", "60d", "90d"] + }, + "bucketTimezone": { + "type": "string" + }, + "checkTTLDays": { + "type": "number" + } + }, + "required": ["statusPage", "monitors"], + "example": { + "statusPage": { + "id": "65f1c2a4d8b9e0123456789c", + "userId": "65f1c2a4d8b9e01234567891", + "teamId": "65f1c2a4d8b9e01234567890", + "type": ["uptime"], + "companyName": "Acme", + "url": "acme-status", + "customDomain": null, + "timezone": "America/Toronto", + "color": "#4169E1", + "monitors": ["65f1c2a4d8b9e0123456789a"], + "subMonitors": [], + "isPublished": true, + "showCharts": true, + "showUptimePercentage": true, + "showAdminLoginLink": false, + "showInfrastructure": false, + "customCSS": "", + "theme": "refined", + "themeMode": "auto", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "range": "90d", + "bucketTimezone": "America/Toronto", + "checkTTLDays": 30, + "monitors": [ + { + "id": "65f1c2a4d8b9e0123456789a", + "name": "API", + "type": "http", + "status": "up", + "uptimePercentage": 0.9987, + "recentChecks": [], + "dailyChecks": [ + { + "monitorId": "65f1c2a4d8b9e0123456789a", + "date": "2026-07-19", + "totalChecks": 2880, + "upChecks": 2877, + "downChecks": 3, + "avgResponseTime": 142 + } + ] + } + ] } } }, @@ -2426,6 +3123,9 @@ "minLength": 8, "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" }, + "deleteProfileImage": { + "type": "boolean" + }, "profileImage": { "type": "string", "format": "binary" @@ -3747,7 +4447,7 @@ { "schema": { "type": "string", - "enum": ["manual", "automatic"] + "enum": ["automatic", "manual"] }, "required": false, "name": "resolutionType", @@ -6013,14 +6713,6 @@ "required": true, "name": "dateRange", "in": "query" - }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "normalize", - "in": "query" } ], "responses": { @@ -6039,10 +6731,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/UptimeDetails" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } @@ -6432,14 +7124,6 @@ "name": "numToDisplay", "in": "query" }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "normalize", - "in": "query" - }, { "schema": { "anyOf": [ @@ -6795,6 +7479,133 @@ } } }, + "/monitors/domain/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get domain registration expiry for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "nullable": true + }, + "expiryDate": { + "type": "string", + "nullable": true + } + }, + "required": ["domain", "expiryDate"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/monitors/notifications": { "patch": { "tags": ["monitors"], @@ -6994,6 +7805,14 @@ "type": "boolean", "default": false }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"], + "default": "inherit" + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean", "default": false @@ -7641,6 +8460,14 @@ "type": "boolean", "default": false }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"], + "default": "inherit" + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean", "default": false @@ -8259,6 +9086,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -9419,6 +10253,990 @@ } } }, + "/proxies": { + "post": { + "tags": ["proxies"], + "summary": "Create a proxy for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": ["name", "protocol", "host", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "get": { + "tags": ["proxies"], + "summary": "List all proxies on the instance (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/proxies/team": { + "get": { + "tags": ["proxies"], + "summary": "List proxies for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/proxies/{id}": { + "get": { + "tags": ["proxies"], + "summary": "Get a proxy by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["proxies"], + "summary": "Edit a proxy", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "clearPassword": { + "type": "boolean" + }, + "clearUsername": { + "type": "boolean" + } + }, + "required": ["name", "protocol", "host", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["proxies"], + "summary": "Delete a proxy", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "409": { + "description": "Proxy is in use by monitors or set as the global proxy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/queue/jobs": { "get": { "tags": ["queue"], @@ -10048,6 +11866,13 @@ "maximum": 150 } } + }, + "globalProxyEnabled": { + "type": "boolean" + }, + "globalProxyId": { + "type": "string", + "nullable": true } } } @@ -10538,7 +12363,7 @@ }, "theme": { "type": "string", - "enum": ["refined", "modern", "bold", "editorial"] + "enum": ["refined", "modern", "bold", "editorial", "minimal"] }, "themeMode": { "type": "string", @@ -10756,7 +12581,7 @@ }, "theme": { "type": "string", - "enum": ["refined", "modern", "bold", "editorial"] + "enum": ["refined", "modern", "bold", "editorial", "minimal"] }, "themeMode": { "type": "string", @@ -10999,11 +12824,12 @@ }, { "schema": { - "type": "number", - "nullable": true + "type": "string", + "enum": ["latest", "30d", "60d", "90d"], + "default": "latest" }, "required": false, - "name": "timeFrame", + "name": "range", "in": "query" }, { @@ -11031,10 +12857,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/PublicStatusPagePayload" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } @@ -11104,11 +12930,12 @@ }, { "schema": { - "type": "number", - "nullable": true + "type": "string", + "enum": ["latest", "30d", "60d", "90d"], + "default": "latest" }, "required": false, - "name": "timeFrame", + "name": "range", "in": "query" } ], @@ -11128,10 +12955,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/PublicStatusPagePayload" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } diff --git a/server/openapi/index.ts b/server/openapi/index.ts index ef3e0b1d0..a50008a75 100644 --- a/server/openapi/index.ts +++ b/server/openapi/index.ts @@ -9,6 +9,7 @@ import "./routes/log.js"; import "./routes/maintenanceWindow.js"; import "./routes/monitor.js"; import "./routes/notification.js"; +import "./routes/proxies.js"; import "./routes/queue.js"; import "./routes/settings.js"; import "./routes/statusPage.js"; @@ -39,11 +40,13 @@ export function getOpenApiSpec(): JsonObject { { name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." }, { name: "notifications", - description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.", + description: + "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts.", }, { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, { name: "settings", description: "Global application settings (admin/superadmin)." }, + { name: "proxies", description: "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." }, { name: "invite", description: "Team invitations and accepting them." }, { name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." }, { name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." }, diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index a941e54bf..ddb207eb8 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -7,6 +7,7 @@ import { getMonitorsByTeamIdQueryValidation, getMonitorsWithChecksQueryValidation, getCertificateParamValidation, + getDomainParamValidation, createMonitorBodyValidation, editMonitorBodyValidation, pauseMonitorParamValidation, @@ -16,6 +17,7 @@ import { getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, monitorResponseSchema, + uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; @@ -81,6 +83,8 @@ registry.registerPath({ responses: { "200": okJson(z.array(z.string())), ...standardErrors }, }); +const uptimeDetailsObject = uptimeDetailsResponseSchema.openapi("UptimeDetails"); + registry.registerPath({ method: "get", path: "/monitors/uptime/details/{monitorId}", @@ -88,7 +92,7 @@ registry.registerPath({ summary: "Get uptime details for a monitor", security: bearer, request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation }, - responses: { "200": okUnknown, ...standardErrors }, + responses: { "200": okJson(uptimeDetailsObject), ...standardErrors }, }); registry.registerPath({ @@ -141,6 +145,19 @@ registry.registerPath({ responses: { "200": okJson(z.object({ certificateDate: z.string() })), ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/domain/{monitorId}", + tags, + summary: "Get domain registration expiry for a monitor", + security: bearer, + request: { params: getDomainParamValidation }, + responses: { + "200": okJson(z.object({ domain: z.string().nullable(), expiryDate: z.string().nullable() })), + ...standardErrors, + }, +}); + registry.registerPath({ method: "patch", path: "/monitors/notifications", diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index f1c70ca6d..5950ab4a3 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -23,6 +23,14 @@ const notificationVariantMeta: Record=20" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -100,28 +108,43 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz", - "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -141,702 +164,6 @@ "zod": "^4.0.0" } }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-sesv2": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.1003.0.tgz", - "integrity": "sha512-NR1rfYBcT4QWMG+VN2CTgbtWpf8I6aYsrU81hslucQd+z9O0qHMzZ3KHEdIcT9+PGYtqDwqhxUArboF7G09Oiw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-node": "^3.972.17", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.973.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.18.tgz", - "integrity": "sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/xml-builder": "^3.972.10", - "@smithy/core": "^3.23.8", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.16.tgz", - "integrity": "sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.18.tgz", - "integrity": "sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.16.tgz", - "integrity": "sha512-hzAnzNXKV0A4knFRWGu2NCt72P4WWxpEGnOc6H3DptUjC4oX3hGw846oN76M1rTHAOwDdbhjU0GAOWR4OUfTZg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-env": "^3.972.16", - "@aws-sdk/credential-provider-http": "^3.972.18", - "@aws-sdk/credential-provider-login": "^3.972.16", - "@aws-sdk/credential-provider-process": "^3.972.16", - "@aws-sdk/credential-provider-sso": "^3.972.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.16", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.16.tgz", - "integrity": "sha512-VI0kXTlr0o1FTay+Jvx6AKqx5ECBgp7X4VevGBEbuXdCXnNp7SPU0KvjsOLVhIz3OoPK4/lTXphk43t0IVk65w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.17.tgz", - "integrity": "sha512-98MAcQ2Dk7zkvgwZ5f6fLX2lTyptC3gTSDx4EpvTdJWET8qs9lBPYggoYx7GmKp/5uk0OwVl0hxIDZsDNS/Y9g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.16", - "@aws-sdk/credential-provider-http": "^3.972.18", - "@aws-sdk/credential-provider-ini": "^3.972.16", - "@aws-sdk/credential-provider-process": "^3.972.16", - "@aws-sdk/credential-provider-sso": "^3.972.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.16", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.16.tgz", - "integrity": "sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.16.tgz", - "integrity": "sha512-b9of7tQgERxgcEcwAFWvRe84ivw+Kw6b3jVuz/6LQzonkomiY5UoWfprkbjc8FSCQ2VjDqKTvIRA9F0KSQ025w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/token-providers": "3.1003.0", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.16.tgz", - "integrity": "sha512-PaOH5jFoPQX4WkqpKzKh9cM7rieKtbgEGqrZ+ybGmotJhcvhI/xl69yCwMbHGnpQJJmHZIX9q2zaPB7HTBn/4w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", - "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", - "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", - "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.18.tgz", - "integrity": "sha512-5E3XxaElrdyk6ZJ0TjH7Qm6ios4b/qQCiLr6oQ8NK7e4Kn6JBTJCaYioQCQ65BpZ1+l1mK5wTAac2+pEz0Smpw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.8", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.18.tgz", - "integrity": "sha512-KcqQDs/7WtoEnp52+879f8/i1XAJkgka5i4arOtOCPR10o4wWo3VRecDI9Gxoh6oghmLCnIiOSKyRcXI/50E+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@smithy/core": "^3.23.8", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.6.tgz", - "integrity": "sha512-blNJ3ugn4gCQ9ZSZi/firzKCvVl5LvPFVxv24LprENeWI4R8UApG006UQkF4SkmLygKq2BQXRad2/anQ13Te4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", - "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.6.tgz", - "integrity": "sha512-NnsOQsVmJXy4+IdPFUjRCWPn9qNH1TzS/f7MiWgXeoHs903tJpAWQWQtoFvLccyPoBgomKP9L89RRr2YsT/L0g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1003.0.tgz", - "integrity": "sha512-SOyyWNdT7njKRwtZ1JhwHlH1csv6Pkgf305X96/OIfnhq1pU/EjmT6W6por57rVrjrKuHBuEIXgpWv8OgoMHpg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", - "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", - "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-endpoints": "^3.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", - "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.3.tgz", - "integrity": "sha512-8s2cQmTUOwcBlIJyI9PAZNnnnF+cGtdhHc1yzMMsSD/GR/Hxj7m0IGUE92CslXXb8/p5Q76iqOCjN1GFwyf+1A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -903,14 +230,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -936,16 +263,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1052,13 +369,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1123,13 +440,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1331,18 +648,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1350,9 +667,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -1376,10 +693,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@colordx/core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.5.0.tgz", - "integrity": "sha512-3PxTH8itZzltK0U9jTwVVnjLXvnDYuq3m+QXsHkENxWiPRh4WaoLcs1SQjqgZ55kS+QyirpH5BVwzP2gMVG6EQ==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.6.0.tgz", + "integrity": "sha512-EDlcg/Hmlj1WuFh/Os9YhA+A2aasB2XlgwFfGePUDuW7fVHC07HBi5AFIMx0Ct1eM8kHM9NRyOtZ0MuVYa8xvg==", "license": "MIT" }, "node_modules/@colors/colors": { @@ -1507,9 +836,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", "funding": [ { "type": "github", @@ -1521,11 +850,13 @@ } ], "license": "MIT-0", - "engines": { - "node": ">=18" - }, "peerDependencies": { - "postcss": "^8.4" + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, "node_modules/@csstools/css-tokenizer": { @@ -1559,22 +890,22 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -1582,9 +913,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -2036,9 +1367,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -2054,19 +1385,6 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -2078,37 +1396,68 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2119,20 +1468,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -2142,6 +1491,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -2155,10 +1522,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -2179,19 +1569,36 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", @@ -2206,14 +1613,14 @@ } }, "node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", - "protobufjs": "^7.5.3", + "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { @@ -2224,29 +1631,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2275,10 +1696,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -2288,19 +1718,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -2310,19 +1740,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -2336,9 +1785,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -2352,9 +1801,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -2368,9 +1817,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -2383,10 +1832,42 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -2400,9 +1881,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -2416,9 +1897,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -2432,9 +1913,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -2448,9 +1929,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -2460,19 +1941,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -2482,19 +1963,63 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -2504,19 +2029,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -2526,19 +2051,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -2548,19 +2073,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -2570,38 +2095,73 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -2611,16 +2171,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -2630,7 +2190,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2707,9 +2267,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -2773,9 +2333,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -3184,46 +2744,36 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", - "integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.0.tgz", + "integrity": "sha512-Hk1SKJCMcCos38+vqDnZzlIo4XRj9yCGzYkjB4LcqpeXRIYfia1UWTz+VrueLxoU+uSRJzgkufxoRZg8gi52YA==", "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3343,9 +2893,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@scarf/scarf": { @@ -3362,16 +2912,16 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, "node_modules/@sindresorhus/is": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.0.tgz", - "integrity": "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "license": "MIT", "engines": { "node": ">=18" @@ -3400,626 +2950,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", - "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", - "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.8.tgz", - "integrity": "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.2.12", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", - "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", - "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", - "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", - "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", - "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.22", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.22.tgz", - "integrity": "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-middleware": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.39", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.39.tgz", - "integrity": "sha512-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/service-error-classification": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", - "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", - "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", - "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", - "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", - "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", - "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", - "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", - "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", - "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", - "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", - "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.2.tgz", - "integrity": "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", - "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.38", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.38.tgz", - "integrity": "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.41", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.41.tgz", - "integrity": "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.10", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", - "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", - "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", - "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.17", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", - "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -4030,52 +2960,6 @@ "text-hex": "1.0.x" } }, - "node_modules/@so-ric/colorspace/node_modules/color": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", - "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.0.1", - "color-string": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-convert": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", - "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", - "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-string": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", - "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -4089,9 +2973,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", "dev": true, "license": "MIT" }, @@ -4117,9 +3001,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -4236,9 +3120,9 @@ } }, "node_modules/@types/dockerode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.0.tgz", - "integrity": "sha512-NBm/UIL1KYWivh9ihJELK4Jv0dzOQCleS0OT5JklErra8mZ/t0UQpHfTkMmPrAqtra7OyFius1EFLVs0DhiHQA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4248,9 +3132,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -4267,9 +3151,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "dev": true, "license": "MIT", "dependencies": { @@ -4287,9 +3171,9 @@ "license": "MIT" }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "license": "MIT" }, "node_modules/@types/http-errors": { @@ -4374,13 +3258,6 @@ "@types/node": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mjml": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-4.7.4.tgz", @@ -4392,9 +3269,9 @@ } }, "node_modules/@types/mjml-core": { - "version": "4.15.2", - "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-4.15.2.tgz", - "integrity": "sha512-Q7SxFXgoX979HP57DEVsRI50TV8x1V4lfCA4Up9AvfINDM5oD/X9ARgfoyX1qS987JCnDLv85JjkqAjt3hZSiQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-5.1.0.tgz", + "integrity": "sha512-dYYmvGIHFi4wzsV+zX7UhP4C5QFSKeTjk53uQcadyIpTrrpJSs8JCZEKfSCrbKS7Hev3aR62b2dg58IeuwiR/w==", "dev": true, "license": "MIT" }, @@ -4406,9 +3283,9 @@ "license": "MIT" }, "node_modules/@types/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", "dev": true, "license": "MIT", "dependencies": { @@ -4416,22 +3293,21 @@ } }, "node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", + "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/nodemailer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.1.tgz", - "integrity": "sha512-UfHAghPmGZVzaL8x9y+mKZMWyHC399+iq0MOmya5tIyenWX3lcdSb60vOmp0DocR6gCDTYTozv/ULQnREyyjkg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", "dev": true, "license": "MIT", "dependencies": { - "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, @@ -4453,9 +3329,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -4483,25 +3359,13 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -4577,9 +3441,10 @@ "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/webidl-conversions": "*" @@ -4613,20 +3478,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4636,32 +3501,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "engines": { @@ -4673,36 +3528,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "engines": { @@ -4713,36 +3550,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4753,9 +3572,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", "dev": true, "license": "MIT", "engines": { @@ -4766,21 +3585,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4791,31 +3610,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", "dev": true, "license": "MIT", "engines": { @@ -4827,21 +3628,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4851,77 +3652,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4932,17 +3676,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4967,9 +3711,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -5321,12 +4065,12 @@ ] }, "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/accepts": { @@ -5352,9 +4096,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -5375,9 +4119,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -5388,18 +4132,21 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5452,9 +4199,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "license": "MIT", "engines": { "node": ">=12" @@ -5498,6 +4245,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -5565,42 +4325,17 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -5658,43 +4393,6 @@ "node": ">=12" } }, - "node_modules/babel-plugin-istanbul/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/babel-plugin-jest-hoist": { "version": "30.4.0", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", @@ -5753,10 +4451,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-events": { "version": "2.9.1", @@ -5774,9 +4475,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", - "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5787,7 +4488,7 @@ "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.16.0" + "bare": ">=1.28.0" }, "peerDependencies": { "bare-buffer": "*" @@ -5798,33 +4499,21 @@ } } }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, "node_modules/bare-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", - "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } + "license": "Apache-2.0" }, "node_modules/bare-stream": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.2.tgz", - "integrity": "sha512-2V5j0dOfxv3iIngIWMnW1O6UPXpeoU70HJzUJGVth/+RURhDyq7SEWTD70Y2SkUB0MQonYYWpIX3f85P/I22+Q==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, @@ -5846,9 +4535,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", - "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5903,9 +4592,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5966,9 +4655,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -5998,63 +4687,28 @@ "ms": "2.0.0" } }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -6071,9 +4725,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -6090,11 +4744,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6127,12 +4781,13 @@ } }, "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.2.tgz", + "integrity": "sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==", + "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=16.20.1" + "node": ">=20.19.0" } }, "node_modules/buffer": { @@ -6288,9 +4943,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -6373,53 +5028,33 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -6433,9 +5068,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "dev": true, "license": "MIT" }, @@ -6456,14 +5091,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -6473,14 +5108,14 @@ } }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -6518,6 +5153,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6580,16 +5224,16 @@ "license": "MIT" }, "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" + "color-convert": "^3.1.3", + "color-string": "^2.1.3" }, "engines": { - "node": ">=12.5.0" + "node": ">=18" } }, "node_modules/color-convert": { @@ -6611,13 +5255,45 @@ "license": "MIT" }, "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" } }, "node_modules/colorette": { @@ -6643,6 +5319,7 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -6831,9 +5508,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -6841,6 +5518,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { @@ -6933,13 +5614,13 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -7109,15 +5790,6 @@ "node": ">=18" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7132,9 +5804,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7390,9 +6062,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7471,10 +6143,16 @@ "node": ">=14" } }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7511,9 +6189,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.388", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", - "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "version": "1.5.414", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.414.tgz", + "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==", "license": "ISC" }, "node_modules/emittery": { @@ -7646,9 +6324,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7755,25 +6433,26 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -7792,7 +6471,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -7908,6 +6587,37 @@ } }, "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", @@ -7920,6 +6630,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -7948,6 +6681,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -7963,9 +6709,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8179,11 +6925,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { @@ -8211,12 +6958,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8275,45 +7016,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -8334,35 +7036,29 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8390,17 +7086,17 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -8498,9 +7194,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -8571,18 +7267,6 @@ "node": ">= 18" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8763,34 +7447,6 @@ "node": ">=12.20" } }, - "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gaxios/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gbxremote": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/gbxremote/-/gbxremote-0.2.1.tgz", @@ -8826,9 +7482,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -8902,9 +7558,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -8918,6 +7574,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -8947,10 +7604,16 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -9005,6 +7668,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9119,12 +7792,24 @@ } }, "node_modules/helmet": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", - "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", "license": "MIT", "engines": { "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" } }, "node_modules/html-encoding-sniffer": { @@ -9147,15 +7832,16 @@ "license": "MIT" }, "node_modules/htmlnano": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-3.3.2.tgz", - "integrity": "sha512-VtiwPbplKD8Xp/6mCJxbiTnJaqQvwIp+IovfFmgNL42Ltksl94zxP4YbVdR5qQ5shtEBhYzx+vpGc8v4QnjoyQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-3.4.0.tgz", + "integrity": "sha512-5rgW9c/830dlDiWLlsT3ZLBs52UAupymGwkwr4rn2yyuzrNY0RZwgr6F4q2AkUqrRuuHb3kPelqYsUgtzQDtQw==", "license": "MIT", "dependencies": { "@types/relateurl": "^0.2.33", - "commander": "^14.0.0", + "commander": "^15.0.0", "cosmiconfig": "^9.0.0", - "posthtml": "^0.16.5" + "posthtml": "^0.16.5", + "tinyglobby": "^0.2.17" }, "bin": { "htmlnano": "dist/bin.js" @@ -9197,6 +7883,15 @@ } } }, + "node_modules/htmlnano/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -9223,19 +7918,23 @@ "license": "BSD-2-Clause" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -9251,6 +7950,15 @@ "node": ">= 14" } }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/http2-wrapper": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", @@ -9265,16 +7973,16 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/human-signals": { @@ -9320,9 +8028,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -9407,22 +8115,27 @@ "license": "ISC" }, "node_modules/install-artifact-from-github": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.6.0.tgz", - "integrity": "sha512-wKsuzN8fy8QK7iEUqyWTQmvZ1QFGPn1xyl3/1iIIDthDjS7Hn9HoPwHlNakZirWbCsbad0lZMkr6Xfbpe1pUzw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.8.0.tgz", + "integrity": "sha512-EA861BGTOO91yuymA5V6Sx//J5471NBpMHnVZJetquqOYknm4zz25Y2tk/f74ggxerqtd8nfBrn9eaR0oxReEA==", "license": "BSD-3-Clause", "bin": { + "hash-github-cache": "bin/hash-github-cache.js", "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/uhop" } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -9467,12 +8180,19 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-generator-fn": { @@ -9545,99 +8265,248 @@ "license": "ISC" }, "node_modules/isomorphic-dompurify": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.30.1.tgz", - "integrity": "sha512-VJFbthRrns7BE+q3qSUJ5zxGNjuq4FqiaWXKCwnMoJbumnoQJoeOeOzP/oejKLPPtENckLWoDxGQiv5OkEFC+Q==", + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz", + "integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==", "license": "MIT", "dependencies": { - "dompurify": "^3.3.0", - "jsdom": "^27.0.1" + "dompurify": "^3.3.1", + "jsdom": "^28.0.0" }, "engines": { "node": ">=20.19.5" } }, "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/css-color": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", - "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.1" + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/isomorphic-dompurify/node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" }, "engines": { "node": ">=20" } }, "node_modules/isomorphic-dompurify/node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/isomorphic-dompurify/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/isomorphic-dompurify/node_modules/jsdom": { - "version": "27.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", - "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "node_modules/isomorphic-dompurify/node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^6.7.2", - "cssstyle": "^5.3.1", - "data-urls": "^6.0.0", + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/isomorphic-dompurify/node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", - "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", + "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -9649,48 +8518,30 @@ } }, "node_modules/isomorphic-dompurify/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/isomorphic-dompurify/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/isomorphic-dompurify/node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.17" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/isomorphic-dompurify/node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", - "license": "MIT" - }, "node_modules/isomorphic-dompurify/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -9711,54 +8562,45 @@ "node": ">=20" } }, + "node_modules/isomorphic-dompurify/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/isomorphic-dompurify/node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "license": "BSD-2-Clause", "engines": { "node": ">=20" } }, - "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "node_modules/isomorphic-dompurify/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, "engines": { "node": ">=20" } }, - "node_modules/isomorphic-unfetch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-4.0.2.tgz", - "integrity": "sha512-1Yd+CF/7al18/N2BDbsLBcp6RO3tucSW+jcLq24dqdX5MNbCNTw1z4BsGsp4zNmjr/Izm2cs/cEqZPp4kvWSCA==", + "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "license": "MIT", "dependencies": { - "node-fetch": "^3.2.0", - "unfetch": "^5.0.0" - } - }, - "node_modules/isomorphic-unfetch/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/istanbul-lib-coverage": { @@ -10095,19 +8937,6 @@ "fsevents": "^2.3.3" } }, - "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-leak-detector": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", @@ -10160,19 +8989,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-mock": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", @@ -10369,19 +9185,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", @@ -10496,30 +9299,6 @@ "node": ">=14" } }, - "node_modules/js-beautify/node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/js-beautify/node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/js-cookie": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", @@ -10533,9 +9312,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -10593,6 +9372,28 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10852,19 +9653,18 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.2", + "commander": "^14.0.3", "listr2": "^9.0.5", - "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", + "picomatch": "^4.0.3", "string-argv": "^0.3.2", - "yaml": "^2.8.1" + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -11095,6 +9895,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -11167,20 +9984,13 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/mailersend": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mailersend/-/mailersend-2.6.0.tgz", - "integrity": "sha512-YX2Gyc6Wyw4Q4IsJ4np2Reof8nFWQ2OP/yXZTZcGCz4B7B1BOAYs71Kjb1uNRTTfDChP7rzzWBoOaX6iNlvPAg==", - "license": "MIT", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", "dependencies": { - "gaxios": "^6.0.0", - "isomorphic-unfetch": "^4.0.0", - "qs": "^6.11.0" + "yallist": "^3.0.2" } }, "node_modules/make-dir": { @@ -11226,9 +10036,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -11301,6 +10111,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -11379,16 +10202,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -11401,10 +10226,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -11511,42 +10336,6 @@ "node": ">=18" } }, - "node_modules/mjml-cli/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/mjml-cli/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/mjml-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/mjml-cli/node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -11587,29 +10376,14 @@ } }, "node_modules/mjml-cli/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/mjml-cli/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mjml-cli/node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -11626,19 +10400,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mjml-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/mjml-column": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-5.4.0.tgz", @@ -12028,6 +10789,183 @@ "license": "MIT" }, "node_modules/mongodb": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.6.0.tgz", + "integrity": "sha512-WbZ6OCjYw2c53LOjfkQa+reXr7kIiOVpXXglnASFuiMtif0BvsMwHe3ClJHLm1/r7wJFwaasfFtD6iYIktB01g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz", + "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", + "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "11.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", + "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.16.0", + "https-proxy-agent": "^7.0.6", + "mongodb": "^7.2.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.8", + "tslib": "^2.8.1", + "yauzl": "^3.3.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongoose": { + "version": "8.24.4", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.4.tgz", + "integrity": "sha512-DoV8hYy5ssoh/j99rhYzeS2BLLcLJZ+EGQYfe/Ln+yMorBy0wgKdlwJBj59zJ1N0MnEKQypbeqUYFB0FVThmYg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongoose/node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", @@ -12073,7 +11011,7 @@ } } }, - "node_modules/mongodb-connection-string-url": { + "node_modules/mongoose/node_modules/mongodb-connection-string-url": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", @@ -12083,192 +11021,6 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, - "node_modules/mongodb-memory-server": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", - "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "mongodb-memory-server-core": "11.2.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", - "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-mutex": "^0.5.0", - "camelcase": "^6.3.0", - "debug": "^4.4.3", - "find-cache-dir": "^3.3.2", - "follow-redirects": "^1.16.0", - "https-proxy-agent": "^7.0.6", - "mongodb": "^7.2.0", - "new-find-package-json": "^2.0.0", - "semver": "^7.7.3", - "tar-stream": "^3.1.8", - "tslib": "^2.8.1", - "yauzl": "^3.3.1" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mongodb-memory-server-core/node_modules/mongodb": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.3.0.tgz", - "integrity": "sha512-WpCqSx7JAU9vcyjm/SU7ydnHls2YrfU3Y3sx4Ml9D7sPe4mXPlaapndiurDXrQ7/VvJkB4/i7b7WovHb8bd8sg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.2.0", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-memory-server-core/node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/mongoose": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", - "integrity": "sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==", - "license": "MIT", - "dependencies": { - "bson": "^6.10.4", - "kareem": "2.6.3", - "mongodb": "~6.20.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -12330,28 +11082,15 @@ } }, "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "license": "MIT" }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" - } - }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -12417,90 +11156,37 @@ "node": ">=12.22.0" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-13.0.1.tgz", + "integrity": "sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==", "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" + "undici": "^8.4.1", + "which": "^7.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/node-gyp/node_modules/isexe": { @@ -12512,10 +11198,34 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -12524,7 +11234,7 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/node-int64": { @@ -12535,34 +11245,34 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/nodemailer": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", - "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -12581,6 +11291,44 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -12591,6 +11339,32 @@ "node": ">=4" } }, + "node_modules/nodemon/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -12605,18 +11379,18 @@ } }, "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "license": "ISC", "dependencies": { - "abbrev": "^4.0.0" + "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/normalize-path": { @@ -12630,9 +11404,9 @@ } }, "node_modules/normalize-url": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz", - "integrity": "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", "license": "MIT", "engines": { "node": ">=14.16" @@ -12667,9 +11441,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "license": "MIT" }, "node_modules/object-assign": { @@ -12749,13 +11523,13 @@ } }, "node_modules/openapi3-ts": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", - "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.1.tgz", + "integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==", "dev": true, "license": "MIT", "dependencies": { - "yaml": "^2.8.0" + "yaml": "^2.9.0" } }, "node_modules/optionator": { @@ -12931,22 +11705,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -12982,6 +11740,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -13012,31 +11776,17 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/ping": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/ping/-/ping-0.4.4.tgz", @@ -13139,9 +11889,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -13158,7 +11908,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13549,9 +12299,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -13729,9 +12479,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -13784,12 +12534,12 @@ } }, "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/process-nextick-args": { @@ -13805,9 +12555,9 @@ "license": "ISC" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13857,9 +12607,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -13893,12 +12643,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -13981,48 +12732,19 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/re2": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/re2/-/re2-1.24.1.tgz", - "integrity": "sha512-uRl9cLDKuobJQp+6lVz7E3AyVszubUJ0fqAMWout4ocUWTIFvdHgpqLwwMh/vuNGGGJGh2p2mJZJIQr9am9M/A==", + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/re2/-/re2-1.26.1.tgz", + "integrity": "sha512-oi79a4h6EO3PAwNsDMWgeCcsRGQEUa52DIgOiFTZGDEZocEXG9h+oXy0qZqndo47huUeJuVWSoOJIEhOupqOcg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { - "install-artifact-from-github": "^1.6.0", - "nan": "^2.27.0", - "node-gyp": "^12.3.0" + "install-artifact-from-github": "^1.7.0", + "nan": "^2.28.0", + "node-gyp": "^13.0.1" }, "engines": { - "node": ">=22" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "funding": { "url": "https://github.com/sponsors/uhop" @@ -14038,9 +12760,9 @@ }, "node_modules/react-is-19": { "name": "react-is", - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "dev": true, "license": "MIT" }, @@ -14059,16 +12781,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/require-directory": { @@ -14269,9 +12991,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -14312,9 +13034,9 @@ } }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14324,24 +13046,24 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -14362,25 +13084,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -14393,42 +13106,52 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -14453,14 +13176,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -14472,13 +13195,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -14542,21 +13265,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -14581,17 +13289,17 @@ } }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" @@ -14610,22 +13318,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/slick": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", @@ -14742,9 +13434,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -14759,9 +13451,9 @@ } }, "node_modules/streamx": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.27.0.tgz", - "integrity": "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==", + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", "dev": true, "license": "MIT", "dependencies": { @@ -14919,6 +13611,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -14932,12 +13633,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -15001,19 +13702,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/stylehacks": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.11.tgz", @@ -15044,18 +13732,18 @@ } }, "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", "license": "MIT", "dependencies": { "commander": "^11.1.0", - "css-select": "^5.1.0", + "css-select": "^6.0.0", "css-tree": "^3.0.1", - "css-what": "^6.1.0", + "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", - "sax": "^1.5.0" + "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" @@ -15077,10 +13765,38 @@ "node": ">=16" } }, + "node_modules/svgo/node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/swagger-ui-dist": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.0.tgz", - "integrity": "sha512-BoiDSeT9PCtHfDYMgX5UpB/qTQy44UoSFRmzHqvhGfgxzEVPHxIW78a+HMLUIHnSFu3z63wjtbq6L6+Rto20Rw==", + "version": "5.32.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.14.tgz", + "integrity": "sha512-nOA2pSQhcmODMUQZpJHYKNuwniDUqcOWGNaSCOoZv12FdOSJ9JxV95HtyRGNMqEBj6h6lCNTy20TgZDYTSuUIg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -15124,9 +13840,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -15140,9 +13856,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -15151,7 +13867,13 @@ "tar-stream": "^2.1.4" } }, - "node_modules/tar-stream": { + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-fs/node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", @@ -15167,13 +13889,17 @@ "node": ">=6" } }, - "node_modules/tar/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "node_modules/tar/node_modules/yallist": { @@ -15195,6 +13921,74 @@ "streamx": "^2.12.5" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -15211,14 +14005,24 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -15227,51 +14031,22 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", "license": "MIT" }, "node_modules/tmpl": { @@ -15325,6 +14100,24 @@ "node": ">=16" } }, + "node_modules/tough-cookie/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tough-cookie/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -15347,9 +14140,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -15360,9 +14153,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -15372,7 +14165,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -15478,6 +14271,31 @@ "node": ">=16.20.2" } }, + "node_modules/tsc-alias/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/tsc-alias/node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -15488,6 +14306,45 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/tsc-alias/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tsc-alias/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tsc-alias/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -15590,16 +14447,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -15610,7 +14467,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uglify-js": { @@ -15634,29 +14491,20 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, - "node_modules/unfetch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz", - "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==", - "license": "MIT", - "workspaces": [ - "./packages/isomorphic-unfetch" - ] - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -15705,9 +14553,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "funding": [ { "type": "opencollective", @@ -15759,19 +14607,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -15887,15 +14722,6 @@ "node": ">=4.0.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -15909,6 +14735,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -15966,10 +14793,22 @@ "node": ">= 8" } }, + "node_modules/whoiser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/whoiser/-/whoiser-1.18.0.tgz", + "integrity": "sha512-QRIGreBuouc8d9i+UVMFqYJSiG7gaoaGX8nKugYDGqnuNLLgjDBwmlKODOIGHveBawza3Kfkk/OuM9VsTUYwaA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=15.0.0" + } + }, "node_modules/winston": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", - "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -16080,6 +14919,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -16139,9 +14987,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16168,22 +15016,6 @@ "node": ">=18" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", @@ -16245,9 +15077,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -16261,9 +15093,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -16302,6 +15134,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -16365,9 +15206,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/server/package.json b/server/package.json index e67bcdb78..36906c936 100755 --- a/server/package.json +++ b/server/package.json @@ -5,6 +5,7 @@ "main": "index.js", "type": "module", "scripts": { + "lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only", "test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit", "test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications", @@ -49,20 +50,22 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "hpagent": "1.2.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", "multer": "^1.4.5-lts.2", - "nodemailer": "8.0.11", + "nodemailer": "9.0.5", "ping": "0.4.4", "re2": "^1.24.1", - "sharp": "0.33.5", + "sharp": "0.35.3", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", + "tldts": "^7.4.10", + "whoiser": "^1.18.0", "winston": "^3.13.0", "ws": "^8.19.0", "zod": "^4.3.6" @@ -82,7 +85,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/mjml": "^4.7.4", "@types/multer": "^2.0.0", - "@types/nodemailer": "7.0.1", + "@types/nodemailer": "8.0.1", "@types/papaparse": "^5.5.2", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", diff --git a/server/src/api/controllers/controllerUtils.ts b/server/src/api/controllers/controllerUtils.ts index 1980ec0b4..608c39d57 100755 --- a/server/src/api/controllers/controllerUtils.ts +++ b/server/src/api/controllers/controllerUtils.ts @@ -2,7 +2,10 @@ import { AppError } from "@/utils/AppError.js"; import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js"; import { UserRole } from "@/domain/users/user.type.js"; import sslChecker, { SSLDetails } from "ssl-checker"; +import * as whoiser from "whoiser"; +import { parse as parseDomain } from "tldts"; type SSLCheckerType = typeof sslChecker; +type WhoisModule = typeof whoiser; export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise => { const monitorUrl = new URL(monitor.url); @@ -13,6 +16,114 @@ export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: } return cert; }; + +const WHOIS_TIMEOUT_MS = 10_000; +const DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS = 60 * 60 * 1000; // 1 hour +const DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const EXPIRY_DATE_KEY_PATTERN = /(expiry date|expiration|paid-?till|registration expiration)/i; + +export interface DomainExpiryResult { + domain: string | null; + expiryDate: string | null; +} + +interface DomainExpiryCacheEntry extends DomainExpiryResult { + expiresAt: number; +} + +interface DomainExpiryCache { + get(domain: string): DomainExpiryResult | undefined; + set(domain: string, result: DomainExpiryResult): void; +} + +export const createDomainExpiryCache = (positiveTtlMs: number, negativeTtlMs: number): DomainExpiryCache => { + const entries = new Map(); + + return { + get(domain: string): DomainExpiryResult | undefined { + const entry = entries.get(domain); + if (entry === undefined) { + return undefined; + } + if (entry.expiresAt <= Date.now()) { + entries.delete(domain); + return undefined; + } + return { domain: entry.domain, expiryDate: entry.expiryDate }; + }, + set(domain: string, result: DomainExpiryResult): void { + entries.set(domain, { + ...result, + expiresAt: Date.now() + (result.expiryDate === null ? negativeTtlMs : positiveTtlMs), + }); + }, + }; +}; + +const domainExpiryCache = createDomainExpiryCache(DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS, DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS); + +export const extractDomainExpiryDate = (whoisData: object): string | null => { + const matchingKey = Object.keys(whoisData).find((key) => EXPIRY_DATE_KEY_PATTERN.test(key)); + if (!matchingKey) { + return null; + } + const raw = extractString((whoisData as Record)[matchingKey] ?? null); + if (!raw) { + return null; + } + + const parsed = parseDomainExpiryValue(raw); + return parsed === null ? null : parsed.toISOString(); +}; + +const parseDomainExpiryValue = (raw: string): Date | null => { + // Some registries (e.g. Nominet for .uk) return "14-Feb-2027" style values. + const monthMatch = /^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/.exec(raw.trim()); + if (monthMatch) { + const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + const monthIndex = months.indexOf((monthMatch[2] ?? "").toLowerCase()); + if (monthIndex !== -1) { + const parsed = new Date(Date.UTC(Number(monthMatch[3]), monthIndex, Number(monthMatch[1]))); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + } + + const date = new Date(raw); + if (!Number.isNaN(date.getTime())) { + return date; + } + return null; +}; + +export const fetchMonitorDomain = async ( + client: WhoisModule, + monitor: Monitor, + cache: DomainExpiryCache = domainExpiryCache +): Promise => { + const monitorUrl = new URL(monitor.url); + const registrableDomain = parseDomain(monitorUrl.hostname).domain; + if (registrableDomain === null) { + // Bare IP addresses, localhost, and other non-domain hosts have no WHOIS expiry. + return { domain: null, expiryDate: null }; + } + + const cached = cache.get(registrableDomain); + if (cached !== undefined) { + return cached; + } + + const result = await client.domain(registrableDomain, { timeout: WHOIS_TIMEOUT_MS }); + const firstServerData = Object.values(result)[0]; + const expiryDate = extractDomainExpiryDate( + typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {} + ); + const domainExpiry: DomainExpiryResult = { domain: registrableDomain, expiryDate }; + cache.set(registrableDomain, domainExpiry); + return domainExpiry; +}; + export const requireString = (value: unknown, fieldName: string): string => { if (typeof value === "string" && value.trim().length > 0) { return value; diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index 664a78f45..f05e0df5c 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -10,6 +10,7 @@ import { editMonitorBodyValidation, pauseMonitorParamValidation, getCertificateParamValidation, + getDomainParamValidation, getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, getUptimeDetailsByIdParamValidation, @@ -18,13 +19,15 @@ import { bulkPauseMonitorBodyValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; -import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; +import * as whoiser from "whoiser"; +import { fetchMonitorCertificate, fetchMonitorDomain, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService } from "@/domain/monitors/monitor.service.js"; import { INotificationsService } from "@/domain/notifications/notification.service.js"; export interface IMonitorController { getMonitorCertificate: RequestHandler; + getMonitorDomain: RequestHandler; getUptimeDetailsById: RequestHandler; getHardwareDetailsById: RequestHandler; getPageSpeedDetailsById: RequestHandler; @@ -68,6 +71,22 @@ class MonitorController implements IMonitorController { }); }); + getMonitorDomain = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDomainParamValidation.parse(req.params); + const teamId = requireTeamId(req.user?.teamId); + const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: validatedParams.monitorId }); + const domainExpiry = await fetchMonitorDomain(whoiser, monitor); + + return res.status(200).json({ + success: true, + msg: "Domain expiry retrieved successfully", + data: { + domain: domainExpiry.domain, + expiryDate: domainExpiry.expiryDate === null ? null : new Date(domainExpiry.expiryDate), + }, + }); + }); + getUptimeDetailsById = catchAsync(async (req: Request, res: Response) => { const validatedParams = getUptimeDetailsByIdParamValidation.parse(req.params); const validatedQuery = getUptimeDetailsByIdQueryValidation.parse(req.query); diff --git a/server/src/api/controllers/proxyController.ts b/server/src/api/controllers/proxyController.ts new file mode 100644 index 000000000..d509a490a --- /dev/null +++ b/server/src/api/controllers/proxyController.ts @@ -0,0 +1,89 @@ +import { Request, Response, RequestHandler } from "express"; +import { catchAsync } from "@/utils/catchAsync.js"; +import { IProxiesService } from "@/domain/proxies/proxy.service.js"; +import { requireTeamId } from "./controllerUtils.js"; +import { + createProxyBodyValidation, + editProxyBodyValidation, + getProxyByIdParamValidation, + editProxyParamValidation, + deleteProxyParamValidation, +} from "@/api/validation/index.js"; + +export interface IProxiesController { + createProxy: RequestHandler; + getProxyById: RequestHandler; + getAllProxies: RequestHandler; + getProxiesByTeamId: RequestHandler; + editProxy: RequestHandler; + deleteProxy: RequestHandler; +} + +class ProxyController implements IProxiesController { + constructor(private proxiesService: IProxiesService) {} + + createProxy = catchAsync(async (req: Request, res: Response) => { + const validatedBody = createProxyBodyValidation.parse(req.body); + const teamId = requireTeamId(req.user?.teamId); + + const proxy = await this.proxiesService.createProxy(validatedBody, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy created successfully", + data: proxy, + }); + }); + getProxyById = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = getProxyByIdParamValidation.parse(req.params); + const proxy = await this.proxiesService.getProxy(proxyId, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy retrieved successfully", + data: proxy, + }); + }); + + getAllProxies = catchAsync(async (req: Request, res: Response) => { + const proxies = await this.proxiesService.getProxies(); + return res.status(200).json({ + success: true, + msg: "Proxies retrieved successfully", + data: proxies, + }); + }); + + getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const proxies = await this.proxiesService.getProxiesByTeamId(teamId); + return res.status(200).json({ + success: true, + msg: "Proxies retrieved successfully", + data: proxies, + }); + }); + + editProxy = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = editProxyParamValidation.parse(req.params); + const validatedBody = editProxyBodyValidation.parse(req.body); + const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody); + return res.status(200).json({ + success: true, + msg: "Proxy updated successfully", + data: updatedProxy, + }); + }); + + deleteProxy = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = deleteProxyParamValidation.parse(req.params); + await this.proxiesService.deleteProxy(proxyId, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy deleted successfully", + }); + }); +} + +export default ProxyController; diff --git a/server/src/api/controllers/settingsController.ts b/server/src/api/controllers/settingsController.ts index 3cb032e93..d02c3886a 100644 --- a/server/src/api/controllers/settingsController.ts +++ b/server/src/api/controllers/settingsController.ts @@ -5,6 +5,7 @@ import { sendTestEmailBodyValidation } from "@/api/validation/notificationValida import { AppError } from "@/utils/AppError.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { IEmailService } from "@/service/emailService.js"; +import { IProxiesService } from "@/domain/proxies/proxy.service.js"; import { Settings } from "@/domain/app-settings/app-settings.type.js"; export interface ISettingsController { @@ -16,18 +17,23 @@ export interface ISettingsController { class SettingsController implements ISettingsController { private settingsService: ISettingsService; private emailService: IEmailService; - constructor(settingsService: ISettingsService, emailService: IEmailService) { + private proxiesService: IProxiesService; + constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) { this.settingsService = settingsService; this.emailService = emailService; + this.proxiesService = proxiesService; } - buildAppSettings = (dbSettings: Settings) => { + buildAppSettings = async (dbSettings: Settings) => { const sanitizedSettings: Record = { ...dbSettings }; delete sanitizedSettings.version; delete sanitizedSettings.jwtSecret; + const globalProxy = + dbSettings.globalProxyEnabled && dbSettings.globalProxyId ? await this.proxiesService.getProxySummary(dbSettings.globalProxyId) : null; const returnSettings: Record = { pagespeedKeySet: false, emailPasswordSet: false, + globalProxy, settings: null, }; @@ -46,7 +52,7 @@ class SettingsController implements ISettingsController { getAppSettings = catchAsync(async (req: Request, res: Response) => { const dbSettings = await this.settingsService.getDBSettings(); - const returnSettings = this.buildAppSettings(dbSettings); + const returnSettings = await this.buildAppSettings(dbSettings); return res.status(200).json({ success: true, msg: "App settings fetched successfully", @@ -57,8 +63,15 @@ class SettingsController implements ISettingsController { updateAppSettings = catchAsync(async (req: Request, res: Response) => { const validatedBody = updateAppSettingsBodyValidation.parse(req.body); + if (validatedBody.globalProxyId) { + const proxy = await this.proxiesService.getProxySummary(validatedBody.globalProxyId); + if (!proxy) { + throw new AppError({ message: "Referenced proxy does not exist", status: 422 }); + } + } + const updatedSettings = await this.settingsService.updateDbSettings(validatedBody); - const returnSettings = this.buildAppSettings(updatedSettings); + const returnSettings = await this.buildAppSettings(updatedSettings); return res.status(200).json({ success: true, msg: "App settings updated successfully", @@ -93,24 +106,30 @@ class SettingsController implements ISettingsController { if (!html) { throw new AppError({ message: "Failed to build email template.", status: 500 }); } - const messageId = await this.emailService.sendEmail(to, subject, html, { - systemEmailHost, - systemEmailPort, - systemEmailUser, - systemEmailAddress, - systemEmailDisplayName, - systemEmailPassword, - systemEmailConnectionHost, - systemEmailSecure, - systemEmailPool, - systemEmailIgnoreTLS, - systemEmailRequireTLS, - systemEmailRejectUnauthorized, - systemEmailTLSServername, - }); - - if (!messageId) { - throw new AppError({ message: "Failed to send test email.", status: 500 }); + let messageId: string; + try { + messageId = await this.emailService.sendEmail(to, subject, html, { + systemEmailHost, + systemEmailPort, + systemEmailUser, + systemEmailAddress, + systemEmailDisplayName, + systemEmailPassword, + systemEmailConnectionHost, + systemEmailSecure, + systemEmailPool, + systemEmailIgnoreTLS, + systemEmailRequireTLS, + systemEmailRejectUnauthorized, + systemEmailTLSServername, + }); + } catch (error: unknown) { + // Surface the underlying SMTP failure: diagnosing the settings is the whole + // point of the test email endpoint. + throw new AppError({ + message: error instanceof Error ? `Failed to send test email. ${error.message}` : "Failed to send test email.", + status: 500, + }); } return res.status(200).json({ diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 038ddb423..94783b5a9 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -69,14 +69,14 @@ class StatusPageController implements IStatusPageController { getStatusPageByUrl = catchAsync(async (req: Request, res: Response) => { getStatusPageParamValidation.parse(req.params); - getStatusPageQueryValidation.parse(req.query); + const { range } = getStatusPageQueryValidation.parse(req.query); if (!req.params.url) { throw new AppError({ message: "Status page URL is required", status: 400 }); } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); - const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId); + const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId, range); return res.status(200).json({ success: true, @@ -86,8 +86,7 @@ class StatusPageController implements IStatusPageController { }); resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => { - resolveStatusPageQueryValidation.parse(req.query); - + const { range } = resolveStatusPageQueryValidation.parse(req.query); const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); if (!domain) { throw new AppError({ message: "Domain is required", status: 400 }); @@ -98,7 +97,7 @@ class StatusPageController implements IStatusPageController { throw new AppError({ message: "Status page not found", status: 404 }); } - const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId); + const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId, range); return res.status(200).json({ success: true, diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts index c5175b81d..a04820ebd 100644 --- a/server/src/api/middleware/statusPageDocumentCsp.ts +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -7,7 +7,7 @@ const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; // the status page on top of the global helmet policy, blocking external images, // fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. const STATUS_PAGE_CSP = [ - "img-src 'self' data:", + "img-src 'self' data: blob:", "font-src 'self' data: https://fonts.gstatic.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", ].join("; "); diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts index 43a94b5c4..4cd90a9a0 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -29,6 +29,9 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout router.get("/certificate/:monitorId", (req, res, next) => { monitorController.getMonitorCertificate(req, res, next); }); + router.get("/domain/:monitorId", (req, res, next) => { + monitorController.getMonitorDomain(req, res, next); + }); // General monitor CRUD routes router.patch("/notifications", isAllowed(["admin", "superadmin"]), monitorController.updateNotifications); diff --git a/server/src/api/routes/proxyRoutes.ts b/server/src/api/routes/proxyRoutes.ts new file mode 100644 index 000000000..fca695099 --- /dev/null +++ b/server/src/api/routes/proxyRoutes.ts @@ -0,0 +1,14 @@ +import { IProxiesController } from "@/api/controllers/proxyController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { Router } from "express"; + +export const createProxyRoutes = (proxyController: IProxiesController): Router => { + const router = Router(); + router.post("/", proxyController.createProxy); + router.get("/", isAllowed(["admin", "superadmin"]), proxyController.getAllProxies); + router.get("/team", proxyController.getProxiesByTeamId); + router.get("/:id", proxyController.getProxyById); + router.delete("/:id", proxyController.deleteProxy); + router.patch("/:id", proxyController.editProxy); + return router; +}; diff --git a/server/src/api/validation/index.ts b/server/src/api/validation/index.ts index 55c586769..9246395e4 100644 --- a/server/src/api/validation/index.ts +++ b/server/src/api/validation/index.ts @@ -22,3 +22,4 @@ export * from "./notificationValidation.js"; export * from "./userValidation.js"; export * from "./incidentValidation.js"; export * from "./tagValidation.js"; +export * from "./proxyValidation.js"; diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 0e8b7b0bd..65a64ed10 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -10,6 +10,7 @@ import { MonitorStatuses, MonitorTypes, PageSpeedStrategies, + ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; @@ -25,7 +26,6 @@ export const getMonitorByIdQueryValidation = z.object({ limit: z.coerce.number().optional(), dateRange: z.enum(DateRanges).optional(), numToDisplay: z.coerce.number().optional(), - normalize: booleanCoercion.optional(), continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), }); @@ -53,6 +53,10 @@ export const getCertificateParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); +export const getDomainParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), +}); + const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => { if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) { ctx.addIssue({ @@ -97,6 +101,16 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole } }; +const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => { + if (body.proxyMode === "custom" && !body.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -107,6 +121,8 @@ export const createMonitorBodyValidation = z statusWindowThreshold: z.number().min(1).max(100).default(60), url: z.string().min(1, "URL is required"), ignoreTlsErrors: z.boolean().default(false), + proxyMode: z.enum(ProxyModes).default("inherit"), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().default(false), port: z.number().optional(), isActive: z.boolean().optional(), @@ -137,7 +153,8 @@ export const createMonitorBodyValidation = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const editMonitorBodyValidation = z .object({ @@ -153,6 +170,8 @@ export const editMonitorBodyValidation = z customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), + proxyMode: z.enum(ProxyModes).optional(), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -177,7 +196,8 @@ export const editMonitorBodyValidation = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -212,6 +232,8 @@ const importedMonitorSchema = z statusWindowThreshold: z.number().min(1).max(100).default(60), type: z.enum(MonitorTypes, "Invalid monitor type"), ignoreTlsErrors: z.boolean().default(false), + proxyMode: z.enum(ProxyModes).default("inherit"), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().default(false), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -250,7 +272,8 @@ const importedMonitorSchema = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -282,6 +305,8 @@ export const monitorResponseSchema = z statusWindowSize: z.number(), statusWindowThreshold: z.number(), ignoreTlsErrors: z.boolean(), + proxyMode: z.enum(ProxyModes), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean(), jsonPath: z.string().optional(), expectedValue: z.string().optional(), @@ -311,3 +336,52 @@ export const monitorResponseSchema = z lastEvaluatedAt: z.number(), }) .passthrough(); + +// Grouped-check buckets returned by GET /monitors/uptime/details/{monitorId}. Keep +// aligned with GroupedCheck / GroupedUptimeCheck in domain/checks/check.type.ts. +export const groupedCheckResponseSchema = z.object({ + bucketDate: z.string(), + avgResponseTime: z.number(), + totalChecks: z.number(), +}); + +export const groupedUptimeCheckResponseSchema = groupedCheckResponseSchema.extend({ + avgDns: z.number(), + avgTcp: z.number(), + avgTls: z.number(), + avgRequest: z.number(), + avgFirstByte: z.number(), + avgDownload: z.number(), +}); + +// Keep aligned with MonitorStats in domain/monitor-stats/monitor-stats.type.ts. +export const monitorStatsResponseSchema = z.object({ + id: z.string(), + monitorId: z.string(), + avgResponseTime: z.number(), + maxResponseTime: z.number(), + totalChecks: z.number(), + totalUpChecks: z.number(), + totalDownChecks: z.number(), + uptimePercentage: z.number(), + lastCheckTimestamp: z.number(), + lastResponseTime: z.number(), + timeOfLastFailure: z.number().optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Response of GET /monitors/uptime/details/{monitorId}. Keep aligned with +// UptimeDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the +// repository's domain entity, which serializes `id` rather than `_id`. +export const uptimeDetailsResponseSchema = z.object({ + monitorData: z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + groupedChecks: z.array(groupedUptimeCheckResponseSchema), + groupedUpChecks: z.array(groupedCheckResponseSchema), + groupedDownChecks: z.array(groupedCheckResponseSchema), + groupedAvgResponseTime: z.number(), + groupedUptimePercentage: z.number(), + }), + monitorStats: monitorStatsResponseSchema.nullable(), +}); diff --git a/server/src/api/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts index bd3cdd6c2..405ec8e08 100644 --- a/server/src/api/validation/notificationValidation.ts +++ b/server/src/api/validation/notificationValidation.ts @@ -1,9 +1,44 @@ import { z } from "zod"; +import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js"; //**************************************** // Notification Validations //**************************************** +// ntfy stores its secret in accessToken for both auth types: the bearer token for +// "token", the password for "basic". Reject partially filled credentials so a channel +// can't be saved looking authenticated while posting anonymously. +const refineNtfyAuth = (body: { ntfyAuthType?: string; ntfyUsername?: string; accessToken?: string }, ctx: z.RefinementCtx) => { + const authType = body.ntfyAuthType ?? "none"; + + if (authType === "none") { + if (body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Select an authentication type to use an access token" }); + } + if (body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Select an authentication type to use a username" }); + } + return; + } + + if (authType === "token") { + if (!body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Access token is required for token authentication" }); + } + if (body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is only used with basic authentication" }); + } + return; + } + + if (!body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is required for basic authentication" }); + } + if (!body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Password is required for basic authentication" }); + } +}; + export const createNotificationBodyValidation = z.discriminatedUnion("type", [ // Email notification z.object({ @@ -23,6 +58,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), }), + // Rocket.Chat notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("rocket_chat"), + address: z.url({ + protocol: /^https?$/, + message: "Please enter a valid Rocket.Chat webhook URL", + }), + }), // Slack notification z.object({ notificationName: z.string().min(1, "Notification name is required"), @@ -89,12 +133,17 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), // ntfy notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("ntfy"), - address: z.url({ message: "Please enter a valid ntfy server URL" }), - topic: z.string().min(1, "Topic is required"), - }), + z + .object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("ntfy"), + address: z.url({ message: "Please enter a valid ntfy server URL" }), + topic: z.string().min(1, "Topic is required"), + ntfyAuthType: z.enum(NtfyAuthTypes).optional(), + ntfyUsername: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }) + .superRefine(refineNtfyAuth), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/src/api/validation/proxyValidation.ts b/server/src/api/validation/proxyValidation.ts new file mode 100644 index 000000000..cab0ec2ea --- /dev/null +++ b/server/src/api/validation/proxyValidation.ts @@ -0,0 +1,45 @@ +import { ProxyProtocols } from "@/domain/proxies/proxy.type.js"; +import { z } from "zod"; + +//**************************************** +// Proxy Validations +//**************************************** + +export const createProxyBodyValidation = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z.number().int().min(1, "Proxy port is required").max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export const editProxyBodyValidation = createProxyBodyValidation.extend({ + clearPassword: z.boolean().optional(), + clearUsername: z.boolean().optional(), +}); + +export const getProxyByIdParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); +export const editProxyParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); +export const deleteProxyParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); + +export const proxyResponseSchema = z + .object({ + id: z.string(), + teamId: z.string(), + name: z.string(), + protocol: z.enum(ProxyProtocols), + host: z.string(), + port: z.number(), + username: z.string().optional(), + hasPassword: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); diff --git a/server/src/api/validation/settingsValidation.ts b/server/src/api/validation/settingsValidation.ts index d118cbd4f..50c75b043 100644 --- a/server/src/api/validation/settingsValidation.ts +++ b/server/src/api/validation/settingsValidation.ts @@ -40,5 +40,16 @@ export const updateAppSettingsBodyValidation = z temperature: z.number().min(1).max(150).optional(), }) .optional(), + globalProxyEnabled: z.boolean().optional(), + globalProxyId: z.string().nullable().optional(), }) - .strip(); + .strip() + .superRefine((body, ctx) => { + if (body.globalProxyEnabled === true && !body.globalProxyId) { + ctx.addIssue({ + code: "custom", + path: ["globalProxyId"], + message: "A proxy must be selected to enable the global proxy", + }); + } + }); diff --git a/server/src/api/validation/shared.ts b/server/src/api/validation/shared.ts index 18b8ba463..ef3ec24ef 100644 --- a/server/src/api/validation/shared.ts +++ b/server/src/api/validation/shared.ts @@ -25,6 +25,21 @@ export const dnsServerValidation = z .min(1, "DNS server is required") .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); +// Canonical casing matters: MongoDB's date operators reject non-canonical zone names (e.g. "america/toronto") that Intl accepts, +// so the resolved canonical form is what gets stored. +export const resolveTimezone = (value: string): string | undefined => { + try { + return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; + } catch { + return undefined; + } +}; + +export const timezoneValidation = z + .string() + .refine((v) => resolveTimezone(v) !== undefined, "Enter a valid IANA timezone (e.g. America/Toronto)") + .transform((v) => resolveTimezone(v) as string); + // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, // optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). // No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index ee2b7376c..ad2277797 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,13 @@ import { z } from "zod"; -import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; +import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; +import { + StatusPageTypes, + StatusPageThemes, + StatusPageThemeModes, + StatusPageRanges, + StatusPageDayRanges, +} from "@/domain/status-pages/status-page.type.js"; +import { MonitorTypes, MonitorStatuses } from "@/domain/monitors/monitor.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; import { ImageMimeTypes } from "@/types/upload.js"; @@ -15,7 +22,7 @@ export const getStatusPageParamValidation = z.object({ export const getStatusPageQueryValidation = z.object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), - timeFrame: z.coerce.number().optional(), + range: z.enum(StatusPageRanges).optional().default("latest"), }); export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({ @@ -40,7 +47,7 @@ export const createStatusPageBodyValidation = z message: "URL can only contain letters, numbers, underscores, and hyphens", }), customDomain: customDomainValidation, - timezone: z.string().optional(), + timezone: timezoneValidation.optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), subMonitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/)).optional(), @@ -80,3 +87,82 @@ export const imageValidation = z .refine((data) => data.buffer, { message: "Image file is required", }); + +// Keep aligned with DailyCheckBucket in domain/checks/check.type.ts. avgResponseTime is null +// when no check that day recorded a response time ($avg skips missing, $round passes null through). +export const dailyCheckBucketResponseSchema = z.object({ + monitorId: z.string(), + date: z.string(), + totalChecks: z.number(), + upChecks: z.number(), + downChecks: z.number(), + avgResponseTime: z.number().nullable(), +}); + +// Keep aligned with CheckSnapshot in domain/checks/check.type.ts. +export const checkSnapshotResponseSchema = z + .object({ + id: z.string(), + status: z.boolean(), + responseTime: z.number(), + statusCode: z.number(), + message: z.string(), + createdAt: z.string(), + accessibility: z.number().optional(), + bestPractices: z.number().optional(), + seo: z.number().optional(), + performance: z.number().optional(), + }) + .passthrough(); + +// Status page entity as serialized by the repository. Keep aligned with StatusPage in +// domain/status-pages/status-page.type.ts. +export const statusPageResponseSchema = z.object({ + id: z.string(), + userId: z.string(), + teamId: z.string(), + type: z.array(z.enum(StatusPageTypes)), + companyName: z.string(), + url: z.string(), + customDomain: z.string().nullable().optional(), + timezone: z.string().optional(), + color: z.string(), + monitors: z.array(z.string()), + subMonitors: z.array(z.string()), + originalMonitors: z.array(z.string()).optional(), + logo: z.object({ data: z.string(), contentType: z.string() }).nullable().optional(), + isPublished: z.boolean(), + showCharts: z.boolean(), + showUptimePercentage: z.boolean(), + showAdminLoginLink: z.boolean(), + showInfrastructure: z.boolean(), + customCSS: z.string(), + theme: z.enum(StatusPageThemes), + themeMode: z.enum(StatusPageThemeModes), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Keep aligned with PublicStatusPageMonitor in domain/status-pages/status-page.type.ts. +// url/port are present only when the showURL setting is enabled; dailyChecks only when range !== "latest". +export const publicStatusPageMonitorResponseSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(MonitorTypes), + status: z.enum(MonitorStatuses), + uptimePercentage: z.number().optional(), + recentChecks: z.array(checkSnapshotResponseSchema), + url: z.string().optional(), + port: z.number().optional(), + dailyChecks: z.array(dailyCheckBucketResponseSchema).optional(), +}); + +// Response of the public status page endpoints. Keep aligned with PublicStatusPagePayload in +// domain/status-pages/status-page.type.ts; range, bucketTimezone, and checkTTLDays are present only when range !== "latest". +export const publicStatusPagePayloadResponseSchema = z.object({ + statusPage: statusPageResponseSchema, + monitors: z.array(publicStatusPageMonitorResponseSchema), + range: z.enum(StatusPageDayRanges).optional(), + bucketTimezone: z.string().optional(), + checkTTLDays: z.number().optional(), +}); diff --git a/server/src/app.ts b/server/src/app.ts index 12e552406..181fa6064 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -76,7 +76,7 @@ export const createApp = ({ directives: { upgradeInsecureRequests: null, "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - "img-src": ["'self'", "data:", "https://img.shields.io"], + "img-src": ["'self'", "data:", "blob:", "https://img.shields.io"], "object-src": ["'none'"], "base-uri": ["'self'"], }, diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 5b7d3c3f7..7787a389e 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -12,6 +12,7 @@ import NotificationController from "../api/controllers/notificationController.js import TagsController from "../api/controllers/tagController.js"; import DiagnosticController from "../api/controllers/diagnosticController.js"; import IncidentController from "../api/controllers/incidentController.js"; +import ProxyController from "@/api/controllers/proxyController.js"; import { ApiServices } from "@/config/services.api.js"; export interface InitializedControllers { @@ -29,12 +30,13 @@ export interface InitializedControllers { tagController: TagsController; diagnosticController: DiagnosticController; incidentController: IncidentController; + proxyController: ProxyController; } export const initializeControllers = (apiServices: ApiServices): InitializedControllers => { return { authController: new AuthController(apiServices.userService), monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService), - settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService), + settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService), checkController: new CheckController(apiServices.checkService), geoCheckController: new GeoCheckController(apiServices.geoChecksService), inviteController: new InviteController(apiServices.inviteService), @@ -46,5 +48,6 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont tagController: new TagsController(apiServices.tagsService), diagnosticController: new DiagnosticController(apiServices.diagnosticService), incidentController: new IncidentController(apiServices.incidentService), + proxyController: new ProxyController(apiServices.proxiesService), }; }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index 810594354..6704fa83b 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -19,6 +19,7 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js"; import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js"; import { createTagRoutes } from "@/api/routes/tagRoutes.js"; import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js"; +import { createProxyRoutes } from "@/api/routes/proxyRoutes.js"; export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => { const verifyJWT = createVerifyJWT(apiServices.settingsService); @@ -37,6 +38,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const tagRoutes = createTagRoutes(controllers.tagController); const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = createIncidentRoutes(controllers.incidentController); + const proxyRoutes = createProxyRoutes(controllers.proxyController); app.use("/api/v1/auth", authApiLimiter, authRoutes); app.use("/api/v1/monitors", verifyJWT, monitorRoutes); @@ -52,4 +54,5 @@ export const setupRoutes = (app: Application, controllers: InitializedController app.use("/api/v1/tags", verifyJWT, tagRoutes); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes); app.use("/api/v1/incidents", verifyJWT, incidentRoutes); + app.use("/api/v1/proxies", verifyJWT, proxyRoutes); }; diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 2768770c3..5ce76aa3d 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta import { ITagsService, TagsService } from "@/domain/tags/tag.service.js"; import { IUserService, UserService } from "@/domain/users/user.service.js"; import { IJobScheduler } from "@/worker/worker.interface.js"; +import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js"; +import { SharedServices } from "@/config/services.shared.js"; // Third-party import jwt from "jsonwebtoken"; import crypto from "crypto"; import { games } from "gamedig"; -import { SharedServices } from "@/config/services.shared.js"; - export interface ApiServices extends SharedServices { worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only) userService: IUserService; @@ -23,6 +23,7 @@ export interface ApiServices extends SharedServices { statusPageService: IStatusPageService; tagsService: ITagsService; diagnosticService: IDiagnosticService; + proxiesService: IProxiesService; } export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => { @@ -45,6 +46,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A teamsRepository, maintenanceWindowsRepository, jobsRepository, + proxiesRepository, } = shared; const userService = new UserService({ @@ -91,10 +93,10 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A emailService, }); - const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository); const tagsService = new TagsService(tagsRepository, monitorsRepository); const diagnosticService = new DiagnosticService(db); - + const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService); return { ...shared, worker: jobScheduler, @@ -105,5 +107,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A statusPageService, tagsService, diagnosticService, + proxiesService, }; }; diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 0f7813d8a..7ea873c4b 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -23,6 +23,7 @@ import { ILogger } from "@/utils/logger.js"; // Notification providers import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js"; import { WebhookProvider } from "@/domain/notifications/providers/webhook.js"; +import { RocketChatProvider } from "@/domain/notifications/providers/rocketChat.js"; import { SlackProvider } from "@/domain/notifications/providers/slack.js"; import { EmailProvider } from "@/domain/notifications/providers/email.js"; import { DiscordProvider } from "@/domain/notifications/providers/discord.js"; @@ -51,6 +52,7 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js"; import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; import { IUsersRepository } from "@/domain/users/user.repository.interface.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; // Mongo repository implementations import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js"; @@ -69,6 +71,7 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js"; import MongoTeamsRepository from "@/domain/teams/team.repository.model.js"; import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; +import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js"; // Shared infrastructure + business services that both the API and the worker process construct. export interface SharedServices { @@ -102,6 +105,7 @@ export interface SharedServices { incidentsRepository: IIncidentsRepository; teamsRepository: ITeamsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; + proxiesRepository: IProxiesRepository; } export const buildShared = async ({ @@ -138,6 +142,7 @@ export const buildShared = async ({ const tagsRepository = new MongoTagsRepository(); const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + const proxiesRepository = new MongoProxiesRepository(); // Inject settings repository into settings service (now that DB is connected) (settingsService as SettingsService).setRepository(settingsRepository); @@ -157,6 +162,7 @@ export const buildShared = async ({ }); const webhookProvider = new WebhookProvider(logger); + const rocketChatProvider = new RocketChatProvider(logger); const slackProvider = new SlackProvider(logger); const emailProvider = new EmailProvider(emailService, logger); const discordProvider = new DiscordProvider(logger); @@ -170,6 +176,7 @@ export const buildShared = async ({ const notificationProviders: NotificationProviderRegistry = { webhook: webhookProvider, + rocket_chat: rocketChatProvider, email: emailProvider, slack: slackProvider, discord: discordProvider, @@ -218,6 +225,7 @@ export const buildShared = async ({ incidentsRepository, teamsRepository, maintenanceWindowsRepository, + proxiesRepository, }; return sharedServices; }; diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index b0bdff2d6..9b4ea8609 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js"; import { IncidentReactor } from "@/worker/reactors/reactor.incident.js"; import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; import { DBQueueWorker } from "@/worker/worker.db-queue.js"; - +import { ProxyResolver } from "@/service/network/ProxyResolver.js"; // Network providers import { PingProvider } from "@/service/network/PingProvider.js"; import { HttpProvider } from "@/service/network/HttpProvider.js"; @@ -64,6 +64,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig incidentsRepository, teamsRepository, maintenanceWindowsRepository, + proxiesRepository, } = shared; // *********************** @@ -93,6 +94,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig dnsProvider, ]); + const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger); const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository); const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); const monitorStatusPolicy = new MonitorStatusPolicy(); @@ -110,7 +112,15 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig // Check producer/evaluator // Handles creating and evaluatiog checks // *********************** - const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger); + const checkProducer = new CheckProducer( + monitorsRepository, + maintenanceWindowsRepository, + checkService, + networkService, + proxyResolver, + bufferService, + logger + ); const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy); const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger); diff --git a/server/src/db/migration/0010_slimRecentChecks.ts b/server/src/db/migration/0010_slimRecentChecks.ts new file mode 100644 index 000000000..2b8f8e661 --- /dev/null +++ b/server/src/db/migration/0010_slimRecentChecks.ts @@ -0,0 +1,128 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +const KEEP_KEYS = [ + "id", + "status", + "responseTime", + "statusCode", + "message", + "createdAt", + "cpu", + "memory", + "disk", + "host", + "accessibility", + "bestPractices", + "seo", + "performance", + "audits", +]; + +/** + * Slim stored recentChecks snapshots down to the fields the slim CheckSnapshot type keeps. + * The schema change only affects new writes; paused monitors never rotate their snapshots out. + * Idempotent: re-running on already-slim snapshots is a no-op re-pick. + */ +export async function slimRecentChecks(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:SlimRecentChecks"; + try { + logger.info({ service: SERVICE_NAME, message: "Slimming recentChecks snapshots" }); + + const result = await MonitorModel.updateMany({ "recentChecks.0": { $exists: true } }, [ + { + $set: { + recentChecks: { + $map: { + input: "$recentChecks", + as: "snap", + in: { + $mergeObjects: [ + // 1) keep whitelisted top-level keys only (drops timings/errors/capture/net) + { + $arrayToObject: { + $filter: { + input: { $objectToArray: "$$snap" }, + as: "kv", + cond: { $in: ["$$kv.k", KEEP_KEYS] }, + }, + }, + }, + // 2) where a nested group exists, overwrite it with its slim pick + { + $cond: [ + { $eq: [{ $type: "$$snap.cpu" }, "object"] }, + { + cpu: { + physical_core: "$$snap.cpu.physical_core", + logical_core: "$$snap.cpu.logical_core", + frequency: "$$snap.cpu.frequency", + current_frequency: "$$snap.cpu.current_frequency", + temperature: "$$snap.cpu.temperature", + usage_percent: "$$snap.cpu.usage_percent", + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.memory" }, "object"] }, + { + memory: { + total_bytes: "$$snap.memory.total_bytes", + used_bytes: "$$snap.memory.used_bytes", + usage_percent: "$$snap.memory.usage_percent", + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.disk" }, "array"] }, + { + disk: { + $map: { + input: "$$snap.disk", + as: "d", + in: { + device: "$$d.device", + total_bytes: "$$d.total_bytes", + used_bytes: "$$d.used_bytes", + usage_percent: "$$d.usage_percent", + }, + }, + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.host" }, "object"] }, + { + host: { + os: "$$snap.host.os", + platform: "$$snap.host.platform", + pretty_name: "$$snap.host.pretty_name", + }, + }, + {}, + ], + }, + ], + }, + }, + }, + }, + }, + ]); + + logger.info({ service: SERVICE_NAME, message: `Slimmed recentChecks on ${result.modifiedCount} monitors` }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error slimming recentChecks: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/0011_backfillMonitorProxyMode.ts b/server/src/db/migration/0011_backfillMonitorProxyMode.ts new file mode 100644 index 000000000..e703ece34 --- /dev/null +++ b/server/src/db/migration/0011_backfillMonitorProxyMode.ts @@ -0,0 +1,27 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +/** + * Backfill proxyMode on monitors created before the field existed. + * + * Mongoose defaults only apply to new documents, and monitorResponseSchema + * requires proxyMode on every monitor read. + */ +export async function backfillMonitorProxyMode(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:BackfillMonitorProxyMode"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" }); + + const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } }); + + logger.info({ + service: SERVICE_NAME, + message: `Backfilled proxyMode on ${result.modifiedCount} monitors`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index f59af32b7..94829241d 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -8,6 +8,8 @@ import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplic import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; +import { slimRecentChecks } from "./0010_slimRecentChecks.js"; +import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -25,6 +27,8 @@ const migrations: MigrationEntry[] = [ { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, + { name: "0010_slimRecentChecks", execute: slimRecentChecks }, + { name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/src/domain/app-settings/app-settings.model.ts b/server/src/domain/app-settings/app-settings.model.ts index da766fa83..3dd3c3abe 100644 --- a/server/src/domain/app-settings/app-settings.model.ts +++ b/server/src/domain/app-settings/app-settings.model.ts @@ -40,6 +40,8 @@ const AppSettingsSchema = new Schema( singleton: { type: Boolean, required: true, unique: true, default: true }, version: { type: Number, default: 1 }, globalThresholds: { type: thresholdsSchema }, + globalProxyEnabled: { type: Boolean, default: false }, + globalProxyId: { type: String, default: null }, }, { timestamps: true } ); diff --git a/server/src/domain/app-settings/app-settings.repository.mongo.ts b/server/src/domain/app-settings/app-settings.repository.mongo.ts index d88141ce5..d80289a7b 100644 --- a/server/src/domain/app-settings/app-settings.repository.mongo.ts +++ b/server/src/domain/app-settings/app-settings.repository.mongo.ts @@ -28,6 +28,8 @@ class MongoSettingsRepository implements ISettingsRepository { singleton: doc.singleton, version: doc.version ?? 1, globalThresholds: doc.globalThresholds ?? undefined, + globalProxyEnabled: doc.globalProxyEnabled ?? false, + globalProxyId: doc.globalProxyId ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/domain/app-settings/app-settings.type.ts b/server/src/domain/app-settings/app-settings.type.ts index 8233ced63..e4d2f9559 100644 --- a/server/src/domain/app-settings/app-settings.type.ts +++ b/server/src/domain/app-settings/app-settings.type.ts @@ -49,6 +49,8 @@ export interface Settings { singleton: boolean; version: number; globalThresholds?: SettingsThresholds; + globalProxyEnabled: boolean; + globalProxyId?: string | null; createdAt: string; updatedAt: string; } diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index e6b7c6368..1767b2ea1 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -2,12 +2,12 @@ import type { Check, ChecksQueryResult, ChecksSummary, + DailyCheckBucket, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, } from "@/domain/checks/check.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.type.js"; -import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js"; import { CheckFilter, DateRange } from "@/types/query.js"; export interface IChecksRepository { @@ -34,7 +34,6 @@ export interface IChecksRepository { teamId: string, filter?: CheckFilter ): Promise; - findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise; findByDateRangeAndMonitorId( monitorId: string, dateRange: DateRange, @@ -42,6 +41,8 @@ export interface IChecksRepository { ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; + // update //delete deleteByMonitorId(monitorId: string): Promise; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index fcc967828..e64113b6c 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -25,9 +25,7 @@ import { CheckFilter, DateRange } from "@/types/query.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; -const SERVICE_NAME = "StatusService"; - -export type LatestChecksMap = Record; +const SERVICE_NAME = "ChecksRepository"; class MongoChecksRepository implements IChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -83,6 +81,7 @@ class MongoChecksRepository implements IChecksRepository { os: host?.os ?? "", platform: host?.platform ?? "", kernel_version: host?.kernel_version ?? "", + pretty_name: host?.pretty_name ?? "", }); const mapCapture = (capture?: CheckCaptureInfo): CheckCaptureInfo => ({ @@ -214,6 +213,9 @@ class MongoChecksRepository implements IChecksRepository { }; private filterToMatch = (filter: CheckFilter | undefined): Record => { + if (filter === undefined) { + return {}; + } switch (filter) { case "up": return { status: true }; @@ -288,32 +290,6 @@ class MongoChecksRepository implements IChecksRepository { return { checksCount, checks: this.mapDocuments(checks) }; }; - findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { - if (monitorIds.length === 0) { - return {}; - } - const limitPerMonitor = options?.limitPerMonitor ?? 25; - const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); - const results = await Promise.all( - monitorIds.map(async (monitorId) => { - const docs = await CheckModel.find({ - "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), - createdAt: { $gte: dateFilter }, - }) - .sort({ createdAt: -1 }) - .limit(limitPerMonitor) - .lean(); - return { monitorId, docs }; - }) - ); - - const mapped = results.reduce((acc, { monitorId, docs }) => { - acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc)); - return acc; - }, {}); - return mapped; - }; - findByDateRangeAndMonitorId = async (monitorId: string, dateRange: DateRange, options?: { type?: MonitorType }) => { const monitorObjectId = new mongoose.Types.ObjectId(monitorId); const start = getDateForRange(dateRange); @@ -356,6 +332,44 @@ class MongoChecksRepository implements IChecksRepository { return docs.map(this.toEntity); }; + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket + const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const results = await CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": { $in: objectIds }, + createdAt: { $gte: windowStart }, + }, + }, + { + $group: { + _id: { + monitorId: "$metadata.monitorId", + day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, + }, + totalChecks: { $sum: 1 }, + upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + avgResponseTime: { $avg: "$responseTime" }, + }, + }, + { $sort: { "_id.day": 1 } }, + { + $project: { + _id: 0, + monitorId: { $toString: "$_id.monitorId" }, + date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, + totalChecks: 1, + upChecks: 1, + downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, + avgResponseTime: { $round: ["$avgResponseTime", 0] }, + }, + }, + ]); + return results; + }; + deleteByMonitorId = async (monitorId: string): Promise => { const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) }); return result.deletedCount; @@ -532,35 +546,33 @@ class MongoChecksRepository implements IChecksRepository { totalChecks: upChecksDoc?.totalChecks ?? 0, }; - const checks = (hardwareMetrics ?? []).map( - (metric): HardwareCheckStats => ({ - bucketDate: metric._id, - avgCpuUsage: metric.avgCpuUsage ?? 0, - avgMemoryUsage: metric.avgMemoryUsage ?? 0, - avgTemperature: metric.avgTemperature ?? [], - disks: (metric.disks ?? []).map((disk) => ({ - name: disk?.name ?? "", - readSpeed: disk?.readSpeed ?? 0, - writeSpeed: disk?.writeSpeed ?? 0, - totalBytes: disk?.totalBytes ?? 0, - freeBytes: disk?.freeBytes ?? 0, - usagePercent: disk?.usagePercent ?? 0, - })), - net: (metric.net ?? []).map((iface) => ({ - name: iface?.name ?? "", - bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0, - deltaBytesRecv: iface?.deltaBytesRecv ?? 0, - deltaPacketsSent: iface?.deltaPacketsSent ?? 0, - deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0, - deltaErrIn: iface?.deltaErrIn ?? 0, - deltaErrOut: iface?.deltaErrOut ?? 0, - deltaDropIn: iface?.deltaDropIn ?? 0, - deltaDropOut: iface?.deltaDropOut ?? 0, - deltaFifoIn: iface?.deltaFifoIn ?? 0, - deltaFifoOut: iface?.deltaFifoOut ?? 0, - })), - }) - ); + const checks = (hardwareMetrics ?? []).map((metric): HardwareCheckStats => ({ + bucketDate: metric._id, + avgCpuUsage: metric.avgCpuUsage ?? 0, + avgMemoryUsage: metric.avgMemoryUsage ?? 0, + avgTemperature: metric.avgTemperature ?? [], + disks: (metric.disks ?? []).map((disk) => ({ + name: disk?.name ?? "", + readSpeed: disk?.readSpeed ?? 0, + writeSpeed: disk?.writeSpeed ?? 0, + totalBytes: disk?.totalBytes ?? 0, + freeBytes: disk?.freeBytes ?? 0, + usagePercent: disk?.usagePercent ?? 0, + })), + net: (metric.net ?? []).map((iface) => ({ + name: iface?.name ?? "", + bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0, + deltaBytesRecv: iface?.deltaBytesRecv ?? 0, + deltaPacketsSent: iface?.deltaPacketsSent ?? 0, + deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0, + deltaErrIn: iface?.deltaErrIn ?? 0, + deltaErrOut: iface?.deltaErrOut ?? 0, + deltaDropIn: iface?.deltaDropIn ?? 0, + deltaDropOut: iface?.deltaDropOut ?? 0, + deltaFifoIn: iface?.deltaFifoIn ?? 0, + deltaFifoOut: iface?.deltaFifoOut ?? 0, + })), + })); return { monitorType: "hardware" as const, diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts new file mode 100644 index 000000000..3ee208d04 --- /dev/null +++ b/server/src/domain/checks/check.snapshot.ts @@ -0,0 +1,85 @@ +import type { + Check, + CheckAudits, + CheckCpuInfo, + CheckDiskInfo, + CheckHostInfo, + CheckMemoryInfo, + CheckSnapshot, + SnapshotCpuInfo, + SnapshotDiskInfo, + SnapshotHostInfo, + SnapshotMemoryInfo, +} from "@/domain/checks/check.type.js"; +import { toDateString } from "@/utils/mongoMappers.js"; + +export type CheckSnapshotSource = Pick< + Check, + "id" | "status" | "responseTime" | "statusCode" | "message" | "accessibility" | "bestPractices" | "seo" | "performance" +> & { + createdAt: string | Date; + cpu?: CheckCpuInfo; + memory?: CheckMemoryInfo; + disk?: CheckDiskInfo[]; + host?: CheckHostInfo; + audits?: CheckAudits; +}; + +const mapCpu = (cpu?: CheckCpuInfo): SnapshotCpuInfo | undefined => + cpu && { + physical_core: cpu.physical_core, + logical_core: cpu.logical_core, + frequency: cpu.frequency, + current_frequency: cpu.current_frequency, + temperature: cpu.temperature, + usage_percent: cpu.usage_percent, + }; + +const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined => + memory && { + total_bytes: memory.total_bytes, + used_bytes: memory.used_bytes, + usage_percent: memory.usage_percent, + }; + +const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined => + disk?.map((entry) => ({ + device: entry.device, + total_bytes: entry.total_bytes, + used_bytes: entry.used_bytes, + usage_percent: entry.usage_percent, + })); + +const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined => + host && { + os: host.os, + platform: host.platform, + pretty_name: host.pretty_name, + }; + +const mapAudits = (audits?: CheckAudits): CheckAudits | undefined => + audits && { + cls: audits.cls, + si: audits.si, + fcp: audits.fcp, + lcp: audits.lcp, + tbt: audits.tbt, + }; + +export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ({ + id: source.id, + status: source.status, + responseTime: source.responseTime, + statusCode: source.statusCode, + message: source.message, + createdAt: toDateString(source.createdAt), + cpu: mapCpu(source.cpu), + memory: mapMemory(source.memory), + disk: mapDisk(source.disk), + host: mapHost(source.host), + accessibility: source.accessibility, + bestPractices: source.bestPractices, + seo: source.seo, + performance: source.performance, + audits: mapAudits(source.audits), +}); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index d4c47cfb9..aa1eb3782 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -168,12 +168,36 @@ export interface HasResponseTime { responseTime: number; } -export type NormalizedCheck = T & { - originalResponseTime: number; +export type SnapshotCpuInfo = Pick< + CheckCpuInfo, + "physical_core" | "logical_core" | "frequency" | "current_frequency" | "temperature" | "usage_percent" +>; +export type SnapshotMemoryInfo = Pick; +export type SnapshotDiskInfo = Pick; +export type SnapshotHostInfo = Pick; + +export type CheckSnapshot = Pick< + Check, + // uptime charts and down check tooltips + | "id" + | "status" + | "responseTime" + | "statusCode" + | "message" + | "createdAt" + // pagespeed + | "accessibility" + | "bestPractices" + | "seo" + | "performance" + | "audits" +> & { + // hardware monitors only + cpu?: SnapshotCpuInfo; + memory?: SnapshotMemoryInfo; + disk?: SnapshotDiskInfo[]; + host?: SnapshotHostInfo; }; - -export type CheckSnapshot = Omit; - export interface HardwareDiskStats { name: string; readSpeed: number; @@ -219,3 +243,12 @@ export interface HardwareStats { export interface HardwareChecksResult extends HardwareStats { monitorType: "hardware"; } + +export type DailyCheckBucket = { + monitorId: string; + date: string; + totalChecks: number; + upChecks: number; + downChecks: number; + avgResponseTime: number | null; +}; diff --git a/server/src/domain/invites/invite.service.ts b/server/src/domain/invites/invite.service.ts index 3dcffd9e0..8182f4026 100644 --- a/server/src/domain/invites/invite.service.ts +++ b/server/src/domain/invites/invite.service.ts @@ -96,13 +96,15 @@ export class InviteService implements IInviteService { }); } - const result = await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html); - if (!result) { + try { + await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html); + } catch (error: unknown) { throw new AppError({ message: "Failed to send invite e-mail... Please verify your settings.", service: SERVICE_NAME, method: "sendInviteEmail", status: 500, + details: { cause: error instanceof Error ? error.message : "Unknown error" }, }); } }; diff --git a/server/src/domain/maintenance-windows/maintenance-window.service.ts b/server/src/domain/maintenance-windows/maintenance-window.service.ts index 350e755aa..767e17699 100644 --- a/server/src/domain/maintenance-windows/maintenance-window.service.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.service.ts @@ -106,7 +106,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { start: string; end: string; }) => { - const monitors = await this.monitorsRepository.findByIds(monitorIDs); + const monitors = await this.monitorsRepository.findByIds(monitorIDs, { recentChecks: "none" }); const unauthorizedMonitors = monitors.filter((monitor) => monitor.teamId !== teamId); @@ -193,7 +193,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const update: Partial = rest; if (monitors !== undefined) { - const monitorDocs = await this.monitorsRepository.findByIds(monitors); + const monitorDocs = await this.monitorsRepository.findByIds(monitors, { recentChecks: "none" }); const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); if (unauthorizedMonitors.length > 0) { throw new AppError({ diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 446631f45..15b277efb 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -1,24 +1,20 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; -import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js"; import type { CheckAudits, - CheckCaptureInfo, - CheckCpuInfo, - CheckDiskInfo, - CheckErrorInfo, - CheckHostInfo, - CheckMemoryInfo, - CheckNetworkInterfaceInfo, - GotTimings, ILighthouseAudit, + SnapshotCpuInfo, + SnapshotDiskInfo, + SnapshotHostInfo, + SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; @@ -26,6 +22,7 @@ type MonitorDocumentBase = Omit< tags: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; + proxyId?: Types.ObjectId; }; interface MonitorDocument extends MonitorDocumentBase { @@ -37,121 +34,46 @@ interface MonitorDocument extends MonitorDocumentBase { lastEvaluatedAt: number; // epoch ms } -const snapshotTimingPhasesSchema = new Schema( - { - wait: { type: Number }, - dns: { type: Number }, - tcp: { type: Number }, - tls: { type: Number }, - request: { type: Number }, - firstByte: { type: Number }, - download: { type: Number }, - total: { type: Number }, - }, - { _id: false } -); - -const snapshotTimingsSchema = new Schema( - { - start: { type: Number }, - socket: { type: Number }, - lookup: { type: Number }, - connect: { type: Number }, - secureConnect: { type: Number }, - upload: { type: Number }, - response: { type: Number }, - end: { type: Number }, - phases: { type: snapshotTimingPhasesSchema }, - }, - { _id: false } -); - -const snapshotCpuSchema = new Schema( +const snapshotCpuSchema = new Schema( { physical_core: { type: Number }, logical_core: { type: Number }, frequency: { type: Number }, current_frequency: { type: Number }, temperature: { type: [Number] }, - free_percent: { type: Number }, usage_percent: { type: Number }, }, { _id: false } ); -const snapshotMemorySchema = new Schema( +const snapshotMemorySchema = new Schema( { total_bytes: { type: Number }, - available_bytes: { type: Number }, used_bytes: { type: Number }, usage_percent: { type: Number }, }, { _id: false } ); -const snapshotDiskSchema = new Schema( +const snapshotDiskSchema = new Schema( { device: { type: String }, - mountpoint: { type: String }, total_bytes: { type: Number }, - free_bytes: { type: Number }, used_bytes: { type: Number }, usage_percent: { type: Number }, - total_inodes: { type: Number }, - free_inodes: { type: Number }, - used_inodes: { type: Number }, - inodes_usage_percent: { type: Number }, - read_bytes: { type: Number }, - write_bytes: { type: Number }, - read_time: { type: Number }, - write_time: { type: Number }, }, { _id: false } ); -const snapshotHostSchema = new Schema( +const snapshotHostSchema = new Schema( { os: { type: String }, platform: { type: String }, - kernel_version: { type: String }, pretty_name: { type: String }, }, { _id: false } ); -const snapshotErrorSchema = new Schema( - { - metric: { type: [String] }, - err: { type: String }, - }, - { _id: false } -); - -const snapshotCaptureSchema = new Schema( - { - version: { type: String }, - mode: { type: String }, - }, - { _id: false } -); - -const snapshotNetworkInterfaceSchema = new Schema( - { - name: { type: String }, - bytes_sent: { type: Number }, - bytes_recv: { type: Number }, - packets_sent: { type: Number }, - packets_recv: { type: Number }, - err_in: { type: Number }, - err_out: { type: Number }, - drop_in: { type: Number }, - drop_out: { type: Number }, - fifo_in: { type: Number }, - fifo_out: { type: Number }, - }, - { _id: false } -); - const snapshotLighthouseAuditSchema = new Schema( { id: { type: String }, @@ -180,16 +102,12 @@ const checkSnapshotSchema = new Schema( id: { type: String, required: true }, status: { type: Boolean, required: true }, responseTime: { type: Number }, - timings: { type: snapshotTimingsSchema }, statusCode: { type: Number }, message: { type: String }, cpu: { type: snapshotCpuSchema }, memory: { type: snapshotMemorySchema }, disk: { type: [snapshotDiskSchema] }, host: { type: snapshotHostSchema }, - errors: { type: [snapshotErrorSchema] }, - capture: { type: snapshotCaptureSchema }, - net: { type: [snapshotNetworkInterfaceSchema] }, accessibility: { type: Number }, bestPractices: { type: Number }, seo: { type: Number }, @@ -253,6 +171,16 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + proxyMode: { + type: String, + enum: ProxyModes, + default: "inherit", + }, + proxyId: { + type: Schema.Types.ObjectId, + ref: "Proxy", + }, + useAdvancedMatching: { type: Boolean, default: false, diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index d3dc0597e..1ce2b8fa8 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -1,4 +1,4 @@ -import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; +import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot, MonitorScheduleFields } from "@/domain/monitors/monitor.type.js"; export interface TeamQueryConfig { limit?: number; @@ -16,6 +16,8 @@ export interface SummaryConfig { tags?: string | string[]; } +export type RecentChecksMode = "all" | "latestHardware" | "none"; + export interface IMonitorsRepository { // create create(monitor: Monitor, teamId: string, userId: string): Promise; @@ -26,11 +28,10 @@ export interface IMonitorsRepository { findByIdLean(monitorId: string): Promise; // collection fetch - findAll(): Promise; - findByTeamId(teamId: string, config: TeamQueryConfig): Promise; + findAllForScheduling(): Promise; + findByTeamId(teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; - findByIds(monitorIds: string[]): Promise; - findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; + findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; @@ -52,6 +53,7 @@ export interface IMonitorsRepository { // counts findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise; + findMonitorCountByProxyId(proxyId: string): Promise; // other findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index b96ec3907..6cf2e0ecf 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -1,12 +1,13 @@ import { MonitorModel } from "@/domain/monitors/monitor.model.js"; import type { MonitorDocument, CheckSnapshotDocument } from "@/domain/monitors/monitor.model.js"; import type { CheckSnapshot } from "@/domain/checks/check.type.js"; -import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; +import type { Monitor, MonitorScheduleFields, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; -import { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "@/domain/monitors/monitor.repository.interface.js"; +import { IMonitorsRepository, TeamQueryConfig, SummaryConfig, RecentChecksMode } from "@/domain/monitors/monitor.repository.interface.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; class MongoMonitorsRepository implements IMonitorsRepository { create = async (monitor: Monitor, teamId: string, userId: string) => { const monitorModel = new MonitorModel({ ...monitor, teamId, userId }); @@ -40,13 +41,20 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findByIdLean = async (monitorId: string): Promise => { - const monitor = await MonitorModel.findOne({ _id: monitorId }); + const monitor = await MonitorModel.findOne({ _id: monitorId }).select({ recentChecks: 0 }); return monitor ? this.toEntity(monitor) : null; }; - findAll = async (): Promise => { - const monitors = await MonitorModel.find(); - return this.mapDocuments(monitors); + findAllForScheduling = async (): Promise => { + const docs = await MonitorModel.find({}, { type: 1, isActive: 1, interval: 1, geoCheckEnabled: 1, geoCheckInterval: 1 }).lean(); + return docs.map((doc) => ({ + id: doc._id.toString(), + type: doc.type, + isActive: doc.isActive, + interval: doc.interval, + geoCheckEnabled: doc.geoCheckEnabled ?? false, + geoCheckInterval: doc.geoCheckInterval ?? 300000, + })); }; private queryBuilder = (config: TeamQueryConfig, teamId: string): FilterQuery => { @@ -96,10 +104,15 @@ class MongoMonitorsRepository implements IMonitorsRepository { return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage }; }; - findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { + findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise => { const query = this.queryBuilder(config, teamId); const { sort, skip, limit } = this.pageOptions(config); - const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limit); + + const cursor = MonitorModel.find(query).sort(sort).skip(skip).limit(limit); + if (options?.includeRecentChecks === false) { + cursor.select({ recentChecks: 0 }); + } + const documents = await cursor; return this.mapDocuments(documents); }; @@ -118,20 +131,26 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIds = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - return []; + private recentChecksStages = (mode: RecentChecksMode = "all"): PipelineStage[] => { + switch (mode) { + case "none": + return [{ $project: { recentChecks: 0 } }]; + case "latestHardware": + return [ + { + $set: { + recentChecks: { + $cond: [{ $eq: ["$type", "hardware"] }, { $slice: [{ $ifNull: ["$recentChecks", []] }, -1] }, []], + }, + }, + }, + ]; + case "all": + return []; } - - const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - - const pipeline: PipelineStage[] = [{ $match: { _id: { $in: objectIds } } }, ...this.uptimeStatsLookupStages]; - - const documents = await MonitorModel.aggregate(pipeline); - return documents.map((doc) => this.toEntity(doc)); }; - findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { + findByIds = async (monitorIds: string[], options?: { recentChecks?: "all" | "latestHardware" | "none" }): Promise => { if (!monitorIds.length) { return []; } @@ -140,57 +159,9 @@ class MongoMonitorsRepository implements IMonitorsRepository { const pipeline: PipelineStage[] = [ { $match: { _id: { $in: objectIds } } }, - { - $lookup: { - from: "checks", - let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $eq: ["$metadata.monitorId", "$$monitorId"] } } }, { $sort: { createdAt: -1 } }, { $limit: checksCount }], - as: "checks", - }, - }, - { - $lookup: { - from: "maintenancewindows", - let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $in: ["$$monitorId", { $ifNull: ["$monitorIds", []] }] } } }], - as: "maintenanceWindows", - }, - }, - { - $lookup: { - from: "monitorstats", - localField: "_id", - foreignField: "monitorId", - as: "stats", - }, - }, - { - $addFields: { - isMaintenance: { - $reduce: { - input: "$maintenanceWindows", - initialValue: false, - in: { - $or: [ - "$$value", - { - $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], - }, - ], - }, - }, - }, - uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] }, - }, - }, - { - $project: { - maintenanceWindows: 0, - stats: 0, - }, - }, + ...this.recentChecksStages(options?.recentChecks), + ...this.uptimeStatsLookupStages, ]; - const documents = await MonitorModel.aggregate(pipeline); return documents.map((doc) => this.toEntity(doc)); }; @@ -201,6 +172,11 @@ class MongoMonitorsRepository implements IMonitorsRepository { return count; }; + findMonitorCountByProxyId = async (proxyId: string): Promise => { + const count = await MonitorModel.countDocuments({ proxyId }); + return count; + }; + updateById = async (monitorId: string, teamId: string, patch: Partial) => { const updatedMonitor = await MonitorModel.findOneAndUpdate( { _id: monitorId, teamId }, @@ -245,7 +221,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }, ...(statusPatch && { $set: statusPatch }), }, - { returnDocument: "after" } + { returnDocument: "after", projection: { recentChecks: 0 } } ); if (!updatedMonitor) { @@ -447,6 +423,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + proxyMode: doc.proxyMode ?? "inherit", + proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined, useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, @@ -473,7 +451,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { grpcServiceName: doc.grpcServiceName ?? undefined, strategy: doc.strategy ?? undefined, group: doc.group ?? null, - recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), + recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, @@ -485,30 +463,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; }; - private toCheckSnapshot = (doc: CheckSnapshotDocument): CheckSnapshot => { - return { - id: doc.id, - status: doc.status, - responseTime: doc.responseTime, - timings: doc.timings, - statusCode: doc.statusCode, - message: doc.message, - cpu: doc.cpu, - memory: doc.memory, - disk: doc.disk, - host: doc.host, - errors: doc.errors, - capture: doc.capture, - net: doc.net, - accessibility: doc.accessibility, - bestPractices: doc.bestPractices, - seo: doc.seo, - performance: doc.performance, - audits: doc.audits, - createdAt: toDateString(doc.createdAt), - }; - }; - deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { const objectIds = teamIds.map((id) => new mongoose.Types.ObjectId(id)); const result = await MonitorModel.deleteMany({ teamId: { $nin: objectIds } }); diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 29046f83d..31488c378 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -1,4 +1,3 @@ -import { NormalizeData } from "@/utils/dataUtils.js"; import { type Monitor } from "@/domain/monitors/monitor.type.js"; import type { MonitorType, @@ -364,7 +363,7 @@ export class MonitorService implements IMonitorService { const monitorsWithChecks = monitors.map((monitor: Monitor) => { const rawChecks = monitor.recentChecks ?? []; const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); - const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100); + const checks = isSnapshotType ? rawChecks.slice(-1) : rawChecks; monitor.recentChecks = checks; return monitor; }); @@ -520,7 +519,7 @@ export class MonitorService implements IMonitorService { }; exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise => { - const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}, { includeRecentChecks: false }); if (monitors.length === 0) { throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 }); diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 865bafd76..59d91ced9 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -41,6 +41,9 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); +export const ProxyModes = ["inherit", "none", "custom"] as const; +export type ProxyMode = (typeof ProxyModes)[number]; + export const UptimeDetailsSupportedTypes = [ "http", "ping", @@ -81,6 +84,8 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + proxyMode: ProxyMode; + proxyId?: string; useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; @@ -178,3 +183,5 @@ export interface Game { } export type GamesMap = Record; + +export type MonitorScheduleFields = Pick; diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 797b3e768..b2dce9ebe 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -1,5 +1,6 @@ import { Schema, model, type Types } from "mongoose"; import type { Notification, NotificationChannel } from "@/domain/notifications/notification.type.js"; +import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js"; interface NotificationDocument extends Omit { _id: Types.ObjectId; @@ -30,6 +31,7 @@ const NotificationSchema = new Schema( "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", @@ -52,6 +54,8 @@ const NotificationSchema = new Schema( accountSid: { type: String }, twilioPhoneNumber: { type: String }, topic: { type: String }, + ntfyAuthType: { type: String, enum: NtfyAuthTypes }, + ntfyUsername: { type: String }, }, { timestamps: true, diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 8ae617a88..a45d4c4f8 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", @@ -13,6 +14,11 @@ export const NotificationChannels = [ ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +// ntfy servers accept either a bearer access token or HTTP basic credentials. +// Both store their secret in `accessToken`; basic auth pairs it with `ntfyUsername`. +export const NtfyAuthTypes = ["none", "token", "basic"] as const; +export type NtfyAuthType = (typeof NtfyAuthTypes)[number]; + export interface Notification { id: string; userId: string; @@ -27,6 +33,8 @@ export interface Notification { accountSid?: string; twilioPhoneNumber?: string; topic?: string; + ntfyAuthType?: NtfyAuthType; + ntfyUsername?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/domain/notifications/providers/email.ts b/server/src/domain/notifications/providers/email.ts index fa84be66d..2b5cde245 100644 --- a/server/src/domain/notifications/providers/email.ts +++ b/server/src/domain/notifications/providers/email.ts @@ -35,16 +35,18 @@ export class EmailProvider extends NotificationProvider { return false; } - const messageId = await this.emailService.sendEmail(notification.address, subject, html); - if (!messageId) { + try { + await this.emailService.sendEmail(notification.address, subject, html); + return true; + } catch (error: unknown) { this.logger.warn({ message: "Email test alert failed", service: SERVICE_NAME, method: "sendTestAlert", + stack: error instanceof Error ? error.stack : undefined, }); return false; } - return true; } async sendMessage(notification: Notification, message: NotificationMessage): Promise { @@ -64,16 +66,20 @@ export class EmailProvider extends NotificationProvider { return false; } - const messageId = await this.emailService.sendEmail(notification.address, subject, html); - if (!messageId) { + // Providers must resolve to a boolean: NotificationsService fans these out through + // Promise.all, so a rejection here would abort the other channels' alerts too. + try { + await this.emailService.sendEmail(notification.address, subject, html); + return true; + } catch (error: unknown) { this.logger.warn({ message: "Email notification failed", service: SERVICE_NAME, method: "sendMessage", + stack: error instanceof Error ? error.stack : undefined, }); return false; } - return true; } private buildSubject(message: NotificationMessage): string { diff --git a/server/src/domain/notifications/providers/matrix.ts b/server/src/domain/notifications/providers/matrix.ts index a1f35b0ca..1fafc44b1 100644 --- a/server/src/domain/notifications/providers/matrix.ts +++ b/server/src/domain/notifications/providers/matrix.ts @@ -38,10 +38,13 @@ export class MatrixProvider extends NotificationProvider { private sendMessageWithBody = async (notification: Partial, body: unknown): Promise => { const { homeserverUrl, accessToken, roomId } = notification; - const txnId = randomUUID(); - const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`; + const baseUrl = this.normalizeHomeserverUrl(homeserverUrl as string); try { + const resolvedRoomId = await this.resolveRoomId(baseUrl, accessToken as string, roomId as string); + const txnId = randomUUID(); + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoomId)}/send/m.room.message/${txnId}`; + await got.put(url, { json: body, headers: { @@ -63,6 +66,35 @@ export class MatrixProvider extends NotificationProvider { } }; + private normalizeHomeserverUrl(homeserverUrl: string): string { + return homeserverUrl.replace(/\/+$/, ""); + } + + /** + * The send endpoint only accepts a room ID (`!id:server`). Element shows users the room + * alias (`#name:server`) instead, and an unresolved alias breaks the request outright: + * the `#` opens a URI fragment, so the PUT lands on /rooms/ rather than the send + * endpoint. Resolve aliases through the directory first, per the client-server spec. + */ + private resolveRoomId = async (baseUrl: string, accessToken: string, roomIdOrAlias: string): Promise => { + if (!roomIdOrAlias.startsWith("#")) { + return roomIdOrAlias; + } + + const url = `${baseUrl}/_matrix/client/v3/directory/room/${encodeURIComponent(roomIdOrAlias)}`; + const response = await got.get(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + responseType: "json", + ...this.gotRequestOptions(), + }); + + const roomId = (response.body as { room_id?: string })?.room_id; + if (!roomId) { + throw new Error(`Matrix directory returned no room_id for alias ${roomIdOrAlias}`); + } + return roomId; + }; + /** * Build Matrix message from NotificationMessage * Returns both plain text and HTML formatted versions diff --git a/server/src/domain/notifications/providers/ntfy.ts b/server/src/domain/notifications/providers/ntfy.ts index 7ca351d87..b415f648b 100644 --- a/server/src/domain/notifications/providers/ntfy.ts +++ b/server/src/domain/notifications/providers/ntfy.ts @@ -11,12 +11,18 @@ export class NtfyProvider extends NotificationProvider { return false; } + const authHeaders = this.buildAuthHeaders(notification, "sendTestAlert"); + if (authHeaders === null) { + return false; + } + try { await got.post(this.buildTopicUrl(notification.address, notification.topic), { body: getTestMessage(), headers: { Title: "Checkmate test notification", Priority: "default", + ...authHeaders, }, ...this.gotRequestOptions(), }); @@ -38,6 +44,11 @@ export class NtfyProvider extends NotificationProvider { return false; } + const authHeaders = this.buildAuthHeaders(notification, "sendMessage"); + if (authHeaders === null) { + return false; + } + try { await got.post(this.buildTopicUrl(notification.address, notification.topic), { body: this.buildNtfyText(message), @@ -45,6 +56,7 @@ export class NtfyProvider extends NotificationProvider { Title: message.content.title, Priority: this.mapPriority(message.severity), Tags: this.mapTags(message.severity), + ...authHeaders, }, ...this.gotRequestOptions(), }); @@ -70,6 +82,42 @@ export class NtfyProvider extends NotificationProvider { return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`; } + /** + * Returns the Authorization header for the configured auth type, or null when the + * credentials are incomplete. Null is treated as a send failure rather than falling + * back to an anonymous post, so a misconfigured channel surfaces instead of quietly + * publishing to a topic the server may leave world-readable. + */ + private buildAuthHeaders(notification: Partial, method: string): Record | null { + const { ntfyAuthType, accessToken, ntfyUsername } = notification; + + if (!ntfyAuthType || ntfyAuthType === "none") { + return {}; + } + + if (ntfyAuthType === "token") { + if (!accessToken) { + this.logMissingCredentials("access token", method); + return null; + } + return { Authorization: `Bearer ${accessToken}` }; + } + + if (!ntfyUsername || !accessToken) { + this.logMissingCredentials("username and password", method); + return null; + } + return { Authorization: `Basic ${Buffer.from(`${ntfyUsername}:${accessToken}`).toString("base64")}` }; + } + + private logMissingCredentials(missing: string, method: string): void { + this.logger.warn({ + message: `ntfy notification is configured for authentication but is missing its ${missing}`, + service: SERVICE_NAME, + method, + }); + } + private buildNtfyText(message: NotificationMessage): string { const lines = [ message.content.summary, diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts new file mode 100644 index 000000000..2ef8d7efb --- /dev/null +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -0,0 +1,171 @@ +const SERVICE_NAME = "RocketChatProvider"; +import { supportsUptimeDetails, type MonitorType } from "@/domain/monitors/monitor.type.js"; +import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import got from "got"; + +type RocketChatField = { title: string; value: string; short: boolean }; +type RocketChatAttachment = { + color: string; + title: string; + title_link?: string; + fields?: RocketChatField[]; + ts?: string; +}; +type RocketChatPayload = { text: string; attachments: [RocketChatAttachment] }; + +export class RocketChatProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: this.buildTestPayload(), + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + private buildTestPayload(): RocketChatPayload { + return { + text: getTestMessage(), + attachments: [ + { + color: "#0000FF", + title: "Checkmate test notification", + fields: [ + { title: "Channel", value: "Rocket.Chat", short: true }, + { title: "Status", value: "Test notification", short: true }, + ], + }, + ], + }; + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: this.buildPayload(message), + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildPayload(message: NotificationMessage): RocketChatPayload { + const monitorUrl = this.monitorUrl(message); + const fields: RocketChatField[] = [ + { title: "Monitor", value: message.monitor.name, short: true }, + { title: "Type", value: message.monitor.type, short: true }, + { title: "Status", value: message.monitor.status, short: true }, + { title: "URL", value: message.monitor.url, short: false }, + ]; + + if (message.content.thresholds?.length) { + fields.push( + ...message.content.thresholds.map((breach) => ({ + title: breach.metric.toUpperCase(), + value: `${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`, + short: true, + })) + ); + } + + if (message.content.details?.length) { + fields.push({ + title: "Details", + value: message.content.details.map((detail) => `- ${detail}`).join("\n"), + short: false, + }); + } + + if (message.content.incident?.url.trim()) { + fields.push({ + title: "Incident", + value: message.content.incident.url, + short: false, + }); + } + + return { + text: message.content.summary, + attachments: [ + { + color: this.severityColor(message.severity), + title: message.content.title, + ...(monitorUrl ? { title_link: monitorUrl } : {}), + fields, + ts: message.content.timestamp.toISOString(), + }, + ], + }; + } + + private monitorUrl(message: NotificationMessage): string | undefined { + const clientHost = message.clientHost?.trim(); + if (!clientHost || clientHost === "Host not defined") { + return undefined; + } + + if (message.monitor.type === "hardware") { + return `${clientHost}/infrastructure/${message.monitor.id}`; + } + + if (message.monitor.type === "pagespeed") { + return `${clientHost}/pagespeed/${message.monitor.id}`; + } + + if (supportsUptimeDetails(message.monitor.type as MonitorType)) { + return `${clientHost}/uptime/${message.monitor.id}`; + } + + return undefined; + } + + private severityColor(severity: NotificationMessage["severity"]): string { + switch (severity) { + case "critical": + return "#FF0000"; + case "warning": + return "#FFA500"; + case "success": + return "#00FF00"; + case "info": + return "#0000FF"; + default: + return "#808080"; + } + } +} diff --git a/server/src/domain/proxies/proxy.model.ts b/server/src/domain/proxies/proxy.model.ts new file mode 100644 index 000000000..fbb0b1294 --- /dev/null +++ b/server/src/domain/proxies/proxy.model.ts @@ -0,0 +1,38 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js"; +import { ProxyProtocols } from "@/domain/proxies/proxy.type.js"; + +type ProxyDocumentBase = Omit; + +interface ProxyDocument extends ProxyDocumentBase { + _id: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const ProxySchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { type: String, required: true }, + protocol: { type: String, enum: ProxyProtocols, required: true }, + host: { type: String, required: true }, + port: { type: Number, required: true }, + username: { type: String, required: false }, + password: { type: String, required: false }, + }, + { timestamps: true } +); + +ProxySchema.index({ teamId: 1, name: 1 }, { unique: true }); + +const ProxyModel = model("Proxy", ProxySchema); + +export type { ProxyDocument }; +export { ProxyModel }; +export default ProxyModel; diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts new file mode 100644 index 000000000..12d145ef3 --- /dev/null +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -0,0 +1,15 @@ +import { Proxy } from "@/domain/proxies/proxy.type.js"; + +export interface IProxiesRepository { + // create + create(proxyData: Partial): Promise; + // read + findAll(): Promise; + findById(proxyId: string, teamId: string): Promise; + findByIdOrNull(proxyId: string, teamId?: string): Promise; + findByTeamId(teamId: string): Promise; + // update + updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise; + // delete + deleteById(proxyId: string, teamId: string): Promise; +} diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts new file mode 100644 index 000000000..cf11fd458 --- /dev/null +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -0,0 +1,112 @@ +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { Proxy } from "@/domain/proxies/proxy.type.js"; +import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js"; +import { AppError } from "@/utils/AppError.js"; +import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import { Error as MongooseError, UpdateQuery } from "mongoose"; + +const SERVICE_NAME = "ProxiesRepository"; + +class MongoProxiesRepository implements IProxiesRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toEntity = (doc: ProxyDocument): Proxy => { + return { + id: toStringId(doc._id), + teamId: toStringId(doc.teamId), + name: doc.name, + protocol: doc.protocol, + host: doc.host, + port: doc.port, + username: doc.username, + password: doc.password, + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + async create(proxyData: Partial): Promise { + try { + const proxy = await ProxyModel.create({ ...proxyData }); + return this.toEntity(proxy); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async findAll(): Promise { + const proxies = await ProxyModel.find(); + return proxies.map(this.toEntity); + } + + async findById(proxyId: string, teamId: string): Promise { + const proxy = await ProxyModel.findOne({ _id: proxyId, teamId }); + if (!proxy) { + throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(proxy); + } + + async findByIdOrNull(proxyId: string, teamId?: string): Promise { + try { + const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId }); + return proxy ? this.toEntity(proxy) : null; + } catch (error) { + if (error instanceof MongooseError.CastError) { + return null; + } + throw error; + } + } + + async findByTeamId(teamId: string): Promise { + const proxies = await ProxyModel.find({ teamId }); + return proxies.map(this.toEntity); + } + + async updateById( + proxyId: string, + teamId: string, + patch: Partial, + options?: { unsetPassword?: boolean; unsetUsername?: boolean } + ): Promise { + try { + const update: UpdateQuery = { + $set: { + ...patch, + }, + }; + if (options?.unsetPassword) { + delete update.$set?.password; + update.$unset = { ...update.$unset, password: 1 }; + } + if (options?.unsetUsername) { + delete update.$set?.username; + update.$unset = { ...update.$unset, username: 1 }; + } + const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true }); + if (!updatedProxy) { + throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedProxy); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async deleteById(proxyId: string, teamId: string): Promise { + const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId }); + if (!deletedProxy) { + throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedProxy); + } +} + +export default MongoProxiesRepository; diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts new file mode 100644 index 000000000..9758f2856 --- /dev/null +++ b/server/src/domain/proxies/proxy.service.ts @@ -0,0 +1,111 @@ +import { Proxy, ProxyResponse, ProxySummary } from "@/domain/proxies/proxy.type.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { AppError } from "@/utils/AppError.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; + +export interface IProxiesService { + createProxy(proxy: Partial, teamId: string): Promise; + getProxy(proxyId: string, teamId: string): Promise; + getProxies(): Promise; + getProxySummary(proxyId: string): Promise; + getProxiesByTeamId(teamId: string): Promise; + updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean; clearUsername?: boolean }): Promise; + deleteProxy(proxyId: string, teamId: string): Promise; +} + +const SERVICE_NAME = "ProxiesService"; +export class ProxiesService implements IProxiesService { + constructor( + private proxiesRepository: IProxiesRepository, + private monitorsRepository: IMonitorsRepository, + private settingsService: ISettingsService + ) {} + + private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) }); + + createProxy = async (proxyData: Partial, teamId: string): Promise => { + proxyData.teamId = teamId; + if (!proxyData.password) { + delete proxyData.password; + } + if (!proxyData.username) { + delete proxyData.username; + } + const raw = await this.proxiesRepository.create(proxyData); + return this.toResponse(raw); + }; + + getProxy = async (proxyId: string, teamId: string): Promise => { + const raw = await this.proxiesRepository.findById(proxyId, teamId); + return this.toResponse(raw); + }; + + getProxies = async (): Promise => { + const raw = await this.proxiesRepository.findAll(); + const clean = raw.map((r) => { + return this.toResponse(r); + }); + return clean; + }; + + getProxySummary = async (proxyId: string): Promise => { + const proxy = await this.proxiesRepository.findByIdOrNull(proxyId); + if (!proxy) { + return null; + } + const { id, name, host, port } = proxy; + return { id, name, host, port }; + }; + + getProxiesByTeamId = async (teamId: string): Promise => { + const raw = await this.proxiesRepository.findByTeamId(teamId); + const clean = raw.map((r) => { + return this.toResponse(r); + }); + return clean; + }; + + updateProxy = async ( + proxyId: string, + teamId: string, + patch: Partial & { clearPassword?: boolean; clearUsername?: boolean } + ): Promise => { + // Empty/absent password and username don't delete, clearPassword / clearUsername does + const { clearPassword, clearUsername, ...proxyPatch } = patch; + if (!proxyPatch.password) { + delete proxyPatch.password; + } + if (!proxyPatch.username) { + delete proxyPatch.username; + } + const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, { + unsetPassword: clearPassword === true, + unsetUsername: clearUsername === true, + }); + return this.toResponse(raw); + }; + deleteProxy = async (proxyId: string, teamId: string): Promise => { + // Make sure proxy is not in use + const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId); + if (monitorsUsingProxy > 0) { + throw new AppError({ + message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`, + service: SERVICE_NAME, + status: 409, + }); + } + + // Make sure this is not set as a global proxy + const settings = await this.settingsService.getDBSettings(); + if (settings.globalProxyId === proxyId) { + throw new AppError({ + message: "Proxy is set as the global proxy in settings", + service: SERVICE_NAME, + status: 409, + }); + } + const raw = await this.proxiesRepository.deleteById(proxyId, teamId); + return this.toResponse(raw); + }; +} diff --git a/server/src/domain/proxies/proxy.type.ts b/server/src/domain/proxies/proxy.type.ts new file mode 100644 index 000000000..c16b0c28c --- /dev/null +++ b/server/src/domain/proxies/proxy.type.ts @@ -0,0 +1,19 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} + +export type ProxyResponse = Omit & { hasPassword: boolean }; + +export type ProxySummary = Pick; diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 834f87384..5f700857f 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -5,19 +5,22 @@ import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, PublicStatusPagePayload, + STATUS_PAGE_RANGE_DAYS, StatusPage, + StatusPageRange, } from "@/domain/status-pages/status-page.type.js"; import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { Monitor } from "@/domain/monitors/monitor.type.js"; -import { NormalizeData } from "@/utils/dataUtils.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import type { DailyCheckBucket } from "@/domain/checks/check.type.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; - getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined): Promise; + getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; deleteStatusPage(statusPageId: string, teamId: string): Promise; @@ -27,7 +30,8 @@ export class StatusPageService implements IStatusPageService { constructor( private statusPagesRepository: IStatusPagesRepository, private settingsService: ISettingsService, - private monitorsRepository: IMonitorsRepository + private monitorsRepository: IMonitorsRepository, + private checksRepository: IChecksRepository ) {} private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { @@ -79,7 +83,6 @@ export class StatusPageService implements IStatusPageService { status: monitor.status, uptimePercentage: monitor.uptimePercentage, recentChecks: monitor.recentChecks, - checks: NormalizeData(monitor.recentChecks, 10, 100), }; if (showURL) { @@ -118,19 +121,50 @@ export class StatusPageService implements IStatusPageService { return statusPages.map((sp) => this.normalizeTheme(sp)); }; - getPublicStatusPagePayload = async (statusPage: StatusPage, requesterTeamId: string | undefined): Promise => { + getPublicStatusPagePayload = async ( + statusPage: StatusPage, + requesterTeamId: string | undefined, + range: StatusPageRange = "latest" + ): Promise => { if (!statusPage.isPublished) { if (!requesterTeamId || statusPage.teamId !== requesterTeamId) { throw new AppError({ message: "Forbidden", status: 403 }); } } - const showURL = (await this.settingsService.getDBSettings()).showURL; - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); + const dbSettings = await this.settingsService.getDBSettings(); + const showURL = dbSettings.showURL; + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { recentChecks: range === "latest" ? "all" : "latestHardware" }); const order = new Map(statusPage.monitors.map((id, i) => [id, i])); const sorted = [...monitors].sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER)); - return { statusPage, monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)) }; + if (range === "latest") { + return { statusPage, monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)) }; + } + + const days = STATUS_PAGE_RANGE_DAYS[range]; + const bucketTimezone = statusPage.timezone ?? "Etc/UTC"; + const buckets = await this.checksRepository.getDailyStatusBuckets(statusPage.monitors, days, bucketTimezone); + const bucketsByMonitor = buckets.reduce((grouped, bucket) => { + const monitorBuckets = grouped.get(bucket.monitorId); + if (monitorBuckets) { + monitorBuckets.push(bucket); + } else { + grouped.set(bucket.monitorId, [bucket]); + } + return grouped; + }, new Map()); + + return { + statusPage, + range, + bucketTimezone, + checkTTLDays: dbSettings.checkTTL, + monitors: sorted.map((monitor) => ({ + ...this.toPublicMonitor(monitor, showURL), + dailyChecks: bucketsByMonitor.get(monitor.id) ?? [], + })), + }; }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index 65f331ad9..630a22ce2 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,5 +1,5 @@ +import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { Monitor } from "@/domain/monitors/monitor.type.js"; -import type { NormalizedCheck, CheckSnapshot } from "@/domain/checks/check.type.js"; export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; @@ -11,6 +11,15 @@ export const StatusPageThemeModes = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; +export const StatusPageDayRanges = ["30d", "60d", "90d"] as const; +export type StatusPageDayRange = (typeof StatusPageDayRanges)[number]; +export const StatusPageRanges = ["latest", ...StatusPageDayRanges] as const; +export type StatusPageRange = (typeof StatusPageRanges)[number]; +export const STATUS_PAGE_RANGE_DAYS: Record = { + "30d": 30, + "60d": 60, + "90d": 90, +}; export interface StatusPageLogo { data: string; contentType: string; @@ -47,10 +56,16 @@ export interface StatusPage { updatedAt: string; } -export type PublicStatusPageMonitor = Pick & - Partial> & { checks: NormalizedCheck[] }; +export type PublicStatusPageMonitor = Pick & { + url?: string; + port?: number; + dailyChecks?: DailyCheckBucket[]; // Only present when range !== "latest" +}; export interface PublicStatusPagePayload { statusPage: StatusPage; monitors: PublicStatusPageMonitor[]; + range?: StatusPageDayRange; + bucketTimezone?: string; + checkTTLDays?: number; } diff --git a/server/src/domain/users/user.service.ts b/server/src/domain/users/user.service.ts index f3db2f3b5..58a3ec1a3 100644 --- a/server/src/domain/users/user.service.ts +++ b/server/src/domain/users/user.service.ts @@ -30,7 +30,7 @@ export interface IUserService { currentUserEmail: string ): Promise; checkSuperadminExists(): Promise; - requestRecovery(email: string): Promise; + requestRecovery(email: string): Promise; validateRecovery(recoveryToken: string): Promise; resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>; deleteUser(params: { userId: string; teamId: string; roles: UserRole[] }): Promise; @@ -324,7 +324,7 @@ export class UserService implements IUserService { } if (roles.includes("superadmin")) { - const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}, { includeRecentChecks: false }); if (monitors) { await Promise.all(monitors.map((monitor) => this.scheduler.deleteJob(monitor))); } diff --git a/server/src/service/emailService.ts b/server/src/service/emailService.ts index 26b89f84b..3aca5362c 100755 --- a/server/src/service/emailService.ts +++ b/server/src/service/emailService.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "url"; import { EmailTransportConfig } from "@/domain/app-settings/app-settings.type.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; import fs from "node:fs"; import path from "node:path"; import nodemailer from "nodemailer"; @@ -20,7 +21,7 @@ type TemplateCompiler = (template: string) => (context: Record) export interface IEmailService { init(): void; buildEmail(template: string, context: Record): Promise; - sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise; + sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise; } export class EmailService implements IEmailService { @@ -151,7 +152,12 @@ export class EmailService implements IEmailService { method: "verifyTransporter", stack: error instanceof Error ? error.stack : undefined, }); - return false; + throw new AppError({ + message: "Email transporter verification failed", + service: SERVICE_NAME, + method: "sendEmail", + details: { cause: error instanceof Error ? error.message : "Unknown error" }, + }); } const trimmedDisplayName = systemEmailDisplayName?.trim(); @@ -172,6 +178,12 @@ export class EmailService implements IEmailService { method: "sendEmail", stack: error instanceof Error ? error.stack : undefined, }); + throw new AppError({ + message: "Failed to send email", + service: SERVICE_NAME, + method: "sendEmail", + details: { cause: error instanceof Error ? error.message : "Unknown error" }, + }); } }; } diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index fb5f9fea5..1ef35f474 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { isStatusUp } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; import CacheableLookup from "cacheable-lookup"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; +import { CheckContext } from "@/types/network.js"; + +export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]); export class HttpProvider implements IStatusProvider { readonly type = "http"; // Shared, pooled agents reused across every check + private static readonly PROXY_AGENT_CACHE_MAX = 50; + private readonly proxyAgents = new Map(); private readonly httpAgent: HttpAgent; private readonly httpsAgent: HttpsAgent; private readonly httpsAgentInsecure: HttpsAgent; @@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider { return type === "http"; } + private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => { + if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) { + return undefined; + } + const { hostname, port } = new URL(proxyUrl); + return `Proxy connection failed (${hostname}:${port}): ${error.message}`; + }; + + private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) { + const key = `${proxyUrl}|${ignoreTlsErrors}`; + const hit = this.proxyAgents.get(key); + // Agent already exists, refresh + if (hit) { + this.proxyAgents.delete(key); // Refresh + this.proxyAgents.set(key, hit); + return hit; + } + + // Create new agents + const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl }; + const pair = { + http: new HttpProxyAgent(agentOptions), + https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }), + }; + + // Cache agents + this.proxyAgents.set(key, pair); + if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) { + const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item + if (oldestKey === undefined) return pair; + const oldest = this.proxyAgents.get(oldestKey); + oldest?.http.destroy(); + oldest?.https.destroy(); + this.proxyAgents.delete(oldestKey); + } + return pair; + } + private buildResponse( monitor: Monitor, opts: { @@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider { }; } - private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { + private handleHttpError(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { const statusCode = error.response?.statusCode; const statusUp = isStatusUp(statusCode, monitor.customUpCodes); const responseTime = error.timings?.phases?.total ?? 0; if (!statusUp) { + const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message; + return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, code: statusCode ?? NETWORK_ERROR, - message: error.message, + message: message, responseTime, timings: error.timings, payload: null as T, @@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider { }; } - async handle(monitor: Monitor): Promise> { + async handle(monitor: Monitor, ctx?: CheckContext): Promise> { const { url, secret, ignoreTlsErrors } = monitor; if (!url) { @@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider { headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, }; - options.agent = { - http: this.httpAgent, - https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, - }; + options.agent = ctx?.proxyUrl + ? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors)) + : { + http: this.httpAgent, + https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, + }; options.method = monitor.method; @@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider { timings: response.timings, }); } catch (error: unknown) { - return this.handleHttpError(error, monitor); + return this.handleHttpError(error, monitor, ctx); } } } diff --git a/server/src/service/network/IStatusProvider.ts b/server/src/service/network/IStatusProvider.ts index 6da91b30f..e8f63f671 100644 --- a/server/src/service/network/IStatusProvider.ts +++ b/server/src/service/network/IStatusProvider.ts @@ -1,8 +1,8 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { MonitorStatusResponse } from "@/types/network.js"; - +import { CheckContext } from "@/types/network.js"; export interface IStatusProvider { type: string; supports: (type: MonitorType) => boolean; - handle(monitor: Monitor): Promise>; + handle(monitor: Monitor, ctx?: CheckContext): Promise>; } diff --git a/server/src/service/network/ProxyResolver.ts b/server/src/service/network/ProxyResolver.ts new file mode 100644 index 000000000..573905f4b --- /dev/null +++ b/server/src/service/network/ProxyResolver.ts @@ -0,0 +1,111 @@ +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { Monitor } from "@/domain/monitors/monitor.type.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { ILogger } from "@/utils/logger.js"; +import { Proxy } from "@/domain/proxies/proxy.type.js"; + +export interface IProxyResolver { + resolve(monitor: Monitor): Promise; +} + +const SERVICE_NAME = "ProxyResolver"; + +export const buildProxyUrl = (proxy: Proxy): string => { + const { protocol, host, port, username, password } = proxy; + let credentials = ""; + if (username) { + credentials = password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username)}@`; + } + return `${protocol}://${credentials}${host}:${port}`; +}; + +export class ProxyResolver implements IProxyResolver { + static SERVICE_NAME = SERVICE_NAME; + constructor( + private proxiesRepository: IProxiesRepository, + private settingsService: ISettingsService, + private logger: ILogger, + private ttlMs = 60_000 + ) {} + + private cache = new Map; expiresAt: number }>(); + + private getCached(key: string, fetch: () => Promise): Promise { + const hit = this.cache.get(key); + if (hit && hit.expiresAt > Date.now()) { + return hit.value as Promise; + } + // Cache the in flight promise so concurrent misses share one fetch + const value = fetch(); + this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + // Don't negative cache rejections, evict so the next resolve retries + value.catch(() => this.cache.delete(key)); + return value; + } + + private warn(message: string, monitor: Monitor, error?: unknown) { + this.logger.warn({ + message, + service: SERVICE_NAME, + method: "resolve", + details: { + monitorId: monitor.id, + proxyMode: monitor.proxyMode, + proxyId: monitor.proxyId, + ...(error instanceof Error ? { error: error.message } : {}), + }, + }); + } + + private resolveCustom = async (monitor: Monitor): Promise => { + const { proxyId, teamId } = monitor; + if (!proxyId) { + this.warn("Proxy mode is custom, but proxyId is missing, connecting direct", monitor); + return undefined; + } + + const proxy = await this.getCached(`proxy:${proxyId}:${teamId}`, () => this.proxiesRepository.findByIdOrNull(proxyId, teamId)); + if (!proxy) { + this.warn("Proxy not found, connecting direct", monitor); + return undefined; + } + return buildProxyUrl(proxy); + }; + + private resolveInherit = async (monitor: Monitor): Promise => { + const settings = await this.getCached("settings", () => this.settingsService.getDBSettings()); + if (!settings.globalProxyEnabled) { + return undefined; + } + const globalProxyId = settings.globalProxyId; + if (!globalProxyId) { + return undefined; + } + const proxy = await this.getCached(`proxy:${globalProxyId}`, () => this.proxiesRepository.findByIdOrNull(globalProxyId)); + if (!proxy) { + this.warn("Global proxy references a proxy that no longer exists, connecting direct", monitor); + return undefined; + } + return buildProxyUrl(proxy); + }; + + async resolve(monitor: Monitor): Promise { + if (monitor.type !== "http") { + return undefined; + } + try { + switch (monitor.proxyMode) { + case "none": + return undefined; + case "custom": + return await this.resolveCustom(monitor); + default: + // "inherit" is the default mode + return await this.resolveInherit(monitor); + } + } catch (error: unknown) { + this.warn("Proxy resolution failed, connecting direct", monitor, error); + return undefined; + } + } +} diff --git a/server/src/service/networkService.ts b/server/src/service/networkService.ts index 75ff66bda..69e97b092 100644 --- a/server/src/service/networkService.ts +++ b/server/src/service/networkService.ts @@ -1,5 +1,5 @@ import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; -import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; +import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; @@ -8,7 +8,7 @@ import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; export interface INetworkService { - requestStatus(monitor: Monitor & { type: T }): Promise>; + requestStatus(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise>; requestWebhook( type: string, url: string, @@ -39,12 +39,15 @@ export class NetworkService implements INetworkService { } // Main entry point - async requestStatus(monitor: Monitor & { type: T }): Promise> { + async requestStatus( + monitor: Monitor & { type: T }, + ctx?: CheckContext + ): Promise> { const provider = this.providers.find((p) => p.supports(monitor.type)); if (!provider) { return this.handleUnsupportedType(monitor.type) as Promise>; } - return provider.handle(monitor) as Promise>; + return provider.handle(monitor, ctx) as Promise>; } private async handleUnsupportedType(type: string): Promise { diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index 748960a37..07ddee2a0 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -1,7 +1,7 @@ import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js"; -import type { Monitor, MonitorStatus } from "@/domain/monitors/monitor.type.js"; +import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js"; +import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js"; import type { DockerStatusPayload, GameStatusPayload, @@ -18,6 +18,7 @@ import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import type { HardwareStatusMetrics } from "@/types/network.js"; import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js"; +import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; const SERVICE_NAME = "StatusService"; const HARDWARE_ALERT_COUNTER_START = 5; @@ -87,30 +88,6 @@ export class StatusService implements IStatusService { } }; - private toCheckSnapshot = (check: Check): CheckSnapshot => { - return { - id: check.id, - status: check.status, - responseTime: check.responseTime, - timings: check.timings, - statusCode: check.statusCode, - message: check.message, - cpu: check.cpu, - memory: check.memory, - disk: check.disk, - host: check.host, - errors: check.errors, - capture: check.capture, - net: check.net, - accessibility: check.accessibility, - bestPractices: check.bestPractices, - seo: check.seo, - performance: check.performance, - audits: check.audits, - createdAt: check.createdAt, - }; - }; - private computeReachability = ( currentStatus: MonitorStatus, window: Array, @@ -207,7 +184,7 @@ export class StatusService implements IStatusService { await this.tryUpdateRunningStats(monitor, statusResponse); const prevStatus = monitor.status; - const checkSnapshot = this.toCheckSnapshot(check); + const checkSnapshot = toCheckSnapshot(check); // Project the window as it will look after updating DB // This is done because we need the updated status window to compute new status, but we don't @@ -218,10 +195,13 @@ export class StatusService implements IStatusService { const patch: Partial = {}; // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down + // Unknown values (e.g. legacy boolean statuses from old versions) are resolved the same way, + // otherwise computeReachability can never transition them and they stay stuck forever. + const isUnknownStatus = !MonitorStatuses.includes(monitor.status); let newStatus: MonitorStatus = monitor.status; let statusChanged = false; - if (monitor.status === "initializing" || monitor.status === "maintenance") { - newStatus = status === true ? "up" : "down"; + if (monitor.status === "initializing" || monitor.status === "maintenance" || isUnknownStatus) { + newStatus = status ? "up" : "down"; patch.status = newStatus; statusChanged = newStatus === "down"; } diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 2839ed95f..0c8dd7cae 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -152,3 +152,7 @@ export type StatusChangeResult = { }; export type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; + +export type CheckContext = { + proxyUrl?: string; +}; diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index 2c15b943e..08e8de740 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,4 +1,3 @@ -import type { HasResponseTime, NormalizedCheck } from "@/domain/checks/check.type.js"; import { type DateRange } from "@/types/query.js"; export const getDateForRange = (dateRange: DateRange): Date => { @@ -32,32 +31,3 @@ export const getDateFormat = (dateRange: DateRange): string => { }; return formatLookup[dateRange]; }; - -const percentileBy = (arr: T[], percentile: number, value: (item: T) => number): number => { - const sorted = arr.slice().sort((a, b) => value(a) - value(b)); - const index = (percentile / 100) * (sorted.length - 1); - const lower = Math.floor(index); - const upper = lower + 1; - const weight = index % 1; - if (upper >= sorted.length) return value(sorted[lower]!); - return value(sorted[lower]!) * (1 - weight) + value(sorted[upper]!) * weight; -}; - -const rescale = (raw: number, min: number, max: number, rangeMin: number, rangeMax: number): number => { - const scaled = rangeMin + ((raw - min) * (rangeMax - rangeMin)) / (max - min); - return Math.max(rangeMin, Math.min(rangeMax, scaled)); -}; - -export const NormalizeData = (checks: T[], rangeMin: number, rangeMax: number): NormalizedCheck[] => { - const original = (check: T) => (typeof check.responseTime === "number" ? check.responseTime : 0); - if (checks.length <= 1) { - return checks.map((check) => ({ ...check, originalResponseTime: original(check) })); - } - const min = percentileBy(checks, 0, (c) => c.responseTime); - const max = percentileBy(checks, 95, (c) => c.responseTime); - return checks.map((check) => ({ - ...check, - responseTime: rescale(check.responseTime, min, max, rangeMin, rangeMax), - originalResponseTime: original(check), - })); -}; diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index b73e2c6de..f297d01be 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -9,6 +9,7 @@ import { ICheckService } from "@/domain/checks/check.service.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { IProxyResolver } from "@/service/network/ProxyResolver.js"; export interface ICheckProducer { produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>; @@ -22,6 +23,7 @@ export class CheckProducer implements ICheckProducer { private maintenanceWindowsRepository: IMaintenanceWindowsRepository, private checkService: ICheckService, private networkService: INetworkService, + private proxyResolver: IProxyResolver, private bufferService: IBufferService, private logger: ILogger ) {} @@ -56,7 +58,8 @@ export class CheckProducer implements ICheckProducer { } // Step 1b: Acquire status - const status = await this.networkService.requestStatus(monitor); + const proxyUrl = await this.proxyResolver.resolve(monitor); + const status = await this.networkService.requestStatus(monitor, { proxyUrl }); if (!status) { throw new Error("No network response"); } diff --git a/server/src/worker/worker.job-scheduler.ts b/server/src/worker/worker.job-scheduler.ts index 8d103e87f..aae8a489b 100644 --- a/server/src/worker/worker.job-scheduler.ts +++ b/server/src/worker/worker.job-scheduler.ts @@ -1,7 +1,7 @@ import { IJobScheduler } from "@/worker/worker.interface.js"; import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.type.js"; +import { Monitor, MonitorScheduleFields, supportsGeoCheck } from "@/domain/monitors/monitor.type.js"; import { type Job, type JobSeed, type JobType, jobId } from "@/domain/jobs/job.type.js"; import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; import { WorkerJobsPagination, WorkerJobSummary, WorkerMetrics } from "@/worker/worker.interface.js"; @@ -43,7 +43,7 @@ export class JobScheduler implements IJobScheduler { "cleanup-retention": POLL_MS, }; // Helpers, builds job seeds. immediate=true runs on the next tick; otherwise jitter spreads the herd. - protected toCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + protected toCheckJob = (monitor: MonitorScheduleFields, now: number, immediate = false): JobSeed => ({ id: jobId("check", monitor.id), type: "check", refId: monitor.id, @@ -52,7 +52,7 @@ export class JobScheduler implements IJobScheduler { intervalMs: monitor.interval, }); - protected toGeoCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + protected toGeoCheckJob = (monitor: MonitorScheduleFields, now: number, immediate = false): JobSeed => ({ ...this.toCheckJob(monitor, now, immediate), id: jobId("geo-check", monitor.id), type: "geo-check", @@ -187,7 +187,7 @@ export class JobScheduler implements IJobScheduler { // Seed queue protected reconcile = async () => { const now = Date.now(); - const monitors = await this.monitorsRepository.findAll(); + const monitors = await this.monitorsRepository.findAllForScheduling(); for (const monitor of monitors) { await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now)); if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index 1ff0b0383..2ec36e8e2 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -1,6 +1,6 @@ import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/domain/monitors/monitor.repository.interface.ts"; import type { CheckSnapshot } from "../../src/domain/checks/check.type.ts"; -import type { Monitor, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts"; +import type { Monitor, MonitorScheduleFields, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts"; export class InMemoryMonitorsRepository implements IMonitorsRepository { private monitors: Monitor[] = []; @@ -24,8 +24,15 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return { ...monitor }; } - async findAll(): Promise { - return this.monitors.map((m) => ({ ...m })); + async findAllForScheduling(): Promise { + return this.monitors.map((m) => ({ + id: m.id, + type: m.type, + isActive: m.isActive, + interval: m.interval, + geoCheckEnabled: m.geoCheckEnabled ?? false, + geoCheckInterval: m.geoCheckInterval ?? 300000, + })); } async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise { @@ -45,10 +52,6 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return monitor ? { ...monitor } : null; } - async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise { - return this.findByIds(monitorIds); - } - async updateById(monitorId: string, teamId: string, updates: Partial): Promise { const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); if (index === -1) { diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 898c3c91e..9c2161790 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -127,12 +127,14 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { }; const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; + const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) }; const checkProducer = new CheckProducer( monitorsRepo as any, maintenanceWindowsRepo as any, checkService as any, networkService as any, + proxyResolver as any, bufferStub as any, logger ); diff --git a/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts new file mode 100644 index 000000000..5517c3a98 --- /dev/null +++ b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts"; +import { CheckModel } from "../../src/domain/checks/check.model.ts"; +import type { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantees under test — timezone-correct $dateTrunc day boundaries, $avg +// skipping missing responseTime (null bucket, not 0), and zero-check days yielding +// no rows — are all aggregation-engine behavior, so they can only be exercised +// against a live mongod. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await CheckModel.createCollection(); // timeseries collections must exist before insert +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await CheckModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MONITOR_A = new mongoose.Types.ObjectId(); +const MONITOR_B = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +const DAY_MS = 24 * 60 * 60 * 1000; + +// One shared instant per seeded "day" keeps every check of that day in the same +// bucket no matter when the test runs; instants exactly 24h apart always land on +// distinct calendar days. +const TODAY = new Date(Date.now() - 10 * 60 * 1000); +const ONE_DAY_AGO = new Date(TODAY.getTime() - DAY_MS); + +const utcDate = (date: Date) => date.toISOString().slice(0, 10); + +// Yesterday at 03:30 UTC: in America/Toronto that is 23:30 (EDT) or 22:30 (EST) +// of the *prior* day, so the local calendar date is always one behind UTC. +const yesterdayAtUtc = (hours: number, minutes: number) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 1); + date.setUTCHours(hours, minutes, 0, 0); + return date; +}; + +const seedCheck = (monitorId: mongoose.Types.ObjectId, createdAt: Date, overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt, + ...overrides, + }); + +describe("MongoChecksRepository getDailyStatusBuckets", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("groups by monitor and calendar day with up/down math; zero-check days produce no rows", async () => { + await seedCheck(MONITOR_A, TODAY, { responseTime: 100 }); + await seedCheck(MONITOR_A, TODAY, { responseTime: 300 }); + await seedCheck(MONITOR_A, TODAY, { status: false, responseTime: 200 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { responseTime: 100 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { status: false, responseTime: undefined }); + await seedCheck(MONITOR_B, TODAY, { status: false, responseTime: undefined }); + // Two days ago is deliberately unseeded: the gap day must be absent, not zero-filled. + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString(), MONITOR_B.toString()], 7, "UTC"); + + const monitorABuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_A.toString()); + const monitorBBuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_B.toString()); + + // Ascending by day; the day with a responseTime-less check averages over the + // checks that have one ($avg skips missing) rather than coercing them to 0. + expect(monitorABuckets).toEqual([ + { monitorId: MONITOR_A.toString(), date: utcDate(ONE_DAY_AGO), totalChecks: 2, upChecks: 1, downChecks: 1, avgResponseTime: 100 }, + { monitorId: MONITOR_A.toString(), date: utcDate(TODAY), totalChecks: 3, upChecks: 2, downChecks: 1, avgResponseTime: 200 }, + ]); + // A day where no check recorded a responseTime yields null, not 0. + expect(monitorBBuckets).toEqual([ + { monitorId: MONITOR_B.toString(), date: utcDate(TODAY), totalChecks: 1, upChecks: 0, downChecks: 1, avgResponseTime: null }, + ]); + }); + + it("cuts day boundaries in the requested timezone", async () => { + const lateEveningLocal = yesterdayAtUtc(3, 30); + await seedCheck(MONITOR_A, lateEveningLocal); + + const utcBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + const torontoBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "America/Toronto"); + + const previousUtcDay = new Date(lateEveningLocal); + previousUtcDay.setUTCDate(previousUtcDay.getUTCDate() - 1); + + expect(utcBuckets).toHaveLength(1); + expect(utcBuckets[0].date).toBe(utcDate(lateEveningLocal)); + expect(torontoBuckets).toHaveLength(1); + expect(torontoBuckets[0].date).toBe(utcDate(previousUtcDay)); + }); + + it("excludes checks older than the requested window", async () => { + await seedCheck(MONITOR_A, new Date(Date.now() - 10 * DAY_MS)); + await seedCheck(MONITOR_A, TODAY); + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + + expect(buckets).toHaveLength(1); + expect(buckets[0]).toMatchObject({ date: utcDate(TODAY), totalChecks: 1 }); + }); +}); diff --git a/server/test/integration/checksRepositoryGroupedChecks.test.ts b/server/test/integration/checksRepositoryGroupedChecks.test.ts new file mode 100644 index 000000000..cf5384d0c --- /dev/null +++ b/server/test/integration/checksRepositoryGroupedChecks.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts"; +import { CheckModel } from "../../src/domain/checks/check.model.ts"; +import type { UptimeChecksResult } from "../../src/domain/checks/check.type.ts"; +import type { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantee under test — every phase average uses the full bucket as its +// denominator — IS the $ifNull-inside-$avg behavior of the groupedChecks facet, so it +// can only be exercised against a live aggregation engine. Reused keep-alive sockets +// produce checks with no timings subdocument at all; without the coercion, $avg would +// skip them and average dns/tcp/tls over fresh-connection checks only. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await CheckModel.createCollection(); // timeseries collections must exist before insert +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await CheckModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MONITOR_ID = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +// One shared createdAt puts every seeded check in the same hourly bucket of the "day" +// range, keeping the facet's group stage deterministic. +const BUCKET_TIME = new Date(Date.now() - 10 * 60 * 1000); + +const seedCheck = (overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId: MONITOR_ID, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt: BUCKET_TIME, + ...overrides, + }); + +const findGrouped = async (repo: MongoChecksRepository) => + (await repo.findByDateRangeAndMonitorId(MONITOR_ID.toString(), "day", { type: "http" })) as UptimeChecksResult; + +describe("MongoChecksRepository groupedChecks facet", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("averages each phase over the full bucket, counting checks without timings as 0", async () => { + await seedCheck({ + responseTime: 100, + timings: { phases: { dns: 10, tcp: 20, tls: 30, request: 8, firstByte: 40, download: 6 } }, + }); + await seedCheck({ responseTime: 200 }); // reused socket: no timings subdocument at all + + const result = await findGrouped(repo); + + expect(result.groupedChecks).toHaveLength(1); + // Plain $avg would skip the timings-less check and report 10/20/30/8/40/6 here. + expect(result.groupedChecks[0]).toMatchObject({ + totalChecks: 2, + avgDns: 5, + avgTcp: 10, + avgTls: 15, + avgRequest: 4, + avgFirstByte: 20, + avgDownload: 3, + avgResponseTime: 150, + }); + }); + + it("yields 0 (not null) for every phase in a bucket of only timings-less checks", async () => { + await seedCheck(); + await seedCheck(); + + const result = await findGrouped(repo); + + expect(result.groupedChecks).toHaveLength(1); + // The client sums these fields for the stacked chart; null would poison the math. + expect(result.groupedChecks[0]).toMatchObject({ + totalChecks: 2, + avgDns: 0, + avgTcp: 0, + avgTls: 0, + avgRequest: 0, + avgFirstByte: 0, + avgDownload: 0, + }); + }); +}); diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts new file mode 100644 index 000000000..ed7a7a7a6 --- /dev/null +++ b/server/test/integration/monitorRepository.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { MonitorModel } from "../../src/domain/monitors/monitor.model.ts"; +import MongoMonitorsRepository from "../../src/domain/monitors/monitor.repository.mongo.ts"; + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await MonitorModel.init(); +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await MonitorModel.deleteMany({}); +}); + +const seedMonitor = (teamId: mongoose.Types.ObjectId, name: string, tags: mongoose.Types.ObjectId[]) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId, + name, + type: "http", + url: "https://example.com", + tags, + }); + +const makeSnapshot = (id: string, dayOfMonth: number) => ({ + id, + status: true, + responseTime: 100, + createdAt: new Date(2026, 7, dayOfMonth), +}); + +const seedMonitorWithChecks = (teamId: mongoose.Types.ObjectId, name: string, type: string, snapshotIds: string[]) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId, + name, + type, + url: "https://example.com", + recentChecks: snapshotIds.map((id, i) => makeSnapshot(id, i + 1)), + }); + +describe("MongoMonitorsRepository", () => { + describe("tag filtering", () => { + it("matches ObjectId tag references when filters arrive as query strings", async () => { + const repo = new MongoMonitorsRepository(); + const teamId = new mongoose.Types.ObjectId(); + const selectedTagId = new mongoose.Types.ObjectId(); + const otherTagId = new mongoose.Types.ObjectId(); + + await seedMonitor(teamId, "selected", [selectedTagId]); + await seedMonitor(teamId, "not-selected", [otherTagId]); + await seedMonitor(new mongoose.Types.ObjectId(), "other-team", [selectedTagId]); + + const tags = selectedTagId.toString(); + const monitors = await repo.findByTeamIdWithStats(teamId.toString(), { tags }); + const summary = await repo.findMonitorsSummaryByTeamId(teamId.toString(), { tags }); + + expect(monitors.map((monitor) => monitor.name)).toEqual(["selected"]); + expect(summary.totalMonitors).toBe(1); + }); + }); + + describe("findByIds recentChecks modes", () => { + const seedFixtures = async () => { + const teamId = new mongoose.Types.ObjectId(); + const hardware = await seedMonitorWithChecks(teamId, "hw", "hardware", ["hw-1", "hw-2", "hw-3"]); + const http = await seedMonitorWithChecks(teamId, "web", "http", ["web-1", "web-2"]); + return { ids: [hardware._id.toString(), http._id.toString()] }; + }; + + const checksByName = (monitors: Awaited>) => + Object.fromEntries(monitors.map((monitor) => [monitor.name, monitor.recentChecks.map((check) => check.id)])); + + it("returns full recentChecks by default and for mode 'all'", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const byDefault = await repo.findByIds(ids); + const byAll = await repo.findByIds(ids, { recentChecks: "all" }); + + for (const monitors of [byDefault, byAll]) { + expect(checksByName(monitors)).toEqual({ hw: ["hw-1", "hw-2", "hw-3"], web: ["web-1", "web-2"] }); + } + }); + + it("strips recentChecks for mode 'none'", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const monitors = await repo.findByIds(ids, { recentChecks: "none" }); + + expect(checksByName(monitors)).toEqual({ hw: [], web: [] }); + }); + + it("keeps only the newest snapshot for hardware monitors in 'latestHardware' mode", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const monitors = await repo.findByIds(ids, { recentChecks: "latestHardware" }); + + expect(checksByName(monitors)).toEqual({ hw: ["hw-3"], web: [] }); + }); + + it("returns an empty array in 'latestHardware' mode for a hardware monitor with no recentChecks field at all", async () => { + const repo = new MongoMonitorsRepository(); + const teamId = new mongoose.Types.ObjectId(); + const hardware = await seedMonitorWithChecks(teamId, "hw-fresh", "hardware", []); + // $slice on a missing operand is a hard aggregation error; the $ifNull guard must cover it + await MonitorModel.updateOne({ _id: hardware._id }, { $unset: { recentChecks: 1 } }); + + const monitors = await repo.findByIds([hardware._id.toString()], { recentChecks: "latestHardware" }); + + expect(monitors).toHaveLength(1); + expect(monitors[0].recentChecks).toEqual([]); + }); + }); +}); diff --git a/server/test/unit/notifications/notificationType.test.ts b/server/test/unit/notifications/notificationType.test.ts new file mode 100644 index 000000000..0029fbd46 --- /dev/null +++ b/server/test/unit/notifications/notificationType.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@jest/globals"; +import { NotificationModel } from "@/domain/notifications/notification.model.js"; +import { NotificationChannels } from "@/domain/notifications/notification.type.js"; + +describe("NotificationChannels", () => { + it("includes Rocket.Chat", () => { + expect(NotificationChannels).toContain("rocket_chat"); + }); + + it("allows Rocket.Chat to be persisted", () => { + const typePath = NotificationModel.schema.path("type") as unknown as { enumValues: string[] }; + + expect(typePath.enumValues).toContain("rocket_chat"); + }); +}); diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 9ab69f25d..4e559ab06 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; @@ -494,4 +495,152 @@ describe("HttpProvider", () => { expect(advancedMatcher.validate).not.toHaveBeenCalled(); }); }); + + // ── Proxy support ──────────────────────────────────────────────────── + + describe("proxy support", () => { + const PROXY_URL = "http://proxy.example.com:8080"; + + const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any; + + const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => { + const err = new RequestError(message); + (err as any).code = code; + return err; + }; + + it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent); + expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent); + }); + + it("uses the shared agents when ctx has no proxyUrl", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor()); + await provider.handle(makeMonitor(), {}); + + expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent); + expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http); + expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https); + }); + + it("reuses the cached agent pair across checks with the same proxyUrl", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http); + expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https); + }); + + it("builds distinct agent pairs for distinct proxyUrls", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" }); + + expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http); + }); + + it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL }); + + expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https); + expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true); + expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false); + }); + + it("evicts and destroys the least-recently-used pair past the cache cap", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" }); + const first = gotOptions(0).agent; + const httpDestroy = jest.spyOn(first.http, "destroy"); + const httpsDestroy = jest.spyOn(first.https, "destroy"); + + for (let i = 1; i <= 49; i++) { + await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` }); + } + expect(httpDestroy).not.toHaveBeenCalled(); + + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" }); + + expect(httpDestroy).toHaveBeenCalled(); + expect(httpsDestroy).toHaveBeenCalled(); + // The evicted pair is gone — the same proxyUrl now gets a fresh pair + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" }); + expect(gotOptions(51).agent.http).not.toBe(first.http); + }); + + it("names the proxy in the message when the proxy connection fails", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080"); + }); + + it("does not attribute timeouts to the proxy", async () => { + mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.message).toBe("Timeout awaiting 'request' for 30000ms"); + }); + + it("leaves connection-error messages unprefixed for direct checks", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080"); + }); + + it("never leaks proxy credentials into the message", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" }); + + expect(result.message).toContain("proxy.example.com:8080"); + expect(result.message).not.toContain("bob"); + expect(result.message).not.toContain("sekret"); + }); + + it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => { + const err = new RequestError("Response code 500 (Internal Server Error)"); + (err as any).code = "ECONNRESET"; + (err as any).response = { statusCode: 500, body: "", headers: {} }; + (err as any).timings = { phases: { total: 25 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.code).toBe(500); + expect(result.message).toBe("Response code 500 (Internal Server Error)"); + }); + }); }); diff --git a/server/test/unit/providers/notifications/emailProvider.test.ts b/server/test/unit/providers/notifications/emailProvider.test.ts index fecce8ef6..7f3b34d4e 100644 --- a/server/test/unit/providers/notifications/emailProvider.test.ts +++ b/server/test/unit/providers/notifications/emailProvider.test.ts @@ -50,9 +50,9 @@ describe("EmailProvider", () => { expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" })); }); - it("returns false when sendEmail returns falsy", async () => { + it("returns false when sendEmail rejects", async () => { const { provider, emailService, logger } = createProvider(); - (emailService.sendEmail as jest.Mock).mockResolvedValue(false); + (emailService.sendEmail as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); expect(await provider.sendTestAlert(makeNotification())).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" })); }); @@ -82,9 +82,11 @@ describe("EmailProvider", () => { expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" })); }); - it("returns false when sendEmail returns falsy", async () => { + // NotificationsService fans providers out through Promise.all, so a failed email must + // resolve to false rather than reject and take the other channels down with it. + it("returns false when sendEmail rejects", async () => { const { provider, emailService, logger } = createProvider(); - (emailService.sendEmail as jest.Mock).mockResolvedValue(undefined); + (emailService.sendEmail as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" })); }); diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts index 65305e731..1294a677b 100644 --- a/server/test/unit/providers/notifications/matrixProvider.test.ts +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -4,7 +4,8 @@ import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWi import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; const mockGotPut = jest.fn().mockResolvedValue({}); -jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } })); +const mockGotGet = jest.fn().mockResolvedValue({ body: { room_id: "!resolved:example.com" } }); +jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut, get: mockGotGet } })); jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" })); const { MatrixProvider } = await import("../../../../src/domain/notifications/providers/matrix.ts"); @@ -23,13 +24,16 @@ testNotificationProviderContract("MatrixProvider", { }); describe("MatrixProvider", () => { - beforeEach(() => mockGotPut.mockReset().mockResolvedValue({})); + beforeEach(() => { + mockGotPut.mockReset().mockResolvedValue({}); + mockGotGet.mockReset().mockResolvedValue({ body: { room_id: "!resolved:example.com" } }); + }); describe("sendTestAlert", () => { it("sends to Matrix API and returns true", async () => { expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); expect(mockGotPut).toHaveBeenCalledWith( - expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"), + expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room%3Aexample.com/send/m.room.message/test-uuid-1234"), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-abc" }), }) @@ -141,4 +145,69 @@ describe("MatrixProvider", () => { expect(html).toContain("#808080"); }); }); + + describe("room identifiers", () => { + const putUrl = () => mockGotPut.mock.calls[0][0] as string; + + it("percent-encodes the room ID in the send path", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "!abc123:example.com" })); + expect(putUrl()).toContain("/rooms/!abc123%3Aexample.com/send/"); + }); + + it("does not resolve a room ID through the directory", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "!abc123:example.com" })); + expect(mockGotGet).not.toHaveBeenCalled(); + }); + + // An unresolved alias breaks the request outright: the "#" opens a URI fragment, so + // the PUT lands on /rooms/ instead of the send endpoint. + it("resolves a room alias through the directory before sending", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" }))).toBe(true); + expect(mockGotGet).toHaveBeenCalledWith( + "https://matrix.example.com/_matrix/client/v3/directory/room/%23alerts%3Aexample.com", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-abc" }) }) + ); + expect(putUrl()).toContain("/rooms/!resolved%3Aexample.com/send/"); + }); + + it("never leaves an unencoded # in the request URL", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" })); + expect(putUrl()).not.toContain("#"); + expect(new URL(putUrl()).hash).toBe(""); + }); + + it("returns false and logs when the alias cannot be resolved", async () => { + mockGotGet.mockRejectedValue(new Error("M_NOT_FOUND")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#missing:example.com" }))).toBe(false); + expect(mockGotPut).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("returns false when the directory response carries no room_id", async () => { + mockGotGet.mockResolvedValue({ body: {} }); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" }))).toBe(false); + expect(mockGotPut).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("strips a trailing slash from the homeserver URL", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ homeserverUrl: "https://matrix.example.com/" })); + expect(putUrl()).toContain("https://matrix.example.com/_matrix/"); + expect(putUrl()).not.toContain("com//_matrix"); + }); + + it("resolves aliases for real alerts, not just test alerts", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification({ roomId: "#alerts:example.com" }) as any, makeMessage()); + expect(mockGotGet).toHaveBeenCalled(); + expect(putUrl()).toContain("/rooms/!resolved%3Aexample.com/send/"); + }); + }); }); diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts index ffd95a0d7..d365584a1 100644 --- a/server/test/unit/providers/notifications/ntfyProvider.test.ts +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -109,4 +109,87 @@ describe("NtfyProvider", () => { expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1"); }); }); + + describe("authentication", () => { + const authHeaderOf = () => mockGotPost.mock.calls[0][1].headers.Authorization; + + it("sends no Authorization header when no auth type is configured", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessage()); + expect(authHeaderOf()).toBeUndefined(); + }); + + it("sends no Authorization header when auth type is none", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification({ ntfyAuthType: "none" }) as any, makeMessage()); + expect(authHeaderOf()).toBeUndefined(); + }); + + it("sends a bearer token for token auth", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true); + expect(authHeaderOf()).toBe("Bearer tk_secret"); + }); + + it("sends base64 encoded credentials for basic auth", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true); + expect(authHeaderOf()).toBe(`Basic ${Buffer.from("alice:s3cret").toString("base64")}`); + }); + + it("authenticates test alerts as well as alerts", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + expect(await provider.sendTestAlert(notification)).toBe(true); + expect(authHeaderOf()).toBe("Bearer tk_secret"); + }); + + it("preserves the notification headers alongside the Authorization header", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + await provider.sendMessage(notification as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Title: "Monitor Down: Test Monitor", + Priority: "high", + Tags: "rotating_light", + Authorization: "Bearer tk_secret", + }) + ); + }); + + // A misconfigured channel must fail loudly rather than post anonymously to a topic + // the operator believes is protected. + it("refuses to send unauthenticated when token auth has no access token", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send unauthenticated when basic auth has no username", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", accessToken: "s3cret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send unauthenticated when basic auth has no password", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send an unauthenticated test alert when credentials are incomplete", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }))).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts new file mode 100644 index 000000000..816e19801 --- /dev/null +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { MonitorType } from "../../../../src/domain/monitors/monitor.type.ts"; +import type { NotificationSeverity } from "../../../../src/domain/notifications/notification.type.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { RocketChatProvider } = await import("../../../../src/domain/notifications/providers/rocketChat.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + const provider = new RocketChatProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("RocketChatProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification({ type: "rocket_chat" }), +}); + +describe("RocketChatProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + it("sends a rich test alert without webhook overrides", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + json: { + text: "This is a test notification from Checkmate", + attachments: [ + { + color: "#0000FF", + title: "Checkmate test notification", + fields: [ + { title: "Channel", value: "Rocket.Chat", short: true }, + { title: "Status", value: "Test notification", short: true }, + ], + }, + ], + }, + timeout: { request: 30000 }, + retry: { limit: 0 }, + }) + ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload).not.toHaveProperty("alias"); + expect(payload).not.toHaveProperty("avatar"); + expect(payload).not.toHaveProperty("emoji"); + expect(payload).not.toHaveProperty("channel"); + }); + + it("returns false when the test webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat", address: "" })); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("returns false and logs when test delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat test alert failed", + service: "RocketChatProvider", + method: "sendTestAlert", + }) + ); + }); + + it("sends a rich monitor attachment without webhook overrides", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + timeout: { request: 30000 }, + retry: { limit: 0 }, + json: { + text: 'Monitor "Test Monitor" is currently down.', + attachments: [ + { + color: "#FF0000", + title: "Monitor Down: Test Monitor", + title_link: "https://app.example.com/uptime/mon-1", + fields: [ + { title: "Monitor", value: "Test Monitor", short: true }, + { title: "Type", value: "http", short: true }, + { title: "Status", value: "down", short: true }, + { title: "URL", value: "https://example.com", short: false }, + { + title: "Details", + value: "- URL: https://example.com\n- Status: Down", + short: false, + }, + ], + ts: "2025-01-01T00:00:00.000Z", + }, + ], + }, + }) + ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload).not.toHaveProperty("alias"); + expect(payload).not.toHaveProperty("avatar"); + expect(payload).not.toHaveProperty("emoji"); + expect(payload).not.toHaveProperty("channel"); + }); + + it.each([ + ["http", "https://app.example.com/uptime/mon-1"], + ["ping", "https://app.example.com/uptime/mon-1"], + ["pagespeed", "https://app.example.com/pagespeed/mon-1"], + ["hardware", "https://app.example.com/infrastructure/mon-1"], + ])("links %s monitors to %s", async (type, expectedLink) => { + const { provider } = createProvider(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: type as MonitorType, status: "down" } }) + ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.attachments[0].title_link).toBe(expectedLink); + }); + + it.each([["Host not defined"], [""], [" "]])("omits title_link when clientHost is %j", async (clientHost) => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage({ clientHost })); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.attachments[0]).not.toHaveProperty("title_link"); + }); + + it.each([ + ["critical", "#FF0000"], + ["warning", "#FFA500"], + ["success", "#00FF00"], + ["info", "#0000FF"], + ["unknown", "#808080"], + ])("maps %s severity to %s", async (severity, color) => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage({ severity: severity as NotificationSeverity })); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.attachments[0].color).toBe(color); + }); + + it("appends threshold fields before details", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds()); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).toEqual([ + { title: "Monitor", value: "Infra Server", short: true }, + { title: "Type", value: "hardware", short: true }, + { title: "Status", value: "up", short: true }, + { title: "URL", value: "https://infra.example.com", short: false }, + { title: "CPU", value: "90.0% (threshold: 80%)", short: true }, + { title: "MEMORY", value: "85.0% (threshold: 70%)", short: true }, + { title: "Details", value: "- URL: https://infra.example.com", short: false }, + ]); + }); + + it("appends the incident URL after details", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident()); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).toEqual([ + { title: "Monitor", value: "Test Monitor", short: true }, + { title: "Type", value: "http", short: true }, + { title: "Status", value: "down", short: true }, + { title: "URL", value: "https://example.com", short: false }, + { title: "Details", value: "- URL: https://example.com\n- Status: Down", short: false }, + { title: "Incident", value: "https://app.example.com/incidents/inc-1", short: false }, + ]); + }); + + it("omits empty optional fields", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ + content: { + ...message.content, + details: [], + thresholds: [], + incident: undefined, + }, + }) + ); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).toEqual([ + { title: "Monitor", value: "Test Monitor", short: true }, + { title: "Type", value: "http", short: true }, + { title: "Status", value: "down", short: true }, + { title: "URL", value: "https://example.com", short: false }, + ]); + }); + + it("omits the incident field when its URL is empty", async () => { + const { provider } = createProvider(); + const message = makeMessageWithIncident(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ + content: { + ...message.content, + incident: { ...message.content.incident!, url: "" }, + }, + }) + ); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).not.toContainEqual(expect.objectContaining({ title: "Incident" })); + }); + + it("returns false when the message webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat", address: "" }), makeMessage()); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("returns false and logs when message delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat notification failed", + service: "RocketChatProvider", + method: "sendMessage", + }) + ); + }); +}); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index dacff3d9b..2a1aa29f1 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -26,6 +26,7 @@ const createProducer = (overrides?: Record) => { maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, + proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) }, buffer: { addToBuffer: jest.fn() }, ...overrides, }; @@ -34,6 +35,7 @@ const createProducer = (overrides?: Record) => { defaults.maintenanceWindowsRepository as any, defaults.checkService as any, defaults.networkService as any, + defaults.proxyResolver as any, defaults.buffer as any, defaults.logger as any ); @@ -82,6 +84,29 @@ describe("CheckProducer", () => { expect(defaults.networkService.requestStatus).toHaveBeenCalled(); }); + // ── proxy resolution ────────────────────────────────────────────────────── + + it("passes the resolved proxy url into requestStatus", async () => { + const { producer, defaults } = createProducer({ + proxyResolver: { resolve: jest.fn().mockResolvedValue("http://proxy.example.com:8080") }, + }); + const monitor = makeMonitor(); + + await producer.produce(monitor); + + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" }); + }); + + it("still produces a check when the resolver returns undefined", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor(); + + const result = await producer.produce(monitor); + + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined }); + expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } }); + }); + // ── acquire / record ────────────────────────────────────────────────────── it("throws when the network response is null", async () => { diff --git a/server/test/unit/services/dbQueueWorker.test.ts b/server/test/unit/services/dbQueueWorker.test.ts index d903ae887..8b13287d4 100644 --- a/server/test/unit/services/dbQueueWorker.test.ts +++ b/server/test/unit/services/dbQueueWorker.test.ts @@ -84,7 +84,7 @@ const createWorker = (overrides?: { queueMode?: QueueMode; queuePrimaryProcesses const monitorsRepository = { findByIds: jest.fn().mockResolvedValue([makeMonitor()]), findByIdLean: jest.fn().mockResolvedValue(makeMonitor()), - findAll: jest.fn().mockResolvedValue([]), + findAllForScheduling: jest.fn().mockResolvedValue([]), updateById: jest.fn().mockResolvedValue({}), }; const checksRepository = { @@ -183,7 +183,11 @@ describe("DBQueueWorker", () => { queueMode: "primary", queuePrimaryProcesses: false, // isolate reconcile from the polling loops mocks: { - monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([makeMonitor()]), updateById: jest.fn() }, + monitorsRepository: { + findByIds: jest.fn(), + findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]), + updateById: jest.fn(), + }, }, }); @@ -200,7 +204,11 @@ describe("DBQueueWorker", () => { queueMode: "primary", queuePrimaryProcesses: false, mocks: { - monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([geoMonitor]), updateById: jest.fn() }, + monitorsRepository: { + findByIds: jest.fn(), + findAllForScheduling: jest.fn().mockResolvedValue([geoMonitor]), + updateById: jest.fn(), + }, }, }); diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts index 971c712da..d18b83917 100644 --- a/server/test/unit/services/emailService.test.ts +++ b/server/test/unit/services/emailService.test.ts @@ -326,15 +326,17 @@ describe("EmailService", () => { expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" })); }); - it("returns false when transporter verification fails", async () => { + // Callers wrap sendEmail in .catch()/try-catch to react to delivery failures, so a + // transport error has to reject rather than resolve to a falsy value. + it("throws when transporter verification fails", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBe(false); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toThrow( + "Email transporter verification failed" + ); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ message: "Email transporter verification failed", @@ -344,27 +346,38 @@ describe("EmailService", () => { ); }); + it("reports the underlying cause when verification fails", async () => { + const transporter = createMockTransporter(); + (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toMatchObject({ + status: 500, + details: { cause: "SMTP auth failed" }, + }); + }); + it("logs stack as undefined when verification fails with non-Error", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue("connection refused"); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBe(false); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toThrow(); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); }); - it("returns undefined and logs error when sendMail throws", async () => { + it("throws and logs error when sendMail throws", async () => { const transporter = createMockTransporter(); (transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected")); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBeUndefined(); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toMatchObject({ + message: "Failed to send email", + details: { cause: "Recipient rejected" }, + }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Recipient rejected", @@ -380,9 +393,9 @@ describe("EmailService", () => { const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBeUndefined(); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toMatchObject({ + details: { cause: "Unknown error" }, + }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Unknown error", diff --git a/server/test/unit/services/inviteService.test.ts b/server/test/unit/services/inviteService.test.ts index 59d472539..d60b0830e 100644 --- a/server/test/unit/services/inviteService.test.ts +++ b/server/test/unit/services/inviteService.test.ts @@ -127,11 +127,11 @@ describe("InviteService", () => { ).rejects.toThrow("Failed to build invite e-mail"); }); - it("throws 500 when sendEmail returns falsy", async () => { + it("throws 500 when sendEmail rejects", async () => { const { service } = createService({ emailService: { buildEmail: jest.fn().mockResolvedValue("ok"), - sendEmail: jest.fn().mockResolvedValue(false), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")), }, }); diff --git a/server/test/unit/services/jobScheduler.test.ts b/server/test/unit/services/jobScheduler.test.ts index 098cef245..90c8c2850 100644 --- a/server/test/unit/services/jobScheduler.test.ts +++ b/server/test/unit/services/jobScheduler.test.ts @@ -38,7 +38,7 @@ const createScheduler = (overrides?: { queueMode?: QueueMode; mocks?: Record().mockResolvedValue(undefined), }; const monitorsRepository = { - findAll: jest.fn().mockResolvedValue([]), + findAllForScheduling: jest.fn().mockResolvedValue([]), }; const logger = createMockLogger(); const mocks = { jobsRepository, queueWorkersRepository, monitorsRepository, logger, ...overrides?.mocks }; @@ -73,7 +73,7 @@ describe("JobScheduler", () => { it("primary mode reconciles: seeds a check row per monitor plus the two cleanup rows", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "primary", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]) } }, }); await scheduler.init(); @@ -87,7 +87,7 @@ describe("JobScheduler", () => { it("primary mode also seeds a geo-check row for geo-enabled monitors", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "primary", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, }); await scheduler.init(); @@ -98,12 +98,12 @@ describe("JobScheduler", () => { it("worker mode heartbeats but does not reconcile (no monitor reads, no seeds)", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "worker", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]) } }, }); await scheduler.init(); expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker", false); - expect(mocks.monitorsRepository.findAll).not.toHaveBeenCalled(); + expect(mocks.monitorsRepository.findAllForScheduling).not.toHaveBeenCalled(); expect(mocks.jobsRepository.upsertJob).not.toHaveBeenCalled(); expect(mocks.jobsRepository.upsertCleanupJob).not.toHaveBeenCalled(); }); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index e15f20d46..691291ebe 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -123,7 +123,7 @@ describe("MaintenanceWindowService", () => { await service.createMaintenanceWindow(defaultCreateParams); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "none" }); }); it("throws 403 when a monitor belongs to a different team", async () => { @@ -404,7 +404,7 @@ describe("MaintenanceWindowService", () => { body: { monitors: ["mon-1", "mon-2"] }, }); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "none" }); }); it("throws 403 when a new monitor belongs to a different team", async () => { diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 69edbba9c..0f56e5d2b 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -583,7 +583,7 @@ describe("MonitorService", () => { expect(result.monitors).toEqual([]); }); - it("uses snapshot (first check only) for hardware type monitors", async () => { + it("uses snapshot (latest check only) for hardware type monitors", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); @@ -602,6 +602,7 @@ describe("MonitorService", () => { const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: "hardware" }); expect(result.monitors[0].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks[0].responseTime).toBe(30); }); it("uses snapshot for hardware type when type is array with hardware", async () => { @@ -622,6 +623,7 @@ describe("MonitorService", () => { const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: ["hardware"] }); expect(result.monitors[0].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks[0].responseTime).toBe(20); }); it("normalizes checks for non-snapshot types when type is an array", async () => { diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts index 2e6627452..b33983bf9 100644 --- a/server/test/unit/services/networkService.test.ts +++ b/server/test/unit/services/networkService.test.ts @@ -54,7 +54,7 @@ describe("NetworkService", () => { const result = await service.requestStatus(monitor); expect(provider.supports).toHaveBeenCalledWith("http"); - expect(provider.handle).toHaveBeenCalledWith(monitor); + expect(provider.handle).toHaveBeenCalledWith(monitor, undefined); expect(result).toEqual( expect.objectContaining({ monitorId: "mon-1", @@ -73,7 +73,18 @@ describe("NetworkService", () => { await service.requestStatus(monitor); expect(httpProvider.handle).not.toHaveBeenCalled(); - expect(pingProvider.handle).toHaveBeenCalledWith(monitor); + expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined); + }); + + it("forwards the check context to the provider", async () => { + const provider = createMockProvider("http"); + const { service } = createService([provider]); + const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" }; + const ctx = { proxyUrl: "http://proxy.example.com:8080" }; + + await service.requestStatus(monitor, ctx); + + expect(provider.handle).toHaveBeenCalledWith(monitor, ctx); }); it("returns unsupported-type response when no provider matches", async () => { diff --git a/server/test/unit/services/proxiesService.test.ts b/server/test/unit/services/proxiesService.test.ts new file mode 100644 index 000000000..b2c89ba7d --- /dev/null +++ b/server/test/unit/services/proxiesService.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createProxiesRepo = () => ({ + create: jest.fn(), + findAll: jest.fn(), + findById: jest.fn(), + findByIdOrNull: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + findMonitorCountByProxyId: jest.fn(), +}); + +const createSettingsService = () => ({ + getDBSettings: jest.fn(), +}); + +const createService = () => { + const proxiesRepository = createProxiesRepo(); + const monitorsRepository = createMonitorsRepo(); + const settingsService = createSettingsService(); + + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null }); + + const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any); + + return { service, proxiesRepository, monitorsRepository, settingsService }; +}; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + username: "user", + password: "secret", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Proxy; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("ProxiesService", () => { + describe("createProxy", () => { + it("sets teamId and delegates to repository", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1"); + + expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" })); + }); + + it("omits password from the response and reports hasPassword", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.createProxy({ name: "Egress proxy" }, "team-1"); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("strips blank credentials before creating", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1"); + + const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial; + expect(created).not.toHaveProperty("username"); + expect(created).not.toHaveProperty("password"); + }); + }); + + describe("getProxy", () => { + it("delegates to repository and masks the password", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("reports hasPassword false when none is stored", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined })); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(result.hasPassword).toBe(false); + }); + }); + + describe("getProxies", () => { + it("lists every proxy on the instance and masks passwords", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findAll as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", teamId: "team-2", password: undefined })]); + + const result = await service.getProxies(); + + expect(proxiesRepository.findAll).toHaveBeenCalledWith(); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("getProxySummary", () => { + it("looks up without a team filter and returns only display fields", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxySummary("proxy-1"); + + expect(proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("proxy-1"); + expect(result).toEqual({ id: "proxy-1", name: "Egress proxy", host: "proxy.internal", port: 3128 }); + }); + + it("returns null when the proxy does not exist", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(null); + + const result = await service.getProxySummary("missing"); + + expect(result).toBeNull(); + }); + }); + + describe("getProxiesByTeamId", () => { + it("masks every proxy in the list", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]); + + const result = await service.getProxiesByTeamId("team-1"); + + expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("updateProxy", () => { + it("keeps the stored password when the patch has a blank one", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch).not.toHaveProperty("password"); + expect(patch).not.toHaveProperty("username"); + expect(patch.name).toBe("Renamed"); + }); + + it("passes a new password through", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" })); + + await service.updateProxy("proxy-1", "team-1", { password: "next" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch.password).toBe("next"); + }); + + it("maps clear flags to unset options and keeps them out of the patch", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true }); + + const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0]; + expect(patch).not.toHaveProperty("clearPassword"); + expect(patch).not.toHaveProperty("clearUsername"); + expect(options).toEqual({ unsetPassword: true, unsetUsername: true }); + }); + + it("does not unset when clear flags are absent", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3]; + expect(options).toEqual({ unsetPassword: false, unsetUsername: false }); + }); + + it("masks the updated proxy in the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + }); + + describe("deleteProxy", () => { + it("deletes when unreferenced and masks the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.deleteProxy("proxy-1", "team-1"); + + expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + }); + + it("rejects with 409 when monitors reference the proxy", async () => { + const { service, proxiesRepository, monitorsRepository } = createService(); + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + + it("rejects with 409 when the proxy is the global proxy", async () => { + const { service, proxiesRepository, settingsService } = createService(); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" }); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/services/proxyResolver.test.ts b/server/test/unit/services/proxyResolver.test.ts new file mode 100644 index 000000000..d2c5eefa2 --- /dev/null +++ b/server/test/unit/services/proxyResolver.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + proxyMode: "inherit", + ...overrides, + }) as Monitor; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "p1", + teamId: "team", + name: "corp", + protocol: "http", + host: "proxy.example.com", + port: 8080, + ...overrides, + }) as Proxy; + +const createResolver = (overrides?: Record) => { + const defaults = { + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(makeProxy()) }, + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) }, + logger: createMockLogger(), + ttlMs: 60_000, + ...overrides, + }; + const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs); + return { resolver, defaults }; +}; + +describe("ProxyResolver", () => { + // ── gates ───────────────────────────────────────────────────────────────── + + it("returns undefined for non-http monitors without touching repo or settings", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial)); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled(); + }); + + it("returns undefined for proxyMode 'none' without calling settings", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "none" })); + + expect(result).toBeUndefined(); + expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled(); + }); + + // ── custom mode ─────────────────────────────────────────────────────────── + + it("resolves a custom proxy with a team-scoped lookup", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team"); + }); + + it("returns undefined and warns when the custom proxy no longer exists", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + // ── inherit mode ────────────────────────────────────────────────────────── + + it("returns undefined when the global proxy is disabled, without a repo call", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + it("resolves the global proxy with an unscoped lookup when enabled", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1"); + }); + + it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("treats an unknown proxyMode as inherit", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.settingsService.getDBSettings).toHaveBeenCalled(); + }); + + // ── never throws ────────────────────────────────────────────────────────── + + it("returns undefined and warns when the repository throws", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and warns when the settings service throws", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + // ── caching ─────────────────────────────────────────────────────────────── + + it("serves a second resolve within the TTL from cache", async () => { + const { resolver, defaults } = createResolver(); + + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("refetches after the TTL expires", async () => { + const { resolver, defaults } = createResolver({ ttlMs: 0 }); + + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2); + }); + + it("negative-caches a dangling ref, costing one repo call per TTL window", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("shares one in-flight fetch between concurrent resolves", async () => { + const { resolver, defaults } = createResolver(); + + await Promise.all([ + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + ]); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("does not cache a rejected fetch — the next resolve retries", async () => { + const findByIdOrNull = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy()); + const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); + + const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(first).toBeUndefined(); + expect(second).toBe("http://proxy.example.com:8080"); + expect(findByIdOrNull).toHaveBeenCalledTimes(2); + }); + + it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => { + const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise>(async (_proxyId, teamId) => + teamId ? null : makeProxy() + ); + const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); + + const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(customResult).toBeUndefined(); + expect(inheritResult).toBe("http://proxy.example.com:8080"); + expect(findByIdOrNull).toHaveBeenCalledTimes(2); + }); +}); + +describe("buildProxyUrl", () => { + it("builds a URL without credentials when there is no username", () => { + expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080"); + }); + + it("builds a URL with username and password", () => { + expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080"); + }); + + it("percent-encodes credentials", () => { + expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080"); + }); + + it("builds a URL with a username only, without a colon", () => { + expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080"); + }); + + it("emits no credentials for a password without a username", () => { + expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080"); + }); + + it("uses the https scheme without a trailing slash", () => { + expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080"); + }); +}); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index e0115ab99..ba3ac298e 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -3,6 +3,8 @@ import { StatusPageService } from "../../../src/domain/status-pages/status-page. import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; +import type { CheckSnapshot } from "../../../src/domain/checks/check.type.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/domain/status-pages/status-page.type.ts"; @@ -69,7 +71,7 @@ const createSettingsService = (themesEnabled: boolean, clientHost = "http://loca ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), getSettings: jest.fn().mockReturnValue({ clientHost }), - getDBSettings: jest.fn().mockResolvedValue({ showURL }), + getDBSettings: jest.fn().mockResolvedValue({ showURL, checkTTL: 30 }), }) as unknown as jest.Mocked; const createMonitorsRepo = () => @@ -77,12 +79,18 @@ const createMonitorsRepo = () => findByIds: jest.fn().mockResolvedValue([makeMonitor()]), }) as unknown as jest.Mocked; +const createChecksRepo = () => + ({ + getDailyStatusBuckets: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + const createService = (themesEnabled = true, clientHost = "http://localhost:5173", showURL = false) => { const repo = createRepo(); const settingsService = createSettingsService(themesEnabled, clientHost, showURL); const monitorsRepo = createMonitorsRepo(); - const service = new StatusPageService(repo, settingsService, monitorsRepo); - return { service, repo, settingsService, monitorsRepo }; + const checksRepo = createChecksRepo(); + const service = new StatusPageService(repo, settingsService, monitorsRepo, checksRepo); + return { service, repo, settingsService, monitorsRepo, checksRepo }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -315,8 +323,8 @@ describe("StatusPageService", () => { status: "up", uptimePercentage: 99.9, recentChecks: [], - checks: [], }); + expect(monitors[0]).not.toHaveProperty("checks"); }); it("orders monitors to match the status page's monitor list", async () => { @@ -353,6 +361,67 @@ describe("StatusPageService", () => { statusPage: expect.objectContaining({ id: "sp-1" }), }); }); + + describe("range", () => { + it("omits range-mode fields and never queries buckets for the default (latest) range", async () => { + const { service, checksRepo, monitorsRepo } = createService(); + + const implicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined); + const explicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined, "latest"); + + for (const payload of [implicitLatest, explicitLatest]) { + expect(payload).not.toHaveProperty("range"); + expect(payload).not.toHaveProperty("bucketTimezone"); + expect(payload).not.toHaveProperty("checkTTLDays"); + expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); + } + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { recentChecks: "all" }); + }); + + it("attaches dailyChecks, passes the repo-trimmed recentChecks through, and echoes range metadata for a day range", async () => { + const { service, checksRepo, monitorsRepo } = createService(); + const bucket = { monitorId: "mon-1", date: "2026-08-01", totalChecks: 10, upChecks: 9, downChecks: 1, avgResponseTime: 120 }; + const hardwareSnapshot = { id: "chk-1" } as CheckSnapshot; + (checksRepo.getDailyStatusBuckets as jest.Mock).mockResolvedValue([bucket]); + (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([ + makeMonitor({ id: "mon-1", type: "hardware", recentChecks: [hardwareSnapshot] }), + makeMonitor({ id: "mon-2", recentChecks: [] }), + ]); + const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto", monitors: ["mon-1", "mon-2"] }); + + const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1", "mon-2"], 30, "America/Toronto"); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "latestHardware" }); + expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); + expect(payload.monitors[0].recentChecks).toEqual([hardwareSnapshot]); + expect(payload.monitors[0].dailyChecks).toEqual([bucket]); + expect(payload.monitors[1].recentChecks).toEqual([]); + expect(payload.monitors[1].dailyChecks).toEqual([]); + for (const field of SENSITIVE_FIELDS) { + expect(payload.monitors[0]).not.toHaveProperty(field); + } + }); + + it("falls back to Etc/UTC when the page has no timezone and defaults dailyChecks to empty", async () => { + const { service, checksRepo } = createService(); + + const payload = await service.getPublicStatusPagePayload(publishedPage(), undefined, "60d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 60, "Etc/UTC"); + expect(payload.bucketTimezone).toBe("Etc/UTC"); + expect(payload.monitors[0].dailyChecks).toEqual([]); + }); + + it("does not bypass the unpublished-page 403", async () => { + const { service, checksRepo } = createService(); + const unpublished = makeStatusPage({ isPublished: false, teamId: "team-A" }); + + await expect(service.getPublicStatusPagePayload(unpublished, undefined, "90d")).rejects.toMatchObject({ status: 403 }); + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + }); }); describe("deleteStatusPage", () => { diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index 9e66ddbec..071465d48 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -498,6 +498,45 @@ describe("StatusService", () => { expect(result.monitor.status).toBe("down"); }); + it("resolves a legacy boolean status ('true') to 'up' on a passing check (regression: pre-upgrade monitors stuck forever)", async () => { + // Old Checkmate versions stored monitor.status as a boolean. Pre-fix, a monitor + // carrying status='true' never matched the initializing override and + // computeReachability only transitions to 'up' from 'down', so the legacy value + // was written back unchanged on every evaluation — stuck forever while the UI + // rendered it as 'initializing'. + const monitor = makeMonitor({ + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "true" as MonitorStatus, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); + + expect(result.monitor.status).toBe("up"); + expect(result.statusChanged).toBe(false); // was effectively up already — no notification storm + }); + + it("resolves a legacy boolean status ('false') to 'down' on a failing check (regression mirror)", async () => { + const monitor = makeMonitor({ + statusWindow: [false, false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "false" as MonitorStatus, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); + + expect(result.monitor.status).toBe("down"); + expect(result.statusChanged).toBe(true); // down must surface so an incident is opened + }); + it("during warmup, sub-threshold raw checks no longer flip stored status (regression: down-during-init incident not resolving)", async () => { // Once the monitor has left 'initializing', the warmup branch must NOT silently // flip status from raw checks. Otherwise a down check during warmup writes diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts index bce8769cf..ee47ffded 100644 --- a/server/test/unit/services/userService.test.ts +++ b/server/test/unit/services/userService.test.ts @@ -196,6 +196,22 @@ describe("UserService", () => { expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser" })); }); + // The welcome email is fire-and-forget, but its .catch() only runs if sendEmail + // actually rejects; swallowing the transport error made that handler dead code. + it("logs a warning when the welcome email fails to send", async () => { + const { service, logger } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("Welcome"), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")), + }, + }); + + const result = await service.registerUser({ password: "pass" }, "invite-token", null); + + expect(result.user).toBeDefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser", message: "SMTP auth failed" })); + }); + it("uses default role when invite has no role", async () => { const { service, usersRepository } = createService({ invitesRepository: { @@ -423,6 +439,19 @@ describe("UserService", () => { await expect(service.requestRecovery("test@example.com")).rejects.toThrow("Failed to build password reset email HTML"); }); + + // Previously the send failure was swallowed and the endpoint still reported + // "Password recovery email sent successfully" while the user got nothing. + it("propagates the failure when the recovery email cannot be sent", async () => { + const { service } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("Reset"), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")), + }, + }); + + await expect(service.requestRecovery("test@example.com")).rejects.toThrow("SMTP auth failed"); + }); }); // ── validateRecovery ──────────────────────────────────────────────────── @@ -472,7 +501,7 @@ describe("UserService", () => { await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); - expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}); + expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}, { includeRecentChecks: false }); expect(scheduler.deleteJob).toHaveBeenCalledTimes(2); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); }); diff --git a/server/test/unit/utils/dataUtils.test.ts b/server/test/unit/utils/dataUtils.test.ts index af9434bab..d89cc0762 100644 --- a/server/test/unit/utils/dataUtils.test.ts +++ b/server/test/unit/utils/dataUtils.test.ts @@ -1,18 +1,7 @@ import { describe, expect, it } from "@jest/globals"; -import { getDateForRange, getDateFormat, NormalizeData } from "../../../src/utils/dataUtils.ts"; +import { getDateForRange, getDateFormat } from "../../../src/utils/dataUtils.ts"; import { DateRanges, type DateRange } from "../../../src/types/query.ts"; -// ── Helpers ────────────────────────────────────────────────────────────────── - -const makeCheck = (responseTime: number, overrides?: Record) => ({ - id: `check-${responseTime}`, - status: true, - responseTime, - ...overrides, -}); - -// ── Tests ──────────────────────────────────────────────────────────────────── - describe("getDateForRange", () => { it.each([ ["recent", 2 * 60 * 60 * 1000], @@ -53,68 +42,3 @@ describe("getDateFormat", () => { } }); }); - -describe("NormalizeData", () => { - // Fixture math: p0 of [100..500] is 100; p95 interpolates between the 4th and 5th - // sorted values at weight 0.8 → 400*0.2 + 500*0.8 = 480. Values rescale into - // [10, 100] via 10 + (rt - 100) * 90 / 380, clamped. - it("rescales responseTime into [rangeMin, rangeMax] anchored to the p0–p95 window", () => { - const checks = [100, 200, 300, 400, 500].map((rt) => makeCheck(rt)); - - const result = NormalizeData(checks, 10, 100); - - expect(result[0]!.responseTime).toBe(10); - expect(result[1]!.responseTime).toBeCloseTo(33.6842, 4); - expect(result[2]!.responseTime).toBeCloseTo(57.3684, 4); - expect(result[3]!.responseTime).toBeCloseTo(81.0526, 4); - }); - - it("clamps values above the p95 anchor to rangeMax", () => { - const checks = [100, 200, 300, 400, 500].map((rt) => makeCheck(rt)); - - const result = NormalizeData(checks, 10, 100); - - expect(result[4]!.responseTime).toBe(100); - }); - - it("preserves the raw value in originalResponseTime and spreads other fields", () => { - const checks = [makeCheck(100, { message: "OK" }), makeCheck(500)]; - - const result = NormalizeData(checks, 10, 100); - - expect(result[0]!.originalResponseTime).toBe(100); - expect(result[1]!.originalResponseTime).toBe(500); - expect(result[0]).toMatchObject({ id: "check-100", status: true, message: "OK" }); - }); - - it("passes a single check through, only adding originalResponseTime", () => { - const result = NormalizeData([makeCheck(250)], 10, 100); - - expect(result).toEqual([{ id: "check-250", status: true, responseTime: 250, originalResponseTime: 250 }]); - }); - - it("returns an empty array for empty input", () => { - expect(NormalizeData([], 10, 100)).toEqual([]); - }); - - it("records originalResponseTime as 0 for a non-numeric responseTime (failed checks)", () => { - const checks = [makeCheck(undefined as unknown as number), makeCheck(100), makeCheck(200)]; - - const result = NormalizeData(checks, 10, 100); - - expect(result[0]!.originalResponseTime).toBe(0); - }); - - // Characterization, not endorsement: when every value is equal the p0–p95 window - // has zero width and the rescale divides by zero. Fixing this is a planned - // follow-up (docs/planning/dataUtils-generic-normalization-refactor.md); this - // test pins the current behavior so the fix is a deliberate, visible change. - it("produces NaN when all values are equal (division by zero — known edge case)", () => { - const result = NormalizeData([makeCheck(50), makeCheck(50), makeCheck(50)], 10, 100); - - result.forEach((check) => { - expect(check.responseTime).toBeNaN(); - expect(check.originalResponseTime).toBe(50); - }); - }); -}); diff --git a/server/test/unit/utils/domainExpiry.test.ts b/server/test/unit/utils/domainExpiry.test.ts new file mode 100644 index 000000000..185a759ee --- /dev/null +++ b/server/test/unit/utils/domainExpiry.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@jest/globals"; +import { createDomainExpiryCache, extractDomainExpiryDate, fetchMonitorDomain } from "../../../src/api/controllers/controllerUtils.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; + +const makeMonitor = (url: string): Monitor => + ({ + url, + type: "http", + }) as unknown as Monitor; + +describe("extractDomainExpiryDate", () => { + it("returns an ISO date for ISO registry expiry values", () => { + expect(extractDomainExpiryDate({ "Registry Expiry Date": "2027-08-13T04:00:00Z" })).toBe("2027-08-13T04:00:00.000Z"); + }); + + it("returns an ISO date for dd-MMM-yyyy expiry values", () => { + expect(extractDomainExpiryDate({ "Expiry Date": "14-Feb-2027" })).toBe("2027-02-14T00:00:00.000Z"); + }); + + it("returns null when no expiry key exists", () => { + expect(extractDomainExpiryDate({ "Domain Name": "example.com" })).toBeNull(); + }); + + it("returns null when the expiry value cannot be parsed", () => { + expect(extractDomainExpiryDate({ "Expiry Date": "not-a-date" })).toBeNull(); + }); +}); + +describe("fetchMonitorDomain", () => { + it("queries the registrable domain for subdomains", async () => { + const queried: string[] = []; + const client = { + domain: async (domain: string) => { + queried.push(domain); + return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } }; + }, + }; + const cache = createDomainExpiryCache(60_000, 10_000); + + const result = await fetchMonitorDomain(client as never, makeMonitor("https://www.example.co.uk"), cache); + + expect(queried).toEqual(["example.co.uk"]); + expect(result).toEqual({ domain: "example.co.uk", expiryDate: "2027-08-13T04:00:00.000Z" }); + }); + + it("returns null expiry for bare IP addresses without querying whois", async () => { + const client = { + domain: async () => { + throw new Error("should not be called"); + }, + }; + + const result = await fetchMonitorDomain(client as never, makeMonitor("https://1.2.3.4")); + + expect(result).toEqual({ domain: null, expiryDate: null }); + }); + + it("returns null expiry when the registry has no expiry record", async () => { + const client = { + domain: async () => ({ "whois.verisign-grs.com": { "Domain Name": "example.com" } }), + }; + + const result = await fetchMonitorDomain(client as never, makeMonitor("https://example.com")); + + expect(result).toEqual({ domain: "example.com", expiryDate: null }); + }); + + it("serves repeat lookups from cache", async () => { + let calls = 0; + const client = { + domain: async () => { + calls++; + return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } }; + }, + }; + const cache = createDomainExpiryCache(60_000, 10_000); + const monitor = makeMonitor("https://example.com"); + + await fetchMonitorDomain(client as never, monitor, cache); + const second = await fetchMonitorDomain(client as never, monitor, cache); + + expect(calls).toBe(1); + expect(second).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" }); + }); + + it("re-queries after the cache expires", async () => { + let calls = 0; + const client = { + domain: async () => { + calls++; + return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } }; + }, + }; + const cache = createDomainExpiryCache(-1, -1); + const monitor = makeMonitor("https://example.com"); + + await fetchMonitorDomain(client as never, monitor, cache); + await fetchMonitorDomain(client as never, monitor, cache); + + expect(calls).toBe(2); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index d6998367a..4de4538e1 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -470,3 +470,72 @@ describe("monitorValidation — customUpCodes", () => { }); }); }); + +describe("monitorValidation — proxy fields", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("defaults proxyMode to inherit", () => { + const parsed = createMonitorBodyValidation.parse(baseHttpBody); + + expect(parsed.proxyMode).toBe("inherit"); + expect(parsed.proxyId).toBeUndefined(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyMode).toBe("custom"); + expect(parsed.proxyId).toBe("proxy-1"); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow(); + }); + + it("rejects an unknown proxyMode", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow(); + }); + + it("allows a stray proxyId on non-custom modes", () => { + for (const proxyMode of ["inherit", "none"] as const) { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" }); + expect(parsed.proxyMode).toBe(proxyMode); + } + }); + }); + + describe("editMonitorBodyValidation", () => { + it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => { + const parsed = editMonitorBodyValidation.parse({ name: "Renamed" }); + + expect(parsed.proxyMode).toBeUndefined(); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyId).toBe("proxy-1"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("defaults proxyMode to inherit on import", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] }); + + expect(parsed.monitors[0].proxyMode).toBe("inherit"); + }); + + it("rejects an imported monitor with custom mode and no proxyId", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow(); + }); + }); +}); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts new file mode 100644 index 000000000..2dd7ffc95 --- /dev/null +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "@jest/globals"; +import { createNotificationBodyValidation } from "@/api/validation/notificationValidation.js"; + +describe("notification validation", () => { + it("accepts a Rocket.Chat incoming webhook", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "https://chat.example.com/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("accepts a self-hosted Rocket.Chat webhook over HTTP", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Local Rocket.Chat alerts", + type: "rocket_chat", + address: "http://localhost:3000/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("rejects an invalid Rocket.Chat incoming webhook URL", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "not-a-url", + }); + + expect(result.success).toBe(false); + }); + + it.each(["ftp://chat.example.com/hooks/integration-id/token", "mailto:admin@example.com", "data:text/plain,hello", "javascript:alert(1)"])( + "rejects a Rocket.Chat webhook using an unsupported protocol: %s", + (address) => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address, + }); + + expect(result.success).toBe(false); + } + ); + + describe("ntfy authentication", () => { + const parseNtfy = (overrides: Record) => + createNotificationBodyValidation.safeParse({ + notificationName: "ntfy alerts", + type: "ntfy", + address: "https://ntfy.example.com", + topic: "checkmate-alerts", + ...overrides, + }); + + const pathsOf = (result: ReturnType) => (result.success ? [] : result.error.issues.map((issue) => issue.path.join("."))); + + it("accepts an unauthenticated channel", () => { + expect(parseNtfy({}).success).toBe(true); + }); + + it("accepts an explicit auth type of none", () => { + expect(parseNtfy({ ntfyAuthType: "none" }).success).toBe(true); + }); + + it("accepts token auth with an access token", () => { + expect(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret" }).success).toBe(true); + }); + + it("accepts basic auth with a username and password", () => { + expect(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }).success).toBe(true); + }); + + it("rejects an unknown auth type", () => { + expect(parseNtfy({ ntfyAuthType: "oauth" }).success).toBe(false); + }); + + it("rejects token auth without an access token", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "token" }))).toContain("accessToken"); + }); + + it("rejects token auth carrying a username", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret", ntfyUsername: "alice" }))).toContain("ntfyUsername"); + }); + + it("rejects basic auth without a username", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", accessToken: "s3cret" }))).toContain("ntfyUsername"); + }); + + it("rejects basic auth without a password", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice" }))).toContain("accessToken"); + }); + + it("reports both fields when basic auth is entirely empty", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic" }))).toEqual(expect.arrayContaining(["ntfyUsername", "accessToken"])); + }); + + // Credentials with auth off would be stored but never sent, which reads as protected + // while posting anonymously. + it("rejects credentials supplied without an auth type", () => { + expect(pathsOf(parseNtfy({ accessToken: "tk_secret" }))).toContain("accessToken"); + expect(pathsOf(parseNtfy({ ntfyUsername: "alice" }))).toContain("ntfyUsername"); + }); + + it("still requires a topic and a valid server URL", () => { + expect(parseNtfy({ topic: "" }).success).toBe(false); + expect(parseNtfy({ address: "not-a-url" }).success).toBe(false); + }); + }); +}); diff --git a/server/test/unit/validation/proxyValidation.test.ts b/server/test/unit/validation/proxyValidation.test.ts new file mode 100644 index 000000000..2046f16df --- /dev/null +++ b/server/test/unit/validation/proxyValidation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@jest/globals"; +import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts"; + +const validBody = { + name: "Egress proxy", + protocol: "http" as const, + host: "proxy.internal", + port: 3128, +}; + +describe("proxyValidation", () => { + describe("createProxyBodyValidation", () => { + it("accepts a minimal valid body", () => { + const parsed = createProxyBodyValidation.parse(validBody); + + expect(parsed).toEqual(validBody); + }); + + it("accepts optional credentials", () => { + const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" }); + + expect(parsed.username).toBe("user"); + expect(parsed.password).toBe("secret"); + }); + + it("rejects an empty name", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow(); + }); + + it("rejects an empty host", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow(); + }); + + it("rejects a protocol outside the tuple", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow(); + }); + + it("accepts the port boundaries", () => { + expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1); + expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535); + }); + + it("rejects out-of-range and non-integer ports", () => { + for (const port of [0, -1, 65536, 3128.5, "3128"]) { + expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow(); + } + }); + }); + + describe("editProxyBodyValidation", () => { + it("accepts the create shape without clear flags", () => { + const parsed = editProxyBodyValidation.parse(validBody); + + expect(parsed.clearPassword).toBeUndefined(); + expect(parsed.clearUsername).toBeUndefined(); + }); + + it("accepts clearPassword and clearUsername", () => { + const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true }); + + expect(parsed.clearPassword).toBe(true); + expect(parsed.clearUsername).toBe(true); + }); + + it("rejects non-boolean clear flags", () => { + expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow(); + }); + }); + + describe("proxyResponseSchema", () => { + const validResponse = { + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + hasPassword: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + + it("accepts a masked proxy", () => { + expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow(); + }); + + it("requires hasPassword", () => { + const { hasPassword: _hasPassword, ...withoutFlag } = validResponse; + + expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow(); + }); + + it("does not declare a password field", () => { + expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password"); + }); + }); +}); diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts index a2d659c34..ce4e0191b 100644 --- a/server/test/unit/validation/statusPageValidation.test.ts +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@jest/globals"; -import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { createStatusPageBodyValidation, getStatusPageQueryValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { StatusPageRanges } from "../../../src/domain/status-pages/status-page.type.ts"; const baseCreateBody = (overrides: Record = {}) => ({ type: "uptime", @@ -61,3 +62,31 @@ describe("createStatusPageBodyValidation", () => { } }); }); + +describe("getStatusPageQueryValidation", () => { + it("defaults range to latest when absent", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe("latest"); + } + }); + + it("accepts every supported range", () => { + for (const range of StatusPageRanges) { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe(range); + } + } + }); + + it("rejects an unsupported range", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range: "45d" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: ["range"] })])); + } + }); +});