From d63a46cac63139bab38515b81cc755416f1009ec Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Sat, 29 Aug 2026 20:23:51 +0700 Subject: [PATCH] feat: add mantain config --- bun.lock | 298 ++++++- migrations/0005_pink_trish_tilby.sql | 22 + migrations/meta/0005_snapshot.json | 810 ++++++++++++++++++ migrations/meta/_journal.json | 7 + package.json | 4 + src/client/App.tsx | 23 +- src/client/api/maintenance.ts | 39 + src/client/api/status.ts | 3 +- src/client/components/AppHeader.tsx | 56 +- .../settings/MaintenanceWindowDialog.tsx | 238 +++++ .../settings/MaintenanceWindowsPanel.tsx | 133 +++ src/client/pages/SettingsPage.tsx | 21 +- src/client/pages/StatusPage.tsx | 45 +- src/client/queries/maintenance.ts | 39 + src/client/styles.css | 378 ++++++++ src/components/ui/badge.tsx | 1 + src/worker/ai/incident-context.ts | 2 +- src/worker/checks/persist-result.ts | 7 +- src/worker/checks/run-due-checks.ts | 4 +- src/worker/db/schema.ts | 34 +- src/worker/index.ts | 2 + src/worker/maintenance/windows.ts | 63 ++ src/worker/routes/maintenance.ts | 222 +++++ src/worker/routes/monitors.ts | 18 +- src/worker/routes/status.ts | 19 +- src/worker/scheduled/rollup.ts | 4 +- test/checks.spec.ts | 2 + test/maintenance.spec.ts | 110 +++ test/rollup.spec.ts | 5 + test/status.spec.ts | 27 +- vite.config.ts | 5 +- 31 files changed, 2552 insertions(+), 89 deletions(-) create mode 100644 migrations/0005_pink_trish_tilby.sql create mode 100644 migrations/meta/0005_snapshot.json create mode 100644 src/client/api/maintenance.ts create mode 100644 src/client/components/settings/MaintenanceWindowDialog.tsx create mode 100644 src/client/components/settings/MaintenanceWindowsPanel.tsx create mode 100644 src/client/queries/maintenance.ts create mode 100644 src/worker/maintenance/windows.ts create mode 100644 src/worker/routes/maintenance.ts create mode 100644 test/maintenance.spec.ts diff --git a/bun.lock b/bun.lock index 565e105..b8be17d 100644 --- a/bun.lock +++ b/bun.lock @@ -20,13 +20,17 @@ "tw-animate-css": "^1.4.0", }, "devDependencies": { + "@babel/core": "^8.0.1", "@cloudflare/vite-plugin": "^1.54.1", "@cloudflare/vitest-plugin": "^1.1.1", + "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.3.3", + "@types/babel__core": "^7.20.5", "@types/node": "^26.4.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "@vitejs/plugin-react": "^6.1.0", + "babel-plugin-react-compiler": "^1.0.0", "drizzle-kit": "^0.31.10", "husky": "^9.1.7", "lint-staged": "^17.4.1", @@ -45,21 +49,21 @@ "workerd", ], "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + "@babel/compat-data": ["@babel/compat-data@8.0.0", "", {}, "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw=="], - "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + "@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="], - "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + "@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@8.0.0", "", { "dependencies": { "@babel/compat-data": "^8.0.0", "@babel/helper-validator-option": "^8.0.0", "browserslist": "^4.24.0", "lru-cache": "^11.0.0", "semver": "^7.7.3" } }, "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew=="], "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], - "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + "@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], @@ -75,15 +79,15 @@ "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@8.0.0", "", {}, "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q=="], - "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + "@babel/helpers": ["@babel/helpers@8.0.0", "", { "dependencies": { "@babel/template": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg=="], - "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + "@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], @@ -95,11 +99,11 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + "@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + "@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], - "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], @@ -257,7 +261,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], @@ -465,6 +469,8 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], @@ -515,6 +521,14 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -539,6 +553,10 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="], + + "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], + "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], @@ -585,6 +603,8 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="], @@ -715,6 +735,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], @@ -835,6 +857,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -879,7 +903,7 @@ "jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -929,7 +953,7 @@ "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="], @@ -1093,7 +1117,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -1253,7 +1277,35 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/preset-typescript/@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -1263,10 +1315,6 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -1281,14 +1329,26 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@types/babel__core/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "babel-plugin-react-compiler/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], @@ -1311,15 +1371,19 @@ "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "shadcn/@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shadcn/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -1327,6 +1391,92 @@ "wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1407,6 +1557,26 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@types/babel__generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@types/babel__template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "babel-plugin-react-compiler/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "babel-plugin-react-compiler/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -1462,5 +1632,79 @@ "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "shadcn/@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "shadcn/@babel/core/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@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" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "shadcn/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "shadcn/@babel/core/@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "shadcn/@babel/core/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "shadcn/@babel/core/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "shadcn/@babel/core/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "shadcn/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "shadcn/@babel/parser/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "shadcn/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "shadcn/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "shadcn/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "shadcn/@babel/core/@babel/helper-compilation-targets/@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "shadcn/@babel/core/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "shadcn/@babel/core/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "shadcn/@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "shadcn/@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "shadcn/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "shadcn/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], } } diff --git a/migrations/0005_pink_trish_tilby.sql b/migrations/0005_pink_trish_tilby.sql new file mode 100644 index 0000000..adc993c --- /dev/null +++ b/migrations/0005_pink_trish_tilby.sql @@ -0,0 +1,22 @@ +CREATE TABLE `maintenance_window_monitors` ( + `window_id` integer NOT NULL, + `monitor_id` integer NOT NULL, + PRIMARY KEY(`window_id`, `monitor_id`), + FOREIGN KEY (`window_id`) REFERENCES `maintenance_windows`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `maintenance_window_monitors_monitor_id_idx` ON `maintenance_window_monitors` (`monitor_id`);--> statement-breakpoint +CREATE TABLE `maintenance_windows` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `start_minute` integer NOT NULL, + `duration_minutes` integer NOT NULL, + `timezone` text DEFAULT 'UTC' NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `maintenance_windows_enabled_idx` ON `maintenance_windows` (`enabled`);--> statement-breakpoint +ALTER TABLE `checks` ADD `maintenance` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0005_snapshot.json b/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..3701b1b --- /dev/null +++ b/migrations/meta/0005_snapshot.json @@ -0,0 +1,810 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bf7ffa06-45b1-43d1-ba9a-193681f05bf2", + "prevId": "5c9f981a-cccd-4832-806c-4b156078d34d", + "tables": { + "admin_credentials": { + "name": "admin_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_settings": { + "name": "ai_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checks": { + "name": "checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "maintenance": { + "name": "maintenance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "checks_monitor_id_checked_at_idx": { + "name": "checks_monitor_id_checked_at_idx", + "columns": [ + "monitor_id", + "checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checks_monitor_id_monitors_id_fk": { + "name": "checks_monitor_id_monitors_id_fk", + "tableFrom": "checks", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incidents": { + "name": "incidents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_status_code": { + "name": "start_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_error": { + "name": "start_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_message": { + "name": "ai_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "incidents_monitor_id_started_at_idx": { + "name": "incidents_monitor_id_started_at_idx", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "incidents_monitor_id_monitors_id_fk": { + "name": "incidents_monitor_id_monitors_id_fk", + "tableFrom": "incidents", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_attempts": { + "name": "login_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_attempted_at_idx": { + "name": "login_attempts_ip_attempted_at_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_window_monitors": { + "name": "maintenance_window_monitors", + "columns": { + "window_id": { + "name": "window_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_window_monitors_monitor_id_idx": { + "name": "maintenance_window_monitors_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_window_monitors_window_id_maintenance_windows_id_fk": { + "name": "maintenance_window_monitors_window_id_maintenance_windows_id_fk", + "tableFrom": "maintenance_window_monitors", + "tableTo": "maintenance_windows", + "columnsFrom": [ + "window_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_window_monitors_monitor_id_monitors_id_fk": { + "name": "maintenance_window_monitors_monitor_id_monitors_id_fk", + "tableFrom": "maintenance_window_monitors", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_window_monitors_window_id_monitor_id_pk": { + "columns": [ + "window_id", + "monitor_id" + ], + "name": "maintenance_window_monitors_window_id_monitor_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_windows": { + "name": "maintenance_windows", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_minute": { + "name": "start_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_windows_enabled_idx": { + "name": "maintenance_windows_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_daily_stats": { + "name": "monitor_daily_stats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_checks": { + "name": "total_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "up_checks": { + "name": "up_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avg_latency_ms": { + "name": "avg_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_latency_ms": { + "name": "min_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_latency_ms": { + "name": "max_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_daily_stats_monitor_id_day_uidx": { + "name": "monitor_daily_stats_monitor_id_day_uidx", + "columns": [ + "monitor_id", + "day" + ], + "isUnique": true + } + }, + "foreignKeys": { + "monitor_daily_stats_monitor_id_monitors_id_fk": { + "name": "monitor_daily_stats_monitor_id_monitors_id_fk", + "tableFrom": "monitor_daily_stats", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitors": { + "name": "monitors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "expected_status": { + "name": "expected_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 200 + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10000 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "alerts_enabled": { + "name": "alerts_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_ok": { + "name": "last_ok", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status_code": { + "name": "last_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_latency_ms": { + "name": "last_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitors_enabled_last_checked_at_idx": { + "name": "monitors_enabled_last_checked_at_idx", + "columns": [ + "enabled", + "last_checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index c3e75f1..02bbc5a 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787997596170, "tag": "0004_smiling_jack_flag", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1788007515959, + "tag": "0005_pink_trish_tilby", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index ceb2cc0..5b4acd7 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,17 @@ "prepare": "husky" }, "devDependencies": { + "@babel/core": "^8.0.1", "@cloudflare/vite-plugin": "^1.54.1", "@cloudflare/vitest-plugin": "^1.1.1", + "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.3.3", + "@types/babel__core": "^7.20.5", "@types/node": "^26.4.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "@vitejs/plugin-react": "^6.1.0", + "babel-plugin-react-compiler": "^1.0.0", "drizzle-kit": "^0.31.10", "husky": "^9.1.7", "lint-staged": "^17.4.1", diff --git a/src/client/App.tsx b/src/client/App.tsx index a4a1ea0..f038ce4 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,11 +1,20 @@ -import { TooltipProvider } from '@/components/ui/tooltip'; +import { lazy, Suspense } from 'react'; import { RequireAuth } from './components/RequireAuth'; import { usePathname } from './lib/router'; -import { DashboardPage } from './pages/DashboardPage'; -import { LoginPage } from './pages/LoginPage'; -import { MonitorDetailPage } from './pages/MonitorDetailPage'; -import { SettingsPage } from './pages/SettingsPage'; -import { StatusPage } from './pages/StatusPage'; + +const DashboardPage = lazy(() => import('./pages/DashboardPage').then((module) => ({ default: module.DashboardPage }))); +const LoginPage = lazy(() => import('./pages/LoginPage').then((module) => ({ default: module.LoginPage }))); +const MonitorDetailPage = lazy(() => import('./pages/MonitorDetailPage').then((module) => ({ default: module.MonitorDetailPage }))); +const SettingsPage = lazy(() => import('./pages/SettingsPage').then((module) => ({ default: module.SettingsPage }))); +const StatusPage = lazy(() => import('./pages/StatusPage').then((module) => ({ default: module.StatusPage }))); + +function PageFallback() { + return ( +
+

Loading…

+
+ ); +} function App() { const pathname = usePathname(); @@ -24,7 +33,7 @@ function App() { content = {page}; } - return {content}; + return }>{content}; } export default App; diff --git a/src/client/api/maintenance.ts b/src/client/api/maintenance.ts new file mode 100644 index 0000000..b35b8f7 --- /dev/null +++ b/src/client/api/maintenance.ts @@ -0,0 +1,39 @@ +import { deleteJson, getJson, patchJson, postJson } from './http'; + +export type MaintenanceWindow = { + id: number; + name: string; + startMinute: number; + durationMinutes: number; + timezone: string; + enabled: boolean; + monitorIds: number[]; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type MaintenanceWindowInput = { + name: string; + startMinute: number; + durationMinutes: number; + timezone: string; + enabled: boolean; + monitorIds: number[]; +}; + +export function getMaintenanceWindows(signal?: AbortSignal) { + return getJson<{ windows: MaintenanceWindow[] }>('/api/maintenance', { signal, credentials: 'same-origin' }); +} + +export function createMaintenanceWindow(input: MaintenanceWindowInput) { + return postJson<{ window: MaintenanceWindow }>('/api/maintenance', input); +} + +export function updateMaintenanceWindow(id: number, input: Partial) { + return patchJson<{ window: MaintenanceWindow }>(`/api/maintenance/${id}`, input); +} + +export function deleteMaintenanceWindow(id: number) { + return deleteJson<{ ok: true }>(`/api/maintenance/${id}`); +} diff --git a/src/client/api/status.ts b/src/client/api/status.ts index cd2a84d..24171b9 100644 --- a/src/client/api/status.ts +++ b/src/client/api/status.ts @@ -1,6 +1,6 @@ import { getJson } from './http'; -export type PublicServiceStatus = 'up' | 'down' | 'unknown'; +export type PublicServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance'; export type PublicOverallStatus = 'operational' | 'degraded' | 'down'; export type PublicService = { @@ -8,6 +8,7 @@ export type PublicService = { name: string; status: PublicServiceStatus; message: string | null; + maintenance: { name: string; endsAt: string } | null; lastCheckedAt: string | null; uptime90d: number | null; history: Array<{ diff --git a/src/client/components/AppHeader.tsx b/src/client/components/AppHeader.tsx index 1cddec7..dc968b3 100644 --- a/src/client/components/AppHeader.tsx +++ b/src/client/components/AppHeader.tsx @@ -1,7 +1,7 @@ import { Activity, LogOut, Menu, Settings, Zap, type LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { navigate, usePathname } from '../lib/router'; import { useLogoutMutation } from '../queries/auth'; @@ -51,33 +51,35 @@ export function AppHeader({ context }: { context?: string }) { upwatch - +
diff --git a/src/client/components/settings/MaintenanceWindowDialog.tsx b/src/client/components/settings/MaintenanceWindowDialog.tsx new file mode 100644 index 0000000..23f55d1 --- /dev/null +++ b/src/client/components/settings/MaintenanceWindowDialog.tsx @@ -0,0 +1,238 @@ +import { type FormEvent, useMemo, useState } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { MaintenanceWindow, MaintenanceWindowInput } from '../../api/maintenance'; +import { useCreateMaintenanceWindowMutation, useUpdateMaintenanceWindowMutation } from '../../queries/maintenance'; +import { useMonitorsQuery } from '../../queries/monitors'; + +export function minutesToTime(minutes: number) { + const normalized = ((minutes % 1440) + 1440) % 1440; + return `${String(Math.floor(normalized / 60)).padStart(2, '0')}:${String(normalized % 60).padStart(2, '0')}`; +} + +export function timeToMinutes(value: string) { + const [hour, minute] = value.split(':').map(Number); + return hour * 60 + minute; +} + +function detectedTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +function timezoneOptions() { + const intl = Intl as typeof Intl & { supportedValuesOf?: (key: 'timeZone') => string[] }; + if (intl.supportedValuesOf) return [...new Set(['UTC', detectedTimezone(), ...intl.supportedValuesOf('timeZone')])]; + return [...new Set(['UTC', detectedTimezone()])]; +} + +type FormState = { + name: string; + start: string; + end: string; + timezone: string; + enabled: boolean; + monitorIds: number[]; +}; + +function initialForm(editing: MaintenanceWindow | null): FormState { + if (!editing) { + return { name: '', start: '02:00', end: '03:00', timezone: detectedTimezone(), enabled: true, monitorIds: [] }; + } + return { + name: editing.name, + start: minutesToTime(editing.startMinute), + end: minutesToTime(editing.startMinute + editing.durationMinutes), + timezone: editing.timezone, + enabled: editing.enabled, + monitorIds: editing.monitorIds, + }; +} + +export function MaintenanceWindowDialog({ editing, onClose }: { editing: MaintenanceWindow | null; onClose: () => void }) { + const createMutation = useCreateMaintenanceWindowMutation(); + const updateMutation = useUpdateMaintenanceWindowMutation(); + const monitorsQuery = useMonitorsQuery(); + const [form, setForm] = useState(() => initialForm(editing)); + const [validationError, setValidationError] = useState(null); + const timezones = useMemo(() => timezoneOptions(), []); + const mutation = editing ? updateMutation : createMutation; + + function close() { + if (!mutation.isPending) onClose(); + } + + function submit(event: FormEvent) { + event.preventDefault(); + const startMinute = timeToMinutes(form.start); + const durationMinutes = (timeToMinutes(form.end) - startMinute + 1440) % 1440; + if (durationMinutes === 0) { + setValidationError('Start and end time must differ'); + return; + } + setValidationError(null); + const input: MaintenanceWindowInput = { + name: form.name, + startMinute, + durationMinutes, + timezone: form.timezone, + enabled: form.enabled, + monitorIds: form.monitorIds, + }; + if (editing) updateMutation.mutate({ id: editing.id, input }, { onSuccess: onClose }); + else createMutation.mutate(input, { onSuccess: onClose }); + } + + function toggleMonitor(id: number, checked: boolean) { + setForm((current) => ({ + ...current, + monitorIds: checked ? [...current.monitorIds, id] : current.monitorIds.filter((monitorId) => monitorId !== id), + })); + } + + const monitors = monitorsQuery.data?.monitors ?? []; + const selectedMonitors = monitors.filter((monitor) => form.monitorIds.includes(monitor.id)); + const selectedServicesLabel = + selectedMonitors.length === 0 + ? 'Select services' + : selectedMonitors.length === 1 + ? selectedMonitors[0].name + : `${selectedMonitors[0].name} +${selectedMonitors.length - 1} more`; + + return ( + !open && close()}> + mutation.isPending && event.preventDefault()} + onInteractOutside={(event) => mutation.isPending && event.preventDefault()} + > + +

Daily schedule

+ {editing ? `Edit ${editing.name}` : 'Add maintenance window'} + Probes continue, but alerts and uptime calculations pause for selected services. +
+
+ + + +
+ Timezone + +
+
+ Services + + + + + + + {monitors.map((monitor) => ( + toggleMonitor(monitor.id, checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {monitor.name} + + ))} + + + + {monitors.length === 0 && !monitorsQuery.isPending && ( + Add a monitor before assigning a maintenance window. + )} +
+
+ setForm({ ...form, enabled })} /> + +
+

Checks run every five minutes. Add a few minutes of padding before and after the backup.

+
+ + +
+ {(validationError || mutation.isError) && ( +

+ {validationError ?? mutation.error?.message ?? 'Unable to save maintenance window'} +

+ )} +
+
+
+ ); +} diff --git a/src/client/components/settings/MaintenanceWindowsPanel.tsx b/src/client/components/settings/MaintenanceWindowsPanel.tsx new file mode 100644 index 0000000..852bc21 --- /dev/null +++ b/src/client/components/settings/MaintenanceWindowsPanel.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty'; +import type { MaintenanceWindow } from '../../api/maintenance'; +import { useDeleteMaintenanceWindowMutation, useMaintenanceWindowsQuery } from '../../queries/maintenance'; +import { useMonitorsQuery } from '../../queries/monitors'; +import { MaintenanceWindowDialog, minutesToTime } from './MaintenanceWindowDialog'; + +export function MaintenanceWindowsPanel() { + const windowsQuery = useMaintenanceWindowsQuery(); + const monitorsQuery = useMonitorsQuery(); + const deleteMutation = useDeleteMaintenanceWindowMutation(); + const [dialog, setDialog] = useState<{ open: boolean; editing: MaintenanceWindow | null }>({ open: false, editing: null }); + const [deleting, setDeleting] = useState(null); + const monitorNames = new Map(monitorsQuery.data?.monitors.map((monitor) => [monitor.id, monitor.name])); + + function confirmDelete() { + if (!deleting) return; + deleteMutation.mutate(deleting.id, { onSuccess: () => setDeleting(null) }); + } + + return ( + <> +
+

Define daily quiet periods for backups or planned service work.

+ +
+ {windowsQuery.isPending ? ( +
Loading maintenance windows…
+ ) : windowsQuery.isError ? ( + + Unable to load maintenance windows + + ) : windowsQuery.data.windows.length === 0 ? ( + + No maintenance windows + Add a recurring window to keep planned downtime out of alerts and uptime. + + ) : ( +
+ {windowsQuery.data.windows.map((window) => ( +
+
+
+ {window.name} + {window.active && Active now} + {!window.enabled && Disabled} +
+

+ {minutesToTime(window.startMinute)}–{minutesToTime(window.startMinute + window.durationMinutes)}{' '} + Daily · {window.timezone} +

+
+ {window.monitorIds.length ? ( + window.monitorIds.map((id) => {monitorNames.get(id) ?? `Service ${id}`}) + ) : ( + No services assigned + )} +
+
+
+ + +
+
+ ))} +
+ )} + + {dialog.open && setDialog({ open: false, editing: null })} />} + !open && !deleteMutation.isPending && setDeleting(null)}> + deleteMutation.isPending && event.preventDefault()}> + +

Confirm

+ Delete {deleting?.name}? + This removes the recurring schedule. Existing check history is unchanged. +
+ + + + + + + + +
+
+ + ); +} diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index cef8ec2..edec721 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import { type FormEvent, useState } from 'react'; -import { ArrowLeft, BellRing, Send, Sparkles } from 'lucide-react'; +import { ArrowLeft, BellRing, Send, Sparkles, Wrench } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Empty, EmptyTitle } from '@/components/ui/empty'; @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import type { AiSettings, NotificationSettings } from '../api/settings'; import { AppHeader } from '../components/AppHeader'; +import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel'; import { navigate } from '../lib/router'; import { useAiSettingsQuery, @@ -158,8 +159,8 @@ export function SettingsPage() {

Integrations

-

Notifications & AI

-

Configure incident alerts and visitor-friendly status updates from one place.

+

Notifications, AI & maintenance

+

Configure incident alerts, visitor-friendly updates, and planned downtime from one place.

@@ -207,6 +208,20 @@ export function SettingsPage() { )}
+ +
+
+ + + +
+

Maintenance windows

+

Keep probing during planned work while suppressing alerts and excluding those checks from uptime.

+
+
+ +
+

Payload preview

{`{
diff --git a/src/client/pages/StatusPage.tsx b/src/client/pages/StatusPage.tsx
index 0594a08..a816035 100644
--- a/src/client/pages/StatusPage.tsx
+++ b/src/client/pages/StatusPage.tsx
@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
-import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
+import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Wrench, Zap } from 'lucide-react';
 import { Badge, type BadgeVariant } from '@/components/ui/badge';
 import { Button } from '@/components/ui/button';
 import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
@@ -29,8 +29,11 @@ const SERVICE_STATUS: Record service.message) ?? [];
+	const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? [];
 
 	useEffect(() => {
 		const timer = window.setInterval(() => setNow(Date.now()), 5_000);
@@ -126,6 +130,43 @@ export function StatusPage() {
 							
 						
+ {maintenanceServices.length > 0 && ( +
+
+
+ + +
+

Scheduled maintenance

+

Planned service work is in progress

+
+
+ + {maintenanceServices.length} {maintenanceServices.length === 1 ? 'service' : 'services'} + +
+
+ {maintenanceServices.map((service) => ( +
+
+ + + +
+ {service.name} + {service.maintenance?.name} +
+
+ + until {maintenanceTime.format(new Date(service.maintenance!.endsAt))} + +
+ ))} +
+
+ )} + {activeIncidents.length > 0 && (
@@ -184,7 +225,7 @@ export function StatusPage() {
{status.services.length === 0 ? ( - + diff --git a/src/client/queries/maintenance.ts b/src/client/queries/maintenance.ts new file mode 100644 index 0000000..893abd1 --- /dev/null +++ b/src/client/queries/maintenance.ts @@ -0,0 +1,39 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + createMaintenanceWindow, + deleteMaintenanceWindow, + getMaintenanceWindows, + updateMaintenanceWindow, + type MaintenanceWindowInput, +} from '../api/maintenance'; +import { queryClient } from '../lib/query-client'; + +export const maintenanceKeys = { all: ['maintenance'] as const }; + +export function useMaintenanceWindowsQuery() { + return useQuery({ + queryKey: maintenanceKeys.all, + queryFn: ({ signal }) => getMaintenanceWindows(signal), + refetchInterval: 60_000, + refetchIntervalInBackground: false, + }); +} + +function invalidateMaintenance() { + return queryClient.invalidateQueries({ queryKey: maintenanceKeys.all }); +} + +export function useCreateMaintenanceWindowMutation() { + return useMutation({ mutationFn: (input: MaintenanceWindowInput) => createMaintenanceWindow(input), onSuccess: invalidateMaintenance }); +} + +export function useUpdateMaintenanceWindowMutation() { + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: Partial }) => updateMaintenanceWindow(id, input), + onSuccess: invalidateMaintenance, + }); +} + +export function useDeleteMaintenanceWindowMutation() { + return useMutation({ mutationFn: (id: number) => deleteMaintenanceWindow(id), onSuccess: invalidateMaintenance }); +} diff --git a/src/client/styles.css b/src/client/styles.css index 815d210..09e51db 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1393,6 +1393,232 @@ button { color: #16754f; background: #f2fbf7; } +.maintenance-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px 26px; + border-bottom: 1px solid #ededed; +} +.maintenance-panel-header p { + margin: 0; + font-size: 13px; + color: var(--muted); +} +.maintenance-panel-header svg, +.maintenance-window-actions svg { + width: 15px; + height: 15px; +} +.maintenance-empty { + padding: 44px 24px; +} +.maintenance-window-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 21px 26px; +} +.maintenance-window-row + .maintenance-window-row { + border-top: 1px solid #ededed; +} +.maintenance-window-main { + min-width: 0; +} +.maintenance-window-title { + display: flex; + align-items: center; + gap: 10px; +} +.maintenance-window-title > strong { + font-size: 14px; + font-weight: 600; +} +.maintenance-disabled { + font-size: 11px; + color: var(--faint); +} +.maintenance-window-main > p { + margin: 7px 0 10px; + font: + 500 12px/1.4 'IBM Plex Mono', + monospace; + color: #444; +} +.maintenance-window-main > p span { + color: var(--faint); +} +.maintenance-window-services { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.maintenance-window-services span { + padding: 4px 7px; + border: 1px solid #dedede; + border-radius: 5px; + font-size: 11px; + color: var(--muted); + background: #fafafa; +} +.maintenance-window-services em { + font-size: 11px; + font-style: normal; + color: var(--faint); +} +.maintenance-window-actions { + display: flex; + flex: 0 0 auto; + gap: 6px; +} +.danger-icon-button { + color: #a74545; +} +.maintenance-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px 16px; + margin-top: 12px; +} +.maintenance-name, +.maintenance-timezone, +.maintenance-services, +.maintenance-enabled, +.maintenance-helper, +.maintenance-actions { + grid-column: 1 / -1; +} +.maintenance-services { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.maintenance-services legend { + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + color: #353535; +} +.maintenance-service-select { + display: flex; + width: 100%; + min-height: 42px; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 12px; + border: 1px solid #dedede; + border-radius: 6px; + font-size: 14px; + text-align: left; + color: var(--ink); + background: #fff; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025); + cursor: pointer; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} +.maintenance-service-select:hover { + border-color: #aaa; +} +.maintenance-service-select:focus-visible, +.maintenance-service-select[data-state='open'] { + border-color: var(--primary-deep); + outline: none; + box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); +} +.maintenance-service-select:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.maintenance-service-select .is-placeholder { + color: var(--faint); +} +.maintenance-service-select-meta { + display: flex; + align-items: center; + gap: 9px; + color: var(--faint); +} +.maintenance-service-select-meta small { + font: + 500 10px/1.3 'IBM Plex Mono', + monospace; + white-space: nowrap; +} +.maintenance-service-select-meta svg { + width: 16px; + height: 16px; + transition: transform 120ms ease; +} +.maintenance-service-select[data-state='open'] .maintenance-service-select-meta svg { + transform: rotate(180deg); +} +.maintenance-service-menu { + z-index: 70; + width: var(--radix-dropdown-menu-trigger-width); + max-height: min(256px, var(--radix-dropdown-menu-content-available-height)); + overflow-y: auto; + padding: 4px; + border: 1px solid var(--hairline); + border-radius: 8px; + background: #fff; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.08); +} +.maintenance-service-option { + position: relative; + display: flex; + align-items: center; + gap: 10px; + min-height: 36px; + padding: 7px 9px; + border-radius: 6px; + font-size: 13px; + color: var(--ink); + cursor: pointer; + outline: none; + user-select: none; +} +.maintenance-service-option:focus, +.maintenance-service-option[data-highlighted] { + background: #f2f5f3; +} +.maintenance-service-check { + display: grid; + width: 17px; + height: 17px; + place-items: center; + flex: 0 0 auto; + border: 1px solid #cfcfcf; + border-radius: 4px; + color: #fff; + background: #fff; +} +.maintenance-service-option[data-state='checked'] .maintenance-service-check { + border-color: var(--primary-deep); + background: var(--primary-deep); +} +.maintenance-service-check svg { + width: 12px; + height: 12px; + stroke-width: 2.5; +} +.maintenance-services-empty { + display: block; + margin-top: 7px; + font-size: 11px; + color: var(--muted); +} +.maintenance-helper { + margin: -6px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--muted); +} .payload-preview { margin-top: 18px; padding: 24px 26px; @@ -1540,6 +1766,126 @@ button { box-shadow: 0 14px 40px rgb(117 38 38 / 0.06); animation: enter 500ms 45ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.scheduled-maintenance { + margin-top: 18px; + overflow: hidden; + border: 1px solid #c9d9ed; + border-radius: 10px; + background: #fff; + box-shadow: 0 14px 40px rgb(49 93 151 / 0.06); + animation: enter 500ms 45ms cubic-bezier(0.16, 1, 0.3, 1) both; +} +.scheduled-maintenance-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 92px; + padding: 18px 22px; + border-bottom: 1px solid #dce7f4; + background: linear-gradient(110deg, #f2f7fd 0%, #f8fbff 72%, #fff 100%); +} +.scheduled-maintenance-heading, +.scheduled-maintenance-service { + display: flex; + align-items: center; + gap: 14px; +} +.scheduled-maintenance-icon { + display: grid; + width: 40px; + height: 40px; + place-items: center; + flex: 0 0 auto; + border: 1px solid rgb(63 111 184 / 0.2); + border-radius: 8px; + color: #3f6fb8; + background: rgb(255 255 255 / 0.82); +} +.scheduled-maintenance-icon svg { + width: 18px; + height: 18px; +} +.scheduled-maintenance-heading p { + margin: 0 0 3px; + font: + 500 9px/1.3 'IBM Plex Mono', + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5079b3; +} +.scheduled-maintenance-heading h2 { + margin: 0; + font-size: 17px; + font-weight: 600; + letter-spacing: -0.25px; + color: #29496f; +} +.scheduled-maintenance-count { + padding: 5px 8px; + border: 1px solid #cad9eb; + border-radius: 5px; + font: + 500 9px/1.3 'IBM Plex Mono', + monospace; + color: #466b9c; + background: rgb(255 255 255 / 0.72); +} +.scheduled-maintenance-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 88px; + padding: 18px 22px; +} +.scheduled-maintenance-row + .scheduled-maintenance-row { + border-top: 1px solid #e6edf6; +} +.scheduled-maintenance-service-icon { + display: grid; + flex: 0 0 36px; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid #d8e1ed; + border-radius: 7px; + background: #f8fafd; +} +.scheduled-maintenance-service-icon img { + display: block; + width: 20px; + height: 20px; + object-fit: contain; +} +.scheduled-maintenance-service strong, +.scheduled-maintenance-service div span { + display: block; +} +.scheduled-maintenance-service strong { + font-size: 13px; +} +.scheduled-maintenance-service div span { + margin-top: 4px; + font-size: 12px; + color: var(--muted); +} +.scheduled-maintenance-state { + font: + 500 10px/1.4 'IBM Plex Mono', + monospace; + color: #3f6fb8; + white-space: nowrap; +} +.scheduled-maintenance-state i { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 5px; + border-radius: 50%; + background: #5a8fd0; +} .active-incidents-header { display: flex; align-items: center; @@ -2168,6 +2514,20 @@ button { .settings-form { padding: 20px; } + .maintenance-panel-header, + .maintenance-window-row { + padding-right: 20px; + padding-left: 20px; + } + .maintenance-panel-header, + .maintenance-window-row { + align-items: flex-start; + flex-direction: column; + } + .maintenance-window-actions { + align-self: flex-end; + margin-top: -48px; + } .settings-actions { align-items: stretch; flex-direction: column-reverse; @@ -2196,6 +2556,24 @@ button { gap: 14px; padding: 18px 16px; } + .scheduled-maintenance-header { + align-items: flex-start; + flex-direction: column; + gap: 14px; + padding: 18px 16px; + } + .scheduled-maintenance-count { + margin-left: 54px; + } + .scheduled-maintenance-row { + align-items: flex-start; + flex-direction: column; + gap: 14px; + padding: 18px 16px; + } + .scheduled-maintenance-state { + margin-left: 50px; + } .active-incidents-count { margin-left: 54px; } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index a0f77e1..c0cbee8 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -9,6 +9,7 @@ const badgeVariants = cva('inline-flex items-center gap-1.75 text-[12px] font-me online: 'text-[#16885b] [&>i]:bg-(--primary-deep) [&>i]:shadow-[0_0_0_3px_rgb(62_207_142/0.12)]', offline: 'text-[#ae3d3d] [&>i]:bg-[#d05a5a]', checking: 'text-[#8b7722] [&>i]:animate-[blink_1.1s_ease-in-out_infinite] [&>i]:bg-[#d7bd53]', + maintenance: 'text-[#3f6fb8] [&>i]:bg-[#5a8fd0]', }, }, defaultVariants: { diff --git a/src/worker/ai/incident-context.ts b/src/worker/ai/incident-context.ts index f21a905..58b161c 100644 --- a/src/worker/ai/incident-context.ts +++ b/src/worker/ai/incident-context.ts @@ -109,7 +109,7 @@ export async function buildIncidentContext(db: Database, monitor: Monitor, resul const recent = await db .select({ ok: checks.ok, latencyMs: checks.latencyMs, checkedAt: checks.checkedAt }) .from(checks) - .where(and(eq(checks.monitorId, monitor.id), gte(checks.checkedAt, new Date(now - RECENT_WINDOW_MS)))) + .where(and(eq(checks.monitorId, monitor.id), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(now - RECENT_WINDOW_MS)))) .orderBy(desc(checks.checkedAt)) .limit(RECENT_LIMIT); diff --git a/src/worker/checks/persist-result.ts b/src/worker/checks/persist-result.ts index 7aa9361..e2e26e4 100644 --- a/src/worker/checks/persist-result.ts +++ b/src/worker/checks/persist-result.ts @@ -7,7 +7,7 @@ export type IncidentTransition = 'opened' | 'resolved' | null; type BatchStatement = Parameters[0][number]; -export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date) { +export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date, maintenance = false) { const statements: BatchStatement[] = [ db.insert(checks).values({ monitorId: monitor.id, @@ -16,11 +16,12 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch latencyMs: result.latencyMs, error: result.error, checkedAt, + maintenance, }), db .update(monitors) .set({ - lastOk: result.ok, + ...(maintenance ? {} : { lastOk: result.ok }), lastStatusCode: result.statusCode, lastLatencyMs: result.latencyMs, lastError: result.error, @@ -30,6 +31,8 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch .where(eq(monitors.id, monitor.id)), ]; + if (maintenance) return { statements, transition: null }; + let transition: IncidentTransition = null; if (monitor.lastOk !== false && !result.ok) { statements.push( diff --git a/src/worker/checks/run-due-checks.ts b/src/worker/checks/run-due-checks.ts index 666f78c..ab16f96 100644 --- a/src/worker/checks/run-due-checks.ts +++ b/src/worker/checks/run-due-checks.ts @@ -2,6 +2,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { generateIncidentMessage } from '../ai/incident-message'; import { getDb } from '../db/client'; import { monitors } from '../db/schema'; +import { loadActiveMaintenance } from '../maintenance/windows'; import { sendIncidentAlert } from '../notifications/webhook'; import { buildResultStatements } from './persist-result'; import { runCheck } from './run-check'; @@ -32,6 +33,7 @@ export async function runDueChecks(env: Env, ctx?: Pick item.statements); diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts index 53854cf..8c4495a 100644 --- a/src/worker/db/schema.ts +++ b/src/worker/db/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; export const adminCredentials = sqliteTable('admin_credentials', { id: integer('id').primaryKey(), @@ -69,6 +69,37 @@ export const monitors = sqliteTable( (table) => [index('monitors_enabled_last_checked_at_idx').on(table.enabled, table.lastCheckedAt)], ); +export const maintenanceWindows = sqliteTable( + 'maintenance_windows', + { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + startMinute: integer('start_minute').notNull(), + durationMinutes: integer('duration_minutes').notNull(), + timezone: text('timezone').notNull().default('UTC'), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + }, + (table) => [index('maintenance_windows_enabled_idx').on(table.enabled)], +); + +export const maintenanceWindowMonitors = sqliteTable( + 'maintenance_window_monitors', + { + windowId: integer('window_id') + .notNull() + .references(() => maintenanceWindows.id, { onDelete: 'cascade' }), + monitorId: integer('monitor_id') + .notNull() + .references(() => monitors.id, { onDelete: 'cascade' }), + }, + (table) => [ + primaryKey({ columns: [table.windowId, table.monitorId] }), + index('maintenance_window_monitors_monitor_id_idx').on(table.monitorId), + ], +); + export const checks = sqliteTable( 'checks', { @@ -81,6 +112,7 @@ export const checks = sqliteTable( latencyMs: integer('latency_ms'), error: text('error'), checkedAt: integer('checked_at', { mode: 'timestamp_ms' }).notNull(), + maintenance: integer('maintenance', { mode: 'boolean' }).notNull().default(false), }, (table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)], ); diff --git a/src/worker/index.ts b/src/worker/index.ts index 23a110e..11b1b3d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { csrf } from 'hono/csrf'; import { runDueChecks } from './checks/run-due-checks'; import authRoutes from './routes/auth'; +import maintenanceRoutes from './routes/maintenance'; import monitorRoutes from './routes/monitors'; import settingsRoutes from './routes/settings'; import statusRoutes from './routes/status'; @@ -25,6 +26,7 @@ app.get('/api/health', async (context) => { }); app.route('/', authRoutes); +app.route('/api/maintenance', maintenanceRoutes); app.route('/api/monitors', monitorRoutes); app.route('/api/settings', settingsRoutes); app.route('/api/status', statusRoutes); diff --git a/src/worker/maintenance/windows.ts b/src/worker/maintenance/windows.ts new file mode 100644 index 0000000..a3ae3ae --- /dev/null +++ b/src/worker/maintenance/windows.ts @@ -0,0 +1,63 @@ +import { eq } from 'drizzle-orm'; +import type { Database } from '../db/client'; +import { maintenanceWindowMonitors, maintenanceWindows } from '../db/schema'; + +export type MaintenanceWindowRow = typeof maintenanceWindows.$inferSelect; +export type ActiveMaintenance = { windowId: number; name: string; endsAt: Date }; + +const formatterCache = new Map(); + +function formatterFor(timezone: string) { + let formatter = formatterCache.get(timezone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }); + formatterCache.set(timezone, formatter); + } + return formatter; +} + +export function localMinuteOfDay(at: Date, timezone: string): number { + const parts = formatterFor(timezone).formatToParts(at); + const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0); + const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0); + return hour * 60 + minute; +} + +export function isWindowActive(window: MaintenanceWindowRow, at: Date): boolean { + if (!window.enabled) return false; + const minute = localMinuteOfDay(at, window.timezone); + const end = window.startMinute + window.durationMinutes; + if (end <= 1440) return minute >= window.startMinute && minute < end; + return minute >= window.startMinute || minute < end - 1440; +} + +export function windowEndsAt(window: MaintenanceWindowRow, at: Date): Date { + const elapsed = (localMinuteOfDay(at, window.timezone) - window.startMinute + 1440) % 1440; + return new Date(at.getTime() + (window.durationMinutes - elapsed) * 60_000); +} + +export async function loadActiveMaintenance(db: Database, at: Date): Promise> { + const rows = await db + .select({ window: maintenanceWindows, monitorId: maintenanceWindowMonitors.monitorId }) + .from(maintenanceWindows) + .innerJoin(maintenanceWindowMonitors, eq(maintenanceWindowMonitors.windowId, maintenanceWindows.id)) + .where(eq(maintenanceWindows.enabled, true)); + + const active = new Map(); + for (const row of rows) { + if (!isWindowActive(row.window, at)) continue; + const candidate = { + windowId: row.window.id, + name: row.window.name, + endsAt: windowEndsAt(row.window, at), + }; + const current = active.get(row.monitorId); + if (!current || candidate.endsAt > current.endsAt) active.set(row.monitorId, candidate); + } + return active; +} diff --git a/src/worker/routes/maintenance.ts b/src/worker/routes/maintenance.ts new file mode 100644 index 0000000..a383165 --- /dev/null +++ b/src/worker/routes/maintenance.ts @@ -0,0 +1,222 @@ +import { eq, inArray } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { getDb, type Database } from '../db/client'; +import { maintenanceWindowMonitors, maintenanceWindows, monitors } from '../db/schema'; +import { requireAuth, type AuthVariables } from '../lib/require-auth'; +import { isWindowActive } from '../maintenance/windows'; +import { parseInteger } from './monitors'; + +type ParsedMaintenanceWindowInput = { + name?: string; + startMinute?: number; + durationMinutes?: number; + timezone?: string; + enabled?: boolean; + monitorIds?: number[]; +}; + +type ParseResult = { ok: true; value: ParsedMaintenanceWindowInput } | { ok: false; message: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseId(rawId: string) { + const id = Number(rawId); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} + +export function parseMaintenanceWindowInput(body: unknown, partial = false): ParseResult { + if (!isRecord(body)) return { ok: false, message: 'Invalid request body' }; + const value: ParsedMaintenanceWindowInput = {}; + + if (!partial || 'name' in body) { + if (typeof body.name !== 'string' || body.name.trim().length < 1 || body.name.trim().length > 100) { + return { ok: false, message: 'Name must be between 1 and 100 characters' }; + } + value.name = body.name.trim(); + } + + for (const [key, label, minimum, maximum] of [ + ['startMinute', 'startMinute', 0, 1439], + ['durationMinutes', 'durationMinutes', 1, 1440], + ] as const) { + if (!partial || key in body) { + const parsed = parseInteger(body[key], label, minimum, maximum); + if (!parsed.ok) return parsed; + value[key] = parsed.value; + } + } + + if (!partial || 'timezone' in body) { + if (typeof body.timezone !== 'string' || body.timezone.length === 0 || body.timezone.length > 100) { + return { ok: false, message: 'Enter a valid IANA timezone' }; + } + try { + new Intl.DateTimeFormat('en-US', { timeZone: body.timezone }).format(); + } catch { + return { ok: false, message: 'Enter a valid IANA timezone' }; + } + value.timezone = body.timezone; + } + + if ('enabled' in body) { + if (typeof body.enabled !== 'boolean') return { ok: false, message: 'enabled must be a boolean' }; + value.enabled = body.enabled; + } + + if (!partial || 'monitorIds' in body) { + if (!Array.isArray(body.monitorIds) || body.monitorIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) { + return { ok: false, message: 'monitorIds must be an array of positive integers' }; + } + value.monitorIds = [...new Set(body.monitorIds as number[])]; + } + + return { ok: true, value }; +} + +async function allMonitorsExist(db: Database, monitorIds: number[]) { + if (monitorIds.length === 0) return true; + const rows = await db.select({ id: monitors.id }).from(monitors).where(inArray(monitors.id, monitorIds)); + return rows.length === monitorIds.length; +} + +const maintenanceRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); +maintenanceRoutes.use('*', requireAuth); + +maintenanceRoutes.get('/', async (context) => { + const db = getDb(context.env); + const [windows, assignments] = await Promise.all([ + db.select().from(maintenanceWindows).orderBy(maintenanceWindows.createdAt), + db.select().from(maintenanceWindowMonitors), + ]); + const idsByWindow = new Map(); + for (const assignment of assignments) { + const ids = idsByWindow.get(assignment.windowId); + if (ids) ids.push(assignment.monitorId); + else idsByWindow.set(assignment.windowId, [assignment.monitorId]); + } + const now = new Date(); + return context.json({ + windows: windows.map((window) => ({ + ...window, + monitorIds: idsByWindow.get(window.id) ?? [], + active: isWindowActive(window, now), + })), + }); +}); + +maintenanceRoutes.post('/', async (context) => { + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json({ message: 'Invalid request body' }, 400); + } + const parsed = parseMaintenanceWindowInput(body); + if (!parsed.ok) return context.json({ message: parsed.message }, 400); + + const db = getDb(context.env); + const monitorIds = parsed.value.monitorIds!; + if (!(await allMonitorsExist(db, monitorIds))) return context.json({ message: 'One or more monitors do not exist' }, 400); + + const now = new Date(); + const results = await context.env.DB.batch([ + context.env.DB.prepare( + 'INSERT INTO maintenance_windows (name, start_minute, duration_minutes, timezone, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).bind( + parsed.value.name!, + parsed.value.startMinute!, + parsed.value.durationMinutes!, + parsed.value.timezone!, + parsed.value.enabled === false ? 0 : 1, + now.getTime(), + now.getTime(), + ), + ...monitorIds.map((monitorId) => + context.env.DB.prepare( + 'INSERT INTO maintenance_window_monitors (window_id, monitor_id) VALUES ((SELECT max(id) FROM maintenance_windows), ?)', + ).bind(monitorId), + ), + ]); + const id = Number(results[0].meta.last_row_id); + const window = await context.env.DB.prepare('SELECT * FROM maintenance_windows WHERE id = ?').bind(id).first<{ + id: number; + name: string; + start_minute: number; + duration_minutes: number; + timezone: string; + enabled: number; + created_at: number; + updated_at: number; + }>(); + if (!window) return context.json({ message: 'Unable to create maintenance window' }, 500); + const serialized = { + id: window.id, + name: window.name, + startMinute: window.start_minute, + durationMinutes: window.duration_minutes, + timezone: window.timezone, + enabled: window.enabled === 1, + createdAt: new Date(window.created_at), + updatedAt: new Date(window.updated_at), + }; + return context.json({ window: { ...serialized, monitorIds, active: isWindowActive(serialized, new Date()) } }, 201); +}); + +maintenanceRoutes.patch('/:id', async (context) => { + const id = parseId(context.req.param('id')); + if (id === null) return context.json({ message: 'Maintenance window not found' }, 404); + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json({ message: 'Invalid request body' }, 400); + } + const parsed = parseMaintenanceWindowInput(body, true); + if (!parsed.ok) return context.json({ message: parsed.message }, 400); + if (Object.keys(parsed.value).length === 0) return context.json({ message: 'Provide at least one field to update' }, 400); + + const db = getDb(context.env); + const [existing] = await db.select().from(maintenanceWindows).where(eq(maintenanceWindows.id, id)).limit(1); + if (!existing) return context.json({ message: 'Maintenance window not found' }, 404); + if (parsed.value.monitorIds && !(await allMonitorsExist(db, parsed.value.monitorIds))) { + return context.json({ message: 'One or more monitors do not exist' }, 400); + } + + const { monitorIds, ...changes } = parsed.value; + const statements = [ + db + .update(maintenanceWindows) + .set({ ...changes, updatedAt: new Date() }) + .where(eq(maintenanceWindows.id, id)), + ]; + if (monitorIds) { + statements.push(db.delete(maintenanceWindowMonitors).where(eq(maintenanceWindowMonitors.windowId, id))); + statements.push(...monitorIds.map((monitorId) => db.insert(maintenanceWindowMonitors).values({ windowId: id, monitorId }))); + } + await db.batch(statements as [(typeof statements)[number], ...typeof statements]); + const [window] = await db.select().from(maintenanceWindows).where(eq(maintenanceWindows.id, id)).limit(1); + const assignments = await db + .select({ monitorId: maintenanceWindowMonitors.monitorId }) + .from(maintenanceWindowMonitors) + .where(eq(maintenanceWindowMonitors.windowId, id)); + return context.json({ + window: { ...window, monitorIds: assignments.map((row) => row.monitorId), active: isWindowActive(window, new Date()) }, + }); +}); + +maintenanceRoutes.delete('/:id', async (context) => { + const id = parseId(context.req.param('id')); + if (id === null) return context.json({ message: 'Maintenance window not found' }, 404); + const db = getDb(context.env); + const [window] = await db.select({ id: maintenanceWindows.id }).from(maintenanceWindows).where(eq(maintenanceWindows.id, id)).limit(1); + if (!window) return context.json({ message: 'Maintenance window not found' }, 404); + await db.batch([ + db.delete(maintenanceWindowMonitors).where(eq(maintenanceWindowMonitors.windowId, id)), + db.delete(maintenanceWindows).where(eq(maintenanceWindows.id, id)), + ]); + return context.json({ ok: true }); +}); + +export default maintenanceRoutes; diff --git a/src/worker/routes/monitors.ts b/src/worker/routes/monitors.ts index dac88dd..d7ce77c 100644 --- a/src/worker/routes/monitors.ts +++ b/src/worker/routes/monitors.ts @@ -4,8 +4,9 @@ import { generateIncidentMessage } from '../ai/incident-message'; import { buildResultStatements } from '../checks/persist-result'; import { runCheck } from '../checks/run-check'; import { getDb } from '../db/client'; -import { checks, incidents, monitors } from '../db/schema'; +import { checks, incidents, maintenanceWindowMonitors, monitors } from '../db/schema'; import { requireAuth, type AuthVariables } from '../lib/require-auth'; +import { loadActiveMaintenance } from '../maintenance/windows'; import { isSafeRemoteUrl } from '../lib/safe-url'; import { sendIncidentAlert } from '../notifications/webhook'; @@ -214,7 +215,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function parseInteger( +export function parseInteger( value: unknown, name: string, minimum: number, @@ -381,7 +382,7 @@ monitorRoutes.get('/:id/stats', async (context) => { avgLatencyMs: sql`round(avg(${checks.latencyMs}))`, }) .from(checks) - .where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start)))) + .where(and(eq(checks.monitorId, id), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(window.start)))) : await db.select({ totalChecks: sql`coalesce(sum(total_checks), 0)`, upChecks: sql`coalesce(sum(up_checks), 0)`, @@ -393,7 +394,7 @@ monitorRoutes.get('/:id/stats', async (context) => { union all select count(*), coalesce(sum(case when ok = 1 then 1 else 0 end), 0), round(avg(latency_ms)) from checks - where monitor_id = ${id} and checked_at >= ${currentDayMs} + where monitor_id = ${id} and checked_at >= ${currentDayMs} and maintenance = 0 )`); const [incidentAggregate] = await db .select({ count: sql`count(*)` }) @@ -512,7 +513,11 @@ monitorRoutes.delete('/:id', async (context) => { const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1); if (!monitor) return context.json({ message: 'Monitor not found' }, 404); - await db.batch([db.delete(checks).where(eq(checks.monitorId, id)), db.delete(monitors).where(eq(monitors.id, id))]); + await db.batch([ + db.delete(maintenanceWindowMonitors).where(eq(maintenanceWindowMonitors.monitorId, id)), + db.delete(checks).where(eq(checks.monitorId, id)), + db.delete(monitors).where(eq(monitors.id, id)), + ]); return context.json({ ok: true }); }); @@ -526,7 +531,8 @@ monitorRoutes.post('/:id/check', async (context) => { const result = await runCheck(monitor); const checkedAt = new Date(); - const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt); + const activeMaintenance = await loadActiveMaintenance(db, checkedAt); + const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt, activeMaintenance.has(monitor.id)); await db.batch(statements as [(typeof statements)[number], ...typeof statements]); if (transition) { await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt }); diff --git a/src/worker/routes/status.ts b/src/worker/routes/status.ts index 99fc08e..0c0772c 100644 --- a/src/worker/routes/status.ts +++ b/src/worker/routes/status.ts @@ -3,12 +3,13 @@ import { Hono } from 'hono'; import { deterministicIncidentMessage } from '../ai/fallback-message'; import { getDb } from '../db/client'; import { checks, incidents, monitorDailyStats, monitors } from '../db/schema'; +import { loadActiveMaintenance, type ActiveMaintenance } from '../maintenance/windows'; import { resolveFavicon } from './monitors'; const DAY_MS = 24 * 60 * 60 * 1000; const FAVICON_CACHE_SECONDS = 86_400; -type ServiceStatus = 'up' | 'down' | 'unknown'; +type ServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance'; type OverallStatus = 'operational' | 'degraded' | 'down'; type HistoryEntry = { @@ -53,7 +54,7 @@ function overallStatus(statuses: ServiceStatus[]): OverallStatus { let checked = 0; let down = 0; for (const status of statuses) { - if (status === 'unknown') continue; + if (status === 'unknown' || status === 'maintenance') continue; checked += 1; if (status === 'down') down += 1; } @@ -84,9 +85,10 @@ statusRoutes.get('/', async (context) => { let historicalRows: DailyAggregate[] = []; let todayRows: DailyAggregate[] = []; let openIncidentRows: OpenIncident[] = []; + let activeMaintenance = new Map(); if (monitorIds.length > 0) { - [historicalRows, todayRows, openIncidentRows] = await Promise.all([ + [historicalRows, todayRows, openIncidentRows, activeMaintenance] = await Promise.all([ db .select({ monitorId: monitorDailyStats.monitorId, @@ -111,7 +113,7 @@ statusRoutes.get('/', async (context) => { upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`, }) .from(checks) - .where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today)))) + .where(and(inArray(checks.monitorId, monitorIds), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(today)))) .groupBy(checks.monitorId), db .select({ @@ -121,6 +123,7 @@ statusRoutes.get('/', async (context) => { }) .from(incidents) .where(and(inArray(incidents.monitorId, monitorIds), isNull(incidents.resolvedAt))), + loadActiveMaintenance(db, now), ]); } @@ -146,12 +149,16 @@ statusRoutes.get('/', async (context) => { }); const openIncident = openIncidentsByMonitor.get(monitor.id); + const maintenance = activeMaintenance.get(monitor.id); return { id: monitor.id, name: monitor.name, - status: serviceStatus(monitor.lastOk), + status: maintenance ? ('maintenance' as const) : serviceStatus(monitor.lastOk), message: - monitor.lastOk === false ? (openIncident?.aiMessage ?? deterministicIncidentMessage(openIncident?.startStatusCode ?? null)) : null, + !maintenance && monitor.lastOk === false + ? (openIncident?.aiMessage ?? deterministicIncidentMessage(openIncident?.startStatusCode ?? null)) + : null, + maintenance: maintenance ? { name: maintenance.name, endsAt: maintenance.endsAt.toISOString() } : null, lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null, uptime90d: roundUptime(upChecks, totalChecks), history, diff --git a/src/worker/scheduled/rollup.ts b/src/worker/scheduled/rollup.ts index e0cb2f0..612aa85 100644 --- a/src/worker/scheduled/rollup.ts +++ b/src/worker/scheduled/rollup.ts @@ -1,4 +1,4 @@ -import { and, gte, lt, sql } from 'drizzle-orm'; +import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { getDb } from '../db/client'; import { checks, monitorDailyStats } from '../db/schema'; @@ -23,7 +23,7 @@ export async function runDailyRollup(env: Env, now = new Date()): Promise`max(${checks.latencyMs})`, }) .from(checks) - .where(and(gte(checks.checkedAt, dayStart), lt(checks.checkedAt, dayEnd))) + .where(and(gte(checks.checkedAt, dayStart), lt(checks.checkedAt, dayEnd), eq(checks.maintenance, false))) .groupBy(checks.monitorId); if (rows.length > 0) { diff --git a/test/checks.spec.ts b/test/checks.spec.ts index c367ebc..1b01cac 100644 --- a/test/checks.spec.ts +++ b/test/checks.spec.ts @@ -4,6 +4,8 @@ import { runDueChecks } from '../src/worker/checks/run-due-checks'; async function clearMonitoringTables() { await env.DB.batch([ + env.DB.prepare('DELETE FROM maintenance_window_monitors'), + env.DB.prepare('DELETE FROM maintenance_windows'), env.DB.prepare('DELETE FROM checks'), env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), diff --git a/test/maintenance.spec.ts b/test/maintenance.spec.ts new file mode 100644 index 0000000..b1e9d68 --- /dev/null +++ b/test/maintenance.spec.ts @@ -0,0 +1,110 @@ +import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runDueChecks } from '../src/worker/checks/run-due-checks'; +import type { MaintenanceWindowRow } from '../src/worker/maintenance/windows'; +import { isWindowActive, localMinuteOfDay } from '../src/worker/maintenance/windows'; + +function windowRow(overrides: Partial = {}): MaintenanceWindowRow { + return { + id: 1, + name: 'Nightly backup', + startMinute: 120, + durationMinutes: 60, + timezone: 'UTC', + enabled: true, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +async function resetDatabase() { + await env.DB.batch([ + env.DB.prepare('DELETE FROM maintenance_window_monitors'), + env.DB.prepare('DELETE FROM maintenance_windows'), + env.DB.prepare('DELETE FROM checks'), + env.DB.prepare('DELETE FROM incidents'), + env.DB.prepare('DELETE FROM monitor_daily_stats'), + env.DB.prepare('DELETE FROM notification_settings'), + env.DB.prepare('DELETE FROM monitors'), + ]); +} + +async function insertMonitor(lastOk = 1) { + const now = Date.now(); + const result = await env.DB.prepare( + "INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, created_at, updated_at) VALUES ('API', 'https://example.com', 'GET', 200, 300, 10000, 1, 1, ?, ?, ?)", + ) + .bind(lastOk, now, now) + .run(); + return Number(result.meta.last_row_id); +} + +async function insertActiveWindow(monitorId: number, enabled = 1) { + const now = new Date(); + const startMinute = now.getUTCHours() * 60 + now.getUTCMinutes(); + const result = await env.DB.prepare( + "INSERT INTO maintenance_windows (name, start_minute, duration_minutes, timezone, enabled, created_at, updated_at) VALUES ('Nightly backup', ?, 60, 'UTC', ?, ?, ?)", + ) + .bind(startMinute, enabled, now.getTime(), now.getTime()) + .run(); + await env.DB.prepare('INSERT INTO maintenance_window_monitors (window_id, monitor_id) VALUES (?, ?)') + .bind(Number(result.meta.last_row_id), monitorId) + .run(); +} + +describe('maintenance windows', () => { + beforeAll(async () => { + const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + }); + beforeEach(resetDatabase); + afterEach(() => vi.unstubAllGlobals()); + + it('calculates ordinary, overnight, and IANA-timezone windows', () => { + expect(isWindowActive(windowRow(), new Date('2026-08-29T02:30:00Z'))).toBe(true); + expect(isWindowActive(windowRow(), new Date('2026-08-29T03:00:00Z'))).toBe(false); + expect(isWindowActive(windowRow({ startMinute: 23 * 60 + 30 }), new Date('2026-08-29T23:45:00Z'))).toBe(true); + expect(isWindowActive(windowRow({ startMinute: 23 * 60 + 30 }), new Date('2026-08-30T00:15:00Z'))).toBe(true); + expect(isWindowActive(windowRow({ timezone: 'Asia/Ho_Chi_Minh' }), new Date('2026-08-28T19:30:00Z'))).toBe(true); + }); + + it('returns minute zero at local midnight', () => { + expect(localMinuteOfDay(new Date('2026-08-29T00:00:00Z'), 'UTC')).toBe(0); + }); + + it('records failed probes as maintenance without incidents or monitor state changes', async () => { + const id = await insertMonitor(1); + await insertActiveWindow(id); + const fetchMock = vi.fn(async () => new Response(null, { status: 503 })); + vi.stubGlobal('fetch', fetchMock); + + await runDueChecks(env); + const check = await env.DB.prepare('SELECT maintenance, ok FROM checks WHERE monitor_id = ?').bind(id).first(); + const monitor = await env.DB.prepare('SELECT last_ok, last_status_code FROM monitors WHERE id = ?').bind(id).first(); + const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?') + .bind(id) + .first<{ count: number }>(); + expect(check).toMatchObject({ maintenance: 1, ok: 0 }); + expect(monitor).toMatchObject({ last_ok: 1, last_status_code: 503 }); + expect(incident?.count).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('opens an incident when the matching window is disabled', async () => { + const id = await insertMonitor(1); + await insertActiveWindow(id, 0); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 503 })), + ); + + await runDueChecks(env); + const check = await env.DB.prepare('SELECT maintenance FROM checks WHERE monitor_id = ?').bind(id).first(); + const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?') + .bind(id) + .first<{ count: number }>(); + expect(check).toMatchObject({ maintenance: 0 }); + expect(incident?.count).toBe(1); + }); +}); diff --git a/test/rollup.spec.ts b/test/rollup.spec.ts index e73b223..577c7fb 100644 --- a/test/rollup.spec.ts +++ b/test/rollup.spec.ts @@ -9,6 +9,8 @@ describe('daily monitor rollups', () => { }); beforeEach(async () => { await env.DB.batch([ + env.DB.prepare('DELETE FROM maintenance_window_monitors'), + env.DB.prepare('DELETE FROM maintenance_windows'), env.DB.prepare('DELETE FROM monitor_daily_stats'), env.DB.prepare('DELETE FROM checks'), env.DB.prepare('DELETE FROM incidents'), @@ -38,6 +40,9 @@ describe('daily monitor rollups', () => { id, Date.parse('2026-08-27T22:00:00Z'), ), + env.DB.prepare( + 'INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at, maintenance) VALUES (?, 0, 503, 900, ?, 1)', + ).bind(id, Date.parse('2026-08-27T23:00:00Z')), ]); expect(await runDailyRollup(env, now)).toEqual({ day: '2026-08-27', monitors: 1 }); diff --git a/test/status.spec.ts b/test/status.spec.ts index 73f16bf..3c4ee0d 100644 --- a/test/status.spec.ts +++ b/test/status.spec.ts @@ -9,8 +9,9 @@ type PublicStatusResponse = { services: Array<{ id: number; name: string; - status: 'up' | 'down' | 'unknown'; + status: 'up' | 'down' | 'unknown' | 'maintenance'; message: string | null; + maintenance: { name: string; endsAt: string } | null; lastCheckedAt: string | null; uptime90d: number | null; history: Array<{ day: number; uptimePct: number | null }>; @@ -19,6 +20,8 @@ type PublicStatusResponse = { async function resetDatabase() { await env.DB.batch([ + env.DB.prepare('DELETE FROM maintenance_window_monitors'), + env.DB.prepare('DELETE FROM maintenance_windows'), env.DB.prepare('DELETE FROM checks'), env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), @@ -128,6 +131,28 @@ describe('public status API', () => { expect(body.overall).toBe('down'); }); + it('shows active maintenance without degrading overall status', async () => { + const id = await insertMonitor({ name: 'Database', lastOk: false }); + const now = new Date(); + const startMinute = now.getUTCHours() * 60 + now.getUTCMinutes(); + const result = await env.DB.prepare( + "INSERT INTO maintenance_windows (name, start_minute, duration_minutes, timezone, enabled, created_at, updated_at) VALUES ('Nightly backup', ?, 60, 'UTC', 1, ?, ?)", + ) + .bind(startMinute, now.getTime(), now.getTime()) + .run(); + await env.DB.prepare('INSERT INTO maintenance_window_monitors (window_id, monitor_id) VALUES (?, ?)') + .bind(Number(result.meta.last_row_id), id) + .run(); + + const body = await (await statusFetch()).json(); + expect(body.overall).toBe('operational'); + expect(body.services[0]).toMatchObject({ + status: 'maintenance', + message: null, + maintenance: { name: 'Nightly backup' }, + }); + }); + it("combines historical daily rollups and today's checks into 90-day uptime", async () => { const id = await insertMonitor({ name: 'Aggregated', lastOk: true }); const now = new Date(); diff --git a/vite.config.ts b/vite.config.ts index 0e5d743..40dd6c0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,12 @@ import { cloudflare } from '@cloudflare/vite-plugin'; +import babel from '@rolldown/plugin-babel'; import tailwindcss from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; +import react, { reactCompilerPreset } from '@vitejs/plugin-react'; import path from 'node:path'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [react(), tailwindcss(), cloudflare()], + plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss(), cloudflare()], resolve: { alias: { '@': path.resolve(import.meta.dirname, './src'),