From 456ecc298b6bcc9385217831b61980d2f1704dd0 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Thu, 15 Jan 2026 16:50:28 +1030 Subject: [PATCH] feat(api): add health check and OpenAPI docs - Implemented health check endpoint to monitor application status and components. - Added OpenAPI specification endpoint to serve the API documentation. - Introduced new TypeScript definitions for API paths and components. --- deno.json | 7 +- deno.lock | 129 ++++++++- docs/api/v1/openapi.yaml | 31 +++ docs/api/v1/paths/system.yaml | 35 +++ docs/api/v1/schemas/common.yaml | 15 + docs/api/v1/schemas/health.yaml | 156 +++++++++++ package-lock.json | 317 ++++++++++++++++++++++ package.json | 3 + src/lib/api/v1.d.ts | 200 ++++++++++++++ src/routes/api/v1/health/+server.ts | 312 +++++++++++++++++++++ src/routes/api/v1/openapi.json/+server.ts | 16 ++ svelte.config.js | 1 + tsconfig.json | 1 + 13 files changed, 1219 insertions(+), 4 deletions(-) create mode 100644 docs/api/v1/openapi.yaml create mode 100644 docs/api/v1/paths/system.yaml create mode 100644 docs/api/v1/schemas/common.yaml create mode 100644 docs/api/v1/schemas/health.yaml create mode 100644 src/lib/api/v1.d.ts create mode 100644 src/routes/api/v1/health/+server.ts create mode 100644 src/routes/api/v1/openapi.json/+server.ts diff --git a/deno.json b/deno.json index 62a6881..7133cb5 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "imports": { "$lib/": "./src/lib/", + "$api/": "./src/lib/api/", "$config": "./src/lib/server/utils/config/config.ts", "$logger/": "./src/lib/server/utils/logger/", "$shared/": "./src/lib/shared/", @@ -22,7 +23,8 @@ "marked": "npm:marked@^15.0.6", "simple-icons": "npm:simple-icons@^15.17.0", "highlight.js": "npm:highlight.js@^11.11.1", - "croner": "npm:croner@^8.1.2" + "croner": "npm:croner@^8.1.2", + "@std/yaml": "jsr:@std/yaml@^1.0.10" }, "tasks": { "dev": "DENO_ENV=development PORT=6969 HOST=0.0.0.0 APP_BASE_PATH=./dist/dev PARSER_HOST=localhost PARSER_PORT=5000 deno run -A npm:vite dev", @@ -32,7 +34,8 @@ "format": "prettier --write .", "lint": "prettier --check . && eslint .", "test": "APP_BASE_PATH=./dist/test deno test src/tests --allow-read --allow-write --allow-env", - "test:watch": "APP_BASE_PATH=./dist/test deno test src/tests --allow-read --allow-write --allow-env --watch" + "test:watch": "APP_BASE_PATH=./dist/test deno test src/tests --allow-read --allow-write --allow-env --watch", + "generate:api-types": "npx openapi-typescript docs/api/v1/openapi.yaml -o src/lib/api/v1.d.ts" }, "compilerOptions": { "lib": ["deno.window", "dom"], diff --git a/deno.lock b/deno.lock index cbf3a0b..41d8247 100644 --- a/deno.lock +++ b/deno.lock @@ -17,6 +17,7 @@ "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.2", "jsr:@std/path@^1.1.1": "1.1.2", + "jsr:@std/yaml@^1.0.10": "1.0.10", "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", "npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.39.1", "npm:@eslint/js@^9.36.0": "9.39.1", @@ -36,6 +37,7 @@ "npm:kysely@~0.27.2": "0.27.6", "npm:lucide-svelte@0.546": "0.546.0_svelte@5.43.3__acorn@8.15.0", "npm:marked@^15.0.6": "15.0.12", + "npm:openapi-typescript@7": "7.10.1_typescript@5.9.3", "npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.43.3__acorn@8.15.0", "npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.43.3___acorn@8.15.0_svelte@5.43.3__acorn@8.15.0", "npm:prettier@^3.6.2": "3.6.2", @@ -107,9 +109,23 @@ "dependencies": [ "jsr:@std/internal@^1.0.10" ] + }, + "@std/yaml@1.0.10": { + "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" } }, "npm": { + "@babel/code-frame@7.28.6": { + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens", + "picocolors" + ] + }, + "@babel/helper-validator-identifier@7.28.5": { + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" + }, "@deno/vite-plugin@1.0.5_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { "integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==", "dependencies": [ @@ -564,6 +580,32 @@ "@polka/url@1.0.0-next.29": { "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" }, + "@redocly/ajv@8.17.1": { + "integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==", + "dependencies": [ + "fast-deep-equal", + "fast-uri", + "json-schema-traverse@1.0.0", + "require-from-string" + ] + }, + "@redocly/config@0.22.2": { + "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==" + }, + "@redocly/openapi-core@1.34.6": { + "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", + "dependencies": [ + "@redocly/ajv", + "@redocly/config", + "colorette", + "https-proxy-agent", + "js-levenshtein", + "js-yaml", + "minimatch@5.1.6", + "pluralize", + "yaml-ast-parser" + ] + }, "@rollup/rollup-android-arm-eabi@4.52.5": { "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "os": ["android"], @@ -955,15 +997,21 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "bin": true }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, "ajv@6.12.6": { "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dependencies": [ "fast-deep-equal", "fast-json-stable-stringify", - "json-schema-traverse", + "json-schema-traverse@0.4.1", "uri-js" ] }, + "ansi-colors@4.1.3": { + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" + }, "ansi-styles@4.3.0": { "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": [ @@ -1008,9 +1056,12 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ "ansi-styles", - "supports-color" + "supports-color@7.2.0" ] }, + "change-case@5.4.4": { + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==" + }, "chokidar@4.0.3": { "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dependencies": [ @@ -1029,6 +1080,9 @@ "color-name@1.1.4": { "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "colorette@1.4.0": { + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==" + }, "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, @@ -1277,6 +1331,9 @@ "fast-levenshtein@2.0.6": { "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "fast-uri@3.1.0": { + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" + }, "fastq@1.19.1": { "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dependencies": [ @@ -1356,6 +1413,13 @@ "highlight.js@11.11.1": { "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==" }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, @@ -1372,6 +1436,9 @@ "imurmurhash@0.1.4": { "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" }, + "index-to-position@1.2.0": { + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==" + }, "is-extglob@2.1.1": { "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" }, @@ -1397,6 +1464,12 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "bin": true }, + "js-levenshtein@1.1.6": { + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==" + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "js-yaml@4.1.0": { "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dependencies": [ @@ -1410,6 +1483,9 @@ "json-schema-traverse@0.4.1": { "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "json-schema-traverse@1.0.0": { + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "json-stable-stringify-without-jsonify@1.0.1": { "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, @@ -1560,6 +1636,12 @@ "brace-expansion@1.1.12" ] }, + "minimatch@5.1.6": { + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": [ + "brace-expansion@2.0.2" + ] + }, "minimatch@9.0.5": { "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": [ @@ -1582,6 +1664,19 @@ "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "openapi-typescript@7.10.1_typescript@5.9.3": { + "integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==", + "dependencies": [ + "@redocly/openapi-core", + "ansi-colors", + "change-case", + "parse-json", + "supports-color@10.2.2", + "typescript", + "yargs-parser" + ], + "bin": true + }, "optionator@0.9.4": { "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dependencies": [ @@ -1611,6 +1706,14 @@ "callsites" ] }, + "parse-json@8.3.0": { + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dependencies": [ + "@babel/code-frame", + "index-to-position", + "type-fest" + ] + }, "path-exists@4.0.0": { "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, @@ -1626,6 +1729,9 @@ "picomatch@4.0.3": { "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" }, + "pluralize@8.0.0": { + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==" + }, "postcss-load-config@3.1.4_postcss@8.5.6": { "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", "dependencies": [ @@ -1697,6 +1803,9 @@ "readdirp@4.1.2": { "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" }, + "require-from-string@2.0.2": { + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, @@ -1780,6 +1889,9 @@ "strip-json-comments@3.1.1": { "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "supports-color@10.2.2": { + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==" + }, "supports-color@7.2.0": { "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": [ @@ -1874,6 +1986,9 @@ "prelude-ls" ] }, + "type-fest@4.41.0": { + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" + }, "typescript-eslint@8.46.3_eslint@9.39.1_typescript@5.9.3_@typescript-eslint+parser@8.46.3__eslint@9.39.1__typescript@5.9.3": { "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", "dependencies": [ @@ -1939,9 +2054,15 @@ "word-wrap@1.2.5": { "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" }, + "yaml-ast-parser@0.0.43": { + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==" + }, "yaml@1.10.2": { "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, + "yargs-parser@21.1.1": { + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, @@ -1953,6 +2074,7 @@ "dependencies": [ "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "jsr:@std/assert@1", + "jsr:@std/yaml@^1.0.10", "npm:croner@^8.1.2", "npm:highlight.js@^11.11.1", "npm:marked@^15.0.6", @@ -1968,7 +2090,9 @@ "npm:@sveltejs/vite-plugin-svelte@^6.2.0", "npm:@tailwindcss/forms@~0.5.10", "npm:@tailwindcss/vite@^4.1.13", + "npm:@types/deno@^2.5.0", "npm:@types/node@22", + "npm:croner@^9.1.0", "npm:eslint-config-prettier@^10.1.8", "npm:eslint-plugin-svelte@^3.12.4", "npm:eslint@^9.36.0", @@ -1977,6 +2101,7 @@ "npm:kysely@0.27.6", "npm:lucide-svelte@0.546", "npm:marked@^15.0.6", + "npm:openapi-typescript@7", "npm:prettier-plugin-svelte@^3.4.0", "npm:prettier-plugin-tailwindcss@~0.6.14", "npm:prettier@^3.6.2", diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml new file mode 100644 index 0000000..52d3685 --- /dev/null +++ b/docs/api/v1/openapi.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: Profilarr API + description: API for Profilarr - Profile management and sync for *arr applications + version: 1.0.0 + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: /api/v1 + description: API v1 + +tags: + - name: System + description: System health and status endpoints + +paths: + /health: + $ref: './paths/system.yaml#/health' + /openapi.json: + $ref: './paths/system.yaml#/openapi' + +components: + schemas: + ComponentStatus: + $ref: './schemas/common.yaml#/ComponentStatus' + HealthStatus: + $ref: './schemas/common.yaml#/HealthStatus' + HealthResponse: + $ref: './schemas/health.yaml#/HealthResponse' diff --git a/docs/api/v1/paths/system.yaml b/docs/api/v1/paths/system.yaml new file mode 100644 index 0000000..fe3b0a9 --- /dev/null +++ b/docs/api/v1/paths/system.yaml @@ -0,0 +1,35 @@ +health: + get: + operationId: getHealth + summary: Health check + description: | + Returns the health status of the application and its components. + + Status values: + - `healthy`: All components functioning normally + - `degraded`: Core functionality works but some components have issues + - `unhealthy`: Core functionality is broken + tags: + - System + responses: + '200': + description: Health check response + content: + application/json: + schema: + $ref: '../schemas/health.yaml#/HealthResponse' + +openapi: + get: + operationId: getOpenApiSpec + summary: OpenAPI specification + description: Returns the OpenAPI specification for this API + tags: + - System + responses: + '200': + description: OpenAPI specification + content: + application/json: + schema: + type: object diff --git a/docs/api/v1/schemas/common.yaml b/docs/api/v1/schemas/common.yaml new file mode 100644 index 0000000..fe6cef1 --- /dev/null +++ b/docs/api/v1/schemas/common.yaml @@ -0,0 +1,15 @@ +ComponentStatus: + type: string + enum: + - healthy + - degraded + - unhealthy + description: Individual component health status + +HealthStatus: + type: string + enum: + - healthy + - degraded + - unhealthy + description: Overall health status diff --git a/docs/api/v1/schemas/health.yaml b/docs/api/v1/schemas/health.yaml new file mode 100644 index 0000000..14e55b4 --- /dev/null +++ b/docs/api/v1/schemas/health.yaml @@ -0,0 +1,156 @@ +DatabaseHealth: + type: object + required: + - status + - responseTimeMs + properties: + status: + $ref: './common.yaml#/ComponentStatus' + responseTimeMs: + type: number + description: Database query response time in milliseconds + message: + type: string + description: Error message if unhealthy + +DatabasesHealth: + type: object + required: + - status + - total + - enabled + - cached + - disabled + properties: + status: + $ref: './common.yaml#/ComponentStatus' + total: + type: integer + description: Total number of PCD databases configured + enabled: + type: integer + description: Number of enabled databases + cached: + type: integer + description: Number of databases with compiled cache + disabled: + type: integer + description: Number of disabled databases (compilation errors) + message: + type: string + description: Additional status information + +JobsHealth: + type: object + required: + - status + properties: + status: + $ref: './common.yaml#/ComponentStatus' + lastRun: + type: object + additionalProperties: + type: string + format: date-time + nullable: true + description: Last run time for each job + message: + type: string + description: Additional status information + +BackupsHealth: + type: object + required: + - status + - enabled + properties: + status: + $ref: './common.yaml#/ComponentStatus' + enabled: + type: boolean + description: Whether backups are enabled + lastBackup: + type: string + format: date-time + nullable: true + description: Timestamp of last backup + count: + type: integer + description: Number of backup files + totalSizeBytes: + type: integer + description: Total size of all backups in bytes + retentionDays: + type: integer + description: Configured retention period in days + message: + type: string + description: Additional status information + +LogsHealth: + type: object + required: + - status + properties: + status: + $ref: './common.yaml#/ComponentStatus' + totalSizeBytes: + type: integer + description: Total size of log files in bytes + fileCount: + type: integer + description: Number of log files + oldestLog: + type: string + format: date + nullable: true + description: Date of oldest log file + newestLog: + type: string + format: date + nullable: true + description: Date of newest log file + message: + type: string + description: Additional status information + +HealthResponse: + type: object + required: + - status + - timestamp + - version + - uptime + - components + properties: + status: + $ref: './common.yaml#/HealthStatus' + timestamp: + type: string + format: date-time + description: Current server time + version: + type: string + description: Application version + uptime: + type: integer + description: Server uptime in seconds + components: + type: object + required: + - database + - databases + - jobs + - backups + - logs + properties: + database: + $ref: '#/DatabaseHealth' + databases: + $ref: '#/DatabasesHealth' + jobs: + $ref: '#/JobsHealth' + backups: + $ref: '#/BackupsHealth' + logs: + $ref: '#/LogsHealth' diff --git a/package-lock.json b/package-lock.json index 7ba1372..6db0f53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@deno/vite-plugin": "^1.0.5", "@jsr/db__sqlite": "^0.12.0", + "croner": "^9.1.0", "highlight.js": "^11.11.1", "kysely": "0.27.6", "lucide-svelte": "^0.546.0", @@ -24,11 +25,13 @@ "@sveltejs/vite-plugin-svelte": "^6.2.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/vite": "^4.1.13", + "@types/deno": "^2.5.0", "@types/node": "^22", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", "globals": "^16.4.0", + "openapi-typescript": "^7.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.6.14", @@ -40,6 +43,31 @@ "vite": "^7.1.7" } }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@deno/vite-plugin": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@deno/vite-plugin/-/vite-plugin-1.0.6.tgz", @@ -865,6 +893,82 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", + "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.6", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz", + "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.22.0", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.5", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", @@ -1534,6 +1638,13 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/deno": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@types/deno/-/deno-2.5.0.tgz", + "integrity": "sha512-g8JS38vmc0S87jKsFzre+0ZyMOUDHPVokEJymSCRlL57h6f/FdKPWBXgdFh3Z8Ees9sz11qt9VWELU9Y9ZkiVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1840,6 +1951,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1857,6 +1978,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1956,6 +2087,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2001,6 +2139,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2017,6 +2162,15 @@ "node": ">= 0.6" } }, + "node_modules/croner": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", + "integrity": "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2432,6 +2586,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2596,6 +2767,20 @@ "node": ">=12.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2633,6 +2818,19 @@ "node": ">=0.8.19" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2692,6 +2890,23 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -3203,6 +3418,40 @@ "dev": true, "license": "MIT" }, + "node_modules/openapi-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.1.tgz", + "integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.5", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3266,6 +3515,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3305,6 +3572,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3613,6 +3890,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3999,6 +4286,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4623,6 +4923,23 @@ "node": ">=0.10.0" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index bcbf0ab..e6b6c53 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@deno/vite-plugin": "^1.0.5", "@jsr/db__sqlite": "^0.12.0", + "croner": "^9.1.0", "highlight.js": "^11.11.1", "kysely": "0.27.6", "lucide-svelte": "^0.546.0", @@ -20,11 +21,13 @@ "@sveltejs/vite-plugin-svelte": "^6.2.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/vite": "^4.1.13", + "@types/deno": "^2.5.0", "@types/node": "^22", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", "globals": "^16.4.0", + "openapi-typescript": "^7.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/src/lib/api/v1.d.ts b/src/lib/api/v1.d.ts new file mode 100644 index 0000000..4fcaeff --- /dev/null +++ b/src/lib/api/v1.d.ts @@ -0,0 +1,200 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health check + * @description Returns the health status of the application and its components. + * + * Status values: + * - `healthy`: All components functioning normally + * - `degraded`: Core functionality works but some components have issues + * - `unhealthy`: Core functionality is broken + */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * OpenAPI specification + * @description Returns the OpenAPI specification for this API + */ + get: operations["getOpenApiSpec"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description Individual component health status + * @enum {string} + */ + ComponentStatus: "healthy" | "degraded" | "unhealthy"; + /** + * @description Overall health status + * @enum {string} + */ + HealthStatus: "healthy" | "degraded" | "unhealthy"; + HealthResponse: { + status: components["schemas"]["HealthStatus"]; + /** + * Format: date-time + * @description Current server time + */ + timestamp: string; + /** @description Application version */ + version: string; + /** @description Server uptime in seconds */ + uptime: number; + components: { + database: components["schemas"]["DatabaseHealth"]; + databases: components["schemas"]["DatabasesHealth"]; + jobs: components["schemas"]["JobsHealth"]; + backups: components["schemas"]["BackupsHealth"]; + logs: components["schemas"]["LogsHealth"]; + }; + }; + DatabaseHealth: { + status: components["schemas"]["ComponentStatus"]; + /** @description Database query response time in milliseconds */ + responseTimeMs: number; + /** @description Error message if unhealthy */ + message?: string; + }; + DatabasesHealth: { + status: components["schemas"]["ComponentStatus"]; + /** @description Total number of PCD databases configured */ + total: number; + /** @description Number of enabled databases */ + enabled: number; + /** @description Number of databases with compiled cache */ + cached: number; + /** @description Number of disabled databases (compilation errors) */ + disabled: number; + /** @description Additional status information */ + message?: string; + }; + JobsHealth: { + status: components["schemas"]["ComponentStatus"]; + /** @description Last run time for each job */ + lastRun?: { + [key: string]: string | null; + }; + /** @description Additional status information */ + message?: string; + }; + BackupsHealth: { + status: components["schemas"]["ComponentStatus"]; + /** @description Whether backups are enabled */ + enabled: boolean; + /** + * Format: date-time + * @description Timestamp of last backup + */ + lastBackup?: string | null; + /** @description Number of backup files */ + count?: number; + /** @description Total size of all backups in bytes */ + totalSizeBytes?: number; + /** @description Configured retention period in days */ + retentionDays?: number; + /** @description Additional status information */ + message?: string; + }; + LogsHealth: { + status: components["schemas"]["ComponentStatus"]; + /** @description Total size of log files in bytes */ + totalSizeBytes?: number; + /** @description Number of log files */ + fileCount?: number; + /** + * Format: date + * @description Date of oldest log file + */ + oldestLog?: string | null; + /** + * Format: date + * @description Date of newest log file + */ + newestLog?: string | null; + /** @description Additional status information */ + message?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Health check response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + getOpenApiSpec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OpenAPI specification */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; +} diff --git a/src/routes/api/v1/health/+server.ts b/src/routes/api/v1/health/+server.ts new file mode 100644 index 0000000..3b0a24a --- /dev/null +++ b/src/routes/api/v1/health/+server.ts @@ -0,0 +1,312 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { db } from '$db/db.ts'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import { jobsQueries } from '$db/queries/jobs.ts'; +import { backupSettingsQueries } from '$db/queries/backupSettings.ts'; +import { appInfoQueries } from '$db/queries/appInfo.ts'; +import { getCache } from '$pcd/cache.ts'; +import { config } from '$config'; +import type { components } from '$api/v1.d.ts'; + +type HealthResponse = components['schemas']['HealthResponse']; +type ComponentStatus = components['schemas']['ComponentStatus']; + +// Track startup time for uptime calculation +const startupTime = Date.now(); + +// Thresholds +const LOG_SIZE_WARN_BYTES = 100 * 1024 * 1024; // 100MB +const LOG_SIZE_CRITICAL_BYTES = 500 * 1024 * 1024; // 500MB + +export const GET: RequestHandler = async () => { + const response: HealthResponse = { + status: 'healthy', + timestamp: new Date().toISOString(), + version: appInfoQueries.getVersion(), + uptime: Math.floor((Date.now() - startupTime) / 1000), + components: { + database: checkDatabase(), + databases: checkDatabases(), + jobs: checkJobs(), + backups: await checkBackups(), + logs: await checkLogs() + } + }; + + // Determine overall status + response.status = determineOverallStatus(response.components); + + return json(response); +}; + +function determineOverallStatus(components: HealthResponse['components']): ComponentStatus { + const statuses = [ + components.database.status, + components.databases.status, + components.jobs.status, + components.backups.status, + components.logs.status + ]; + + // If database is unhealthy, everything is unhealthy + if (components.database.status === 'unhealthy') { + return 'unhealthy'; + } + + // If all PCD databases are unhealthy, system is unhealthy + if (components.databases.status === 'unhealthy') { + return 'unhealthy'; + } + + // If any component is degraded, system is degraded + if (statuses.some((s) => s === 'degraded')) { + return 'degraded'; + } + + return 'healthy'; +} + +function checkDatabase(): HealthResponse['components']['database'] { + const start = performance.now(); + + try { + db.queryFirst('SELECT 1'); + const responseTimeMs = Math.round((performance.now() - start) * 100) / 100; + + return { + status: 'healthy', + responseTimeMs + }; + } catch (error) { + return { + status: 'unhealthy', + responseTimeMs: -1, + message: error instanceof Error ? error.message : 'Database query failed' + }; + } +} + +function checkDatabases(): HealthResponse['components']['databases'] { + try { + const allDatabases = databaseInstancesQueries.getAll(); + const enabledDatabases = allDatabases.filter((d) => d.enabled === 1); + const disabledDatabases = allDatabases.filter((d) => d.enabled === 0); + + // Check how many have compiled caches + let cachedCount = 0; + for (const dbInstance of enabledDatabases) { + const cache = getCache(dbInstance.id); + if (cache?.isBuilt()) { + cachedCount++; + } + } + + const total = allDatabases.length; + const enabled = enabledDatabases.length; + const disabled = disabledDatabases.length; + + let status: ComponentStatus = 'healthy'; + let message: string | undefined; + + if (total === 0) { + status = 'healthy'; + message = 'No databases configured'; + } else if (enabled === 0) { + status = 'unhealthy'; + message = 'All databases are disabled'; + } else if (disabled > 0) { + status = 'degraded'; + message = `${disabled} database(s) disabled due to errors`; + } else if (cachedCount < enabled) { + status = 'degraded'; + message = `${enabled - cachedCount} database(s) not cached`; + } + + return { + status, + total, + enabled, + cached: cachedCount, + disabled, + message + }; + } catch (error) { + return { + status: 'unhealthy', + total: 0, + enabled: 0, + cached: 0, + disabled: 0, + message: error instanceof Error ? error.message : 'Failed to check databases' + }; + } +} + +function checkJobs(): HealthResponse['components']['jobs'] { + try { + const jobs = jobsQueries.getAll(); + const lastRun: Record = {}; + + for (const job of jobs) { + lastRun[job.name] = job.last_run_at ?? null; + } + + // Check if sync_arr job is stale (hasn't run in 5+ minutes when it should run every minute) + const syncArrJob = jobs.find((j) => j.name === 'sync_arr'); + let status: ComponentStatus = 'healthy'; + let message: string | undefined; + + if (syncArrJob?.enabled && syncArrJob.last_run_at) { + const lastRunTime = new Date(syncArrJob.last_run_at).getTime(); + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + + if (lastRunTime < fiveMinutesAgo) { + status = 'degraded'; + message = 'sync_arr job is stale'; + } + } + + return { + status, + lastRun, + message + }; + } catch (error) { + return { + status: 'unhealthy', + message: error instanceof Error ? error.message : 'Failed to check jobs' + }; + } +} + +async function checkBackups(): Promise { + try { + const settings = backupSettingsQueries.get(); + const enabled = settings?.enabled === 1; + const retentionDays = settings?.retention_days ?? 30; + + if (!enabled) { + return { + status: 'healthy', + enabled: false, + message: 'Backups disabled' + }; + } + + // Check backup directory + const backupPath = config.paths.backups; + let count = 0; + let totalSizeBytes = 0; + let lastBackup: string | null = null; + let lastBackupTime: number | null = null; + + try { + for await (const entry of Deno.readDir(backupPath)) { + if (entry.isFile && entry.name.startsWith('backup-') && entry.name.endsWith('.tar.gz')) { + count++; + const stat = await Deno.stat(`${backupPath}/${entry.name}`); + totalSizeBytes += stat.size; + + if (!lastBackupTime || stat.mtime!.getTime() > lastBackupTime) { + lastBackupTime = stat.mtime!.getTime(); + lastBackup = stat.mtime!.toISOString(); + } + } + } + } catch { + // Directory might not exist yet + } + + let status: ComponentStatus = 'healthy'; + let message: string | undefined; + + if (count === 0) { + status = 'degraded'; + message = 'No backups exist'; + } else if (lastBackupTime) { + // Check if last backup is older than 2x retention period + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000; + if (lastBackupTime < twoDaysAgo) { + status = 'degraded'; + message = 'Last backup is older than 2 days'; + } + } + + return { + status, + enabled, + lastBackup, + count, + totalSizeBytes, + retentionDays, + message + }; + } catch (error) { + return { + status: 'unhealthy', + enabled: false, + message: error instanceof Error ? error.message : 'Failed to check backups' + }; + } +} + +async function checkLogs(): Promise { + try { + const logPath = config.paths.logs; + let totalSizeBytes = 0; + let fileCount = 0; + let oldestLog: string | null = null; + let newestLog: string | null = null; + + const logDateRegex = /^(\d{4}-\d{2}-\d{2})\.log$/; + + try { + for await (const entry of Deno.readDir(logPath)) { + if (entry.isFile) { + const match = entry.name.match(logDateRegex); + if (match) { + fileCount++; + const stat = await Deno.stat(`${logPath}/${entry.name}`); + totalSizeBytes += stat.size; + + const dateStr = match[1]; + if (!oldestLog || dateStr < oldestLog) { + oldestLog = dateStr; + } + if (!newestLog || dateStr > newestLog) { + newestLog = dateStr; + } + } + } + } + } catch { + // Directory might not exist yet + } + + let status: ComponentStatus = 'healthy'; + let message: string | undefined; + + if (totalSizeBytes > LOG_SIZE_CRITICAL_BYTES) { + status = 'degraded'; + message = `Log directory is very large (${Math.round(totalSizeBytes / 1024 / 1024)}MB)`; + } else if (totalSizeBytes > LOG_SIZE_WARN_BYTES) { + status = 'degraded'; + message = `Log directory is getting large (${Math.round(totalSizeBytes / 1024 / 1024)}MB)`; + } + + return { + status, + totalSizeBytes, + fileCount, + oldestLog, + newestLog, + message + }; + } catch (error) { + return { + status: 'unhealthy', + message: error instanceof Error ? error.message : 'Failed to check logs' + }; + } +} diff --git a/src/routes/api/v1/openapi.json/+server.ts b/src/routes/api/v1/openapi.json/+server.ts new file mode 100644 index 0000000..f2fe72e --- /dev/null +++ b/src/routes/api/v1/openapi.json/+server.ts @@ -0,0 +1,16 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { parse } from '@std/yaml'; + +// Cache the parsed spec to avoid re-reading on every request +let cachedSpec: unknown = null; + +export const GET: RequestHandler = async () => { + if (!cachedSpec) { + // Read and parse the OpenAPI spec + const yamlContent = await Deno.readTextFile('docs/api/v1/openapi.yaml'); + cachedSpec = parse(yamlContent); + } + + return json(cachedSpec); +}; diff --git a/svelte.config.js b/svelte.config.js index 012db57..17038f1 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -12,6 +12,7 @@ const config = { }), outDir: 'dist/.svelte-kit', alias: { + $api: './src/lib/api', $config: './src/lib/server/utils/config/config.ts', $logger: './src/lib/server/utils/logger', $shared: './src/lib/shared', diff --git a/tsconfig.json b/tsconfig.json index cf09d3e..1f93f50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "strict": true, "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], + "types": ["deno"], "allowImportingTsExtensions": true, "noEmit": true }