Compare commits

...

36 Commits

Author SHA1 Message Date
Raj Nandan Sharma 880080cea1 Merge pull request #799 from lokiee0/feat/795-monitoring-status-filter
Feat/795 monitoring status filter
2026-08-03 10:16:38 +05:30
Raj Nandan Sharma 733f355e4b Merge pull request #801 from otherwiseGG/main
feat: added UI support for ordinal weekdays
2026-08-03 10:11:22 +05:30
Benjamin Thiele 01cfd119d4 fixed preview skips second occurrences and docs 2026-08-02 11:19:55 +02:00
Benjamin Thiele 4394ca3668 added docs case for ordinal byday (first weekday of month) 2026-08-02 10:52:21 +02:00
Benjamin Thiele ee95642adb fixed sparse monthly rules like FREQ=MONTHLY;BYDAY=1WE 2026-08-02 10:47:15 +02:00
Logesh K 66bc99bf78 Added label to the status 2026-07-26 23:10:38 +05:30
Logesh K a0ca4be3e4 clarify monitoring 2026-07-26 22:56:32 +05:30
Logesh K 833b7a51a4 fix_data deletation by status 2026-07-26 22:44:16 +05:30
Logesh K b0a4279273 fix: Centralize and validate the monitoring status-filter contract 2026-07-25 00:34:14 +05:30
Logesh K 067d4526a5 fix(ci): prevent stale main image publishes 2026-07-25 00:24:15 +05:30
LOGESH K 8420b2cc85 Add monitoring status dropdown 2026-07-24 23:10:19 +05:30
LOGESH K 48e1744c85 Parse monitoring status filter 2026-07-24 23:10:18 +05:30
LOGESH K 9580f0ee78 Filter monitoring data and count by status 2026-07-24 23:10:16 +05:30
LOGESH K 4bd3668a5d Pass monitoring status through controller 2026-07-24 23:10:15 +05:30
LOGESH K a6ce620e4a Merge branch 'rajnandan1:main' into main 2026-07-19 21:55:06 +05:30
Raj Nandan Sharma 9d99a5c11f Merge pull request #797 from rajnandan1/fix/794-event-card-badge-wrap
fix(ui): wrap affected-monitor badges inside event cards
2026-07-18 23:05:45 +05:30
Raj Nandan Sharma bbc85deea7 fix(ui): wrap affected-monitor badges inside event cards
Maintenance and incident cards rendered affected monitors in a
non-wrapping flex row; with many monitors the badges overflowed the
card (maintenance) or hid behind a horizontal scrollbar (incident).
Badge text was also nowrap, so a single long monitor name could still
bleed past the card edge on narrow viewports.

Apply one rule to both components: badges wrap within the box, and
badge text itself wraps when a name exceeds the row width.

