diff --git a/bun.lock b/bun.lock index c2d53f2..01ac417 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "typescript": "~5.8.3", "typescript-eslint": "^8.58.1", "vite": "^7.0.4", + "winston": "^3.19.0", }, }, }, @@ -83,6 +84,10 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -309,6 +314,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -389,6 +396,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="], @@ -425,6 +434,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="], @@ -439,6 +450,14 @@ "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], + + "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -463,6 +482,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -501,6 +522,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -509,6 +532,8 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -531,12 +556,16 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -555,6 +584,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "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=="], @@ -589,6 +620,8 @@ "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -605,6 +638,8 @@ "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -651,12 +686,18 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "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=="], @@ -673,20 +714,28 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -707,10 +756,16 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/package.json b/package.json index 098661c..b6170b6 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "prettier": "^3.8.1", "typescript": "~5.8.3", "typescript-eslint": "^8.58.1", - "vite": "^7.0.4" + "vite": "^7.0.4", + "winston": "^3.19.0" } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fdfa076..ddf2cc7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "dialog:default", "core:event:default", "core:event:allow-listen", - "core:event:allow-emit" + "core:event:allow-emit", + "allow-audit-logs" ] } diff --git a/src-tauri/permissions/audit-logs.toml b/src-tauri/permissions/audit-logs.toml new file mode 100644 index 0000000..7b1b20f --- /dev/null +++ b/src-tauri/permissions/audit-logs.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-audit-logs" +description = "List, read, and delete daily NDJSON audit files under app data." +commands.allow = ["audit_list_files", "audit_read_file", "audit_delete_file"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index fa9959f..584027c 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -1,3 +1,4 @@ +use crate::infrastructure::audit_log; use crate::infrastructure::http_server; use crate::modules::bot::{commands, repository, service as bot_service}; use crate::modules::cron::{repository as cron_repository, scheduler as cron_scheduler}; @@ -68,7 +69,7 @@ pub fn run() { } } - let shared_state = AppState::new(path, mcp_path, mcp_src.to_string()); + let (shared_state, audit_rx) = AppState::new(path, mcp_path, mcp_src.to_string()); { let handle = app.handle().clone(); @@ -80,6 +81,11 @@ pub fn run() { app.manage(shared_state.clone()); + let audit_store = shared_state.store_path.clone(); + tauri::async_runtime::spawn(async move { + audit_log::run_audit_writer(audit_store, audit_rx).await; + }); + // Load persisted cron jobs + last-known Telegram chat id before the scheduler spins up, // so a scheduled job can fire on its first tick after restart. { @@ -183,6 +189,9 @@ pub fn run() { commands::disconnect_bot, commands::pick_mcp_filesystem_folder, commands::list_keyword_groups, + commands::audit_list_files, + commands::audit_read_file, + commands::audit_delete_file, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/infrastructure/audit_log.rs b/src-tauri/src/infrastructure/audit_log.rs new file mode 100644 index 0000000..13da4c4 --- /dev/null +++ b/src-tauri/src/infrastructure/audit_log.rs @@ -0,0 +1,243 @@ +//! Daily JSON-lines audit files under `{app_data}/logs/audit-YYYY-MM-DD.log`. +//! `store_path` in `AppState` is the `connection.json` file; logs live next to it +//! (same as `cron.json`, `skills/`, etc.). Each `AppState::emit_log` line is queued +//! to a background writer for ordered, low-overhead appends. + +use chrono::{Duration, Local, NaiveDate}; +use serde::Serialize; +use serde_json::json; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc; + +fn logs_dir(store_path: &Path) -> std::path::PathBuf { + store_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("logs") +} + +const AUDIT_PREFIX: &str = "audit-"; +const AUDIT_SUFFIX: &str = ".log"; + +/// Maximum size for reading an audit file into memory (HTTP / Tauri read paths). +pub const MAX_AUDIT_BYTES: u64 = 5 * 1024 * 1024; + +const RETENTION_DAYS: i64 = 30; + +static AUDIT_APPEND_WARNED: AtomicBool = AtomicBool::new(false); +static AUDIT_PRUNE_WARNED: AtomicBool = AtomicBool::new(false); + +fn warn_append_once(msg: &str) { + if !AUDIT_APPEND_WARNED.swap(true, Ordering::Relaxed) { + log::warn!("{msg}"); + } +} + +fn warn_prune_once(msg: &str) { + if !AUDIT_PRUNE_WARNED.swap(true, Ordering::Relaxed) { + log::warn!("{msg}"); + } +} + +pub fn parse_audit_date(date: &str) -> Option { + NaiveDate::parse_from_str(date, "%Y-%m-%d").ok() +} + +fn audit_file_path(store_path: &Path, date: &str) -> Option { + parse_audit_date(date)?; + let name = format!("{AUDIT_PREFIX}{date}{AUDIT_SUFFIX}"); + Some(logs_dir(store_path).join(name)) +} + +async fn open_audit_append(store_path: &Path, date: &str) -> std::io::Result { + let path = audit_file_path(store_path, date).ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "invalid audit date (expected YYYY-MM-DD)", + ) + })?; + let Some(parent) = path.parent() else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "invalid audit path", + )); + }; + tokio::fs::create_dir_all(parent).await?; + + let mut std_opts = std::fs::OpenOptions::new(); + std_opts.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std_opts.mode(0o600); + } + tokio::fs::OpenOptions::from(std_opts).open(&path).await +} + +async fn prune_old_audit_files(store_path: &Path, max_age_days: i64) -> std::io::Result<()> { + let dir = logs_dir(store_path); + let mut rd = match tokio::fs::read_dir(&dir).await { + Ok(r) => r, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e), + }; + let cutoff = Local::now().date_naive() - Duration::days(max_age_days); + loop { + match rd.next_entry().await { + Ok(Some(ent)) => { + let name = ent.file_name().to_string_lossy().to_string(); + if !name.starts_with(AUDIT_PREFIX) || !name.ends_with(AUDIT_SUFFIX) { + continue; + } + let mid = &name[AUDIT_PREFIX.len()..name.len() - AUDIT_SUFFIX.len()]; + if let Some(d) = parse_audit_date(mid) { + if d < cutoff { + let _ = tokio::fs::remove_file(ent.path()).await; + } + } + } + Ok(None) => break, + Err(e) => return Err(e), + } + } + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct AuditLine { + pub kind: String, + pub message: String, +} + +/// Owns audit file handles and performs all appends in order. Run this on Tauri’s async runtime +/// (`tauri::async_runtime::spawn`), not via `tokio::spawn` from `AppState::new` / `setup`, where no +/// Tokio runtime handle is installed yet. +pub async fn run_audit_writer(store_path: PathBuf, mut rx: mpsc::Receiver) { + let mut cur: Option<(String, tokio::fs::File)> = None; + while let Some(AuditLine { kind, message }) = rx.recv().await { + let date_str = Local::now().format("%Y-%m-%d").to_string(); + let need_open = cur.as_ref().map(|(d, _)| d != &date_str).unwrap_or(true); + if need_open { + if let Some((old_date, _file)) = cur.take() { + if old_date != date_str { + if let Err(e) = prune_old_audit_files(&store_path, RETENTION_DAYS).await { + warn_prune_once(&format!("audit retention: {e}")); + } + } + } + match open_audit_append(&store_path, &date_str).await { + Ok(f) => cur = Some((date_str, f)), + Err(e) => { + warn_append_once(&format!("audit log open: {e}")); + continue; + } + } + } + + let line = json!({ + "timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "kind": kind, + "message": message, + }); + let mut s = line.to_string(); + s.push('\n'); + + let Some((_d, file)) = cur.as_mut() else { + continue; + }; + if let Err(e) = file.write_all(s.as_bytes()).await { + warn_append_once(&format!("audit log write: {e}")); + cur = None; + continue; + } + if let Err(e) = file.flush().await { + warn_append_once(&format!("audit log flush: {e}")); + cur = None; + } + } +} + +/// Map disk / validation errors for Tauri commands (JSON string preserves `ErrorKind` class). +pub fn command_error_from_io(e: std::io::Error) -> String { + let (code, msg) = match e.kind() { + ErrorKind::NotFound => ("not_found", "audit log not found".to_string()), + ErrorKind::InvalidInput => ("bad_request", e.to_string()), + ErrorKind::InvalidData => ("too_large", e.to_string()), + _ => ("io_error", e.to_string()), + }; + serde_json::json!({ "code": code, "message": msg }).to_string() +} + +#[derive(Serialize)] +pub struct AuditFileEntry { + pub date: String, + pub filename: String, + pub size_bytes: u64, +} + +pub async fn list_audit_files(store_path: &Path) -> std::io::Result> { + let dir = logs_dir(store_path); + let mut out = Vec::new(); + let mut rd = match tokio::fs::read_dir(&dir).await { + Ok(r) => r, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e), + }; + + loop { + match rd.next_entry().await { + Ok(Some(ent)) => { + let name = ent.file_name().to_string_lossy().to_string(); + if !name.starts_with(AUDIT_PREFIX) || !name.ends_with(AUDIT_SUFFIX) { + continue; + } + let mid = &name[AUDIT_PREFIX.len()..name.len() - AUDIT_SUFFIX.len()]; + if parse_audit_date(mid).is_none() { + continue; + } + let meta = ent.metadata().await?; + out.push(AuditFileEntry { + date: mid.to_string(), + filename: name, + size_bytes: meta.len(), + }); + } + Ok(None) => break, + Err(e) => return Err(e), + } + } + + out.sort_by(|a, b| b.date.cmp(&a.date)); + Ok(out) +} + +pub async fn read_audit_file(store_path: &Path, date: &str) -> std::io::Result { + let path = audit_file_path(store_path, date).ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "invalid audit date (expected YYYY-MM-DD)", + ) + })?; + let meta = tokio::fs::metadata(&path).await?; + let len = meta.len(); + if len > MAX_AUDIT_BYTES { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + format!("audit log exceeds max size ({MAX_AUDIT_BYTES} bytes; file is {len} bytes)"), + )); + } + tokio::fs::read_to_string(&path).await +} + +pub async fn remove_audit_file(store_path: &Path, date: &str) -> std::io::Result<()> { + let path = audit_file_path(store_path, date).ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "invalid audit date (expected YYYY-MM-DD)", + ) + })?; + tokio::fs::remove_file(&path).await +} diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index 6cef885..cb0e4ab 100644 --- a/src-tauri/src/infrastructure/http_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -1,4 +1,5 @@ use crate::build_info; +use crate::infrastructure::audit_log; use crate::infrastructure::bot_lifecycle; use crate::modules::bot::{agent as bot_agent, repository, service as bot_service}; use crate::modules::cron::{ @@ -13,10 +14,11 @@ use crate::modules::skills::types::{ClawHubPluginSummary, ClawHubSkill, Skill}; use crate::modules::tool_engine::{runtime as te_runtime, service as te_service}; use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata}; use crate::shared::user_settings; -use axum::extract::Query; -use axum::extract::{Path, State}; +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::header::{self, HeaderValue}; use axum::http::StatusCode; -use axum::response::{Json, Sse}; +use axum::response::{Json, Response, Sse}; use axum::routing::{delete, get, post, put}; use axum::Router; use chrono::Utc; @@ -122,6 +124,11 @@ pub async fn start_server(state: AppState) { .route("/v1/connect", delete(handle_disconnect)) .route("/v1/health", get(handle_health)) .route("/v1/logs", get(handle_logs_sse)) + .route("/v1/logs/audit", get(handle_audit_logs_list)) + .route( + "/v1/logs/audit/{date}", + get(handle_audit_log_get).delete(handle_audit_log_delete), + ) .route("/v1/ollama/models", get(handle_ollama_models)) .route("/v1/ollama/model", put(handle_ollama_model_put)) .route("/v1/settings", get(handle_user_settings_get)) @@ -2210,6 +2217,50 @@ async fn handle_cron_test( )) } +async fn handle_audit_logs_list( + State(state): State, +) -> Result>, (StatusCode, Json)> { + audit_log::list_audit_files(&state.store_path) + .await + .map(Json) + .map_err(audit_io_error) +} + +fn audit_io_error(e: std::io::Error) -> (StatusCode, Json) { + let (status, msg) = match e.kind() { + ErrorKind::NotFound => (StatusCode::NOT_FOUND, "audit log not found".to_string()), + ErrorKind::InvalidInput => (StatusCode::BAD_REQUEST, e.to_string()), + ErrorKind::InvalidData => (StatusCode::PAYLOAD_TOO_LARGE, e.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + (status, Json(ErrorResponse { error: msg })) +} + +async fn handle_audit_log_get( + State(state): State, + Path(date): Path, +) -> Result)> { + let body = audit_log::read_audit_file(&state.store_path, &date) + .await + .map_err(audit_io_error)?; + let mut res = Response::new(Body::from(body)); + res.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-ndjson; charset=utf-8"), + ); + Ok(res) +} + +async fn handle_audit_log_delete( + State(state): State, + Path(date): Path, +) -> Result)> { + audit_log::remove_audit_file(&state.store_path, &date) + .await + .map_err(audit_io_error)?; + Ok(StatusCode::NO_CONTENT) +} + async fn handle_logs_sse( State(state): State, ) -> Sse>> { diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 1d1c8d5..b739c54 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -1,3 +1,4 @@ +pub mod audit_log; pub mod bot_lifecycle; pub mod executable_resolve; pub mod http_server; diff --git a/src-tauri/src/modules/bot/commands.rs b/src-tauri/src/modules/bot/commands.rs index 6abe3b2..bc54f44 100644 --- a/src-tauri/src/modules/bot/commands.rs +++ b/src-tauri/src/modules/bot/commands.rs @@ -1,3 +1,4 @@ +use crate::infrastructure::audit_log; use crate::infrastructure::bot_lifecycle; use crate::modules::bot::repository; use crate::modules::keywords::all_keyword_groups; @@ -69,3 +70,35 @@ pub async fn pick_mcp_filesystem_folder() -> Result, String> { pub fn list_keyword_groups() -> Vec<&'static KeywordGroup> { all_keyword_groups() } + +/// List daily audit files on disk (`{store}/logs/audit-*.log`). +#[tauri::command] +pub async fn audit_list_files( + state: tauri::State<'_, AppState>, +) -> Result, String> { + audit_log::list_audit_files(&state.store_path) + .await + .map_err(audit_log::command_error_from_io) +} + +/// Read one day’s NDJSON audit file. +#[tauri::command] +pub async fn audit_read_file( + state: tauri::State<'_, AppState>, + date: String, +) -> Result { + audit_log::read_audit_file(&state.store_path, date.trim()) + .await + .map_err(audit_log::command_error_from_io) +} + +/// Delete one day’s audit file. +#[tauri::command] +pub async fn audit_delete_file( + state: tauri::State<'_, AppState>, + date: String, +) -> Result<(), String> { + audit_log::remove_audit_file(&state.store_path, date.trim()) + .await + .map_err(audit_log::command_error_from_io) +} diff --git a/src-tauri/src/shared/state.rs b/src-tauri/src/shared/state.rs index d137fe5..657ab9d 100644 --- a/src-tauri/src/shared/state.rs +++ b/src-tauri/src/shared/state.rs @@ -104,14 +104,26 @@ pub struct AppState { pub cron_no_chat_warned: Arc, /// Serializes `cron.json` snapshots + disk writes with HTTP / scheduler / MCP callers. pub cron_save_mutex: Arc>, + /// Bounded queue to the background audit NDJSON writer (`audit_log::run_audit_writer`). + pub audit_tx: tokio::sync::mpsc::Sender, } impl AppState { - pub fn new(store_path: PathBuf, mcp_config_path: PathBuf, mcp_config_source: String) -> Self { + /// Returns the app state and the audit writer receiver; spawn `audit_log::run_audit_writer` + /// on `tauri::async_runtime` after construction (see `app::run`). + pub fn new( + store_path: PathBuf, + mcp_config_path: PathBuf, + mcp_config_source: String, + ) -> ( + Self, + tokio::sync::mpsc::Receiver, + ) { let skills_cap = crate::shared::user_settings::load_skills_hint_max_bytes(&store_path); let cron_path = crate::modules::cron::repository::cron_path(&store_path); let (log_tx, _) = tokio::sync::broadcast::channel(256); - Self { + let (audit_tx, audit_rx) = tokio::sync::mpsc::channel(256); + let this = Self { connection: Arc::new(Mutex::new(None)), shutdown_notify: Arc::new(Notify::new()), bot_running: Arc::new(Mutex::new(false)), @@ -136,7 +148,9 @@ impl AppState { last_chat_id: Arc::new(RwLock::new(None)), cron_no_chat_warned: Arc::new(AtomicBool::new(false)), cron_save_mutex: Arc::new(Mutex::new(())), - } + audit_tx, + }; + (this, audit_rx) } /// Snapshot of recently invoked tool names in **insertion order** (oldest @@ -199,5 +213,11 @@ impl AppState { if let Some(handle) = self.app_handle.lock().await.as_ref() { let _ = handle.emit("pengine-log", &entry); } + + let line = crate::infrastructure::audit_log::AuditLine { + kind: kind.to_string(), + message: message.to_string(), + }; + let _ = self.audit_tx.try_send(line); } } diff --git a/src/modules/bot/api/index.ts b/src/modules/bot/api/index.ts index a2a342c..4e6d208 100644 --- a/src/modules/bot/api/index.ts +++ b/src/modules/bot/api/index.ts @@ -1,5 +1,6 @@ -import { PENGINE_API_BASE } from "../../../shared/api/config"; -import type { PengineHealth } from "../types"; +import { invoke } from "@tauri-apps/api/core"; +import { makeTimeoutSignal, PENGINE_API_BASE } from "../../../shared/api/config"; +import type { AuditLogFileInfo, PengineHealth } from "../types"; /** Loopback HTTP API paths (Tauri `connection_server`). */ export const PENGINE = { @@ -8,35 +9,76 @@ export const PENGINE = { logs: `${PENGINE_API_BASE}/v1/logs`, } as const; +export async function auditListFiles(): Promise { + try { + return await invoke("audit_list_files"); + } catch (err) { + console.error("[auditListFiles] invoke failed", err); + return null; + } +} + +export async function auditReadFile(date: string): Promise { + try { + return await invoke("audit_read_file", { date }); + } catch (err) { + console.error("[auditReadFile] invoke failed", { date, err }); + return null; + } +} + +export async function auditDeleteFile(date: string): Promise { + try { + await invoke("audit_delete_file", { date }); + return true; + } catch (err) { + console.error("[auditDeleteFile] invoke failed", { date, err }); + return false; + } +} + /** GET `/v1/health`; JSON on 200, otherwise `null` (offline / error). */ export async function getPengineHealth(timeoutMs: number): Promise { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); try { - const resp = await fetch(PENGINE.health, { signal: AbortSignal.timeout(timeoutMs) }); + const resp = await fetch(PENGINE.health, { signal }); if (!resp.ok) return null; return (await resp.json()) as PengineHealth; } catch { return null; + } finally { + cleanup(); } } export async function postConnect(botToken: string) { - const resp = await fetch(PENGINE.connect, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ bot_token: botToken.trim() }), - signal: AbortSignal.timeout(15_000), - }); - const data = (await resp.json()) as { bot_id?: string; bot_username?: string; error?: string }; - return { ok: resp.ok, data }; + const { signal, cleanup } = makeTimeoutSignal(15_000); + try { + const resp = await fetch(PENGINE.connect, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bot_token: botToken.trim() }), + signal, + }); + const data = (await resp.json()) as { bot_id?: string; bot_username?: string; error?: string }; + return { ok: resp.ok, data }; + } finally { + cleanup(); + } } export async function deleteConnect() { - const resp = await fetch(PENGINE.connect, { - method: "DELETE", - signal: AbortSignal.timeout(5000), - }); - if (!resp.ok) { - const detail = await resp.text().catch(() => ""); - throw new Error(detail || `Disconnect failed (HTTP ${resp.status})`); + const { signal, cleanup } = makeTimeoutSignal(5000); + try { + const resp = await fetch(PENGINE.connect, { + method: "DELETE", + signal, + }); + if (!resp.ok) { + const detail = await resp.text().catch(() => ""); + throw new Error(detail || `Disconnect failed (HTTP ${resp.status})`); + } + } finally { + cleanup(); } } diff --git a/src/modules/bot/components/AuditLogPanel.tsx b/src/modules/bot/components/AuditLogPanel.tsx new file mode 100644 index 0000000..ef7beb0 --- /dev/null +++ b/src/modules/bot/components/AuditLogPanel.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { auditDeleteFile, auditListFiles, auditReadFile } from "../api"; +import type { AuditLogFileInfo } from "../types"; +import { logLineKindClass } from "./logLineKindClass"; + +function inPengineShell(): boolean { + if (typeof window === "undefined") return false; + const w = window as Window & { isTauri?: boolean; __TAURI_INTERNALS__?: object }; + return Boolean(w.__TAURI_INTERNALS__ ?? w.isTauri); +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function parseNdjson(text: string): { timestamp: string; kind: string; message: string }[] { + const rows: { timestamp: string; kind: string; message: string }[] = []; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + try { + const o = JSON.parse(t) as Record; + rows.push({ + timestamp: typeof o.timestamp === "string" ? o.timestamp : "—", + kind: typeof o.kind === "string" ? o.kind : "—", + message: typeof o.message === "string" ? o.message : t, + }); + } catch { + rows.push({ timestamp: "—", kind: "raw", message: t }); + } + } + return rows; +} + +/** Daily audit files on disk: list, read, delete (Tauri only). */ +export function AuditLogPanel() { + const [open, setOpen] = useState(false); + const [files, setFiles] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const [selected, setSelected] = useState(null); + const [lines, setLines] = useState>([]); + const [loadingFile, setLoadingFile] = useState(false); + const [deleting, setDeleting] = useState(null); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const loadList = useCallback(async () => { + setBusy(true); + setError(false); + const rows = await auditListFiles(); + if (rows === null) { + setError(true); + setFiles([]); + } else { + setFiles(rows); + } + setBusy(false); + }, []); + + useEffect(() => { + if (open) void loadList(); + }, [open, loadList]); + + useEffect(() => { + if (!selected) { + setLines([]); + setLoadingFile(false); + return; + } + setLoadingFile(true); + void auditReadFile(selected).then( + (text) => { + if (!isMountedRef.current) return; + setLines(text ? parseNdjson(text) : []); + setLoadingFile(false); + }, + () => { + if (!isMountedRef.current) return; + setLines([]); + setLoadingFile(false); + }, + ); + }, [selected]); + + useEffect(() => { + if (selected && !files.some((f) => f.date === selected)) { + setSelected(null); + } + }, [files, selected]); + + if (!inPengineShell()) { + return null; + } + + return ( +
+ + + {open && ( +
+ {error && ( +

+ Could not load files.{" "} + +

+ )} + + + + {!error && files.length === 0 && !busy && ( +

No audit files yet.

+ )} + + {files.length > 0 && ( +
+
    + {files.map((f) => ( +
  • + + +
  • + ))} +
+ +
+ {!selected &&

Select a date.

} + {selected && loadingFile &&

Loading…

} + {selected && !loadingFile && lines.length === 0 && ( +

Empty or unreadable.

+ )} + {lines.map((row, i) => ( +
+ {row.timestamp} + + {row.kind} + + {row.message} +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/modules/bot/components/TerminalPreview.tsx b/src/modules/bot/components/TerminalPreview.tsx index b42e8b0..d33ceb7 100644 --- a/src/modules/bot/components/TerminalPreview.tsx +++ b/src/modules/bot/components/TerminalPreview.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { PENGINE } from "../api"; +import { logLineKindClass } from "./logLineKindClass"; type LogLine = { timestamp: string; kind: string; message: string }; @@ -9,16 +10,6 @@ const fallbackLines: LogLine[] = [ const SCROLL_NEAR_BOTTOM_PX = 64; -function kindClass(kind: string) { - if (kind === "ok") return "bg-emerald-400/10 text-emerald-300"; - if (kind === "run") return "bg-sky-400/10 text-sky-300"; - if (kind === "tool") return "bg-yellow-400/10 text-yellow-200"; - if (kind === "time") return "bg-fuchsia-400/10 text-fuchsia-200"; - if (kind === "reply") return "bg-violet-400/10 text-violet-300"; - if (kind === "msg") return "bg-cyan-400/10 text-cyan-300"; - return "bg-slate-400/10 text-slate-300"; -} - export function TerminalPreview() { const [lines, setLines] = useState(fallbackLines); const scrollRef = useRef(null); @@ -119,6 +110,9 @@ export function TerminalPreview() {

pengine runtime

+ + live +
[{line.timestamp}] {line.kind} diff --git a/src/modules/bot/components/logLineKindClass.ts b/src/modules/bot/components/logLineKindClass.ts new file mode 100644 index 0000000..ebbc462 --- /dev/null +++ b/src/modules/bot/components/logLineKindClass.ts @@ -0,0 +1,10 @@ +/** Shared pill colors for runtime + saved audit log lines. */ +export function logLineKindClass(kind: string): string { + if (kind === "ok") return "bg-emerald-400/10 text-emerald-300"; + if (kind === "run") return "bg-sky-400/10 text-sky-300"; + if (kind === "tool") return "bg-yellow-400/10 text-yellow-200"; + if (kind === "time") return "bg-fuchsia-400/10 text-fuchsia-200"; + if (kind === "reply") return "bg-violet-400/10 text-violet-300"; + if (kind === "msg") return "bg-cyan-400/10 text-cyan-300"; + return "bg-slate-400/10 text-slate-300"; +} diff --git a/src/modules/bot/index.ts b/src/modules/bot/index.ts index 390bdce..34c6a0d 100644 --- a/src/modules/bot/index.ts +++ b/src/modules/bot/index.ts @@ -1,5 +1,6 @@ -export { getPengineHealth, postConnect, deleteConnect, PENGINE } from "./api"; -export type { PengineHealth } from "./types"; +export { deleteConnect, getPengineHealth, postConnect, PENGINE } from "./api"; +export type { AuditLogFileInfo, PengineHealth } from "./types"; export { useAppSessionStore } from "./store/appSessionStore"; +export { AuditLogPanel } from "./components/AuditLogPanel"; export { SetupWizard, SETUP_STEPS } from "./components/SetupWizard"; export { TerminalPreview } from "./components/TerminalPreview"; diff --git a/src/modules/bot/types.ts b/src/modules/bot/types.ts index 0fa2cb4..96881ea 100644 --- a/src/modules/bot/types.ts +++ b/src/modules/bot/types.ts @@ -6,3 +6,14 @@ export type PengineHealth = { app_version?: string; git_commit?: string; }; + +/** + * One daily `audit-YYYY-MM-DD.log` row from disk (Tauri `AuditFileEntry` over IPC). + * Keep `size_bytes` in snake_case: it must match serde field names from the Rust struct; + * renaming to camelCase would break deserialization. + */ +export type AuditLogFileInfo = { + date: string; + filename: string; + size_bytes: number; +}; diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index bb70807..e7242e5 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { getPengineHealth } from "../modules/bot/api"; -import { TerminalPreview } from "../modules/bot/components/TerminalPreview"; +import { AuditLogPanel, getPengineHealth, TerminalPreview } from "../modules/bot"; import { useAppSessionStore } from "../modules/bot/store/appSessionStore"; import { CronPanel } from "../modules/cron"; import { McpToolsPanel } from "../modules/mcp/components/McpToolsPanel"; @@ -224,11 +223,16 @@ export function DashboardPage() {

)} - {/* ── Terminal (full width) ────────────────────────────── */} + {/* ── Terminal (full width) — live runtime log ───────── */}
+ {/* ── Saved audit files (disk) — separate from live stream ─ */} +
+ +
+ {/* ── MCP tools & commands ────────────────────────────────── */}
diff --git a/src/shared/api/config.ts b/src/shared/api/config.ts index 29b043c..5a3ece9 100644 --- a/src/shared/api/config.ts +++ b/src/shared/api/config.ts @@ -4,6 +4,22 @@ export const PENGINE_API_BASE = "http://127.0.0.1:21516"; /** Default Ollama HTTP API (same host as typical desktop install). */ export const OLLAMA_API_BASE = "http://localhost:11434"; +/** + * `AbortSignal.timeout` is missing in some embedded WebViews; fall back to + * `AbortController` so loopback fetches still work. + */ +export function makeTimeoutSignal(timeoutMs: number): { + signal: AbortSignal; + cleanup: () => void; +} { + if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") { + return { signal: AbortSignal.timeout(timeoutMs), cleanup: () => {} }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { signal: controller.signal, cleanup: () => clearTimeout(timer) }; +} + /** Browsers often report timeouts as AbortError / “Fetch is aborted”. */ export function fetchErrorMessage(e: unknown): string { if (e instanceof DOMException && e.name === "AbortError") { diff --git a/vite.config.ts b/vite.config.ts index 3d290c9..f2791c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,12 +2,16 @@ import process from "node:process"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { createPengineViteLogger } from "./vite/pengine-logger"; const host = process.env.TAURI_DEV_HOST; -export default defineConfig(async () => ({ - plugins: [tailwindcss(), react()], - clearScreen: false, +export default defineConfig(async () => { + const clearScreen = false; + return { + customLogger: createPengineViteLogger("info", { allowClearScreen: clearScreen }), + plugins: [tailwindcss(), react()], + clearScreen, server: { port: 1420, strictPort: true, @@ -23,4 +27,5 @@ export default defineConfig(async () => ({ ignored: ["**/src-tauri/**"], }, }, -})); + }; +}); diff --git a/vite/pengine-logger.ts b/vite/pengine-logger.ts new file mode 100644 index 0000000..5e37a2d --- /dev/null +++ b/vite/pengine-logger.ts @@ -0,0 +1,132 @@ +import * as readline from "node:readline"; +import winston from "winston"; +import type { Logger, LogLevel, LogOptions, LogErrorOptions, LogType } from "vite"; + +const LogLevels: Record = { + silent: 0, + error: 1, + warn: 2, + info: 3, +}; + +export type PengineViteLoggerOptions = { + /** When false, skip TTY clear-screen (should match Vite `clearScreen: false`). Default true. */ + allowClearScreen?: boolean; +}; + +/** + * Vite `customLogger` backed by Winston: structured levels, timestamps on info+, + * same dedupe / clear-screen behavior as Vite’s default logger. + */ +export function createPengineViteLogger( + level: LogLevel = "info", + opts: PengineViteLoggerOptions = {}, +): Logger { + const loggedErrors = new WeakSet(); + const warnedMessages = new Set(); + const thresh = LogLevels[level]; + const allowClearScreen = opts.allowClearScreen !== false; + + const winstonLog = winston.createLogger({ + silent: level === "silent", + levels: winston.config.npm.levels, + level: level === "silent" ? "error" : level, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize({ all: true }), + winston.format.printf(({ message }) => `${message}`), + ), + }), + ], + }); + + const canClearScreen = process.stdout.isTTY && !process.env.CI && allowClearScreen; + const clear = canClearScreen + ? () => { + const repeatCount = process.stdout.rows - 2; + const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : ""; + // Match Vite’s clear-screen helper (scroll then home). + if (blank) process.stdout.write(blank); + readline.cursorTo(process.stdout, 0, 0); + readline.clearScreenDown(process.stdout); + } + : () => {}; + + const timeFmt = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + + let lastType: LogType | undefined; + let lastMsg: string | undefined; + let sameCount = 0; + + function formatLine(type: LogType, msg: string, opts: LogOptions = {}): string { + const env = opts.environment ? `${opts.environment} ` : ""; + const lvl = type.toUpperCase(); + if (opts.timestamp) { + return `${timeFmt.format(new Date())} [pengine:dev] [${lvl}] ${env}${msg}`; + } + return `[pengine:dev] [${lvl}] ${env}${msg}`; + } + + function output(type: LogType, msg: string, opts: LogOptions = {}) { + if (thresh < LogLevels[type]) { + return; + } + if ("error" in opts && opts.error) { + loggedErrors.add(opts.error as object); + } + const line = formatLine(type, msg, opts); + const winstonMethod = type === "info" ? "info" : type; + + if (canClearScreen && type === lastType && msg === lastMsg) { + sameCount += 1; + clear(); + winstonLog.log(winstonMethod, `${line} (x${sameCount + 1})`); + } else { + sameCount = 0; + lastMsg = msg; + lastType = type; + if (opts.clear) { + clear(); + } + winstonLog.log(winstonMethod, line); + } + } + + const logger: Logger = { + hasWarned: false, + info(msg, opts) { + output("info", msg, opts ?? {}); + }, + warn(msg, opts) { + logger.hasWarned = true; + output("warn", msg, opts ?? {}); + }, + warnOnce(msg, opts) { + if (warnedMessages.has(msg)) { + return; + } + logger.hasWarned = true; + output("warn", msg, opts ?? {}); + warnedMessages.add(msg); + }, + error(msg, opts?: LogErrorOptions) { + logger.hasWarned = true; + output("error", msg, opts ?? {}); + }, + clearScreen(type: LogType) { + if (thresh >= LogLevels[type]) { + clear(); + } + }, + hasErrorLogged(error) { + return loggedErrors.has(error); + }, + }; + + return logger; +}