Fixes #794

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 23:01:09 +05:30
Raj Nandan Sharma a937727d62 Merge pull request #789 from 123FLO321/feat/test-setup
test: add Vitest projects, CI workflow, and new frontend/backend test suites
2026-07-18 22:36:54 +05:30
Raj Nandan Sharma 946f5ed49d Merge pull request #787 from 123FLO321/chore/lf-line-endings
chore: enforce LF line endings via .gitattributes + gitignore updates
2026-07-18 22:23:51 +05:30
Raj Nandan Sharma ec98c71108 Merge pull request #793 from ScchutzZ/patch-1
Update Turkish translations for various terms
2026-07-18 22:16:54 +05:30
Ahmet Deniz VARLI 65e346be6d Update Turkish translations for various terms 2026-07-17 06:59:41 +03:00
Logesh K 9b2989cbfc ci: allow main image matrix jobs to complete 2026-07-15 22:25:27 +05:30
Florian Kostenzer 385fb63d55 ci: cancel superseded test runs on the same branch or PR
Add a workflow-level concurrency group keyed by workflow + ref with
cancel-in-progress, so pushing a new commit cancels the still-running
test job for the previous commit on that branch/PR.
2026-07-15 17:52:53 +02:00
Florian Kostenzer 01a428ced9 chore: restrict permissions for test workflow 2026-07-14 18:28:44 +02:00
Florian Kostenzer 3fc1d750fd test: use extends: true for Vitest projects, guard clipboard spy target 2026-07-14 06:21:40 +02:00
Florian Kostenzer c40b41a8b9 ci: disable credential persistence, read Playwright version from installed CLI 2026-07-14 06:12:42 +02:00
Florian Kostenzer a7b5c2c171 ci: add test workflow with Playwright browser caching 2026-07-14 05:59:27 +02:00
Florian Kostenzer ae1586cde7 test: pin test timezone to UTC, document test commands 2026-07-13 23:31:44 +02:00
Florian Kostenzer 2dc46ab007 test: add StatusBarCalendar browser-mode component test 2026-07-13 23:02:11 +02:00
Florian Kostenzer e8008671d9 test: add Vitest browser-mode client project with CopyButton test 2026-07-13 22:52:18 +02:00
Florian Kostenzer 048518f2a8 test: cover tool.ts validation and string helpers 2026-07-13 22:29:55 +02:00
Florian Kostenzer 180c963b67 test: cover tool.ts uptime math (ParseUptime, formulas, UptimeCalculator) 2026-07-13 22:24:26 +02:00
Florian Kostenzer fca7624252 test: add Vitest server project with tool.ts timestamp tests 2026-07-13 22:18:03 +02:00
Florian Kostenzer 060d8d9c70 chore: enforce LF line endings via .gitattributes, ignore .idea and .superpowers
Shell scripts checked out with CRLF on Windows (core.autocrlf=true) broke
the Docker entrypoint at container start. Force LF for all text files at
the git layer, matching the existing .editorconfig and Prettier settings.
2026-07-13 20:51:00 +02:00
LOGESH K 272b37047d Merge branch 'rajnandan1:main' into main 2026-07-13 22:39:08 +05:30
Logesh K ce9d9dcfd0 Refactor chartId to use for reactive updates 2026-07-12 18:51:12 +05:30
30 changed files with 1291 additions and 44 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+4 -4
View File
@@ -12,16 +12,16 @@ env:
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
GITHUB_IMAGE_NAME: ${{ github.repository }} GITHUB_IMAGE_NAME: ${{ github.repository }}
concurrency:
group: main-docker-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build-and-push-main: build-and-push-main:
name: Build and push main Docker images (with docs) name: Build and push main Docker images (with docs)
strategy: strategy:
fail-fast: false
matrix: matrix:
variant: [debian, alpine] variant: [debian, alpine]
concurrency:
group: main-docker-${{ github.ref }}-${{ matrix.variant }}
cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
+51
View File
@@ -0,0 +1,51 @@
name: Tests
on:
push:
branches:
- main
pull_request:
# Cancel superseded runs on the same branch / PR to save CI minutes.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Get Playwright version
id: playwright-version
run: echo "version=$(npx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Type check
run: npm run check
- name: Run tests
run: npm test
+3 -1
View File
@@ -12,6 +12,7 @@ config/server.yaml
/package /package
.env .env
.vscode .vscode
.idea
.env.* .env.*
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
@@ -39,4 +40,5 @@ translation-report.json
# AI workflow docs (not version-controlled) # AI workflow docs (not version-controlled)
CONTEXT.md CONTEXT.md
docs/adr/ docs/adr/
docs/superpowers/ docs/superpowers/
.superpowers/
+6
View File
@@ -16,8 +16,14 @@ npm run check # Svelte + TypeScript type checking
npm run prettify # Format all files with Prettier npm run prettify # Format all files with Prettier
npm run migrate # Run database migrations via Knex npm run migrate # Run database migrations via Knex
npm run seed # Run database seeds (migrations run automatically first) npm run seed # Run database seeds (migrations run automatically first)
npm test # Run all tests (server unit + browser component projects)
npm run test:server # Server-side unit tests only (Node)
npm run test:client # Component tests only (headless Chromium via Playwright)
npm run test:watch # Watch mode
``` ```
Component tests need a one-time `npx playwright install chromium`.
## Architecture ## Architecture
### Dual Process Model ### Dual Process Model
+503 -1
View File
@@ -103,6 +103,7 @@
"@types/mustache": "^4.2.6", "@types/mustache": "^4.2.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@vitest/browser-playwright": "^4.1.10",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
@@ -126,7 +127,9 @@
"vaul-svelte": "^1.0.0-next.7", "vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.2", "vite": "^7.2.2",
"vite-node": "^5.3.0", "vite-node": "^5.3.0",
"vite-plugin-devtools-json": "^1.0.0" "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -157,6 +160,13 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@codemirror/autocomplete": { "node_modules/@codemirror/autocomplete": {
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
@@ -2645,6 +2655,19 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@testing-library/svelte-core": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz",
"integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
}
},
"node_modules/@types/bcrypt": { "node_modules/@types/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -2666,6 +2689,17 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -2716,6 +2750,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/dns2": { "node_modules/@types/dns2": {
"version": "2.0.10", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/dns2/-/dns2-2.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/dns2/-/dns2-2.0.10.tgz",
@@ -2900,6 +2941,186 @@
"@codemirror/view": ">=6.0.0" "@codemirror/view": ">=6.0.0"
} }
}, },
"node_modules/@vitest/browser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@blazediff/core": "1.9.1",
"@vitest/mocker": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.1.0",
"ws": "^8.19.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "4.1.10",
"@vitest/mocker": "4.1.10",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.1.10"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/browser/node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@zone-eu/mailsplit": { "node_modules/@zone-eu/mailsplit": {
"version": "5.4.8", "version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
@@ -3027,6 +3248,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async-function": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -3539,6 +3770,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3805,6 +4046,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -5018,6 +5266,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -8291,6 +8549,56 @@
"node": ">=22.0.0" "node": ">=22.0.0"
} }
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
@@ -9490,6 +9798,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/simple-concat": { "node_modules/simple-concat": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -9614,6 +9929,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": { "node_modules/standard-as-callback": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -9639,6 +9961,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -10215,6 +10544,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -10231,6 +10577,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tlds": { "node_modules/tlds": {
"version": "1.261.0", "version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
@@ -10729,6 +11085,113 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest-browser-svelte": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-3.0.0.tgz",
"integrity": "sha512-BKM9iBGNsEQzigWuSZFAiw6AJDqQqMttApoD8gM8LTI6vxfd423rDMN8GqUHJyGbhH3XcOo9N9u/4hu47Nxhsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/svelte-core": "^1.1.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
"vitest": "^4.0.0"
}
},
"node_modules/w3c-keyname": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -10864,6 +11327,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -10887,6 +11367,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
+9 -2
View File
@@ -48,7 +48,11 @@
"preview": "vite preview", "preview": "vite preview",
"schedule": "vite-node src/lib/server/startup.ts", "schedule": "vite-node src/lib/server/startup.ts",
"seed": "npx knex seed:run", "seed": "npx knex seed:run",
"start": "node build/main.js" "start": "node build/main.js",
"test": "cross-env TZ=UTC vitest run",
"test:client": "vitest run --project=client",
"test:server": "cross-env TZ=UTC vitest run --project=server",
"test:watch": "cross-env TZ=UTC vitest"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.10.0", "@internationalized/date": "^3.10.0",
@@ -69,6 +73,7 @@
"@types/mustache": "^4.2.6", "@types/mustache": "^4.2.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@vitest/browser-playwright": "^4.1.10",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
@@ -92,7 +97,9 @@
"vaul-svelte": "^1.0.0-next.7", "vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.2", "vite": "^7.2.2",
"vite-node": "^5.3.0", "vite-node": "^5.3.0",
"vite-plugin-devtools-json": "^1.0.0" "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+2 -1
View File
@@ -1,5 +1,6 @@
import type { TimestampStatusCount } from "$lib/server/types/db"; import type { TimestampStatusCount } from "$lib/server/types/db";
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants"; import GC, { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
import type { StatusType } from "$lib/types/status";
function ParseLatency(latencyMs: number): string { function ParseLatency(latencyMs: number): string {
if (!!!latencyMs) { if (!!!latencyMs) {
@@ -0,0 +1,57 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import CopyButton from "./CopyButton.svelte";
// The clipboard is spied on rather than read back: headless Chromium does not
// grant clipboard-read permission, and the component's contract is "calls
// navigator.clipboard.writeText and flips to the copied state".
describe("CopyButton", () => {
beforeAll(() => {
// navigator.clipboard only exists in secure contexts; provide a minimal
// stand-in when absent so the spies below always have a target.
if (!("clipboard" in navigator)) {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: async () => {} },
configurable: true,
});
}
});
beforeEach(() => {
vi.restoreAllMocks();
});
it("copies the text prop and shows the copied state on click", async () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
const screen = await render(CopyButton, { text: "copy-me" });
const copiedTooltip = screen.getByText("Copied");
// Hidden before the click (class-based visibility)
await expect.element(copiedTooltip).toHaveClass(/opacity-0/);
await screen.getByRole("button").click();
expect(writeText).toHaveBeenCalledExactlyOnceWith("copy-me");
await expect.element(copiedTooltip).toHaveClass(/scale-100/);
});
it("invokes the onclick callback", async () => {
vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
const onclick = vi.fn();
const screen = await render(CopyButton, { text: "x", onclick });
await screen.getByRole("button").click();
expect(onclick).toHaveBeenCalledOnce();
});
it("does not touch the clipboard when no text is provided", async () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
const screen = await render(CopyButton, {});
await screen.getByRole("button").click();
expect(writeText).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -52,14 +52,14 @@
</div> </div>
{#if incident.monitors && incident.monitors.length > 0 && !hideMonitors} {#if incident.monitors && incident.monitors.length > 0 && !hideMonitors}
<div class="my-1 overflow-x-auto p-1"> <div class="my-1 p-1">
<div class="flex gap-2"> <div class="flex flex-wrap gap-2">
{#each incident.monitors as monitor (`${incident.id}-${monitor.monitor_tag}`)} {#each incident.monitors as monitor (`${incident.id}-${monitor.monitor_tag}`)}
<Popover.Root> <Popover.Root>
<Popover.Trigger disabled={isEmbedded}> <Popover.Trigger disabled={isEmbedded}>
<Badge <Badge
variant="outline" variant="outline"
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal" class="border-{monitor.monitor_impact.toLowerCase()} max-w-full cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal wrap-anywhere whitespace-normal"
> >
{monitor.monitor_name} {monitor.monitor_name}
</Badge> </Badge>
+2 -2
View File
@@ -52,13 +52,13 @@
{/if} {/if}
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors} {#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
<div class="flex gap-2"> <div class="flex flex-wrap gap-2">
{#each maintenance.monitors as monitor} {#each maintenance.monitors as monitor}
<Popover.Root> <Popover.Root>
<Popover.Trigger disabled={isEmbedded}> <Popover.Trigger disabled={isEmbedded}>
<Badge <Badge
variant="outline" variant="outline"
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal" class="border-{monitor.monitor_impact.toLowerCase()} max-w-full cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal wrap-anywhere whitespace-normal"
> >
{monitor.monitor_name} {monitor.monitor_name}
</Badge> </Badge>
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { render } from "vitest-browser-svelte";
import StatusBarCalendar from "./StatusBarCalendar.svelte";
import type { TimestampStatusCount } from "$lib/server/types/db";
// The browser context is pinned to UTC (see vite.config.ts), so the timezone store
// (which defaults to browser TZ) is deterministic; noon UTC keeps fixtures safe too.
const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z
const day = (overrides: Partial<TimestampStatusCount>): TimestampStatusCount => ({
ts: NOON_UTC,
countOfUp: 0,
countOfDown: 0,
countOfDegraded: 0,
countOfMaintenance: 0,
avgLatency: 0,
maxLatency: 0,
minLatency: 0,
...overrides,
});
describe("StatusBarCalendar", () => {
it("labels the canvas with the number of days", async () => {
const data = [day({ countOfUp: 10 }), day({ countOfUp: 10 }), day({ countOfUp: 10 })];
const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true });
await expect.element(screen.getByLabelText("Status calendar showing 3-day uptime data")).toBeInTheDocument();
});
it("shows status summary, date, and latency in the tooltip of the hovered day", async () => {
// Three bars: hovering the canvas center lands on the middle bar (index 1),
// which is 90% down => "Major System Outage" (>= 75% threshold).
const data = [
day({ countOfUp: 100 }),
day({ countOfDown: 90, countOfUp: 10, avgLatency: 250 }),
day({ countOfUp: 90, countOfDegraded: 10 }),
];
const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true });
await screen.getByLabelText("Status calendar showing 3-day uptime data").hover();
await expect.element(screen.getByText("Major System Outage")).toBeInTheDocument();
// page.data.dateAndTimeFormat.dateOnly is mocked as "yyyy-MM-dd" in vitest-setup-client.ts
await expect.element(screen.getByText("2026-01-15", { exact: false })).toBeInTheDocument();
await expect.element(screen.getByText("250ms")).toBeInTheDocument();
});
it("reports a partial outage when downtime is below the 75% threshold", async () => {
const data = [day({ countOfUp: 100 }), day({ countOfDown: 20, countOfUp: 80 }), day({ countOfUp: 100 })];
const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true });
await screen.getByLabelText("Status calendar showing 3-day uptime data").hover();
await expect.element(screen.getByText("Partial System Outage")).toBeInTheDocument();
});
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { isMonitoringStatus } from "./global-constants.js";
describe("isMonitoringStatus", () => {
it("accepts only statuses supported by the monitoring filter", () => {
expect(isMonitoringStatus("UP")).toBe(true);
expect(isMonitoringStatus("DOWN")).toBe(true);
expect(isMonitoringStatus("DEGRADED")).toBe(true);
expect(isMonitoringStatus("MAINTENANCE")).toBe(false);
expect(isMonitoringStatus("")).toBe(false);
expect(isMonitoringStatus("invalid")).toBe(false);
});
});
+7 -1
View File
@@ -1,9 +1,15 @@
import type { MonitoringStatus } from "$lib/types/status.js";
// Badge styles supported by badge-maker // Badge styles supported by badge-maker
export const BADGE_STYLES = ["flat", "plastic", "flat-square", "for-the-badge", "social"] as const; export const BADGE_STYLES = ["flat", "plastic", "flat-square", "for-the-badge", "social"] as const;
export type BadgeStyle = (typeof BADGE_STYLES)[number]; export type BadgeStyle = (typeof BADGE_STYLES)[number];
// Status types // Status types
export type StatusType = "UP" | "DOWN" | "DEGRADED" | "MAINTENANCE" | "NO_DATA"; export const MONITORING_STATUSES = ["UP", "DOWN", "DEGRADED"] as const satisfies readonly MonitoringStatus[];
export function isMonitoringStatus(value: unknown): value is MonitoringStatus {
return typeof value === "string" && MONITORING_STATUSES.includes(value as MonitoringStatus);
}
// Incident states // Incident states
export type IncidentState = "INVESTIGATING" | "IDENTIFIED" | "MONITORING" | "RESOLVED"; export type IncidentState = "INVESTIGATING" | "IDENTIFIED" | "MONITORING" | "RESOLVED";
+6 -6
View File
@@ -10,7 +10,7 @@
"Badges": "Rozetler", "Badges": "Rozetler",
"CANCELLED": "İPTAL EDİLMİŞ", "CANCELLED": "İPTAL EDİLMİŞ",
"COMPLETED": "TAMAMLANMIŞ", "COMPLETED": "TAMAMLANMIŞ",
"Continue": "Devam etmek", "Continue": "Devam et",
"Copied": "Kopyalandı", "Copied": "Kopyalandı",
"Current": "Güncel", "Current": "Güncel",
"Dark": "Karanlık", "Dark": "Karanlık",
@@ -39,14 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Olaylar ve planlı bakım hakkında bildirim alın.", "Get notified about incidents and scheduled maintenance.": "Olaylar ve planlı bakım hakkında bildirim alın.",
"Get notified about incidents updates": "Olay güncellemeleri hakkında bildirim alın", "Get notified about incidents updates": "Olay güncellemeleri hakkında bildirim alın",
"Get notified about scheduled maintenance": "Planlanmış bakım hakkında bildirim alın", "Get notified about scheduled maintenance": "Planlanmış bakım hakkında bildirim alın",
"IDENTIFIED": "IDENTIFIED", "IDENTIFIED": "BELİRLENDİ",
"iFrame": "iFrame", "iFrame": "iFrame",
"Impact": "Darbe", "Impact": "Darbe",
"incident": "Olay", "incident": "Olay",
"Incident Updates": "Olay Güncellemeleri", "Incident Updates": "Olay Güncellemeleri",
"Incidents": "Olaylar", "Incidents": "Olaylar",
"Included Monitors (%count)": "Included Monitors (%count)", "Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING", "INVESTIGATING": "ARAŞTIRILIYOR",
"Last Updated": "Son Güncelleme", "Last Updated": "Son Güncelleme",
"Latency": "Gecikme", "Latency": "Gecikme",
"Latency Embed": "Gecikme Yerleştirme", "Latency Embed": "Gecikme Yerleştirme",
@@ -69,7 +69,7 @@
"Min Latency": "Minimum Gecikme", "Min Latency": "Minimum Gecikme",
"Minimum Latency": "Minimum Gecikme", "Minimum Latency": "Minimum Gecikme",
"Minute-by-minute status data for this day": "Bu günün dakika dakika durum verileri", "Minute-by-minute status data for this day": "Bu günün dakika dakika durum verileri",
"MONITORING": "MONITORING", "MONITORING": "GÖZLEMLENİYOR",
"Network error. Please try again.": "Ağ hatası. ", "Network error. Please try again.": "Ağ hatası. ",
"No Events in %currentMonth": "%currentMonth Bölgesinde Etkinlik Yok", "No Events in %currentMonth": "%currentMonth Bölgesinde Etkinlik Yok",
"No events to show": "Gösterilecek olay yok", "No events to show": "Gösterilecek olay yok",
@@ -99,7 +99,7 @@
"Read more": "Devamını oku", "Read more": "Devamını oku",
"READY": "HAZIR", "READY": "HAZIR",
"Recurring": "Tekrarlayan", "Recurring": "Tekrarlayan",
"RESOLVED": "RESOLVED", "RESOLVED": "ÇÖZÜLDÜ",
"RSS feed": "RSS feed", "RSS feed": "RSS feed",
"SCHEDULED": "PLANLANMIŞ", "SCHEDULED": "PLANLANMIŞ",
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)", "Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
@@ -114,7 +114,7 @@
"Status Badge": "Durum Rozeti", "Status Badge": "Durum Rozeti",
"Status Embed": "Durum Yerleştirme", "Status Embed": "Durum Yerleştirme",
"Status history and latency trend": "Durum geçmişi ve gecikme eğilimi", "Status history and latency trend": "Durum geçmişi ve gecikme eğilimi",
"Subscribe": "Abone", "Subscribe": "Abone Ol",
"Subscribe to Updates": "Güncellemelere abone olun", "Subscribe to Updates": "Güncellemelere abone olun",
"There are no incidents or maintenances scheduled for this month.": "Bu ay için planlanmış herhangi bir olay veya bakım bulunmamaktadır.", "There are no incidents or maintenances scheduled for this month.": "Bu ay için planlanmış herhangi bir olay veya bakım bulunmamaktadır.",
"There are no ongoing incidents or maintenance events.": "Devam eden olay veya bakım etkinliği yok.", "There are no ongoing incidents or maintenance events.": "Devam eden olay veya bakım etkinliği yok.",
+1 -1
View File
@@ -2,7 +2,7 @@ import { json, error } from "@sveltejs/kit";
import type { APIServerRequest } from "$lib/server/types/api-server"; import type { APIServerRequest } from "$lib/server/types/api-server";
import db from "$lib/server/db/db"; import db from "$lib/server/db/db";
import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool"; import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool";
import type { StatusType } from "$lib/global-constants"; import type { StatusType } from "$lib/types/status";
import type { TimestampStatusCount } from "$lib/server/types/db"; import type { TimestampStatusCount } from "$lib/server/types/db";
import { buildMonitorBarResponse } from "./shared"; import { buildMonitorBarResponse } from "./shared";
@@ -1,5 +1,6 @@
import db from "$lib/server/db/db"; import db from "$lib/server/db/db";
import GC, { type StatusType } from "$lib/global-constants"; import GC from "$lib/global-constants";
import type { StatusType } from "$lib/types/status";
import type { MonitorRecord, TimestampStatusCount } from "$lib/server/types/db"; import type { MonitorRecord, TimestampStatusCount } from "$lib/server/types/db";
import { UptimeCalculator } from "$lib/server/tool"; import { UptimeCalculator } from "$lib/server/tool";
import type { MonitorBarResponse } from "./get"; import type { MonitorBarResponse } from "./get";
@@ -2,7 +2,7 @@ import { json, error } from "@sveltejs/kit";
import type { APIServerRequest } from "$lib/server/types/api-server"; import type { APIServerRequest } from "$lib/server/types/api-server";
import db from "$lib/server/db/db"; import db from "$lib/server/db/db";
import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool"; import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool";
import type { StatusType } from "$lib/global-constants"; import type { StatusType } from "$lib/types/status";
import GC from "$lib/global-constants"; import GC from "$lib/global-constants";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get"; import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
import { buildMonitorBarResponseFromRawData } from "$lib/server/api-server/monitor-bar/shared"; import { buildMonitorBarResponseFromRawData } from "$lib/server/api-server/monitor-bar/shared";
@@ -22,6 +22,7 @@ import type { MonitorFilter } from "../db/repositories/base.js";
import db from "../db/db.js"; import db from "../db/db.js";
import type { PaginationInput } from "../../types/common.js"; import type { PaginationInput } from "../../types/common.js";
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js"; import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
import type { MonitoringStatus } from "../../types/status.js";
import { makeBadge } from "badge-maker"; import { makeBadge } from "badge-maker";
import { ErrorSvg } from "../../anywhere.js"; import { ErrorSvg } from "../../anywhere.js";
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js"; import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
@@ -490,7 +491,7 @@ export const GetStatusCountsByInterval = async (
export const GetMonitoringDataPaginated = async ( export const GetMonitoringDataPaginated = async (
page: number, page: number,
limit: number, limit: number,
filter?: { monitor_tag?: string; start_time?: number; end_time?: number }, filter?: { monitor_tag?: string; status?: MonitoringStatus; start_time?: number; end_time?: number },
): Promise<{ data: MonitoringData[]; total: number }> => { ): Promise<{ data: MonitoringData[]; total: number }> => {
const data = await db.getMonitoringDataPaginated(page, limit, filter); const data = await db.getMonitoringDataPaginated(page, limit, filter);
const countResult = await db.getMonitoringDataCount(filter); const countResult = await db.getMonitoringDataCount(filter);
+15 -2
View File
@@ -1,6 +1,7 @@
import type { Knex as KnexType } from "knex"; import type { Knex as KnexType } from "knex";
import { BaseRepository } from "./base.js"; import { BaseRepository } from "./base.js";
import GC from "../../../global-constants.js"; import GC from "../../../global-constants.js";
import type { MonitoringStatus } from "../../../types/status.js";
import { GetMinuteStartNowTimestampUTC } from "../../tool.js"; import { GetMinuteStartNowTimestampUTC } from "../../tool.js";
import type { import type {
MonitoringData, MonitoringData,
@@ -82,7 +83,7 @@ export class MonitoringRepository extends BaseRepository {
async getMonitoringDataPaginated( async getMonitoringDataPaginated(
page: number, page: number,
limit: number, limit: number,
filter?: { monitor_tag?: string; start_time?: number; end_time?: number }, filter?: { monitor_tag?: string; status?: MonitoringStatus; start_time?: number; end_time?: number },
): Promise<MonitoringData[]> { ): Promise<MonitoringData[]> {
let query = this.knex("monitoring_data").select("*"); let query = this.knex("monitoring_data").select("*");
@@ -90,6 +91,10 @@ export class MonitoringRepository extends BaseRepository {
query = query.where("monitor_tag", filter.monitor_tag); query = query.where("monitor_tag", filter.monitor_tag);
} }
if (filter?.status) {
query = query.where("status", filter.status);
}
if (filter?.start_time) { if (filter?.start_time) {
query = query.where("timestamp", ">=", filter.start_time); query = query.where("timestamp", ">=", filter.start_time);
} }
@@ -106,6 +111,7 @@ export class MonitoringRepository extends BaseRepository {
async getMonitoringDataCount(filter?: { async getMonitoringDataCount(filter?: {
monitor_tag?: string; monitor_tag?: string;
status?: MonitoringStatus;
start_time?: number; start_time?: number;
end_time?: number; end_time?: number;
}): Promise<{ count: number }> { }): Promise<{ count: number }> {
@@ -115,6 +121,10 @@ export class MonitoringRepository extends BaseRepository {
query = query.where("monitor_tag", filter.monitor_tag); query = query.where("monitor_tag", filter.monitor_tag);
} }
if (filter?.status) {
query = query.where("status", filter.status);
}
if (filter?.start_time) { if (filter?.start_time) {
query = query.where("timestamp", ">=", filter.start_time); query = query.where("timestamp", ">=", filter.start_time);
} }
@@ -476,7 +486,7 @@ export class MonitoringRepository extends BaseRepository {
}); });
} }
async deleteMonitorDataByTag(tag?: string, start?: number, end?: number): Promise<number> { async deleteMonitorDataByTag(tag?: string, start?: number, end?: number, status?: MonitoringStatus): Promise<number> {
const query = this.knex("monitoring_data"); const query = this.knex("monitoring_data");
if (tag) { if (tag) {
query.where("monitor_tag", tag); query.where("monitor_tag", tag);
@@ -487,6 +497,9 @@ export class MonitoringRepository extends BaseRepository {
if (end !== undefined) { if (end !== undefined) {
query.where("timestamp", "<=", end); query.where("timestamp", "<=", end);
} }
if (status) {
query.where("status", status);
}
return await query.del(); return await query.del();
} }
+378
View File
@@ -0,0 +1,378 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
BeginningOfDay,
BeginningOfMinute,
checkIfDuplicateExists,
DurationInMinutes,
GetDayStartTimestampUTC,
GetDayStartWithOffset,
GetMinuteStartNowTimestampUTC,
GetMinuteStartTimestampUTC,
GetNowTimestampUTC,
GetNowTimestampUTCInMs,
GetRequiredSecrets,
GetWordsStartingWithDollar,
HashString,
IsStringURLSafe,
IsValidHost,
IsValidHTTPMethod,
IsValidNameServer,
IsValidURL,
IsValidUptimeFormula,
MaskString,
ParsePercentage,
ParseUptime,
ReplaceAllOccurrences,
UnparsePercentage,
UptimeCalculator,
ValidateEmail,
ValidateIpAddress,
ValidateMonitorAlerts,
ValidateURL,
} from "./tool";
import type { TimestampStatusCount } from "./types/db";
// The npm scripts pin TZ=UTC, so local-date-based functions (GetDayStartTimestampUTC
// mixes local date parts with Date.UTC) are deterministic here. Fixtures still use
// noon UTC so the tests stay safe even if vitest is run directly without the pin.
const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z
const DAY_START = 1768435200; // 2026-01-15T00:00:00Z
describe("timestamp helpers", () => {
afterEach(() => {
vi.useRealTimers();
});
it("GetNowTimestampUTC returns the current epoch in seconds", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
expect(GetNowTimestampUTC()).toBe(NOON_UTC);
});
it("GetNowTimestampUTCInMs returns the current epoch in milliseconds", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
expect(GetNowTimestampUTCInMs()).toBe(NOON_UTC * 1000);
});
it("GetMinuteStartTimestampUTC floors a timestamp to the start of its minute", () => {
expect(GetMinuteStartTimestampUTC(NOON_UTC + 45)).toBe(NOON_UTC);
expect(GetMinuteStartTimestampUTC(NOON_UTC)).toBe(NOON_UTC);
expect(GetMinuteStartTimestampUTC(NOON_UTC + 59)).toBe(NOON_UTC);
});
it("GetMinuteStartNowTimestampUTC floors the current time to the minute", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T12:00:45Z"));
expect(GetMinuteStartNowTimestampUTC()).toBe(NOON_UTC);
});
it("GetDayStartTimestampUTC returns midnight UTC of the timestamp's day", () => {
expect(GetDayStartTimestampUTC(NOON_UTC)).toBe(DAY_START);
expect(GetDayStartTimestampUTC(DAY_START + 12 * 3600 + 61)).toBe(DAY_START);
});
it("DurationInMinutes floors the difference to whole minutes", () => {
expect(DurationInMinutes(0, 600)).toBe(10);
expect(DurationInMinutes(0, 59)).toBe(0);
expect(DurationInMinutes(DAY_START, NOON_UTC)).toBe(720);
});
it("GetDayStartWithOffset shifts the UTC day start by positive offsets", () => {
// 330 minutes = UTC+5:30 (e.g. IST)
expect(GetDayStartWithOffset(NOON_UTC, 330)).toBe(DAY_START + 330 * 60);
expect(GetDayStartWithOffset(NOON_UTC, 0)).toBe(DAY_START);
});
it("GetDayStartWithOffset moves to the next day for negative offsets", () => {
expect(GetDayStartWithOffset(NOON_UTC, -330)).toBe(DAY_START + 86400 - 330 * 60);
});
it("BeginningOfDay returns midnight in the given timezone", () => {
const date = new Date("2026-01-15T12:00:00Z");
expect(BeginningOfDay({ date, timeZone: "UTC" })).toBe(DAY_START);
// Midnight in New York (UTC-5 in January) is 05:00 UTC
expect(BeginningOfDay({ date, timeZone: "America/New_York" })).toBe(DAY_START + 5 * 3600);
});
it("BeginningOfMinute strips seconds", () => {
const date = new Date("2026-01-15T12:00:45Z");
expect(BeginningOfMinute({ date, timeZone: "UTC" })).toBe(NOON_UTC);
});
});
describe("ParseUptime", () => {
it("returns '-' when there is no data", () => {
expect(ParseUptime(0, 0)).toBe("-");
});
it("returns '0' when nothing is up", () => {
expect(ParseUptime(0, 10)).toBe("0");
});
it("returns whole numbers without decimals", () => {
expect(ParseUptime(10, 10)).toBe("100");
expect(ParseUptime(5, 10)).toBe("50");
});
it("keeps 4 decimal places and strips trailing zeros", () => {
expect(ParseUptime(1, 3)).toBe("33.3333");
expect(ParseUptime(2, 3)).toBe("66.6667");
expect(ParseUptime(1, 8)).toBe("12.5");
expect(ParseUptime(999, 1000)).toBe("99.9");
});
});
describe("ParsePercentage / UnparsePercentage", () => {
it("formats numbers as percentage strings", () => {
expect(ParsePercentage(NaN)).toBe("-");
expect(ParsePercentage(0)).toBe("0");
expect(ParsePercentage(100)).toBe("100");
expect(ParsePercentage(99.9)).toBe("99.9000");
});
it("parses percentage strings back to numbers", () => {
expect(UnparsePercentage("99.9")).toBe(99.9);
expect(UnparsePercentage("0")).toBe(0);
expect(Number.isNaN(UnparsePercentage("-"))).toBe(true);
});
});
describe("IsValidUptimeFormula", () => {
it("accepts single variables and operator chains", () => {
expect(IsValidUptimeFormula("up")).toBe(true);
expect(IsValidUptimeFormula("up + down")).toBe(true);
expect(IsValidUptimeFormula("up+down+degraded+maintenance")).toBe(true);
expect(IsValidUptimeFormula("up - down * maintenance / degraded")).toBe(true);
});
it("is case-insensitive", () => {
expect(IsValidUptimeFormula("UP + Maintenance")).toBe(true);
});
it("rejects malformed formulas", () => {
expect(IsValidUptimeFormula("")).toBe(false);
expect(IsValidUptimeFormula(" ")).toBe(false);
expect(IsValidUptimeFormula("up +")).toBe(false);
expect(IsValidUptimeFormula("+ up")).toBe(false);
expect(IsValidUptimeFormula("banana + up")).toBe(false);
expect(IsValidUptimeFormula("up ** down")).toBe(false);
expect(IsValidUptimeFormula("up down")).toBe(false);
expect(IsValidUptimeFormula("42")).toBe(false);
});
});
describe("UptimeCalculator", () => {
const day = (overrides: Partial<TimestampStatusCount>): TimestampStatusCount => ({
ts: 0,
countOfUp: 0,
countOfDown: 0,
countOfDegraded: 0,
countOfMaintenance: 0,
avgLatency: 0,
maxLatency: 0,
minLatency: 0,
...overrides,
});
it("computes uptime and latency aggregates with the default formulas", () => {
// Defaults (global-constants): numerator "up + maintenance + degraded",
// denominator "up + down + degraded + maintenance"
const data = [
day({ countOfUp: 30, countOfDown: 10, avgLatency: 100, maxLatency: 150, minLatency: 50 }),
day({ countOfUp: 50, countOfDegraded: 10, avgLatency: 200, maxLatency: 300, minLatency: 80 }),
];
expect(UptimeCalculator(data)).toEqual({
uptime: "90", // (80 up + 0 maintenance + 10 degraded) / 100 total
avgLatency: "150ms", // (100 + 200) / 2
maxLatency: "300ms",
minLatency: "50ms",
});
});
it("supports a custom numerator formula", () => {
const data = [day({ countOfUp: 30, countOfDown: 10 }), day({ countOfUp: 50, countOfDegraded: 10 })];
expect(UptimeCalculator(data, "up").uptime).toBe("80");
});
it("falls back to the default formula when the custom one is invalid", () => {
const data = [day({ countOfUp: 30, countOfDown: 10 }), day({ countOfUp: 50, countOfDegraded: 10 })];
expect(UptimeCalculator(data, "banana + up").uptime).toBe("90");
});
it("returns '-' uptime and empty latencies for no data", () => {
expect(UptimeCalculator([])).toEqual({
uptime: "-",
avgLatency: "",
maxLatency: "",
minLatency: "",
});
});
it("only counts entries with a positive avgLatency toward the average", () => {
const data = [
day({ countOfUp: 10, avgLatency: 0 }),
day({ countOfUp: 10, avgLatency: 100, maxLatency: 100, minLatency: 100 }),
];
const result = UptimeCalculator(data);
expect(result.uptime).toBe("100");
expect(result.avgLatency).toBe("100ms"); // (0 + 100) / 1 entry with latency > 0
});
});
describe("ValidateIpAddress", () => {
it("classifies IPv4, IPv6, domain names, and invalid input", () => {
expect(ValidateIpAddress("192.168.1.1")).toBe("IPv4");
expect(ValidateIpAddress("8.8.8.8")).toBe("IPv4");
expect(ValidateIpAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe("IPv6");
expect(ValidateIpAddress("example.com")).toBe("Domain Name");
expect(ValidateIpAddress("kener.ing")).toBe("Domain Name");
expect(ValidateIpAddress("not valid")).toBe("Invalid");
});
it("documents quirks: permissive IPv4 octets, no shortened IPv6", () => {
// The regex does not range-check octets:
expect(ValidateIpAddress("999.999.999.999")).toBe("IPv4");
// Only full-form IPv6 is recognized:
expect(ValidateIpAddress("::1")).toBe("Invalid");
});
});
describe("host / nameserver / URL / method validators", () => {
it("IsValidHost accepts dotted domains with a TLD", () => {
expect(IsValidHost("example.com")).toBe(true);
expect(IsValidHost("sub.domain.co.uk")).toBe(true);
expect(IsValidHost("my-site.example.org")).toBe(true);
expect(IsValidHost("localhost")).toBe(false);
expect(IsValidHost("-bad.com")).toBe(false);
expect(IsValidHost("")).toBe(false);
});
it("IsValidNameServer accepts only dotted-quad IPs", () => {
expect(IsValidNameServer("8.8.8.8")).toBe(true);
expect(IsValidNameServer("example.com")).toBe(false);
expect(IsValidNameServer("8.8.8")).toBe(false);
});
it("IsValidURL requires http(s) and no spaces", () => {
expect(IsValidURL("https://example.com")).toBe(true);
expect(IsValidURL("http://example.com/path?q=1")).toBe(true);
expect(IsValidURL("ftp://example.com")).toBe(false);
expect(IsValidURL("https://exa mple.com")).toBe(false);
});
it("ValidateURL parses with the URL constructor and allows only http(s)", () => {
expect(ValidateURL("https://example.com")).toBe(true);
expect(ValidateURL("http://localhost:3000")).toBe(true);
expect(ValidateURL("ftp://example.com")).toBe(false);
expect(ValidateURL("javascript:alert(1)")).toBe(false);
expect(ValidateURL("not a url")).toBe(false);
});
it("IsValidHTTPMethod is uppercase-only", () => {
expect(IsValidHTTPMethod("GET")).toBe(true);
expect(IsValidHTTPMethod("PATCH")).toBe(true);
expect(IsValidHTTPMethod("get")).toBe(false);
expect(IsValidHTTPMethod("FETCH")).toBe(false);
});
it("IsStringURLSafe allows unreserved URL characters only", () => {
expect(IsStringURLSafe("abc-123_~.")).toBe(true);
expect(IsStringURLSafe("a b")).toBe(false);
expect(IsStringURLSafe("a/b")).toBe(false);
});
});
describe("ValidateEmail", () => {
it("accepts common address shapes", () => {
expect(ValidateEmail("user@example.com")).toBe(true);
expect(ValidateEmail("first.last@sub.example.co")).toBe(true);
expect(ValidateEmail("user+tag@example.com")).toBe(true);
});
it("rejects malformed addresses", () => {
expect(ValidateEmail("no-at.example.com")).toBe(false);
expect(ValidateEmail("user@")).toBe(false);
expect(ValidateEmail("user@nodot")).toBe(false);
expect(ValidateEmail("@example.com")).toBe(false);
});
});
describe("string helpers", () => {
it("MaskString masks everything but the last 4 characters", () => {
expect(MaskString("secret123")).toBe("*****t123");
expect(MaskString("abcd")).toBe("****");
expect(MaskString("ab")).toBe("**");
expect(MaskString("")).toBe("");
});
it("HashString returns the sha256 hex digest", () => {
expect(HashString("hello")).toBe("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
});
it("GetWordsStartingWithDollar extracts $-prefixed words", () => {
expect(GetWordsStartingWithDollar("run $FOO with $BAR_2")).toEqual(["$FOO", "$BAR_2"]);
expect(GetWordsStartingWithDollar("nothing here")).toEqual([]);
});
it("ReplaceAllOccurrences replaces every occurrence of a $-token", () => {
expect(ReplaceAllOccurrences("a $X b $X", "$X", "y")).toBe("a y b y");
});
it("checkIfDuplicateExists detects duplicates", () => {
expect(checkIfDuplicateExists([1, 2, 2])).toBe(true);
expect(checkIfDuplicateExists(["a", "b"])).toBe(false);
});
});
describe("GetRequiredSecrets", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("resolves $-tokens that exist in the environment", () => {
vi.stubEnv("KENER_TEST_SECRET", "s3cret");
expect(GetRequiredSecrets("token: $KENER_TEST_SECRET")).toEqual([
{ find: "$KENER_TEST_SECRET", replace: "s3cret" },
]);
});
it("ignores tokens with no matching environment variable", () => {
expect(GetRequiredSecrets("token: $KENER_TEST_DEFINITELY_UNSET_VAR")).toEqual([]);
});
});
describe("ValidateMonitorAlerts", () => {
beforeEach(() => {
// The function console.logs every rejection reason; keep test output clean.
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
const validAlert = { triggers: [1], failureThreshold: 1, successThreshold: 2 };
it("accepts DOWN and/or DEGRADED alerts with triggers and integer thresholds", () => {
expect(ValidateMonitorAlerts({ DOWN: validAlert })).toBe(true);
expect(ValidateMonitorAlerts({ DOWN: validAlert, DEGRADED: validAlert })).toBe(true);
});
it("rejects missing/empty/unknown-key alert objects", () => {
expect(ValidateMonitorAlerts(null)).toBe(false);
expect(ValidateMonitorAlerts({})).toBe(false);
expect(ValidateMonitorAlerts({ UP: validAlert } as never)).toBe(false);
});
it("rejects missing or empty triggers", () => {
expect(ValidateMonitorAlerts({ DOWN: { failureThreshold: 1, successThreshold: 1 } })).toBe(false);
expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, triggers: [] } })).toBe(false);
});
it("rejects missing, non-integer, or non-positive thresholds", () => {
expect(ValidateMonitorAlerts({ DOWN: { triggers: [1] } })).toBe(false);
expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, failureThreshold: 1.5 } })).toBe(false);
expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, successThreshold: 0 } })).toBe(false);
});
});
+1
View File
@@ -2,3 +2,4 @@ export * from "$lib/types/api";
export * from "$lib/types/monitor"; export * from "$lib/types/monitor";
export * from "$lib/types/result"; export * from "$lib/types/result";
export * from "$lib/types/common"; export * from "$lib/types/common";
export * from "$lib/types/status";
+2
View File
@@ -0,0 +1,2 @@
export type MonitoringStatus = "UP" | "DOWN" | "DEGRADED";
export type StatusType = MonitoringStatus | "MAINTENANCE" | "NO_DATA";
@@ -28,10 +28,11 @@ FREQ=frequency[;INTERVAL=n][;BYDAY=days][;BYMONTHDAY=day][;COUNT=n][;UNTIL=date]
- Default: 1 - Default: 1
- Integer value: 2 = every other, 3 = every third, etc. - Integer value: 2 = every other, 3 = every third, etc.
**BYDAY** (Optional, for WEEKLY) **BYDAY** (Optional, for WEEKLY or MONTHLY with an ordinal prefix)
- Day codes: `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, `SU` - Day codes: `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, `SU`
- Multiple days: `MO,WE,FR` - Multiple days: `MO,WE,FR`
- With MONTHLY, prefix a day code with an ordinal to mean "the Nth such weekday of the month": `1WE` (first Wednesday), `1SU` (first Sunday)
**BYMONTHDAY** (Optional, for MONTHLY) **BYMONTHDAY** (Optional, for MONTHLY)
@@ -240,6 +241,29 @@ Occurrences:
- Continues monthly... - Continues monthly...
``` ```
#### Nth Day-of-Week of Month (Ordinal BYDAY) {#first-weekday-of-month}
**RRULE:** `FREQ=MONTHLY;BYDAY=1WE`
**Description:** Maintenance occurs on the first Wednesday of each month
**Use Case:** Monthly patch windows tied to a specific weekday
**Example:**
```
Title: Monthly Patch Window
Start: June 3, 2026 at 2:30 PM
RRULE: FREQ=MONTHLY;BYDAY=1WE
Duration: 90 minutes
Occurrences:
- Jun 3, 2026 at 2:30 PM
- Jul 1, 2026 at 2:30 PM
- Aug 5, 2026 at 2:30 PM
- Continues monthly...
```
#### Middle of Month {#middle-of-month} #### Middle of Month {#middle-of-month}
**RRULE:** `FREQ=MONTHLY;BYMONTHDAY=15` **RRULE:** `FREQ=MONTHLY;BYMONTHDAY=15`
@@ -362,15 +386,16 @@ Affected Monitors:
Title: Monthly Security Updates Title: Monthly Security Updates
Description: Critical security patches and system updates Description: Critical security patches and system updates
Start Date: Sunday, June 4, 2026 at 2:00 AM Start Date: Sunday, June 4, 2026 at 2:00 AM
RRULE: FREQ=MONTHLY;BYMONTHDAY=1 RRULE: FREQ=MONTHLY;BYDAY=1SU
Duration: 3600 seconds (1 hour) Duration: 3600 seconds (1 hour)
Affected Monitors: Affected Monitors:
- All Servers: MAINTENANCE - All Servers: MAINTENANCE
``` ```
**Note:** Since RRULE doesn't support "first Sunday", use BYMONTHDAY=1 and choose first occurrence that falls on Sunday, or use BYDAY with ordinal (not supported in Kener UI currently). **Note:** Use ordinal weekday syntax in `BYDAY` for "first X weekday" patterns:
**Workaround:** Create as weekly and manage manually or use BYMONTHDAY for specific date. - First Sunday: `BYDAY=1SU`
- First Wednesday: `BYDAY=1WE`
### Example 4: Business Hours Backup {#example-backup} ### Example 4: Business Hours Backup {#example-backup}
@@ -469,6 +494,7 @@ Start: 2:00 AM UTC
`FREQ=WEEKLY;BYDAY=MO,WE,FR` `FREQ=WEEKLY;BYDAY=MO,WE,FR`
`FREQ=WEEKLY;INTERVAL=2;BYDAY=SU` `FREQ=WEEKLY;INTERVAL=2;BYDAY=SU`
`FREQ=MONTHLY;BYMONTHDAY=1` `FREQ=MONTHLY;BYMONTHDAY=1`
`FREQ=MONTHLY;BYDAY=1WE`
`FREQ=MINUTELY;COUNT=1` `FREQ=MINUTELY;COUNT=1`
### Invalid RRULEs {#invalid-rrules} ### Invalid RRULEs {#invalid-rrules}
@@ -602,6 +628,7 @@ Description: |
| Every Sunday | `FREQ=WEEKLY;BYDAY=SU` | | Every Sunday | `FREQ=WEEKLY;BYDAY=SU` |
| Bi-weekly Mondays | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` | | Bi-weekly Mondays | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` |
| First of month | `FREQ=MONTHLY;BYMONTHDAY=1` | | First of month | `FREQ=MONTHLY;BYMONTHDAY=1` |
| First Wednesday | `FREQ=MONTHLY;BYDAY=1WE` |
| 15th of month | `FREQ=MONTHLY;BYMONTHDAY=15` | | 15th of month | `FREQ=MONTHLY;BYMONTHDAY=15` |
| Quarterly | `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1` | | Quarterly | `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1` |
@@ -619,4 +646,4 @@ Description: |
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Apply these patterns in the dashboard - [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Apply these patterns in the dashboard
- [Maintenance Events](/docs/v4/maintenances/events) - Understand how RRULE generates events - [Maintenance Events](/docs/v4/maintenances/events) - Understand how RRULE generates events
- [Maintenance Overview](/docs/v4/maintenances/overview) - Learn maintenance fundamentals - [Maintenance Overview](/docs/v4/maintenances/overview) - Learn maintenance fundamentals
@@ -1,7 +1,8 @@
import { GetMonitors, GetLatestMonitoringData, GetLatestStatusActiveAll } from "$lib/server/controllers/controller.js"; import { GetMonitors, GetLatestMonitoringData, GetLatestStatusActiveAll } from "$lib/server/controllers/controller.js";
import StatusColor, { type StatusColors } from "$lib/color.js"; import StatusColor, { type StatusColors } from "$lib/color.js";
import { ErrorSvg } from "$lib/anywhere.js"; import { ErrorSvg } from "$lib/anywhere.js";
import GC, { type StatusType } from "$lib/global-constants.js"; import GC from "$lib/global-constants.js";
import type { StatusType } from "$lib/types/status.js";
import { GetLastMonitoringValue } from "$lib/server/cache/setGet.js"; import { GetLastMonitoringValue } from "$lib/server/cache/setGet.js";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
+17 -3
View File
@@ -4,7 +4,8 @@ import { format } from "date-fns";
import sharp from "sharp"; import sharp from "sharp";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import db from "$lib/server/db/db"; import db from "$lib/server/db/db";
import GC from "$lib/global-constants.js"; import GC, { isMonitoringStatus } from "$lib/global-constants.js";
import type { MonitoringStatus } from "$lib/types/status.js";
import { import {
CreateUpdateMonitor, CreateUpdateMonitor,
UpdateMonitoringData, UpdateMonitoringData,
@@ -219,7 +220,14 @@ export async function POST({ request, cookies }) {
} else if (action == "deleteMonitor") { } else if (action == "deleteMonitor") {
resp = await DeleteMonitorCompletelyUsingTag(data.tag); resp = await DeleteMonitorCompletelyUsingTag(data.tag);
} else if (action == "deleteMonitorData") { } else if (action == "deleteMonitorData") {
await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end); let status: MonitoringStatus | undefined;
if (data.status !== undefined && data.status !== null && data.status !== "ALL") {
if (!isMonitoringStatus(data.status)) {
return json({ error: "Invalid monitoring status" }, { status: 400 });
}
status = data.status;
}
await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end, status);
resp = { success: true }; resp = { success: true };
} else if (action == "cloneMonitor") { } else if (action == "cloneMonitor") {
resp = await CloneMonitor({ resp = await CloneMonitor({
@@ -245,10 +253,16 @@ export async function POST({ request, cookies }) {
} else if (action == "getMonitoringDataPaginated") { } else if (action == "getMonitoringDataPaginated") {
const page = parseInt(String(data.page)) || 1; const page = parseInt(String(data.page)) || 1;
const limit = parseInt(String(data.limit)) || 50; const limit = parseInt(String(data.limit)) || 50;
const filter: { monitor_tag?: string; start_time?: number; end_time?: number } = {}; const filter: { monitor_tag?: string; status?: MonitoringStatus; start_time?: number; end_time?: number } = {};
if (data.monitor_tag && data.monitor_tag !== "ALL") { if (data.monitor_tag && data.monitor_tag !== "ALL") {
filter.monitor_tag = data.monitor_tag; filter.monitor_tag = data.monitor_tag;
} }
if (data.status !== undefined && data.status !== null && data.status !== "ALL") {
if (!isMonitoringStatus(data.status)) {
return json({ error: "Invalid monitoring status" }, { status: 400 });
}
filter.status = data.status;
}
if (data.start_time) { if (data.start_time) {
filter.start_time = parseInt(String(data.start_time)); filter.start_time = parseInt(String(data.start_time));
} }
@@ -28,7 +28,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { format, formatDistanceToNow, isPast, isFuture, isWithinInterval, addDays } from "date-fns"; import { format, formatDistanceToNow, isPast, isFuture, isWithinInterval } from "date-fns";
import { rrulestr } from "rrule"; import { rrulestr } from "rrule";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js"; import clientResolver from "$lib/client/resolver.js";
@@ -193,9 +193,16 @@
const dtstart = new Date(startDateTimeLocal); const dtstart = new Date(startDateTimeLocal);
const fullRrule = `DTSTART:${dtstart.toISOString().replace(/[-:]/g, "").split(".")[0]}Z\nRRULE:${customRrule}`; const fullRrule = `DTSTART:${dtstart.toISOString().replace(/[-:]/g, "").split(".")[0]}Z\nRRULE:${customRrule}`;
const rule = rrulestr(fullRrule); const rule = rrulestr(fullRrule);
const now = new Date(); const occurrences: Date[] = [];
const windowEnd = addDays(now, 30); let searchFrom = new Date();
const occurrences = rule.between(now, windowEnd, true).slice(0, 5);
for (let i = 0; i < 5; i++) {
const next = rule.after(searchFrom, i === 0);
if (!next) break;
occurrences.push(next);
searchFrom = next;
}
return occurrences.map((d) => format(d, "EEE, MMM d, yyyy 'at' h:mm a")); return occurrences.map((d) => format(d, "EEE, MMM d, yyyy 'at' h:mm a"));
} catch (e) { } catch (e) {
return []; return [];
@@ -980,4 +987,4 @@
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -19,6 +19,8 @@
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js"; import clientResolver from "$lib/client/resolver.js";
import GC, { isMonitoringStatus } from "$lib/global-constants";
import type { MonitoringStatus } from "$lib/types/status.js";
// Types // Types
interface MonitoringData { interface MonitoringData {
@@ -35,6 +37,8 @@
name: string; name: string;
} }
type StatusFilter = "ALL" | MonitoringStatus;
// Helper to format datetime as YYYY-MM-DDTHH:mm for datetime-local input // Helper to format datetime as YYYY-MM-DDTHH:mm for datetime-local input
function formatDateTimeForInput(date: Date): string { function formatDateTimeForInput(date: Date): string {
return format(date, "yyyy-MM-dd'T'HH:mm"); return format(date, "yyyy-MM-dd'T'HH:mm");
@@ -68,12 +72,14 @@
let pageNo = $state(1); let pageNo = $state(1);
let showFilters = $state(false); let showFilters = $state(false);
let monitorTagFilter = $state("ALL"); let monitorTagFilter = $state("ALL");
let statusFilter = $state<StatusFilter>("ALL");
let startDateTime = $state(formatDateTimeForInput(yesterday)); let startDateTime = $state(formatDateTimeForInput(yesterday));
let endDateTime = $state(formatDateTimeForInput(now)); let endDateTime = $state(formatDateTimeForInput(now));
const limit = 50; const limit = 50;
const hasActiveFilters = $derived( const hasActiveFilters = $derived(
monitorTagFilter !== "ALL" || monitorTagFilter !== "ALL" ||
statusFilter !== "ALL" ||
startDateTime !== formatDateTimeForInput(yesterday) || startDateTime !== formatDateTimeForInput(yesterday) ||
endDateTime !== formatDateTimeForInput(now) endDateTime !== formatDateTimeForInput(now)
); );
@@ -113,6 +119,7 @@
function clearFilters() { function clearFilters() {
monitorTagFilter = "ALL"; monitorTagFilter = "ALL";
statusFilter = "ALL";
startDateTime = formatDateTimeForInput(yesterday); startDateTime = formatDateTimeForInput(yesterday);
endDateTime = formatDateTimeForInput(now); endDateTime = formatDateTimeForInput(now);
pageNo = 1; pageNo = 1;
@@ -139,6 +146,7 @@
action: "deleteMonitorData", action: "deleteMonitorData",
data: { data: {
tag: monitorTagFilter === "ALL" ? "" : monitorTagFilter, tag: monitorTagFilter === "ALL" ? "" : monitorTagFilter,
status: statusFilter === "ALL" ? undefined : statusFilter,
start: startTs, start: startTs,
end: endTs end: endTs
} }
@@ -189,12 +197,14 @@
page: number; page: number;
limit: number; limit: number;
monitor_tag: string; monitor_tag: string;
status: StatusFilter;
start_time?: number; start_time?: number;
end_time?: number; end_time?: number;
} = { } = {
page: pageNo, page: pageNo,
limit, limit,
monitor_tag: monitorTagFilter monitor_tag: monitorTagFilter,
status: statusFilter
}; };
if (startDateTime) { if (startDateTime) {
@@ -241,6 +251,12 @@
} }
} }
function handleStatusChange(value: string | undefined) {
if (value === "ALL" || isMonitoringStatus(value)) {
statusFilter = value;
}
}
// Pagination // Pagination
function goToPage(page: number) { function goToPage(page: number) {
pageNo = page; pageNo = page;
@@ -304,6 +320,20 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</div> </div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs font-medium">Status</span>
<Select.Root type="single" value={statusFilter} onValueChange={handleStatusChange}>
<Select.Trigger class="w-36" aria-label="Status">
{statusFilter === "ALL" ? "All Statuses" : statusFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="ALL">All Statuses</Select.Item>
<Select.Item value={GC.UP}>UP</Select.Item>
<Select.Item value={GC.DOWN}>DOWN</Select.Item>
<Select.Item value={GC.DEGRADED}>DEGRADED</Select.Item>
</Select.Content>
</Select.Root>
</div>
<Button size="sm" onclick={applyFilters}> <Button size="sm" onclick={applyFilters}>
<SearchIcon class="size-4" /> <SearchIcon class="size-4" />
Search Search
@@ -437,11 +467,16 @@
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete Monitoring Data</AlertDialog.Title> <AlertDialog.Title>Delete Monitoring Data</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
This will delete monitoring data for
{#if monitorTagFilter === "ALL"} {#if monitorTagFilter === "ALL"}
This will delete monitoring data for <strong>all monitors</strong> from {startDateTime} to {endDateTime}. <strong>all monitors</strong>
{:else} {:else}
This will delete monitoring data for <strong>{monitorTagFilter}</strong> from {startDateTime} to {endDateTime}. <strong>{monitorTagFilter}</strong>
{/if} {/if}
{#if statusFilter !== "ALL"}
with status <strong>{statusFilter}</strong>
{/if}
from {startDateTime} to {endDateTime}.
This action cannot be undone. This action cannot be undone.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
+38
View File
@@ -1,8 +1,11 @@
/// <reference types="vitest/config" />
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import version from "vite-plugin-package-version"; import version from "vite-plugin-package-version";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import devtoolsJson from "vite-plugin-devtools-json"; import devtoolsJson from "vite-plugin-devtools-json";
import { playwright } from "@vitest/browser-playwright";
import { configDefaults } from "vitest/config";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
@@ -56,5 +59,40 @@ export default defineConfig(({ mode }) => {
__KENER_BUILD_ENV__: JSON.stringify(buildEnv), __KENER_BUILD_ENV__: JSON.stringify(buildEnv),
__KENER_IS_PROD__: JSON.stringify(isProduction), __KENER_IS_PROD__: JSON.stringify(isProduction),
}, },
test: {
projects: [
{
extends: true,
test: {
name: "server",
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
exclude: [...configDefaults.exclude, "src/**/*.svelte.{test,spec}.{js,ts}"],
},
},
{
extends: true,
// Pre-bundle the component-test dependency graph: on a cold Vite cache
// (every CI runner) a mid-run dep-optimization reload can flake the suite.
optimizeDeps: {
include: ["layerchart", "mode-watcher", "bits-ui", "d3-scale", "d3-shape"],
},
test: {
name: "client",
browser: {
enabled: true,
headless: true,
provider: playwright({ contextOptions: { timezoneId: "UTC" } }),
instances: [{ browser: "chromium" }],
// Vitest's default browser API port (63315) can fall inside Windows
// Hyper-V excluded TCP port ranges; pin below the ephemeral range.
api: { port: 5180 },
},
include: ["src/**/*.svelte.{test,spec}.{js,ts}"],
setupFiles: ["./vitest-setup-client.ts"],
},
},
],
},
}; };
}); });
+27
View File
@@ -0,0 +1,27 @@
import { vi } from "vitest";
// Shared stubs for SvelteKit runtime modules used by components under test.
// Values are minimal but realistic; individual tests can override with their
// own vi.mock(...) when they need different page data.
vi.mock("$app/state", () => ({
page: {
data: {
siteStatusColors: { UP: "#22c55e", DOWN: "#ef4444", DEGRADED: "#eab308", MAINTENANCE: "#3b82f6" },
dateAndTimeFormat: { dateOnly: "yyyy-MM-dd", timeOnly: "HH:mm" },
nowAtTz: 1768478400, // 2026-01-15T12:00:00Z
},
url: new URL("http://localhost/"),
params: {},
route: { id: null },
status: 200,
error: null,
form: null,
state: {},
},
}));
vi.mock("$app/paths", () => ({
base: "",
assets: "",
resolve: (path: string) => path,
}));