Compare commits
437 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46b9309336 | |||
| e60d2db8cb | |||
| e5e6c00100 | |||
| 40709b7480 | |||
| 900c7154b6 | |||
| 2f4ea02544 | |||
| c24c10a13a | |||
| 22cd2e4bb3 | |||
| 53ac43eb49 | |||
| fa29c46a95 | |||
| d871b4c78e | |||
| 03acdce2c8 | |||
| 9aa3b61efc | |||
| 0a62e3deca | |||
| f454d5f045 | |||
| a5dca65e57 | |||
| 979a7e5d18 | |||
| 4750ff8cd5 | |||
| 72d02010c7 | |||
| b49be42f2d | |||
| eddc183b85 | |||
| 812cf83de4 | |||
| 502cb8403f | |||
| cf78bff21d | |||
| 1e352f4a7e | |||
| 47be85fdc0 | |||
| 5c9849e729 | |||
| d4d2e33619 | |||
| 1603f1ae66 | |||
| 88da476249 | |||
| 1218fde6ea | |||
| 5046398e80 | |||
| 20e5b6cc5a | |||
| c867c4ef51 | |||
| f2e804783b | |||
| 817e18ded2 | |||
| f99b498608 | |||
| d6cd4763f5 | |||
| fdac846ede | |||
| d75c73df30 | |||
| 55c8dffe37 | |||
| 8b35861602 | |||
| 6db7e64ca9 | |||
| 9484f78fb6 | |||
| 1949aeb10f | |||
| 65fad4cc37 | |||
| 876194cdc8 | |||
| cefeac72fc | |||
| 71708c3874 | |||
| f02985e367 | |||
| 2848f672c1 | |||
| f2210946c2 | |||
| 4b79b9a417 | |||
| 10f8735f55 | |||
| 19a3a14417 | |||
| aeaea1289c | |||
| 014ffa9b74 | |||
| 0a9efe0122 | |||
| 82082a4b89 | |||
| fd399edce7 | |||
| 5611b69ad2 | |||
| 7b181046d3 | |||
| b593d50b9a | |||
| 8fe9426f5c | |||
| 0ce5f72df4 | |||
| d88c6570d0 | |||
| 2980f7c9b8 | |||
| 7b2825e5ce | |||
| e47f79bcd4 | |||
| 415020bf5d | |||
| 6ab1582db8 | |||
| 751a02aae8 | |||
| bee637aa48 | |||
| ad6c7ffda8 | |||
| 7f59ca37f2 | |||
| 9056964bb9 | |||
| 154441bc1b | |||
| 873ec75586 | |||
| 751b361528 | |||
| 8990a3e243 | |||
| bc44490e96 | |||
| fe2d0b1d2a | |||
| bf3c5c1602 | |||
| 6364931322 | |||
| 536d7141d9 | |||
| 69e0bfe89a | |||
| c13c7ba626 | |||
| 46b222180a | |||
| cd69b317c0 | |||
| 05fa751137 | |||
| 820317b9bd | |||
| bce01ad7a1 | |||
| bbe014798e | |||
| beea4dea04 | |||
| 71505362b4 | |||
| ff6904d436 | |||
| 1915bb3a9b | |||
| 04474d2e07 | |||
| 518bf0f217 | |||
| ac8f06c3a2 | |||
| feaf7b8abd | |||
| ac71057a3d | |||
| 0cb8900374 | |||
| 9a51a9e635 | |||
| 754a0988ee | |||
| 1985c0f815 | |||
| 889b778d43 | |||
| 26e90aa39e | |||
| f406342b53 | |||
| b7c800b550 | |||
| b6780ba876 | |||
| f74d336072 | |||
| 6aaaa910af | |||
| 35eee03aa5 | |||
| c1f8e7f511 | |||
| 6d39acc627 | |||
| 11f768d26c | |||
| 602afc2954 | |||
| b7203b8219 | |||
| 513774bb7b | |||
| 7375e306fb | |||
| 785d58cb59 | |||
| af61d145da | |||
| 0b75e967f3 | |||
| cfa4210b0a | |||
| 0d6d570ae8 | |||
| 65add36b2f | |||
| 793b3dde12 | |||
| 9112bcc52f | |||
| e534cffcf7 | |||
| a184dcc38f | |||
| 2c80223fc4 | |||
| 59a578fb2d | |||
| 91c3f3520b | |||
| e169200f40 | |||
| 26c75e8309 | |||
| bc6eb0b5a0 | |||
| 9a057ef646 | |||
| a7b06bd5fc | |||
| 9d706d3aa3 | |||
| fdc4253117 | |||
| ff168937aa | |||
| b7d060a1f3 | |||
| c37466e948 | |||
| 09513c0beb | |||
| a3f4277bdc | |||
| b43d8918bd | |||
| 4546adb894 | |||
| db5ac37ad3 | |||
| 098c14f9e0 | |||
| 461a353e92 | |||
| 1fd896fb72 | |||
| d081cc6c21 | |||
| 21cfc63fc0 | |||
| a5f14146b9 | |||
| 2c18f6d975 | |||
| 84d9146c04 | |||
| 6d4006fd93 | |||
| b4a2e5ee11 | |||
| 304b814bb1 | |||
| c5e4774b29 | |||
| e90651b55b | |||
| 2b5c9c2d61 | |||
| a703f1db73 | |||
| 4aaba7619e | |||
| 1a88dd801d | |||
| 83352ab9fe | |||
| 6e268a1bf4 | |||
| 4d65e54576 | |||
| c131ec722e | |||
| 574e12f336 | |||
| 1705868457 | |||
| 8392d111dc | |||
| 8c5ccbadac | |||
| e4aa081e64 | |||
| 8cc74eceb6 | |||
| 45365e3860 | |||
| 3739560956 | |||
| b8cff3e699 | |||
| d1d44afc9d | |||
| be2bfa0087 | |||
| 1ea28d66df | |||
| 8c51957bfa | |||
| 858a1bba4f | |||
| 17e4227978 | |||
| f8b5e07518 | |||
| acc9760690 | |||
| 56dab535c3 | |||
| 94670711e7 | |||
| 673c206e02 | |||
| decd3bd134 | |||
| 6e5c7aeab5 | |||
| 2647550324 | |||
| 424a63011b | |||
| 0e6a125c60 | |||
| 758cae4f86 | |||
| 6e8368c62a | |||
| a14e554323 | |||
| 6435202fa1 | |||
| cf8425ff14 | |||
| 9bb1c1b233 | |||
| c9ccc5e27e | |||
| 7115b2ff54 | |||
| b89e234ba4 | |||
| 70fbaa0bfd | |||
| f6cdd4ff36 | |||
| 1857f46452 | |||
| f95a6f4fd7 | |||
| b0bc66f548 | |||
| 34b4a6c3d8 | |||
| b4c7463226 | |||
| ca5b33ef69 | |||
| acd980091d | |||
| de5317987e | |||
| 9b1495a490 | |||
| f638011d63 | |||
| 8447a95c8a | |||
| 4feceaa1d1 | |||
| 8383e381d1 | |||
| a064a7471e | |||
| f0e3d7d09a | |||
| 2b7f4ccd6c | |||
| 46fa89233b | |||
| 591099e42b | |||
| d08398ea57 | |||
| 83f49742b6 | |||
| 594ee21fcd | |||
| ea2763c48c | |||
| 925fe1cce0 | |||
| d927b462b6 | |||
| 5a79556ab2 | |||
| 260870ad8a | |||
| 5af51096d8 | |||
| 898392725a | |||
| 9089035f18 | |||
| 66d2a68167 | |||
| 4a41e40592 | |||
| 2a75b920a0 | |||
| 2851eae423 | |||
| efc2295b8d | |||
| 2bee28a1d8 | |||
| a8cc995558 | |||
| 9a11c41424 | |||
| 2a4d056b59 | |||
| 5a77a398bd | |||
| 4cf2238c99 | |||
| 58df4f1481 | |||
| da3f99a254 | |||
| f0b8ed20a2 | |||
| 18c6455837 | |||
| e0477015c4 | |||
| e99a4e2b08 | |||
| c44d06b0dc | |||
| 0e8327c085 | |||
| 5c5a86c7fc | |||
| a785213cb2 | |||
| e041440c97 | |||
| 688ca8a604 | |||
| 9fe58935c4 | |||
| 0dfb35730f | |||
| dc52f4c692 | |||
| bcf5395868 | |||
| 3e96a89adf | |||
| c0a882251d | |||
| 6a53b895e5 | |||
| c5354d014c | |||
| 5e9ef37646 | |||
| cb96bea73d | |||
| 95fa2440ce | |||
| 0f1413f130 | |||
| 52a4b604dd | |||
| 3c64ee7af2 | |||
| 026719cd88 | |||
| 9bac00ee29 | |||
| 828c0f66a6 | |||
| 9841e92634 | |||
| 171e7ddcae | |||
| be119a69af | |||
| 800c3c11be | |||
| 1242da5ed1 | |||
| 8bf4fa0cf1 | |||
| 0693acc07b | |||
| 17eecfca9d | |||
| 4d24d6d17b | |||
| ffc202f6a3 | |||
| f7fd728683 | |||
| 46c04e5a81 | |||
| f43feb825f | |||
| 05cd21d44e | |||
| 4182af75ff | |||
| 507f769357 | |||
| e3f7e8c97a | |||
| 49e9e26bff | |||
| fccd4c12ca | |||
| 06c9ff481e | |||
| 50e5775062 | |||
| 91da8db589 | |||
| 0d854ae42b | |||
| ec21050fad | |||
| 67c61a5829 | |||
| e685668959 | |||
| de13eb5b96 | |||
| f134fcb528 | |||
| d5954a3a32 | |||
| bd28e312fc | |||
| 7208d5b2bf | |||
| 8cdeae6c3f | |||
| e7bc6d09f2 | |||
| 4ce2699a48 | |||
| 7c5cdb9161 | |||
| 34d04e57dd | |||
| 1317c5bddc | |||
| 74b6f565e9 | |||
| 08f49d4d0b | |||
| 99605b6a55 | |||
| beeeabc377 | |||
| 31c5eeb6c3 | |||
| 8004ee48c9 | |||
| a1d48a28e9 | |||
| 0f81f45c5f | |||
| 05f7957557 | |||
| 1ed8f5d124 | |||
| 2ee5be7402 | |||
| dcbc52efc6 | |||
| 92b0a1478a | |||
| f27c7fdf31 | |||
| 18a427b501 | |||
| b7951b730d | |||
| f75e30afd0 | |||
| 9f11238d43 | |||
| 070a1b47e5 | |||
| 3e8661f5ca | |||
| 9f8c27ddc1 | |||
| bafaaf9c47 | |||
| 1ee5863da7 | |||
| ace4d83789 | |||
| 1da1c178d0 | |||
| c429cb2ed1 | |||
| 40c40f81fc | |||
| 6647a3b083 | |||
| 782eaef440 | |||
| 6003310a39 | |||
| 229ac5006b | |||
| 322687c658 | |||
| fe3963dfe2 | |||
| e4a57b97b7 | |||
| 9b48c498f5 | |||
| 4228177920 | |||
| e98637321d | |||
| a686360c1f | |||
| 20ee9da1ec | |||
| c89baf34a8 | |||
| 00230d1b8f | |||
| 4396d57e3d | |||
| 86789f677a | |||
| 8fb2deeab0 | |||
| 2099bbe58f | |||
| c4b1820d08 | |||
| 59cc2741b8 | |||
| cc34d33090 | |||
| 06a3e6b472 | |||
| 9108882921 | |||
| 00a0ae6561 | |||
| 6310293190 | |||
| 809930df9a | |||
| f1874d4ab1 | |||
| cc0f401855 | |||
| 42626f3bce | |||
| 22d570b024 | |||
| 6d0a07f212 | |||
| a512b5a110 | |||
| bde3dade14 | |||
| de2058d966 | |||
| 7f191764be | |||
| 7f9da757aa | |||
| f07e8cfe14 | |||
| 3ad5b72ebf | |||
| 567e2e5d6d | |||
| 616bd0ac91 | |||
| 108a169e7c | |||
| eab902d68e | |||
| 985f6e89ec | |||
| 0480989fd2 | |||
| 72ffe420b7 | |||
| f5d169eaa2 | |||
| b3b921e1ae | |||
| 91f15b723e | |||
| 303dcb1eb6 | |||
| 4eaeb1b020 | |||
| f13427ca27 | |||
| 458f2cdf16 | |||
| df588f25bf | |||
| bd0fdff29c | |||
| 774da61da1 | |||
| 42e67e01aa | |||
| 36e201e824 | |||
| 497233c9f1 | |||
| 4b7c9a1bd3 | |||
| 7c2d6d6618 | |||
| c44e0afb81 | |||
| d3ef3c7452 | |||
| 71056d8f15 | |||
| 8d34119e7a | |||
| f159ee77cd | |||
| d336c4f5b7 | |||
| 1870f74f0c | |||
| d19f9c6888 | |||
| c2d2745777 | |||
| fc8bf841bf | |||
| 08f435597a | |||
| 58a4e475ad | |||
| 5bfc911e1b | |||
| d2c7362736 | |||
| 82cac690fa | |||
| 3c3c902087 | |||
| 964538eb43 | |||
| 5e8b2bdb50 | |||
| e3d10495f3 | |||
| 6c3886ad24 | |||
| ba727f53c4 | |||
| 35a4737e43 | |||
| abde8652b2 | |||
| cadef0bf81 | |||
| caac696244 | |||
| 6910a0b4bd | |||
| 74e2584e4d | |||
| 0f5c83c1c2 | |||
| c238711b3e | |||
| f42334917e | |||
| 09004d4c09 | |||
| 68da9b2f69 | |||
| 454ff37a72 | |||
| ca13d18d7d | |||
| 1657a7dbe3 | |||
| 61e925eaab | |||
| 09d3313e15 | |||
| a20d61037e |
@@ -0,0 +1,83 @@
|
||||
name: Build AppImage Beta
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
|
||||
path: |
|
||||
AppImage/dist/*.AppImage
|
||||
AppImage/dist/*.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit AppImage to develop
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage beta build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin develop
|
||||
@@ -9,18 +9,21 @@ on:
|
||||
paths: [ 'AppImage/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
f35de512c1a19843d15a9a3263a5104759d041ffc9d01249450babe0b0c3f889 ProxMenux-1.0.1.AppImage
|
||||
cd04577b4860ad1b66a7b906c381fa4c9ad384ce6e0cf0769ee7aa358399bc41 ProxMenux-1.0.2-beta.AppImage
|
||||
|
||||
@@ -730,6 +730,23 @@ entities:
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
|
||||
- NonCommercial — You may not use the material for commercial purposes
|
||||
|
||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "../components/theme-provider"
|
||||
@@ -20,7 +20,13 @@ export const metadata: Metadata = {
|
||||
shortcut: "/favicon.ico",
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
},
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#2b2f36" },
|
||||
|
||||
@@ -31,8 +31,6 @@ export default function Home() {
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
console.log("[v0] Auth status:", data)
|
||||
|
||||
const authenticated = data.auth_enabled ? data.authenticated : true
|
||||
|
||||
setAuthStatus({
|
||||
@@ -42,7 +40,7 @@ export default function Home() {
|
||||
authenticated,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to check auth status:", error)
|
||||
console.error("Failed to check auth status:", error)
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
@@ -63,9 +61,13 @@ export default function Home() {
|
||||
if (authStatus.loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading...</div>
|
||||
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,20 +4,7 @@ import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Thermometer,
|
||||
Zap,
|
||||
Loader2,
|
||||
CpuIcon,
|
||||
Cpu as Gpu,
|
||||
Network,
|
||||
MemoryStick,
|
||||
PowerIcon,
|
||||
FanIcon,
|
||||
Battery,
|
||||
} from "lucide-react"
|
||||
import { Cpu, HardDrive, Thermometer, Zap, Loader2, CpuIcon, Cpu as Gpu, Network, MemoryStick, PowerIcon, FanIcon, Battery } from "lucide-react"
|
||||
import { Download } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import useSWR from "swr"
|
||||
@@ -241,6 +228,8 @@ export default function Hardware() {
|
||||
const [selectedUPS, setSelectedUPS] = useState<any>(null)
|
||||
const [showNvidiaInstaller, setShowNvidiaInstaller] = useState(false)
|
||||
const [installingNvidiaDriver, setInstallingNvidiaDriver] = useState(false)
|
||||
const [showAmdInstaller, setShowAmdInstaller] = useState(false)
|
||||
const [showIntelInstaller, setShowIntelInstaller] = useState(false)
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const data = await fetchApi(url)
|
||||
@@ -262,6 +251,16 @@ export default function Hardware() {
|
||||
setShowNvidiaInstaller(true)
|
||||
}
|
||||
|
||||
const handleInstallAmdTools = () => {
|
||||
console.log("[v0] Opening AMD GPU tools installer terminal")
|
||||
setShowAmdInstaller(true)
|
||||
}
|
||||
|
||||
const handleInstallIntelTools = () => {
|
||||
console.log("[v0] Opening Intel GPU tools installer terminal")
|
||||
setShowIntelInstaller(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGPU) return
|
||||
|
||||
@@ -330,10 +329,13 @@ export default function Hardware() {
|
||||
|
||||
if (swrLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Loading hardware data...</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading hardware data...</div>
|
||||
<p className="text-xs text-muted-foreground">Detecting CPU, GPU, storage and PCI devices</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -467,10 +469,21 @@ export default function Hardware() {
|
||||
<span className="font-medium">{module.type}</span>
|
||||
</div>
|
||||
)}
|
||||
{module.speed && (
|
||||
{(module.configured_speed || module.max_speed) && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Speed</span>
|
||||
<span className="font-medium">{module.speed}</span>
|
||||
<span className="font-medium">
|
||||
{module.configured_speed && module.max_speed && module.configured_speed !== module.max_speed ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={module.configured_speed.replace(/[^0-9]/g, '') < module.max_speed.replace(/[^0-9]/g, '') ? "text-orange-500" : "text-blue-500"}>
|
||||
{module.configured_speed}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">(max: {module.max_speed})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{module.configured_speed || module.max_speed}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{module.manufacturer && (
|
||||
@@ -1110,6 +1123,28 @@ export default function Hardware() {
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
{(selectedGPU.vendor.toLowerCase().includes("amd") || selectedGPU.vendor.toLowerCase().includes("ati")) && (
|
||||
<Button
|
||||
onClick={handleInstallAmdTools}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Install AMD GPU Tools
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
{selectedGPU.vendor.toLowerCase().includes("intel") && (
|
||||
<Button
|
||||
onClick={handleInstallIntelTools}
|
||||
className="w-full bg-sky-600 hover:bg-sky-700 text-white"
|
||||
>
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Install Intel GPU Tools
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2052,6 +2087,34 @@ export default function Hardware() {
|
||||
title="NVIDIA Driver Installation"
|
||||
description="Installing NVIDIA proprietary drivers for GPU monitoring..."
|
||||
/>
|
||||
</div>
|
||||
<ScriptTerminalModal
|
||||
open={showAmdInstaller}
|
||||
onClose={() => {
|
||||
setShowAmdInstaller(false)
|
||||
mutateHardware()
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/amd_gpu_tools.sh"
|
||||
scriptName="amd_gpu_tools"
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title="AMD GPU Tools Installation"
|
||||
description="Installing amdgpu_top for AMD GPU monitoring..."
|
||||
/>
|
||||
<ScriptTerminalModal
|
||||
open={showIntelInstaller}
|
||||
onClose={() => {
|
||||
setShowIntelInstaller(false)
|
||||
mutateHardware()
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/intel_gpu_tools.sh"
|
||||
scriptName="intel_gpu_tools"
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title="Intel GPU Tools Installation"
|
||||
description="Installing intel-gpu-tools for Intel GPU monitoring..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getAuthToken } from "@/lib/api-config"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Info,
|
||||
Activity,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
@@ -23,16 +25,42 @@ import {
|
||||
RefreshCw,
|
||||
Shield,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
reason?: string
|
||||
details?: any
|
||||
checks?: Record<string, { status: string; detail: string; [key: string]: any }>
|
||||
dismissable?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface DismissedError {
|
||||
error_key: string
|
||||
category: string
|
||||
severity: string
|
||||
reason: string
|
||||
dismissed: boolean
|
||||
permanent?: boolean
|
||||
suppression_remaining_hours: number
|
||||
suppression_hours?: number
|
||||
resolved_at: string
|
||||
}
|
||||
|
||||
interface CustomSuppression {
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
icon: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface HealthDetails {
|
||||
overall: string
|
||||
summary: string
|
||||
@@ -51,6 +79,14 @@ interface HealthDetails {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface FullHealthData {
|
||||
health: HealthDetails
|
||||
active_errors: any[]
|
||||
dismissed: DismissedError[]
|
||||
custom_suppressions: CustomSuppression[]
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface HealthStatusModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -58,65 +94,174 @@ interface HealthStatusModalProps {
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "cpu", label: "CPU Usage & Temperature", Icon: Cpu },
|
||||
{ key: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
||||
{ key: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
||||
{ key: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
||||
{ key: "network", label: "Network Interfaces", Icon: Network },
|
||||
{ key: "vms", label: "VMs & Containers", Icon: Box },
|
||||
{ key: "services", label: "PVE Services", Icon: Settings },
|
||||
{ key: "logs", label: "System Logs", Icon: FileText },
|
||||
{ key: "updates", label: "System Updates", Icon: RefreshCw },
|
||||
{ key: "security", label: "Security & Certificates", Icon: Shield },
|
||||
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
|
||||
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
||||
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
||||
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
||||
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
|
||||
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
|
||||
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
|
||||
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
|
||||
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
|
||||
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
|
||||
]
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const fetchHealthDetails = async () => {
|
||||
const fetchHealthDetails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/details"))
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch health details")
|
||||
let newOverallStatus = "OK"
|
||||
|
||||
// Use the new combined endpoint for fewer round-trips
|
||||
const token = getAuthToken()
|
||||
const authHeaders: Record<string, string> = {}
|
||||
if (token) {
|
||||
authHeaders["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
const data = await response.json()
|
||||
console.log("[v0] Health data received:", data)
|
||||
setHealthData(data)
|
||||
|
||||
const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders })
|
||||
let infoCount = 0
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to legacy endpoint
|
||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
||||
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
|
||||
const data = await legacyResponse.json()
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
setCustomSuppressions([])
|
||||
newOverallStatus = data?.overall || "OK"
|
||||
|
||||
// Count INFO categories from legacy data
|
||||
if (data?.details) {
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const cat = data.details[key as keyof typeof data.details]
|
||||
if (cat && cat.status?.toUpperCase() === "INFO") {
|
||||
infoCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const fullData: FullHealthData = await response.json()
|
||||
setHealthData(fullData.health)
|
||||
setDismissedItems(fullData.dismissed || [])
|
||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||
newOverallStatus = fullData.health?.overall || "OK"
|
||||
|
||||
// Get categories that have dismissed items (these become INFO)
|
||||
const customCats = new Set((fullData.custom_suppressions || []).map((cs: { category: string }) => cs.category))
|
||||
const filteredDismissed = (fullData.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category))
|
||||
const categoriesWithDismissed = new Set<string>()
|
||||
filteredDismissed.forEach((item: { category: string }) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
if (catMeta) {
|
||||
categoriesWithDismissed.add(catMeta.key)
|
||||
}
|
||||
})
|
||||
|
||||
// Count effective INFO categories (original INFO + OK categories with dismissed)
|
||||
if (fullData.health?.details) {
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const cat = fullData.health.details[key as keyof typeof fullData.health.details]
|
||||
if (cat) {
|
||||
const originalStatus = cat.status?.toUpperCase()
|
||||
// Count as INFO if: originally INFO OR (originally OK and has dismissed items)
|
||||
if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) {
|
||||
infoCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalInfoCount = infoCount
|
||||
|
||||
// Emit event with the FRESH data from the response, not the stale state
|
||||
const event = new CustomEvent("healthStatusUpdated", {
|
||||
detail: { status: data.overall },
|
||||
detail: { status: newOverallStatus, infoCount: totalInfoCount },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching health data:", err)
|
||||
setError(err instanceof Error ? err.message : "Unknown error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getApiUrl])
|
||||
|
||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const tickInterval = setInterval(() => setTick(t => t + 1), 30000)
|
||||
return () => clearInterval(tickInterval)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
// Auto-refresh every 5 minutes while modal is open
|
||||
const refreshInterval = setInterval(fetchHealthDetails, 300000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
}
|
||||
}, [open, fetchHealthDetails])
|
||||
|
||||
// Auto-expand non-OK categories when data loads
|
||||
useEffect(() => {
|
||||
if (healthData?.details) {
|
||||
const nonOkCategories = new Set<string>()
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const cat = healthData.details[key as keyof typeof healthData.details]
|
||||
if (cat && cat.status?.toUpperCase() !== "OK") {
|
||||
// Updates section: only auto-expand on WARNING+, not INFO
|
||||
if (key === "updates" && cat.status?.toUpperCase() === "INFO") {
|
||||
return
|
||||
}
|
||||
nonOkCategories.add(key)
|
||||
}
|
||||
})
|
||||
setExpandedCategories(nonOkCategories)
|
||||
}
|
||||
}, [healthData])
|
||||
|
||||
const toggleCategory = (key: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const getStatusIcon = (status: string, size: "sm" | "md" = "md") => {
|
||||
const statusUpper = status?.toUpperCase()
|
||||
const cls = size === "sm" ? "h-4 w-4" : "h-5 w-5"
|
||||
switch (statusUpper) {
|
||||
case "OK":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
return <CheckCircle2 className={`${cls} text-green-500`} />
|
||||
case "INFO":
|
||||
return <Info className={`${cls} text-blue-500`} />
|
||||
case "WARNING":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
return <AlertTriangle className={`${cls} text-yellow-500`} />
|
||||
case "CRITICAL":
|
||||
return <XCircle className="h-5 w-5 text-red-500" />
|
||||
return <XCircle className={`${cls} text-red-500`} />
|
||||
case "UNKNOWN":
|
||||
return <HelpCircle className={`${cls} text-amber-400`} />
|
||||
default:
|
||||
return <Activity className="h-5 w-5 text-gray-500" />
|
||||
return <Activity className={`${cls} text-muted-foreground`} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,45 +270,76 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
switch (statusUpper) {
|
||||
case "OK":
|
||||
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
|
||||
case "INFO":
|
||||
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
|
||||
case "WARNING":
|
||||
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
|
||||
case "CRITICAL":
|
||||
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
|
||||
case "UNKNOWN":
|
||||
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
|
||||
default:
|
||||
return <Badge>Unknown</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) {
|
||||
return { total: 0, healthy: 0, warnings: 0, critical: 0 }
|
||||
// Get categories that have dismissed items (to show as INFO)
|
||||
const getCategoriesWithDismissed = () => {
|
||||
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
||||
const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category))
|
||||
const categoriesWithDismissed = new Set<string>()
|
||||
filteredDismissed.forEach(item => {
|
||||
// Map dismissed category to our CATEGORIES keys
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
if (catMeta) {
|
||||
categoriesWithDismissed.add(catMeta.key)
|
||||
}
|
||||
})
|
||||
return categoriesWithDismissed
|
||||
}
|
||||
|
||||
const categoriesWithDismissed = getCategoriesWithDismissed()
|
||||
|
||||
// Get effective status for a category (considers dismissed items)
|
||||
const getEffectiveStatus = (key: string, originalStatus: string) => {
|
||||
// If category has dismissed items and original status is OK, show as INFO
|
||||
if (categoriesWithDismissed.has(key) && originalStatus?.toUpperCase() === "OK") {
|
||||
return "INFO"
|
||||
}
|
||||
return originalStatus?.toUpperCase() || "UNKNOWN"
|
||||
}
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
||||
|
||||
let healthy = 0
|
||||
let info = 0
|
||||
let warnings = 0
|
||||
let critical = 0
|
||||
let unknown = 0
|
||||
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
if (categoryData) {
|
||||
const status = categoryData.status?.toUpperCase()
|
||||
if (status === "OK") healthy++
|
||||
else if (status === "WARNING") warnings++
|
||||
else if (status === "CRITICAL") critical++
|
||||
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
||||
if (effectiveStatus === "OK") healthy++
|
||||
else if (effectiveStatus === "INFO") info++
|
||||
else if (effectiveStatus === "WARNING") warnings++
|
||||
else if (effectiveStatus === "CRITICAL") critical++
|
||||
else if (effectiveStatus === "UNKNOWN") unknown++
|
||||
}
|
||||
})
|
||||
|
||||
return { total: CATEGORIES.length, healthy, warnings, critical }
|
||||
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
|
||||
const handleCategoryClick = (categoryKey: string, status: string) => {
|
||||
if (status === "OK") return // No navegar si está OK
|
||||
if (status === "OK" || status === "INFO") return
|
||||
|
||||
onOpenChange(false) // Cerrar el modal
|
||||
onOpenChange(false)
|
||||
|
||||
// Mapear categorías a tabs
|
||||
const categoryToTab: Record<string, string> = {
|
||||
storage: "storage",
|
||||
disks: "storage",
|
||||
@@ -176,55 +352,206 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
const targetTab = categoryToTab[categoryKey]
|
||||
if (targetTab) {
|
||||
// Disparar evento para cambiar tab
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: targetTab } })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent navigation
|
||||
|
||||
console.log("[v0] Dismissing error:", errorKey)
|
||||
e.stopPropagation()
|
||||
setDismissingKey(errorKey)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/acknowledge"), {
|
||||
const url = getApiUrl("/api/health/acknowledge")
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({ error_key: errorKey }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error("[v0] Acknowledge failed:", errorData)
|
||||
throw new Error(errorData.error || "Failed to acknowledge error")
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || `Failed to dismiss error (${response.status})`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log("[v0] Acknowledge success:", result)
|
||||
|
||||
// Refresh health data
|
||||
await fetchHealthDetails()
|
||||
} catch (err) {
|
||||
console.error("[v0] Error acknowledging:", err)
|
||||
alert("Failed to dismiss error. Please try again.")
|
||||
console.error("Error dismissing:", err)
|
||||
} finally {
|
||||
setDismissingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getTimeSinceCheck = () => {
|
||||
if (!healthData?.timestamp) return null
|
||||
const checkTime = new Date(healthData.timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - checkTime.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return "just now"
|
||||
if (diffMin === 1) return "1 minute ago"
|
||||
if (diffMin < 60) return `${diffMin} minutes ago`
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
return `${diffHours}h ${diffMin % 60}m ago`
|
||||
}
|
||||
|
||||
const getCategoryRowStyle = (status: string) => {
|
||||
const s = status?.toUpperCase()
|
||||
if (s === "CRITICAL") return "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
|
||||
if (s === "WARNING") return "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
|
||||
if (s === "UNKNOWN") return "bg-amber-500/5 border-amber-500/20 hover:bg-amber-500/10 cursor-pointer"
|
||||
if (s === "INFO") return "bg-blue-500/5 border-blue-500/20 hover:bg-blue-500/10"
|
||||
return "bg-card border-border hover:bg-muted/30"
|
||||
}
|
||||
|
||||
const getOutlineBadgeStyle = (status: string) => {
|
||||
const s = status?.toUpperCase()
|
||||
if (s === "OK") return "border-green-500 text-green-500 bg-transparent"
|
||||
if (s === "INFO") return "border-blue-500 text-blue-500 bg-blue-500/5"
|
||||
if (s === "WARNING") return "border-yellow-500 text-yellow-500 bg-yellow-500/5"
|
||||
if (s === "CRITICAL") return "border-red-500 text-red-500 bg-red-500/5"
|
||||
if (s === "UNKNOWN") return "border-amber-400 text-amber-400 bg-amber-500/5"
|
||||
return ""
|
||||
}
|
||||
|
||||
const formatCheckLabel = (key: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
// CPU
|
||||
cpu_usage: "CPU Usage",
|
||||
cpu_temperature: "Temperature",
|
||||
// Memory
|
||||
ram_usage: "RAM Usage",
|
||||
swap_usage: "Swap Usage",
|
||||
// Disk I/O
|
||||
root_filesystem: "Root Filesystem",
|
||||
smart_health: "SMART Health",
|
||||
io_errors: "I/O Errors",
|
||||
zfs_pools: "ZFS Pools",
|
||||
lvm_volumes: "LVM Volumes",
|
||||
lvm_check: "LVM Status",
|
||||
// Network
|
||||
connectivity: "Connectivity",
|
||||
// VMs & CTs
|
||||
qmp_communication: "QMP Communication",
|
||||
container_startup: "Container Startup",
|
||||
vm_startup: "VM Startup",
|
||||
oom_killer: "OOM Killer",
|
||||
// Services
|
||||
cluster_mode: "Cluster Mode",
|
||||
// Logs (prefixed with log_)
|
||||
log_error_cascade: "Error Cascade",
|
||||
log_error_spike: "Error Spike",
|
||||
log_persistent_errors: "Persistent Errors",
|
||||
log_critical_errors: "Critical Errors",
|
||||
// Updates
|
||||
pve_version: "Proxmox VE Version",
|
||||
security_updates: "Security Updates",
|
||||
system_age: "System Age",
|
||||
pending_updates: "Pending Updates",
|
||||
kernel_pve: "Kernel / PVE",
|
||||
// Security
|
||||
uptime: "Uptime",
|
||||
certificates: "Certificates",
|
||||
login_attempts: "Login Attempts",
|
||||
fail2ban: "Fail2Ban",
|
||||
// Storage (Proxmox)
|
||||
proxmox_storages: "Proxmox Storages",
|
||||
}
|
||||
if (labels[key]) return labels[key]
|
||||
// Convert snake_case or camelCase to Title Case
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
const renderChecks = (
|
||||
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
||||
categoryKey: string
|
||||
) => {
|
||||
if (!checks || Object.keys(checks).length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{Object.entries(checks)
|
||||
.filter(([, checkData]) => checkData.installed !== false)
|
||||
.map(([checkKey, checkData]) => {
|
||||
const isDismissable = checkData.dismissable === true
|
||||
const checkStatus = checkData.status?.toUpperCase() || "OK"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={checkKey}
|
||||
className="flex items-center justify-between gap-1.5 sm:gap-2 text-[10px] sm:text-xs py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
|
||||
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
||||
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
|
||||
{checkData.dismissed && (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
||||
Dismissed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
|
||||
{(checkStatus === "WARNING" || checkStatus === "CRITICAL") && isDismissable && !checkData.dismissed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
||||
disabled={dismissingKey === (checkData.error_key || checkKey)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAcknowledge(checkData.error_key || checkKey, e)
|
||||
}}
|
||||
>
|
||||
{dismissingKey === (checkData.error_key || checkKey) ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 sm:mr-0.5" />
|
||||
<span className="hidden sm:inline">Dismiss</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-3xl w-[calc(100vw-2rem)] sm:w-[95vw] max-h-[85vh] overflow-y-auto overflow-x-hidden p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="flex items-center gap-2 flex-1">
|
||||
<Activity className="h-6 w-6" />
|
||||
System Health Status
|
||||
{healthData && <div className="ml-2">{getStatusBadge(healthData.overall)}</div>}
|
||||
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
|
||||
<span className="truncate text-base sm:text-lg">System Health Status</span>
|
||||
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>Detailed health checks for all system components</DialogDescription>
|
||||
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
|
||||
<span>Detailed health checks for all system components</span>
|
||||
{getTimeSinceCheck() && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{getTimeSinceCheck()}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
@@ -243,116 +570,225 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{healthData && !loading && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Stats Summary */}
|
||||
<div className="grid grid-cols-4 gap-3 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
<div className="text-xs text-muted-foreground">Total Checks</div>
|
||||
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">{stats.healthy}</div>
|
||||
<div className="text-xs text-muted-foreground">Healthy</div>
|
||||
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div>
|
||||
</div>
|
||||
{stats.info > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
||||
<div className="text-xs text-muted-foreground">Warnings</div>
|
||||
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div>
|
||||
</div>
|
||||
{stats.unknown > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||
<div className="text-xs text-muted-foreground">Critical</div>
|
||||
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||
<div className="text-sm p-3 rounded-lg bg-muted/20 border">
|
||||
<span className="font-medium text-foreground">{healthData.summary}</span>
|
||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{healthData.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, label, Icon }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
const status = categoryData?.status || "UNKNOWN"
|
||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
const reason = categoryData?.reason
|
||||
const details = categoryData?.details
|
||||
const checks = categoryData?.checks
|
||||
const isExpanded = expandedCategories.has(key)
|
||||
const hasChecks = checks && Object.keys(checks).length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => handleCategoryClick(key, status)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
status === "OK"
|
||||
? "bg-card border-border hover:bg-muted/30"
|
||||
: status === "WARNING"
|
||||
? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
|
||||
: status === "CRITICAL"
|
||||
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
|
||||
: "bg-muted/30 hover:bg-muted/50"
|
||||
}`}
|
||||
className={`rounded-lg border transition-colors overflow-hidden ${getCategoryRowStyle(status)}`}
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-blue-500" />
|
||||
{getStatusIcon(status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="font-medium text-sm">{label}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`shrink-0 text-xs ${
|
||||
status === "OK"
|
||||
? "border-green-500 text-green-500 bg-transparent"
|
||||
: status === "WARNING"
|
||||
? "border-yellow-500 text-yellow-500 bg-yellow-500/5"
|
||||
: status === "CRITICAL"
|
||||
? "border-red-500 text-red-500 bg-red-500/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* Clickable header row */}
|
||||
<div
|
||||
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 cursor-pointer select-none overflow-hidden"
|
||||
onClick={() => toggleCategory(key)}
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-1.5 sm:gap-2">
|
||||
<Icon className="h-4 w-4 text-blue-500 hidden sm:block" />
|
||||
{getStatusIcon(status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{label}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{reason && !isExpanded && (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-0.5 line-clamp-2 break-words">{reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{reason && <p className="text-xs text-muted-foreground mt-1">{reason}</p>}
|
||||
{details && typeof details === "object" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(details).map(([detailKey, detailValue]: [string, any]) => {
|
||||
if (typeof detailValue === "object" && detailValue !== null) {
|
||||
const isDismissable = detailValue.dismissable !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={detailKey}
|
||||
className="flex items-start justify-between gap-2 text-xs pl-3 border-l-2 border-muted py-1"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{detailKey}:</span>
|
||||
{detailValue.reason && (
|
||||
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{(status === "WARNING" || status === "CRITICAL") && isDismissable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent"
|
||||
onClick={(e) => handleAcknowledge(detailKey, e)}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">Dismiss</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable checks section */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/50 bg-muted/5 px-1.5 sm:px-2 py-1.5 overflow-hidden">
|
||||
{reason && (
|
||||
<p className="text-xs text-muted-foreground px-3 py-1.5 mb-1 break-words whitespace-pre-wrap">{reason}</p>
|
||||
)}
|
||||
{hasChecks ? (
|
||||
renderChecks(checks, key)
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
No issues detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Dismissed Items Section -- hide items whose category has custom suppression */}
|
||||
{(() => {
|
||||
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
||||
const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category))
|
||||
if (filteredDismissed.length === 0) return null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
|
||||
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Dismissed Items ({filteredDismissed.length})
|
||||
</div>
|
||||
{filteredDismissed.map((item) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
const CatIcon = catMeta?.Icon || BellOff
|
||||
const catLabel = catMeta?.label || item.category
|
||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.error_key}
|
||||
className="flex items-start gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border bg-muted/10 border-muted opacity-75"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 flex items-center gap-1.5 sm:gap-2">
|
||||
<CatIcon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isPermanent ? (
|
||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
|
||||
Permanent
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
||||
Dismissed
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
||||
was {item.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isPermanent
|
||||
? "Permanently suppressed"
|
||||
: `Suppressed for ${
|
||||
item.suppression_remaining_hours < 24
|
||||
? `${Math.round(item.suppression_remaining_hours)}h`
|
||||
: item.suppression_remaining_hours < 720
|
||||
? `${Math.round(item.suppression_remaining_hours / 24)} days`
|
||||
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
|
||||
} more`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Custom Suppression Settings Summary */}
|
||||
{customSuppressions.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Custom Suppression Settings
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
|
||||
<div className="space-y-1.5">
|
||||
{customSuppressions.map((cs) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
|
||||
const CatIcon = catMeta?.Icon || Settings2
|
||||
const durationLabel = cs.hours === -1
|
||||
? "Permanent"
|
||||
: cs.hours >= 8760
|
||||
? `${Math.floor(cs.hours / 8760)} year(s)`
|
||||
: cs.hours >= 720
|
||||
? `${Math.floor(cs.hours / 720)} month(s)`
|
||||
: cs.hours >= 168
|
||||
? `${Math.floor(cs.hours / 168)} week(s)`
|
||||
: cs.hours >= 72
|
||||
? `${Math.floor(cs.hours / 24)} days`
|
||||
: `${cs.hours}h`
|
||||
|
||||
return (
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
|
||||
Alerts in these categories are auto-suppressed when detected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData.timestamp && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||
Last updated: {new Date(healthData.timestamp).toLocaleString()}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -237,7 +237,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.2</p>
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.2-beta</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Send,
|
||||
Lightbulb,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DialogHeader, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface LxcTerminalModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
vmid: number
|
||||
vmName: string
|
||||
}
|
||||
|
||||
interface CheatSheetResult {
|
||||
command: string
|
||||
description: string
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
const proxmoxCommands = [
|
||||
{ cmd: "ls -la", desc: "List all files with details" },
|
||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
||||
{ cmd: "cat filename", desc: "Display file contents" },
|
||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
||||
{ cmd: "df -h", desc: "Show disk usage" },
|
||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
||||
{ cmd: "free -h", desc: "Show memory usage" },
|
||||
{ cmd: "top", desc: "Show running processes" },
|
||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
||||
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
|
||||
{ cmd: "apt install package", desc: "Install package" },
|
||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
|
||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
||||
{ cmd: "docker ps", desc: "List running containers" },
|
||||
{ cmd: "docker images", desc: "List Docker images" },
|
||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
||||
{ cmd: "history", desc: "Show command history" },
|
||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
||||
]
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "ws://localhost:8008/ws/terminal"
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/terminal`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal`
|
||||
}
|
||||
}
|
||||
|
||||
export function LxcTerminalModal({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
vmid,
|
||||
vmName,
|
||||
}: LxcTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
const isInsideLxcRef = useRef(false)
|
||||
const outputBufferRef = useRef<string>("")
|
||||
|
||||
const [modalHeight, setModalHeight] = useState(500)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
const modalHeightRef = useRef(500)
|
||||
|
||||
// Search state
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
|
||||
|
||||
|
||||
// Detect mobile/tablet
|
||||
useEffect(() => {
|
||||
const checkDevice = () => {
|
||||
const width = window.innerWidth
|
||||
setIsMobile(width < 640)
|
||||
setIsTablet(width >= 640 && width < 1024)
|
||||
}
|
||||
checkDevice()
|
||||
window.addEventListener("resize", checkDevice)
|
||||
return () => window.removeEventListener("resize", checkDevice)
|
||||
}, [])
|
||||
|
||||
// Cleanup on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
setConnectionStatus("connecting")
|
||||
isInsideLxcRef.current = false
|
||||
outputBufferRef.current = ""
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
// Small delay to ensure Dialog content is rendered
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (!terminalContainerRef.current) return
|
||||
initTerminal()
|
||||
}, 100)
|
||||
|
||||
const initTerminal = async () => {
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
])
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
scrollback: 2000,
|
||||
disableStdin: false,
|
||||
customGlyphs: true,
|
||||
fontWeight: "500",
|
||||
fontWeightBold: "700",
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ffffff",
|
||||
cursorAccent: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddonClass()
|
||||
term.loadAddon(fitAddon)
|
||||
|
||||
if (terminalContainerRef.current) {
|
||||
term.open(terminalContainerRef.current)
|
||||
fitAddon.fit()
|
||||
}
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect WebSocket to host terminal
|
||||
const wsUrl = getWebSocketUrl()
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
// Reset state for new connection
|
||||
isInsideLxcRef.current = false
|
||||
outputBufferRef.current = ""
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
|
||||
// Start heartbeat ping
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} else {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, 25000)
|
||||
|
||||
// Sync terminal size
|
||||
fitAddon.fit()
|
||||
ws.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}))
|
||||
|
||||
// Auto-execute pct enter after connection is ready
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`pct enter ${vmid}\r`)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionStatus("offline")
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to strip ANSI escape codes for pattern matching
|
||||
const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
|
||||
// Buffer output until we detect we're inside the LXC
|
||||
// pct enter always enters directly without login prompt when run as root
|
||||
if (!isInsideLxcRef.current) {
|
||||
outputBufferRef.current += event.data
|
||||
|
||||
const buffer = outputBufferRef.current
|
||||
const cleanBuffer = stripAnsi(buffer)
|
||||
|
||||
// Look for pct enter command followed by a new prompt
|
||||
const pctEnterMatch = cleanBuffer.match(/pct enter (\d+)\r?\n/)
|
||||
|
||||
if (pctEnterMatch) {
|
||||
const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length)
|
||||
|
||||
// Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd")
|
||||
const hostPromptMatch = cleanBuffer.match(/@([a-zA-Z0-9_-]+).*pct enter/)
|
||||
const hostName = hostPromptMatch ? hostPromptMatch[1] : null
|
||||
|
||||
// Look for a new prompt after pct enter that ends with # or $
|
||||
// This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#)
|
||||
const promptMatch = afterPctEnter.match(/[@\[]([a-zA-Z0-9_-]+)[^\r\n]*[#$]\s*$/)
|
||||
|
||||
if (promptMatch) {
|
||||
const lxcHostname = promptMatch[1]
|
||||
|
||||
// If we found a prompt with a DIFFERENT hostname than the Proxmox host,
|
||||
// we're inside the LXC container
|
||||
if (!hostName || lxcHostname !== hostName) {
|
||||
isInsideLxcRef.current = true
|
||||
|
||||
// Find the original prompt with ANSI codes to display it properly
|
||||
const afterPctEnterWithAnsi = buffer.substring(buffer.indexOf('pct enter') + pctEnterMatch[0].length)
|
||||
|
||||
// Write the LXC prompt (last line with # or $)
|
||||
const lastPromptMatch = afterPctEnterWithAnsi.match(/[^\r\n]*[#$]\s*$/)
|
||||
if (lastPromptMatch) {
|
||||
term.write(lastPromptMatch[0])
|
||||
}
|
||||
|
||||
// Detect if this is Alpine/ash shell by checking prompt format
|
||||
// Alpine uses: [root@hostname ~]# or [root@hostname /]#
|
||||
// Other distros use: root@hostname:/# or root@hostname:~#
|
||||
const isAlpine = afterPctEnter.match(/\[[^\]]+@[^\]]+\s+[^\]]*\][#$]/)
|
||||
|
||||
if (isAlpine) {
|
||||
// Send an extra Enter ONLY for Alpine containers (ash shell)
|
||||
// This forces the prompt to refresh properly
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('\r')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Already inside LXC, write directly
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout)
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, vmid])
|
||||
|
||||
// Resize handling
|
||||
useEffect(() => {
|
||||
if (termRef.current && fitAddonRef.current && isOpen) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit()
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}))
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [modalHeight, isOpen])
|
||||
|
||||
// Resize bar handlers
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
modalHeightRef.current = modalHeight
|
||||
}, [modalHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMove = (e: MouseEvent | TouchEvent) => {
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
|
||||
const windowHeight = window.innerHeight
|
||||
const newHeight = windowHeight - clientY - 20
|
||||
const clampedHeight = Math.max(300, Math.min(windowHeight - 100, newHeight))
|
||||
modalHeightRef.current = clampedHeight
|
||||
setModalHeight(clampedHeight)
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleEnd)
|
||||
document.addEventListener("touchmove", handleMove)
|
||||
document.addEventListener("touchend", handleEnd)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleEnd)
|
||||
document.removeEventListener("touchmove", handleMove)
|
||||
document.removeEventListener("touchend", handleEnd)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
// Send key helpers for mobile/tablet
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(key)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendEsc = useCallback(() => sendKey("\x1b"), [sendKey])
|
||||
const sendTab = useCallback(() => sendKey("\t"), [sendKey])
|
||||
const sendArrowUp = useCallback(() => sendKey("\x1b[A"), [sendKey])
|
||||
const sendArrowDown = useCallback(() => sendKey("\x1b[B"), [sendKey])
|
||||
const sendArrowLeft = useCallback(() => sendKey("\x1b[D"), [sendKey])
|
||||
const sendArrowRight = useCallback(() => sendKey("\x1b[C"), [sendKey])
|
||||
const sendEnter = useCallback(() => sendKey("\r"), [sendKey])
|
||||
const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
|
||||
|
||||
// Search effect - debounced search with cheat.sh
|
||||
useEffect(() => {
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true)
|
||||
const searchEndpoint = `/api/terminal/search-command?q=${encodeURIComponent(query)}`
|
||||
const data = await fetchApi<{ success: boolean; examples: any[] }>(searchEndpoint, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||
throw new Error("No examples found")
|
||||
}
|
||||
|
||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||
command: example.command,
|
||||
description: example.description || "",
|
||||
examples: [example.command],
|
||||
}))
|
||||
|
||||
setUseOnline(true)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
const filtered = proxmoxCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
setFilteredCommands(filtered)
|
||||
setSearchResults([])
|
||||
setUseOnline(false)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const debounce = setTimeout(() => {
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendToTerminal = useCallback((command: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(command)
|
||||
setTimeout(() => {
|
||||
setSearchModalOpen(false)
|
||||
}, 100)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showMobileControls = isMobile || isTablet
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="max-w-4xl w-[95vw] p-0 gap-0 bg-black border-border overflow-hidden flex flex-col"
|
||||
style={{ height: `${modalHeight}px` }}
|
||||
hideClose
|
||||
>
|
||||
{/* Resize bar */}
|
||||
<div
|
||||
ref={resizeBarRef}
|
||||
className="h-3 w-full cursor-ns-resize flex items-center justify-center bg-zinc-900 hover:bg-zinc-800 transition-colors touch-none"
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
>
|
||||
<GripHorizontal className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<DialogTitle className="text-sm font-medium text-white">
|
||||
Terminal: {vmName} (ID: {vmid})
|
||||
</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setSearchModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={connectionStatus !== "online"}
|
||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={connectionStatus !== "online"}
|
||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal container */}
|
||||
<div className="flex-1 overflow-hidden bg-black p-1">
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
className="w-full h-full"
|
||||
style={{ minHeight: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet control buttons */}
|
||||
{showMobileControls && (
|
||||
<div className="px-2 py-2 bg-zinc-900 border-t border-zinc-800">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendEsc}
|
||||
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
|
||||
>
|
||||
ESC
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendTab}
|
||||
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
|
||||
>
|
||||
TAB
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowUp}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowDown}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowLeft}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendArrowRight}
|
||||
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendEnter}
|
||||
className="h-8 px-2 text-xs bg-blue-600/20 border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4 mr-1" />
|
||||
Enter
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300 gap-1"
|
||||
>
|
||||
Ctrl
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar at bottom */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-t border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-blue-500" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionStatus === "online"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-yellow-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Search Commands Modal */}
|
||||
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search commands... (e.g., tar, docker, systemctl)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearching && (
|
||||
<div className="text-center py-4 text-zinc-400">
|
||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||
<p className="text-sm">Searching cheat.sh...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2 max-h-[50vh]">
|
||||
{searchResults.length > 0 ? (
|
||||
<>
|
||||
{searchResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{result.description && (
|
||||
<p className="text-xs text-zinc-400 mb-2 leading-relaxed"># {result.description}</p>
|
||||
)}
|
||||
<div
|
||||
onClick={() => sendToTerminal(result.command)}
|
||||
className="flex items-start justify-between gap-2 cursor-pointer group hover:bg-zinc-800/50 rounded p-2 -m-2"
|
||||
>
|
||||
<code className="text-sm text-blue-400 font-mono break-all flex-1">{result.command}</code>
|
||||
<Send className="h-4 w-4 text-zinc-600 group-hover:text-blue-400 flex-shrink-0 mt-0.5 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||
Powered by cheat.sh
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : filteredCommands.length > 0 && !useOnline ? (
|
||||
filteredCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
|
||||
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
sendToTerminal(item.cmd)
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
|
||||
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
sendToTerminal(item.cmd)
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching ? (
|
||||
<div className="text-center py-12 space-y-4">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
||||
<div className="text-sm text-zinc-500 space-y-1">
|
||||
<p>Try searching for:</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||
<code
|
||||
key={cmd}
|
||||
onClick={() => setSearchQuery(cmd)}
|
||||
className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700"
|
||||
>
|
||||
{cmd}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{useOnline && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Powered by cheat.sh</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Tip: Search for any Linux command</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from 'lucide-react'
|
||||
import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer } from 'lucide-react'
|
||||
import useSWR from "swr"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { LatencyDetailModal } from "./latency-detail-modal"
|
||||
import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
|
||||
|
||||
interface NetworkData {
|
||||
interfaces: NetworkInterface[]
|
||||
@@ -150,8 +152,19 @@ export function NetworkMetrics() {
|
||||
const [modalTimeframe, setModalTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day")
|
||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
const [latencyModalOpen, setLatencyModalOpen] = useState(false)
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit())
|
||||
|
||||
// Latency history for sparkline (last hour)
|
||||
const { data: latencyData } = useSWR<{
|
||||
data: Array<{ timestamp: number; value: number }>
|
||||
stats: { min: number; max: number; avg: number; current: number }
|
||||
target: string
|
||||
}>("/api/network/latency/history?target=gateway&timeframe=hour",
|
||||
(url: string) => fetchApi(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
@@ -177,10 +190,13 @@ export function NetworkMetrics() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Loading network data...</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading network data...</div>
|
||||
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -327,48 +343,95 @@ export function NetworkMetrics() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Merged Network Config & Health Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Configuration</CardTitle>
|
||||
<Network className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle>
|
||||
<Badge variant="outline" className={healthColor}>
|
||||
{healthStatus}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Hostname</span>
|
||||
<span className="text-sm font-medium text-foreground truncate">{hostname}</span>
|
||||
<span className="text-xs font-medium text-foreground truncate max-w-[120px]">{hostname}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Domain</span>
|
||||
<span className="text-sm font-medium text-foreground truncate">{domain}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Primary DNS</span>
|
||||
<span className="text-sm font-medium text-foreground truncate">{primaryDNS}</span>
|
||||
<span className="text-xs font-medium text-foreground font-mono">{primaryDNS}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Packet Loss</span>
|
||||
<span className="text-xs font-medium text-foreground">{avgPacketLoss}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Errors</span>
|
||||
<span className="text-xs font-medium text-foreground">{totalErrors}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
{/* Latency Card with Sparkline */}
|
||||
<Card
|
||||
className="bg-card border-border cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setLatencyModalOpen(true)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Health</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Latency</CardTitle>
|
||||
<Timer className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="outline" className={healthColor}>
|
||||
{healthStatus}
|
||||
</Badge>
|
||||
<div className="flex flex-col gap-1 mt-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Packet Loss:</span>
|
||||
<span className="font-medium text-foreground">{avgPacketLoss}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Errors:</span>
|
||||
<span className="font-medium text-foreground">{totalErrors}</span>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{latencyData?.stats?.current ?? 0} <span className="text-sm font-normal text-muted-foreground">ms</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
(latencyData?.stats?.current ?? 0) < 50
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: (latencyData?.stats?.current ?? 0) < 100
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: (latencyData?.stats?.current ?? 0) < 200
|
||||
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
|
||||
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
|
||||
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Sparkline */}
|
||||
{latencyData?.data && latencyData.data.length > 0 && (
|
||||
<div className="h-[40px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={latencyData.data.slice(-30)} margin={{ top: 2, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="latencySparkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#latencySparkGradient)"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
baseValue="dataMin"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1088,6 +1151,12 @@ export function NetworkMetrics() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Latency Detail Modal */}
|
||||
<LatencyDetailModal
|
||||
open={latencyModalOpen}
|
||||
onOpenChange={setLatencyModalOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ export function NodeMetricsCharts() {
|
||||
memory: { memoryTotal: true, memoryUsed: true, memoryZfsArc: true, memoryFree: true },
|
||||
})
|
||||
|
||||
// Check if ZFS ARC or Free memory have any non-zero values to decide if we should show them
|
||||
const hasZfsArc = data.some(d => d.memoryZfsArc > 0)
|
||||
const hasMemoryFree = data.some(d => d.memoryFree > 0)
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[v0] NodeMetricsCharts component mounted")
|
||||
fetchMetrics()
|
||||
@@ -194,6 +198,11 @@ export function NodeMetricsCharts() {
|
||||
return (
|
||||
<div className="flex justify-center gap-4 pb-2 flex-wrap">
|
||||
{payload.map((entry: any, index: number) => {
|
||||
// For memory chart, hide ZFS ARC and Free from legend if they have no data
|
||||
if (chartType === "memory") {
|
||||
if (entry.dataKey === "memoryZfsArc" && !hasZfsArc) return null
|
||||
if (entry.dataKey === "memoryFree" && !hasMemoryFree) return null
|
||||
}
|
||||
const isVisible = visibleLines[chartType][entry.dataKey as keyof (typeof visibleLines)[typeof chartType]]
|
||||
return (
|
||||
<div
|
||||
@@ -428,26 +437,32 @@ export function NodeMetricsCharts() {
|
||||
name="Used"
|
||||
hide={!visibleLines.memory.memoryUsed}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryZfsArc"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
fill="#f59e0b"
|
||||
fillOpacity={0.3}
|
||||
name="ZFS ARC"
|
||||
hide={!visibleLines.memory.memoryZfsArc}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryFree"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.3}
|
||||
name="Available"
|
||||
hide={!visibleLines.memory.memoryFree}
|
||||
/>
|
||||
{/* Only show ZFS ARC if there's data */}
|
||||
{hasZfsArc && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryZfsArc"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
fill="#f59e0b"
|
||||
fillOpacity={0.3}
|
||||
name="ZFS ARC"
|
||||
hide={!visibleLines.memory.memoryZfsArc}
|
||||
/>
|
||||
)}
|
||||
{/* Only show Free memory if there's data */}
|
||||
{hasMemoryFree && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryFree"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.3}
|
||||
name="Free"
|
||||
hide={!visibleLines.memory.memoryFree}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines"
|
||||
import Hardware from "./hardware"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -76,11 +79,63 @@ export function ProxmoxDashboard() {
|
||||
const [componentKey, setComponentKey] = useState(0)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
const [infoCount, setInfoCount] = useState(0)
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
||||
|
||||
// Category keys for health info count calculation
|
||||
const HEALTH_CATEGORY_KEYS = [
|
||||
{ key: "cpu", category: "temperature" },
|
||||
{ key: "memory", category: "memory" },
|
||||
{ key: "storage", category: "storage" },
|
||||
{ key: "disks", category: "disks" },
|
||||
{ key: "network", category: "network" },
|
||||
{ key: "vms", category: "vms" },
|
||||
{ key: "services", category: "pve_services" },
|
||||
{ key: "logs", category: "logs" },
|
||||
{ key: "updates", category: "updates" },
|
||||
{ key: "security", category: "security" },
|
||||
]
|
||||
|
||||
// Fetch health info count independently (for initial load and refresh)
|
||||
const fetchHealthInfoCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchApi("/api/health/full")
|
||||
let calculatedInfoCount = 0
|
||||
|
||||
if (response && response.health?.details) {
|
||||
// Get categories that have dismissed items (these become INFO)
|
||||
const customCats = new Set((response.custom_suppressions || []).map((cs: { category: string }) => cs.category))
|
||||
const filteredDismissed = (response.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category))
|
||||
const categoriesWithDismissed = new Set<string>()
|
||||
filteredDismissed.forEach((item: { category: string }) => {
|
||||
const catMeta = HEALTH_CATEGORY_KEYS.find(c => c.category === item.category || c.key === item.category)
|
||||
if (catMeta) {
|
||||
categoriesWithDismissed.add(catMeta.key)
|
||||
}
|
||||
})
|
||||
|
||||
// Count effective INFO categories (original INFO + OK categories with dismissed)
|
||||
HEALTH_CATEGORY_KEYS.forEach(({ key }) => {
|
||||
const cat = response.health.details[key as keyof typeof response.health.details]
|
||||
if (cat) {
|
||||
const originalStatus = cat.status?.toUpperCase()
|
||||
// Count as INFO if: originally INFO OR (originally OK and has dismissed items)
|
||||
if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) {
|
||||
calculatedInfoCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setInfoCount(calculatedInfoCount)
|
||||
} catch (error) {
|
||||
// Silently fail - infoCount will remain at 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSystemData = useCallback(async () => {
|
||||
try {
|
||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||
@@ -108,7 +163,7 @@ export function ProxmoxDashboard() {
|
||||
})
|
||||
setIsServerConnected(true)
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data from Flask server:", error)
|
||||
// Expected to fail in v0 preview (no Flask server)
|
||||
|
||||
setIsServerConnected(false)
|
||||
setSystemStatus((prev) => ({
|
||||
@@ -125,20 +180,25 @@ export function ProxmoxDashboard() {
|
||||
useEffect(() => {
|
||||
// Siempre fetch inicial
|
||||
fetchSystemData()
|
||||
fetchHealthInfoCount() // Fetch info count on initial load
|
||||
|
||||
// En overview: cada 30 segundos para actualización frecuente del estado de salud
|
||||
// En otras tabs: cada 60 segundos para reducir carga
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let healthInterval: ReturnType<typeof setInterval> | null = null
|
||||
if (activeTab === "overview") {
|
||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
|
||||
} else {
|
||||
interval = setInterval(fetchSystemData, 60000) // 60 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (healthInterval) clearInterval(healthInterval)
|
||||
}
|
||||
}, [fetchSystemData, activeTab])
|
||||
}, [fetchSystemData, fetchHealthInfoCount, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChangeTab = (event: CustomEvent) => {
|
||||
@@ -156,7 +216,7 @@ export function ProxmoxDashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleHealthStatusUpdate = (event: CustomEvent) => {
|
||||
const { status } = event.detail
|
||||
const { status, infoCount: newInfoCount } = event.detail
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
|
||||
if (status === "CRITICAL") {
|
||||
@@ -171,6 +231,11 @@ export function ProxmoxDashboard() {
|
||||
...prev,
|
||||
status: healthStatus,
|
||||
}))
|
||||
|
||||
// Update info count (INFO categories + dismissed items)
|
||||
if (typeof newInfoCount === "number") {
|
||||
setInfoCount(newInfoCount)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
@@ -265,8 +330,10 @@ export function ProxmoxDashboard() {
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
case "security":
|
||||
return "Security"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
@@ -346,10 +413,18 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="ml-1">{infoCount} info</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
@@ -375,11 +450,18 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex lg:hidden items-center gap-2">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
<div className="flex lg:hidden items-start gap-2 pt-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="ml-1">{infoCount}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -389,12 +471,12 @@ export function ProxmoxDashboard() {
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 -mt-1"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,13 +492,13 @@ export function ProxmoxDashboard() {
|
||||
<div
|
||||
className={`sticky z-40 bg-background
|
||||
top-[120px] md:top-[76px]
|
||||
transition-all duration-700 ease-[cubic-bezier(0.4,0,0.2,1)]
|
||||
transition-all duration-700 ease-in-out
|
||||
${showNavigation ? "translate-y-0 opacity-100" : "-translate-y-[120%] opacity-0 pointer-events-none"}
|
||||
`}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-8 bg-card border border-border">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-9 bg-card border border-border">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -459,6 +541,12 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Security
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -588,6 +676,21 @@ export function ProxmoxDashboard() {
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("security")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "security"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
@@ -640,13 +743,17 @@ export function ProxmoxDashboard() {
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.0.2</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.0.2-beta</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Link2, Shield, Zap, HardDrive, Gauge, Wrench, Settings } from "lucide-react"
|
||||
import { X, Sparkles, Thermometer, Terminal, Activity, HardDrive, Bell, Shield, Globe, Cpu, Zap } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.0.2" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.0.2-beta" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -18,6 +18,32 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.1.2-beta": {
|
||||
date: "March 18, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Temperature & Latency Charts - Real-time visual monitoring with interactive graphs",
|
||||
"WebSocket Terminal - Direct access to Proxmox host and LXC containers terminal",
|
||||
"AI-Enhanced Notifications - Intelligent message formatting with multi-provider support (OpenAI, Groq, Anthropic, Ollama)",
|
||||
"Security Section - Comprehensive security settings for ProxMenux and Proxmox",
|
||||
"VPN Integration - Easy Tailscale VPN installation and configuration",
|
||||
"GPU Scripts - Installation utilities for Intel, AMD and NVIDIA drivers",
|
||||
"Disk Observations System - Track and document disk health observations over time",
|
||||
"Enhanced Health Monitor - Configurable monitoring with advanced settings panel",
|
||||
],
|
||||
changed: [
|
||||
"Improved overall performance with optimized data fetching",
|
||||
"Notifications now support rich formatting with contextual emojis",
|
||||
"Health monitor now configurable from Settings section",
|
||||
"Better Proxmox service name translation for non-expert users",
|
||||
],
|
||||
fixed: [
|
||||
"Fixed notification message truncation for large backup reports",
|
||||
"Improved disk error deduplication to prevent repeated alerts",
|
||||
"Corrected AI provider base URL handling for OpenAI-compatible APIs",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.0.1": {
|
||||
date: "November 11, 2025",
|
||||
changes: {
|
||||
@@ -25,23 +51,16 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"Proxy Support - Access ProxMenux through reverse proxies with full functionality",
|
||||
"Authentication System - Secure your dashboard with password protection",
|
||||
"PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
|
||||
"Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
|
||||
"SATA/SAS Information - View detailed interface information for all storage devices",
|
||||
"Two-Factor Authentication (2FA) - Enhanced security with TOTP support",
|
||||
"Health Monitoring System - Comprehensive system health checks with dismissible warnings",
|
||||
"Release Notes Modal - Automatic notification of new features and improvements",
|
||||
],
|
||||
changed: [
|
||||
"Optimized VM & LXC page - Reduced CPU usage by 85% through intelligent caching",
|
||||
"Storage metrics now separate local and remote storage for clarity",
|
||||
"Update warnings now appear only after 365 days instead of 30 days",
|
||||
"API intervals staggered to distribute server load (23s and 37s)",
|
||||
],
|
||||
fixed: [
|
||||
"Fixed dark mode text contrast issues in various components",
|
||||
"Corrected storage calculation discrepancies between Overview and Storage pages",
|
||||
"Resolved JSON stringify error in VM control actions",
|
||||
"Improved IP address fetching for LXC containers",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -63,32 +82,36 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Link2 className="h-5 w-5" />,
|
||||
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
|
||||
icon: <Thermometer className="h-5 w-5" />,
|
||||
text: "Temperature & Latency Charts - Real-time visual monitoring with interactive historical graphs",
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="h-5 w-5" />,
|
||||
text: "WebSocket Terminal - Direct terminal access to Proxmox host and LXC containers from the browser",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
text: "Enhanced Health Monitor - Configurable health monitoring with advanced settings and disk observations",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
text: "AI-Enhanced Notifications - Intelligent message formatting with support for OpenAI, Groq, Anthropic and Ollama",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
text: "Two-Factor Authentication (2FA) - Enhanced security with TOTP support for login protection",
|
||||
text: "Security Section - Comprehensive security configuration for both ProxMenux and Proxmox systems",
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-5 w-5" />,
|
||||
text: "VPN Integration - Easy Tailscale VPN installation and configuration for secure remote access",
|
||||
},
|
||||
{
|
||||
icon: <Cpu className="h-5 w-5" />,
|
||||
text: "GPU Drivers - Installation scripts for Intel, AMD and NVIDIA graphics drivers and utilities",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
text: "Performance Improvements - Optimized loading times and reduced CPU usage across the application",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
text: "Storage Enhancements - Improved disk space consumption display with local and remote storage separation",
|
||||
},
|
||||
{
|
||||
icon: <Gauge className="h-5 w-5" />,
|
||||
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and identify performance bottlenecks",
|
||||
},
|
||||
{
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
text: "Hardware Page Improvements - Enhanced hardware information display with detailed PCIe and interface data",
|
||||
},
|
||||
{
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
text: "New Settings Page - Centralized configuration for authentication, optimizations, and system preferences",
|
||||
text: "Performance Improvements - Optimized data fetching and reduced resource consumption",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,16 @@ import {
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
|
||||
@@ -641,13 +650,13 @@ export function ScriptTerminalModal({
|
||||
ref={resizeBarRef}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
className={`h-2 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
|
||||
className={`h-4 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
|
||||
isResizing ? "bg-blue-500" : "bg-zinc-800 hover:bg-blue-600"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<GripHorizontal
|
||||
className={`h-4 w-4 transition-colors pointer-events-none ${
|
||||
className={`h-5 w-5 transition-colors pointer-events-none ${
|
||||
isResizing ? "text-white" : "text-zinc-600 group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
@@ -736,22 +745,39 @@ export function ScriptTerminalModal({
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
className="h-8 px-2.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x03")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px]"
|
||||
>
|
||||
CTRL+C
|
||||
<CornerDownLeft className="h-4 w-4 mr-1" />
|
||||
Enter
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px] gap-1"
|
||||
>
|
||||
Ctrl
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -786,7 +812,7 @@ export function ScriptTerminalModal({
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="outline"
|
||||
className="bg-red-600 hover:bg-red-700 border-red-500 text-white"
|
||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+527
-812
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive } from "lucide-react"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -34,6 +34,31 @@ interface DiskInfo {
|
||||
wear_leveling_count?: number // SSD: Wear Leveling Count
|
||||
total_lbas_written?: number // SSD/NVMe: Total LBAs Written (GB)
|
||||
ssd_life_left?: number // SSD: SSD Life Left percentage
|
||||
io_errors?: {
|
||||
count: number
|
||||
severity: string
|
||||
sample: string
|
||||
reason: string
|
||||
error_type?: string // 'io' | 'filesystem'
|
||||
}
|
||||
observations_count?: number
|
||||
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
|
||||
removable?: boolean
|
||||
}
|
||||
|
||||
interface DiskObservation {
|
||||
id: number
|
||||
error_type: string
|
||||
error_signature: string
|
||||
first_occurrence: string
|
||||
last_occurrence: string
|
||||
occurrence_count: number
|
||||
raw_message: string
|
||||
severity: string
|
||||
dismissed: boolean
|
||||
device_name: string
|
||||
serial: string
|
||||
model: string
|
||||
}
|
||||
|
||||
interface ZFSPool {
|
||||
@@ -91,6 +116,8 @@ export function StorageOverview() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
|
||||
const [loadingObservations, setLoadingObservations] = useState(false)
|
||||
|
||||
const fetchStorageData = async () => {
|
||||
try {
|
||||
@@ -234,11 +261,43 @@ export function StorageOverview() {
|
||||
return badgeStyles[diskType]
|
||||
}
|
||||
|
||||
const handleDiskClick = (disk: DiskInfo) => {
|
||||
const handleDiskClick = async (disk: DiskInfo) => {
|
||||
setSelectedDisk(disk)
|
||||
setDetailsOpen(true)
|
||||
setDiskObservations([])
|
||||
|
||||
// Always attempt to fetch observations -- the count enrichment may lag
|
||||
// behind the actual observation recording (especially for USB disks).
|
||||
setLoadingObservations(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (disk.name) params.set('device', disk.name)
|
||||
if (disk.serial && disk.serial !== 'Unknown') params.set('serial', disk.serial)
|
||||
const data = await fetchApi<{ observations: DiskObservation[] }>(`/api/storage/observations?${params.toString()}`)
|
||||
setDiskObservations(data.observations || [])
|
||||
} catch {
|
||||
setDiskObservations([])
|
||||
} finally {
|
||||
setLoadingObservations(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatObsDate = (iso: string) => {
|
||||
if (!iso) return 'N/A'
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const day = d.getDate().toString().padStart(2, '0')
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0')
|
||||
const year = d.getFullYear()
|
||||
const hours = d.getHours().toString().padStart(2, '0')
|
||||
const mins = d.getMinutes().toString().padStart(2, '0')
|
||||
return `${day}/${month}/${year} ${hours}:${mins}`
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
const obsTypeLabel = (t: string) =>
|
||||
({ smart_error: 'SMART Error', io_error: 'I/O Error', filesystem_error: 'Filesystem Error', zfs_pool_error: 'ZFS Pool Error', connection_error: 'Connection Error' }[t] || t)
|
||||
|
||||
const getStorageTypeBadge = (type: string) => {
|
||||
const typeColors: Record<string, string> = {
|
||||
pbs: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||
@@ -368,21 +427,26 @@ export function StorageOverview() {
|
||||
|
||||
const getDiskTypesBreakdown = () => {
|
||||
if (!storageData || !storageData.disks) {
|
||||
return { nvme: 0, ssd: 0, hdd: 0 }
|
||||
return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
|
||||
}
|
||||
|
||||
let nvme = 0
|
||||
let ssd = 0
|
||||
let hdd = 0
|
||||
let usb = 0
|
||||
|
||||
storageData.disks.forEach((disk) => {
|
||||
if (disk.connection_type === 'usb') {
|
||||
usb++
|
||||
return
|
||||
}
|
||||
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
||||
if (diskType === "NVMe") nvme++
|
||||
else if (diskType === "SSD") ssd++
|
||||
else if (diskType === "HDD") hdd++
|
||||
})
|
||||
|
||||
return { nvme, ssd, hdd }
|
||||
return { nvme, ssd, hdd, usb }
|
||||
}
|
||||
|
||||
const getWearProgressColor = (wearPercent: number): string => {
|
||||
@@ -476,8 +540,13 @@ export function StorageOverview() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading storage information...</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading storage data...</div>
|
||||
<p className="text-xs text-muted-foreground">Scanning disks, partitions and storage pools</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -565,6 +634,12 @@ export function StorageOverview() {
|
||||
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.usb > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0 || diskTypesBreakdown.hdd > 0) && ", "}
|
||||
<span className="text-orange-400">{diskTypesBreakdown.usb} USB</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
||||
@@ -599,11 +674,20 @@ export function StorageOverview() {
|
||||
{proxmoxStorage.storage
|
||||
.filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((storage) => (
|
||||
.map((storage) => {
|
||||
// Check if storage is excluded from monitoring
|
||||
const isExcluded = storage.excluded === true
|
||||
const hasError = storage.status === "error" && !isExcluded
|
||||
|
||||
return (
|
||||
<div
|
||||
key={storage.name}
|
||||
className={`border rounded-lg p-4 ${
|
||||
storage.status === "error" ? "border-red-500/50 bg-red-500/5" : ""
|
||||
hasError
|
||||
? "border-red-500/50 bg-red-500/5"
|
||||
: isExcluded
|
||||
? "border-purple-500/30 bg-purple-500/5 opacity-75"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -612,27 +696,40 @@ export function StorageOverview() {
|
||||
<Database className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-lg">{storage.name}</h3>
|
||||
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
|
||||
{isExcluded && (
|
||||
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
|
||||
excluded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex md:hidden items-center gap-2 flex-1">
|
||||
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
|
||||
<h3 className="font-semibold text-base flex-1 min-w-0 truncate">{storage.name}</h3>
|
||||
{getStatusIcon(storage.status)}
|
||||
{isExcluded ? (
|
||||
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
|
||||
excluded
|
||||
</Badge>
|
||||
) : (
|
||||
getStatusIcon(storage.status)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Badge active + Porcentaje */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Badge
|
||||
className={
|
||||
storage.status === "active"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: storage.status === "error"
|
||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
isExcluded
|
||||
? "bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
: storage.status === "active"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: storage.status === "error"
|
||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
}
|
||||
>
|
||||
{storage.status}
|
||||
{isExcluded ? "not monitored" : storage.status}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{storage.percent}%</span>
|
||||
</div>
|
||||
@@ -675,7 +772,8 @@ export function StorageOverview() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -722,7 +820,7 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Physical Disks */}
|
||||
{/* Physical Disks (internal only) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -732,7 +830,7 @@ export function StorageOverview() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.map((disk) => (
|
||||
{storageData.disks.filter(d => d.connection_type !== 'usb').map((disk) => (
|
||||
<div key={disk.name}>
|
||||
<div
|
||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
||||
@@ -766,13 +864,34 @@ export function StorageOverview() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count} obs.
|
||||
</Badge>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
||||
<div className={`flex items-start gap-2 p-2 rounded text-xs ${
|
||||
disk.io_errors.severity === 'CRITICAL'
|
||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
||||
}`}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{disk.io_errors.error_type === 'filesystem'
|
||||
? `Filesystem corruption detected`
|
||||
: `${disk.io_errors.count} I/O error${disk.io_errors.count !== 1 ? 's' : ''} in 5 min`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{disk.size_formatted}</p>
|
||||
@@ -831,11 +950,44 @@ export function StorageOverview() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count} obs.
|
||||
</Badge>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
||||
<div className={`flex items-start gap-2 p-2 rounded text-xs ${
|
||||
disk.io_errors.severity === 'CRITICAL'
|
||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
||||
}`}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
{disk.io_errors.error_type === 'filesystem' ? (
|
||||
<>
|
||||
<span className="font-medium">Filesystem corruption detected</span>
|
||||
{disk.io_errors.reason && (
|
||||
<p className="mt-0.5 opacity-90 whitespace-pre-line">{disk.io_errors.reason}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min</span>
|
||||
{disk.io_errors.sample && (
|
||||
<p className="mt-0.5 opacity-80 font-mono truncate max-w-md">{disk.io_errors.sample}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
@@ -858,7 +1010,7 @@ export function StorageOverview() {
|
||||
{disk.serial && disk.serial !== "Unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial</p>
|
||||
<p className="font-medium text-xs">{disk.serial}</p>
|
||||
<p className="font-medium text-xs">{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -869,13 +1021,185 @@ export function StorageOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* External Storage (USB) */}
|
||||
{storageData.disks.filter(d => d.connection_type === 'usb').length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Usb className="h-5 w-5" />
|
||||
External Storage (USB)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.filter(d => d.connection_type === 'usb').map((disk) => (
|
||||
<div key={disk.name}>
|
||||
{/* Mobile card */}
|
||||
<div
|
||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
||||
onClick={() => handleDiskClick(disk)}
|
||||
>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Usb className="h-5 w-5 text-orange-400 flex-shrink-0" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 pl-7">
|
||||
{disk.model && disk.model !== "Unknown" && (
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1 text-[10px] px-1.5 py-0">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count}
|
||||
</Badge>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USB Mobile: Size, SMART, Serial grid */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{disk.size_formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.serial && disk.serial !== "Unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial</p>
|
||||
<p className="font-medium text-xs">{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div
|
||||
className="hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
onClick={() => handleDiskClick(disk)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Usb className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count} obs.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{disk.model && disk.model !== "Unknown" && (
|
||||
<p className="text-sm text-muted-foreground mb-3 ml-7">{disk.model}</p>
|
||||
)}
|
||||
|
||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
||||
<div className={`flex items-start gap-2 p-2 rounded text-xs mb-3 ${
|
||||
disk.io_errors.severity === 'CRITICAL'
|
||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
||||
}`}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
{disk.io_errors.error_type === 'filesystem' ? (
|
||||
<>
|
||||
<span className="font-medium">Filesystem corruption detected</span>
|
||||
{disk.io_errors.reason && (
|
||||
<p className="mt-0.5 opacity-90 whitespace-pre-line">{disk.io_errors.reason}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min</span>
|
||||
{disk.io_errors.sample && (
|
||||
<p className="mt-0.5 opacity-80 font-mono truncate max-w-md">{disk.io_errors.sample}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{disk.size_formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.serial && disk.serial !== "Unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial</p>
|
||||
<p className="font-medium text-xs">{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Disk Details Dialog */}
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
{selectedDisk?.connection_type === 'usb' ? (
|
||||
<Usb className="h-5 w-5 text-orange-400" />
|
||||
) : (
|
||||
<HardDrive className="h-5 w-5" />
|
||||
)}
|
||||
Disk Details: /dev/{selectedDisk?.name}
|
||||
{selectedDisk?.connection_type === 'usb' && (
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete SMART information and health status</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -888,7 +1212,7 @@ export function StorageOverview() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial Number</p>
|
||||
<p className="font-medium">{selectedDisk.serial}</p>
|
||||
<p className="font-medium">{selectedDisk.serial?.replace(/\\x[0-9a-fA-F]{2}/g, '') || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Capacity</p>
|
||||
@@ -896,7 +1220,15 @@ export function StorageOverview() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Health Status</p>
|
||||
<div className="mt-1">{getHealthBadge(selectedDisk.health)}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getHealthBadge(selectedDisk.health)}
|
||||
{(selectedDisk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{selectedDisk.observations_count} obs.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1000,6 +1332,74 @@ export function StorageOverview() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Observations Section */}
|
||||
{(diskObservations.length > 0 || loadingObservations) && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
Observations
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 text-[10px] px-1.5 py-0">
|
||||
{diskObservations.length}
|
||||
</Badge>
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
The following observations have been recorded for this disk:
|
||||
</p>
|
||||
{loadingObservations ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-transparent border-t-blue-400 animate-spin" />
|
||||
Loading observations...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{diskObservations.map((obs) => (
|
||||
<div
|
||||
key={obs.id}
|
||||
className={`rounded-lg border p-3 text-sm ${
|
||||
obs.severity === 'critical'
|
||||
? 'bg-red-500/5 border-red-500/20'
|
||||
: 'bg-blue-500/5 border-blue-500/20'
|
||||
}`}
|
||||
>
|
||||
{/* Header with type badge */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<Badge className={`text-[10px] px-1.5 py-0 ${
|
||||
obs.severity === 'critical'
|
||||
? 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
: 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
}`}>
|
||||
{obsTypeLabel(obs.error_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Error message - responsive text wrap */}
|
||||
<p className="text-xs whitespace-pre-wrap break-words opacity-90 font-mono leading-relaxed mb-3">
|
||||
{obs.raw_message}
|
||||
</p>
|
||||
|
||||
{/* Dates - stacked on mobile, inline on desktop */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 text-[10px] text-muted-foreground border-t border-white/5 pt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="break-words">First: {formatObsDate(obs.first_occurrence)}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="break-words">Last: {formatObsDate(obs.last_occurrence)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Occurrences count */}
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Occurrences: <span className="font-medium text-foreground">{obs.occurrence_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -30,16 +30,6 @@ import {
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface Log {
|
||||
timestamp: string
|
||||
level: string
|
||||
service: string
|
||||
message: string
|
||||
source: string
|
||||
pid?: string
|
||||
hostname?: string
|
||||
}
|
||||
|
||||
interface Backup {
|
||||
volid: string
|
||||
storage: string
|
||||
@@ -76,6 +66,7 @@ interface SystemLog {
|
||||
timestamp: string
|
||||
level: string
|
||||
service: string
|
||||
unit?: string
|
||||
message: string
|
||||
source: string
|
||||
pid?: string
|
||||
@@ -86,6 +77,7 @@ interface CombinedLogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
service: string
|
||||
unit?: string
|
||||
message: string
|
||||
source: string
|
||||
pid?: string
|
||||
@@ -108,161 +100,73 @@ export function SystemLogs() {
|
||||
const [serviceFilter, setServiceFilter] = useState("all")
|
||||
const [activeTab, setActiveTab] = useState("logs")
|
||||
|
||||
const [displayedLogsCount, setDisplayedLogsCount] = useState(50) // Increased from 500 to 50 for initial load, will use pagination
|
||||
const [displayedLogsCount, setDisplayedLogsCount] = useState(100)
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<SystemLog | null>(null)
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null)
|
||||
const [selectedBackup, setSelectedBackup] = useState<Backup | null>(null)
|
||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null) // Added
|
||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
||||
const [isLogModalOpen, setIsLogModalOpen] = useState(false)
|
||||
const [isEventModalOpen, setIsEventModalOpen] = useState(false)
|
||||
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false)
|
||||
const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false) // Added
|
||||
const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false)
|
||||
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
|
||||
const [dateFilter, setDateFilter] = useState("1") // Changed from "now" to "1" to load 1 day by default
|
||||
const [dateFilter, setDateFilter] = useState("1")
|
||||
const [customDays, setCustomDays] = useState("1")
|
||||
const [refreshCounter, setRefreshCounter] = useState(0)
|
||||
|
||||
const getApiUrl = (endpoint: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
|
||||
if (isStandardPort) {
|
||||
return endpoint
|
||||
} else {
|
||||
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
|
||||
}
|
||||
}
|
||||
// This part might not be strictly necessary if only running client-side, but good for SSR safety
|
||||
// In a real SSR scenario, you'd need to handle API_PORT differently
|
||||
const protocol = typeof window !== "undefined" ? window.location.protocol : "http:" // Defaulting to http for SSR safety
|
||||
const hostname = typeof window !== "undefined" ? window.location.hostname : "localhost" // Defaulting to localhost for SSR safety
|
||||
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
|
||||
}
|
||||
|
||||
// Single unified useEffect for all data loading
|
||||
// Fires on mount, when filters change, or when refresh is triggered
|
||||
useEffect(() => {
|
||||
fetchAllData()
|
||||
}, [])
|
||||
|
||||
// CHANGE: Simplified useEffect - always fetch logs with date filter (no more "now" option)
|
||||
useEffect(() => {
|
||||
console.log("[v0] Date filter changed:", dateFilter, "Custom days:", customDays)
|
||||
setLoading(true)
|
||||
fetchSystemLogs()
|
||||
.then((newLogs) => {
|
||||
console.log("[v0] Loaded logs for date filter:", dateFilter, "Count:", newLogs.length)
|
||||
console.log("[v0] First log:", newLogs[0])
|
||||
setLogs(newLogs)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[v0] Error loading logs:", err)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [dateFilter, customDays])
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[v0] Level or service filter changed:", levelFilter, serviceFilter)
|
||||
if (levelFilter !== "all" || serviceFilter !== "all") {
|
||||
setLoading(true)
|
||||
fetchSystemLogs()
|
||||
.then((newLogs) => {
|
||||
console.log(
|
||||
"[v0] Loaded logs for filters - Level:",
|
||||
levelFilter,
|
||||
"Service:",
|
||||
serviceFilter,
|
||||
"Count:",
|
||||
newLogs.length,
|
||||
)
|
||||
setLogs(newLogs)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[v0] Error loading logs:", err)
|
||||
setLoading(false)
|
||||
})
|
||||
} else {
|
||||
// Only reload all data if we're on "now" and all filters are cleared
|
||||
// This else block is now theoretically unreachable given the change above, but kept for safety
|
||||
fetchAllData()
|
||||
}
|
||||
}, [levelFilter, serviceFilter])
|
||||
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
let cancelled = false
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
|
||||
fetchSystemLogs(),
|
||||
fetchApi("/api/backups"),
|
||||
fetchApi("/api/events?limit=50"),
|
||||
fetchApi("/api/notifications"),
|
||||
])
|
||||
|
||||
setLogs(logsRes)
|
||||
setBackups(backupsRes.backups || [])
|
||||
setEvents(eventsRes.events || [])
|
||||
setNotifications(notificationsRes.notifications || [])
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching system logs data:", err)
|
||||
setError("Failed to connect to server")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
try {
|
||||
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
|
||||
fetchSystemLogs(dateFilter, customDays),
|
||||
fetchApi("/api/backups"),
|
||||
fetchApi("/api/events?limit=50"),
|
||||
fetchApi("/api/notifications"),
|
||||
])
|
||||
if (cancelled) return
|
||||
setLogs(logsRes)
|
||||
setBackups(backupsRes.backups || [])
|
||||
setEvents(eventsRes.events || [])
|
||||
setNotifications(notificationsRes.notifications || [])
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError("Failed to connect to server")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
return () => { cancelled = true }
|
||||
}, [dateFilter, customDays, refreshCounter])
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setDisplayedLogsCount(100)
|
||||
}, [searchTerm, levelFilter, serviceFilter, dateFilter, customDays])
|
||||
|
||||
const refreshData = () => {
|
||||
setRefreshCounter((prev) => prev + 1)
|
||||
}
|
||||
|
||||
const fetchSystemLogs = async (): Promise<SystemLog[]> => {
|
||||
const fetchSystemLogs = async (filterDays: string, filterCustom: string): Promise<SystemLog[]> => {
|
||||
try {
|
||||
let apiUrl = "/api/logs"
|
||||
const params = new URLSearchParams()
|
||||
const daysAgo = filterDays === "custom" ? Number.parseInt(filterCustom) : Number.parseInt(filterDays)
|
||||
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
|
||||
const apiUrl = `/api/logs?since_days=${clampedDays}`
|
||||
|
||||
// CHANGE: Always add since_days parameter (no more "now" option)
|
||||
const daysAgo = dateFilter === "custom" ? Number.parseInt(customDays) : Number.parseInt(dateFilter)
|
||||
params.append("since_days", daysAgo.toString())
|
||||
console.log("[v0] Fetching logs since_days:", daysAgo)
|
||||
|
||||
if (levelFilter !== "all") {
|
||||
const priorityMap: Record<string, string> = {
|
||||
error: "3", // 0-3: emerg, alert, crit, err
|
||||
warning: "4", // 4: warning
|
||||
info: "6", // 5-7: notice, info, debug
|
||||
}
|
||||
const priority = priorityMap[levelFilter]
|
||||
if (priority) {
|
||||
params.append("priority", priority)
|
||||
console.log("[v0] Fetching logs with priority:", priority, "for level:", levelFilter)
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceFilter !== "all") {
|
||||
params.append("service", serviceFilter)
|
||||
console.log("[v0] Fetching logs for service:", serviceFilter)
|
||||
}
|
||||
|
||||
params.append("limit", "5000")
|
||||
|
||||
if (params.toString()) {
|
||||
apiUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
console.log("[v0] Making fetch request to:", apiUrl)
|
||||
const data = await fetchApi(apiUrl)
|
||||
console.log("[v0] Received logs data, count:", data.logs?.length || 0)
|
||||
|
||||
const logsArray = Array.isArray(data) ? data : data.logs || []
|
||||
console.log("[v0] Returning logs array with length:", logsArray.length)
|
||||
return logsArray
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system logs:", error)
|
||||
if (error instanceof Error && error.name === "TimeoutError") {
|
||||
setError("Request timed out. Try selecting a more specific filter.")
|
||||
} else {
|
||||
setError("Failed to load logs. Please try again.")
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load logs. Please try again.")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -271,7 +175,6 @@ export function SystemLogs() {
|
||||
try {
|
||||
// Generate filename based on active filters
|
||||
const filters = []
|
||||
// CHANGE: Always include days in filename (no more "now" option)
|
||||
const days = dateFilter === "custom" ? customDays : dateFilter
|
||||
filters.push(`${days}days`)
|
||||
|
||||
@@ -294,7 +197,7 @@ export function SystemLogs() {
|
||||
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||
``,
|
||||
`Filters Applied:`,
|
||||
`- Date Range: ${dateFilter === "now" ? "Current logs" : dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} days ago`}`,
|
||||
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
|
||||
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
|
||||
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
|
||||
`- Search: ${searchTerm || "None"}`,
|
||||
@@ -368,8 +271,7 @@ export function SystemLogs() {
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch task log from Proxmox:", error)
|
||||
} catch {
|
||||
// Fall through to download notification message
|
||||
}
|
||||
}
|
||||
@@ -397,8 +299,8 @@ export function SystemLogs() {
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
console.error("[v0] Error downloading notification:", err)
|
||||
} catch {
|
||||
// Download failed silently
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,70 +309,11 @@ export function SystemLogs() {
|
||||
return String(value).toLowerCase()
|
||||
}
|
||||
|
||||
const memoizedLogs = useMemo(() => logs, [logs])
|
||||
const memoizedEvents = useMemo(() => events, [events])
|
||||
const memoizedBackups = useMemo(() => backups, [backups])
|
||||
const memoizedNotifications = useMemo(() => notifications, [notifications])
|
||||
|
||||
const logsOnly: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
memoizedLogs
|
||||
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
|
||||
[memoizedLogs],
|
||||
)
|
||||
|
||||
const eventsOnly: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
memoizedEvents
|
||||
.map((event) => ({
|
||||
timestamp: event.starttime,
|
||||
level: event.level,
|
||||
service: event.type,
|
||||
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
||||
source: `Node: ${event.node} • User: ${event.user}`,
|
||||
isEvent: true,
|
||||
eventData: event,
|
||||
sortTimestamp: new Date(event.starttime).getTime(),
|
||||
}))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
|
||||
[memoizedEvents],
|
||||
)
|
||||
|
||||
const filteredLogsOnly = logsOnly.filter((log) => {
|
||||
const message = log.message || ""
|
||||
const service = log.service || ""
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
const matchesSearch =
|
||||
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
|
||||
const matchesLevel = levelFilter === "all" || log.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || log.service === serviceFilter
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
|
||||
const filteredEventsOnly = eventsOnly.filter((event) => {
|
||||
const message = event.message || ""
|
||||
const service = event.service || ""
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
const matchesSearch =
|
||||
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
|
||||
const matchesLevel = levelFilter === "all" || event.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || event.service === serviceFilter
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
|
||||
const displayedLogsOnly = filteredLogsOnly.slice(0, displayedLogsCount)
|
||||
const displayedEventsOnly = filteredEventsOnly.slice(0, displayedLogsCount)
|
||||
|
||||
const combinedLogs: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
...memoizedLogs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
|
||||
...memoizedEvents.map((event) => ({
|
||||
...logs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
|
||||
...events.map((event) => ({
|
||||
timestamp: event.starttime,
|
||||
level: event.level,
|
||||
service: event.type,
|
||||
@@ -481,18 +324,20 @@ export function SystemLogs() {
|
||||
sortTimestamp: new Date(event.starttime).getTime(),
|
||||
})),
|
||||
].sort((a, b) => b.sortTimestamp - a.sortTimestamp),
|
||||
[memoizedLogs, memoizedEvents],
|
||||
[logs, events],
|
||||
)
|
||||
|
||||
const filteredCombinedLogs = useMemo(
|
||||
() =>
|
||||
combinedLogs.filter((log) => {
|
||||
const message = log.message || ""
|
||||
const service = log.service || ""
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
const matchesSearch =
|
||||
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
|
||||
const matchesSearch = !searchTermLower ||
|
||||
safeToLowerCase(log.message).includes(searchTermLower) ||
|
||||
safeToLowerCase(log.service).includes(searchTermLower) ||
|
||||
safeToLowerCase(log.pid).includes(searchTermLower) ||
|
||||
safeToLowerCase(log.hostname).includes(searchTermLower) ||
|
||||
safeToLowerCase(log.unit).includes(searchTermLower)
|
||||
const matchesLevel = levelFilter === "all" || log.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || log.service === serviceFilter
|
||||
|
||||
@@ -501,7 +346,6 @@ export function SystemLogs() {
|
||||
[combinedLogs, searchTerm, levelFilter, serviceFilter],
|
||||
)
|
||||
|
||||
// CHANGE: Re-assigning displayedLogs to use the filteredCombinedLogs
|
||||
const displayedLogs = filteredCombinedLogs.slice(0, displayedLogsCount)
|
||||
const hasMoreLogs = displayedLogsCount < filteredCombinedLogs.length
|
||||
|
||||
@@ -577,7 +421,6 @@ export function SystemLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// ADDED: New function for notification source colors
|
||||
const getNotificationSourceColor = (source: string) => {
|
||||
if (!source) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
|
||||
@@ -600,7 +443,10 @@ export function SystemLogs() {
|
||||
info: logs.filter((log) => ["info", "notice", "debug"].includes(log.level)).length,
|
||||
}
|
||||
|
||||
const uniqueServices = useMemo(() => [...new Set(memoizedLogs.map((log) => log.service))], [memoizedLogs])
|
||||
const uniqueServices = useMemo(
|
||||
() => [...new Set(logs.map((log) => log.service).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
||||
[logs],
|
||||
)
|
||||
|
||||
const getBackupType = (volid: string): "vm" | "lxc" => {
|
||||
if (volid.includes("/vm/") || volid.includes("vzdump-qemu")) {
|
||||
@@ -695,8 +541,13 @@ export function SystemLogs() {
|
||||
|
||||
if (loading && logs.length === 0 && events.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -704,11 +555,13 @@ export function SystemLogs() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{loading && (logs.length > 0 || events.length > 0) && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 p-8 rounded-lg bg-card border border-border shadow-lg">
|
||||
<RefreshCw className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="text-lg font-medium text-foreground">Loading logs selected...</div>
|
||||
<div className="text-sm text-muted-foreground">Please wait while we fetch the logs</div>
|
||||
<div className="fixed inset-0 bg-background/60 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-card border border-border shadow-xl">
|
||||
<div className="relative">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -770,7 +623,7 @@ export function SystemLogs() {
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Logs & Events
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={fetchAllData} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -875,7 +728,6 @@ export function SystemLogs() {
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search logs & events..."
|
||||
// CHANGE: Renamed searchTerm to searchQuery
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 bg-background border-border"
|
||||
@@ -928,8 +780,8 @@ export function SystemLogs() {
|
||||
<SelectItem key="service-all" value="all">
|
||||
All Services
|
||||
</SelectItem>
|
||||
{uniqueServices.slice(0, 20).map((service, idx) => (
|
||||
<SelectItem key={`service-${service}-${idx}`} value={service}>
|
||||
{uniqueServices.map((service) => (
|
||||
<SelectItem key={`service-${service}`} value={service}>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -990,6 +842,7 @@ export function SystemLogs() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
|
||||
{log.source}
|
||||
{log.unit && log.unit !== log.service && ` • Unit: ${log.unit}`}
|
||||
{log.pid && ` • PID: ${log.pid}`}
|
||||
{log.hostname && ` • Host: ${log.hostname}`}
|
||||
</div>
|
||||
@@ -1009,7 +862,7 @@ export function SystemLogs() {
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDisplayedLogsCount((prev) => prev + 500)}
|
||||
onClick={() => setDisplayedLogsCount((prev) => prev + 200)}
|
||||
className="border-border"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
@@ -1057,7 +910,7 @@ export function SystemLogs() {
|
||||
|
||||
<ScrollArea className="h-[500px] w-full rounded-md border border-border">
|
||||
<div className="space-y-2 p-4">
|
||||
{memoizedBackups.map((backup, index) => {
|
||||
{backups.map((backup, index) => {
|
||||
const uniqueKey = `backup-${backup.volid.replace(/[/:]/g, "-")}-${backup.timestamp || index}`
|
||||
|
||||
return (
|
||||
@@ -1114,7 +967,7 @@ export function SystemLogs() {
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<ScrollArea className="h-[600px] w-full rounded-md border border-border">
|
||||
<div className="space-y-2 p-4">
|
||||
{memoizedNotifications.map((notification, index) => {
|
||||
{notifications.map((notification, index) => {
|
||||
const timestampMs = new Date(notification.timestamp).getTime()
|
||||
const uniqueKey = `notification-${timestampMs}-${notification.service?.substring(0, 10) || "unknown"}-${notification.source?.substring(0, 10) || "unknown"}-${index}`
|
||||
|
||||
@@ -1202,6 +1055,12 @@ export function SystemLogs() {
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div>
|
||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
|
||||
</div>
|
||||
{selectedLog.unit && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div>
|
||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.pid && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div>
|
||||
|
||||
@@ -7,9 +7,17 @@ import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
interface TempDataPoint {
|
||||
timestamp: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
@@ -17,6 +25,7 @@ interface SystemData {
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
temperature: number
|
||||
temperature_sparkline?: TempDataPoint[]
|
||||
uptime: string
|
||||
load_average: number[]
|
||||
hostname: string
|
||||
@@ -177,6 +186,7 @@ export function SystemOverview() {
|
||||
const [networkTimeframe, setNetworkTimeframe] = useState("day")
|
||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
|
||||
const [tempModalOpen, setTempModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
@@ -457,27 +467,60 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<Card
|
||||
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
|
||||
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
|
||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`}
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
||||
</span>
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"}
|
||||
</p>
|
||||
{systemData.temperature > 0 && systemData.temperature_sparkline && systemData.temperature_sparkline.length > 1 ? (
|
||||
<div className="mt-2 h-10">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={systemData.temperature_sparkline} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="tempSparkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"}
|
||||
strokeWidth={1.5}
|
||||
fill="url(#tempSparkGradient)"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TemperatureDetailModal
|
||||
open={tempModalOpen}
|
||||
onOpenChange={setTempModalOpen}
|
||||
liveTemperature={systemData.temperature}
|
||||
/>
|
||||
|
||||
<NodeMetricsCharts />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -508,7 +551,7 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
{formatNetworkTraffic(totalCapacity, "Bytes")}
|
||||
{formatStorage(totalCapacity)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
@@ -520,13 +563,13 @@ export function SystemOverview() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Used:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatNetworkTraffic(totalUsed, "Bytes")}
|
||||
{formatStorage(totalUsed)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free:{" "}
|
||||
<span className="font-semibold text-green-500">
|
||||
{formatNetworkTraffic(totalAvailable, "Bytes")}
|
||||
{formatStorage(totalAvailable)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -555,20 +598,20 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatNetworkTraffic(vmLxcStorageUsed, "Bytes")}
|
||||
{formatStorage(vmLxcStorageUsed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatNetworkTraffic(vmLxcStorageAvailable, "Bytes")}
|
||||
{formatStorage(vmLxcStorageAvailable)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={vmLxcStoragePercent} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatNetworkTraffic(vmLxcStorageUsed, "Bytes")} /{" "}
|
||||
{formatNetworkTraffic(vmLxcStorageTotal, "Bytes")}
|
||||
{formatStorage(vmLxcStorageUsed)} /{" "}
|
||||
{formatStorage(vmLxcStorageTotal)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{vmLxcStoragePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
@@ -591,20 +634,20 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatNetworkTraffic(localStorage.used, "Bytes")}
|
||||
{formatStorage(localStorage.used)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatNetworkTraffic(localStorage.available, "Bytes")}
|
||||
{formatStorage(localStorage.available)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={localStorage.percent} className="mt-2 [&>div]:bg-purple-500" />
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatNetworkTraffic(localStorage.used, "Bytes")} /{" "}
|
||||
{formatNetworkTraffic(localStorage.total, "Bytes")}
|
||||
{formatStorage(localStorage.used)} /{" "}
|
||||
{formatStorage(localStorage.total)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{localStorage.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
]
|
||||
|
||||
interface TempHistoryPoint {
|
||||
timestamp: number
|
||||
value: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
interface TempStats {
|
||||
min: number
|
||||
max: number
|
||||
avg: number
|
||||
current: number
|
||||
}
|
||||
|
||||
interface TemperatureDetailModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
liveTemperature?: number
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value}°C</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusColor = (temp: number) => {
|
||||
if (temp >= 75) return "#ef4444"
|
||||
if (temp >= 60) return "#f59e0b"
|
||||
return "#22c55e"
|
||||
}
|
||||
|
||||
const getStatusInfo = (temp: number) => {
|
||||
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
||||
const [timeframe, setTimeframe] = useState("hour")
|
||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHistory()
|
||||
}
|
||||
}, [open, timeframe])
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
|
||||
`/api/temperature/history?timeframe=${timeframe}`
|
||||
)
|
||||
if (result && result.data) {
|
||||
setData(result.data)
|
||||
setStats(result.stats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[v0] Failed to fetch temperature history:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (timeframe === "hour") {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
} else if (timeframe === "day") {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
...d,
|
||||
time: formatTime(d.timestamp),
|
||||
}))
|
||||
|
||||
// Use live temperature from the overview card (real-time) instead of last DB record
|
||||
const currentTemp = liveTemperature && liveTemperature > 0 ? Math.round(liveTemperature * 10) / 10 : stats.current
|
||||
const currentStatus = getStatusInfo(currentTemp)
|
||||
const chartColor = getStatusColor(currentTemp)
|
||||
|
||||
// Calculate Y axis domain based on plotted data values only.
|
||||
// Stats cards already show the real historical min/max separately.
|
||||
// Using only graphed values keeps the chart readable and avoids
|
||||
// large empty gaps caused by momentary spikes that get averaged out.
|
||||
const values = data.map((d) => d.value)
|
||||
const yMin = values.length > 0 ? Math.max(0, Math.floor(Math.min(...values) - 3)) : 0
|
||||
const yMax = values.length > 0 ? Math.ceil(Math.max(...values) + 3) : 100
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl bg-card border-border px-3 sm:px-6">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Thermometer className="h-5 w-5" />
|
||||
CPU Temperature
|
||||
</DialogTitle>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-[130px] bg-card border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
|
||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
||||
<div className="text-lg font-bold">{currentTemp}°C</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" /> Min
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||
<Minus className="h-3 w-3" /> Avg
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" /> Max
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="h-[300px] lg:h-[350px]">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="space-y-3 w-full animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-1/4 mx-auto" />
|
||||
<div className="h-[250px] bg-muted/50 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No temperature data available for this period</p>
|
||||
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="tempGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={chartColor} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={chartColor} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={isMobile ? 40 : 60}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[yMin, yMax]}
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
|
||||
tickFormatter={(v) => `${v}°`}
|
||||
width={isMobile ? 40 : 45}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="Temperature"
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill="url(#tempGradient)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,16 @@ import {
|
||||
AlignJustify,
|
||||
Grid2X2,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -34,6 +43,7 @@ interface TerminalInstance {
|
||||
ws: WebSocket | null
|
||||
isConnected: boolean
|
||||
fitAddon: any // Added fitAddon to TerminalInstance
|
||||
pingInterval?: ReturnType<typeof setInterval> | null // Heartbeat interval to keep connection alive
|
||||
}
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
@@ -171,6 +181,29 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle page visibility change for automatic reconnection when user returns
|
||||
// This is especially important for mobile/tablet devices (iPad) where switching apps
|
||||
// puts the browser tab in background and may close WebSocket connections
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// When page becomes visible again, check all terminal connections
|
||||
terminals.forEach((terminal) => {
|
||||
if (terminal.ws && terminal.ws.readyState !== WebSocket.OPEN && terminal.term) {
|
||||
// Terminal is disconnected, attempt to reconnect
|
||||
reconnectTerminal(terminal.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [terminals])
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Bloquear solo en pantallas muy pequeñas (móviles)
|
||||
if (window.innerWidth < 640 && !isTablet) {
|
||||
@@ -273,6 +306,85 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
|
||||
// Function to reconnect a terminal when connection is lost
|
||||
// This is called when page visibility changes (user returns from another app)
|
||||
const reconnectTerminal = async (terminalId: string) => {
|
||||
const terminal = terminals.find(t => t.id === terminalId)
|
||||
if (!terminal || !terminal.term) return
|
||||
|
||||
// Show reconnecting message
|
||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m')
|
||||
|
||||
const wsUrl = websocketUrl || getWebSocketUrl()
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
// Clear any existing ping interval
|
||||
if (terminal.pingInterval) {
|
||||
clearInterval(terminal.pingInterval)
|
||||
}
|
||||
|
||||
// Start heartbeat ping every 25 seconds to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} else {
|
||||
clearInterval(pingInterval)
|
||||
}
|
||||
}, 25000)
|
||||
|
||||
setTerminals((prev) =>
|
||||
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
|
||||
)
|
||||
terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m')
|
||||
|
||||
// Sync terminal size
|
||||
if (terminal.fitAddon) {
|
||||
try {
|
||||
terminal.fitAddon.fit()
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.term.cols,
|
||||
rows: terminal.term.rows,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn('[Terminal] resize on reconnect failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
terminal.term.write(event.data)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m')
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTerminals((prev) => prev.map((t) => {
|
||||
if (t.id === terminalId) {
|
||||
if (t.pingInterval) {
|
||||
clearInterval(t.pingInterval)
|
||||
}
|
||||
return { ...t, isConnected: false, pingInterval: null }
|
||||
}
|
||||
return t
|
||||
}))
|
||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m')
|
||||
}
|
||||
|
||||
terminal.term.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addNewTerminal = () => {
|
||||
if (terminals.length >= 4) return
|
||||
|
||||
@@ -286,6 +398,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
ws: null,
|
||||
isConnected: false,
|
||||
fitAddon: null, // Added fitAddon initialization
|
||||
pingInterval: null, // Added pingInterval initialization
|
||||
},
|
||||
])
|
||||
setActiveTerminalId(newId)
|
||||
@@ -294,6 +407,10 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
const closeTerminal = (id: string) => {
|
||||
const terminal = terminals.find((t) => t.id === id)
|
||||
if (terminal) {
|
||||
// Clear heartbeat interval
|
||||
if (terminal.pingInterval) {
|
||||
clearInterval(terminal.pingInterval)
|
||||
}
|
||||
if (terminal.ws) {
|
||||
terminal.ws.close()
|
||||
}
|
||||
@@ -314,12 +431,18 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
terminals.forEach((terminal) => {
|
||||
const container = containerRefs.current[terminal.id]
|
||||
if (!terminal.term && container) {
|
||||
initializeTerminal(terminal, container)
|
||||
}
|
||||
})
|
||||
// Small delay to ensure DOM refs are available after state update
|
||||
// This fixes the issue where first terminal doesn't connect on mobile/VPN
|
||||
const timer = setTimeout(() => {
|
||||
terminals.forEach((terminal) => {
|
||||
const container = containerRefs.current[terminal.id]
|
||||
if (!terminal.term && container) {
|
||||
initializeTerminal(terminal, container)
|
||||
}
|
||||
})
|
||||
}, 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [terminals, isMobile])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -401,7 +524,22 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
fitAddon.fit()
|
||||
|
||||
const wsUrl = websocketUrl || getWebSocketUrl()
|
||||
|
||||
// Connection with timeout for VPN/mobile (15 seconds)
|
||||
const connectionTimeout = 15000
|
||||
let connectionTimedOut = false
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
// Set connection timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
connectionTimedOut = true
|
||||
ws.close()
|
||||
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
|
||||
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
|
||||
}
|
||||
}, connectionTimeout)
|
||||
|
||||
const syncSizeWithBackend = () => {
|
||||
try {
|
||||
@@ -423,25 +561,66 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
// Clear connection timeout - we're connected!
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// Start heartbeat ping every 25 seconds to keep connection alive
|
||||
// This prevents disconnection when switching apps on mobile/tablet (iPad)
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} else {
|
||||
clearInterval(pingInterval)
|
||||
}
|
||||
}, 25000)
|
||||
|
||||
setTerminals((prev) =>
|
||||
prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws, fitAddon } : t)),
|
||||
prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws, fitAddon, pingInterval } : t)),
|
||||
)
|
||||
syncSizeWithBackend()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
term.write(event.data)
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[v0] TerminalPanel: WebSocket error:", error)
|
||||
setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t)))
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
setTerminals((prev) => prev.map((t) => {
|
||||
if (t.id === terminal.id) {
|
||||
if (t.pingInterval) {
|
||||
clearInterval(t.pingInterval)
|
||||
}
|
||||
return { ...t, isConnected: false, pingInterval: null }
|
||||
}
|
||||
return t
|
||||
}))
|
||||
// Only show error if not already shown by timeout
|
||||
if (!connectionTimedOut) {
|
||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t)))
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
clearTimeout(timeoutId)
|
||||
setTerminals((prev) => prev.map((t) => {
|
||||
if (t.id === terminal.id) {
|
||||
if (t.pingInterval) {
|
||||
clearInterval(t.pingInterval)
|
||||
}
|
||||
return { ...t, isConnected: false, pingInterval: null }
|
||||
}
|
||||
return t
|
||||
}))
|
||||
// Only show close message if not already shown by timeout
|
||||
if (!connectionTimedOut) {
|
||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||
}
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
@@ -518,8 +697,10 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = () => {
|
||||
terminals.forEach((terminal) => {
|
||||
// Clear heartbeat interval
|
||||
if (terminal.pingInterval) clearInterval(terminal.pingInterval)
|
||||
if (terminal.ws) terminal.ws.close()
|
||||
if (terminal.term) terminal.term.dispose()
|
||||
})
|
||||
@@ -611,7 +792,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={terminals.length >= 4}
|
||||
className="h-8 gap-2 bg-green-600 hover:bg-green-700 border-green-500 text-white disabled:opacity-50"
|
||||
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">New</span>
|
||||
@@ -621,7 +802,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!activeTerminal?.isConnected}
|
||||
className="h-8 gap-2 bg-blue-600 hover:bg-blue-700 border-blue-500 text-white disabled:opacity-50"
|
||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
@@ -631,7 +812,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!activeTerminal?.isConnected}
|
||||
className="h-8 gap-2 bg-yellow-600 hover:bg-yellow-700 border-yellow-500 text-white disabled:opacity-50"
|
||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
@@ -640,7 +821,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
onClick={handleClose}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 bg-red-600 hover:bg-red-700 border-red-500 text-white"
|
||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
@@ -738,7 +919,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex flex-wrap gap-1.5 justify-center items-center px-1 bg-zinc-900 text-sm rounded-b-md border-t border-zinc-700 py-1.5">
|
||||
<div className="flex gap-1.5 justify-center items-center px-1 bg-zinc-900 text-sm rounded-b-md border-t border-zinc-700 py-1.5">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -819,22 +1000,38 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
className="h-8 px-2 text-xs bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400"
|
||||
>
|
||||
↵
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendSequence("\x03", e)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
CTRL+C
|
||||
↵ Enter
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs gap-1 bg-transparent"
|
||||
>
|
||||
Ctrl
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -89,14 +89,34 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, type: "secret" | "codes") => {
|
||||
navigator.clipboard.writeText(text)
|
||||
if (type === "secret") {
|
||||
setCopiedSecret(true)
|
||||
setTimeout(() => setCopiedSecret(false), 2000)
|
||||
} else {
|
||||
setCopiedCodes(true)
|
||||
setTimeout(() => setCopiedCodes(false), 2000)
|
||||
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// Fallback for non-secure contexts (HTTP)
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.left = "-9999px"
|
||||
textarea.style.top = "-9999px"
|
||||
textarea.style.opacity = "0"
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
if (type === "secret") {
|
||||
setCopiedSecret(true)
|
||||
setTimeout(() => setCopiedSecret(false), 2000)
|
||||
} else {
|
||||
setCopiedCodes(true)
|
||||
setTimeout(() => setCopiedCodes(false), 2000)
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to copy to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -7,10 +7,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2 } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { Label } from "./ui/label"
|
||||
import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
@@ -120,6 +125,29 @@ interface VMDetails extends VMData {
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupStorage {
|
||||
storage: string
|
||||
type: string
|
||||
content: string
|
||||
total: number
|
||||
used: number
|
||||
avail: number
|
||||
total_human?: string
|
||||
used_human?: string
|
||||
avail_human?: string
|
||||
}
|
||||
|
||||
interface VMBackup {
|
||||
volid: string
|
||||
storage: string
|
||||
type: string
|
||||
size: number
|
||||
size_human: string
|
||||
timestamp: number
|
||||
date: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
return fetchApi(url)
|
||||
}
|
||||
@@ -196,6 +224,28 @@ const getUsageColor = (percent: number): string => {
|
||||
return "text-foreground"
|
||||
}
|
||||
|
||||
// Generate consistent color for storage names
|
||||
const storageColors = [
|
||||
{ bg: "bg-blue-500/20", text: "text-blue-400", border: "border-blue-500/30" },
|
||||
{ bg: "bg-emerald-500/20", text: "text-emerald-400", border: "border-emerald-500/30" },
|
||||
{ bg: "bg-purple-500/20", text: "text-purple-400", border: "border-purple-500/30" },
|
||||
{ bg: "bg-amber-500/20", text: "text-amber-400", border: "border-amber-500/30" },
|
||||
{ bg: "bg-pink-500/20", text: "text-pink-400", border: "border-pink-500/30" },
|
||||
{ bg: "bg-cyan-500/20", text: "text-cyan-400", border: "border-cyan-500/30" },
|
||||
{ bg: "bg-rose-500/20", text: "text-rose-400", border: "border-rose-500/30" },
|
||||
{ bg: "bg-indigo-500/20", text: "text-indigo-400", border: "border-indigo-500/30" },
|
||||
]
|
||||
|
||||
const getStorageColor = (storageName: string) => {
|
||||
// Generate a consistent hash from storage name
|
||||
let hash = 0
|
||||
for (let i = 0; i < storageName.length; i++) {
|
||||
hash = storageName.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const index = Math.abs(hash) % storageColors.length
|
||||
return storageColors[index]
|
||||
}
|
||||
|
||||
const getIconColor = (percent: number): string => {
|
||||
if (percent >= 95) return "text-red-500"
|
||||
if (percent >= 86) return "text-orange-500"
|
||||
@@ -256,6 +306,9 @@ export function VirtualMachines() {
|
||||
const [vmDetails, setVMDetails] = useState<VMDetails | null>(null)
|
||||
const [controlLoading, setControlLoading] = useState(false)
|
||||
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
|
||||
const [terminalVmName, setTerminalVmName] = useState<string>("")
|
||||
const [vmConfigs, setVmConfigs] = useState<Record<number, string>>({})
|
||||
const [currentView, setCurrentView] = useState<"main" | "metrics">("main")
|
||||
const [showAdditionalInfo, setShowAdditionalInfo] = useState(false)
|
||||
@@ -267,6 +320,21 @@ export function VirtualMachines() {
|
||||
const [ipsLoaded, setIpsLoaded] = useState(false)
|
||||
const [loadingIPs, setLoadingIPs] = useState(false)
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes")
|
||||
|
||||
// Backup states
|
||||
const [vmBackups, setVmBackups] = useState<VMBackup[]>([])
|
||||
const [backupStorages, setBackupStorages] = useState<BackupStorage[]>([])
|
||||
const [selectedBackupStorage, setSelectedBackupStorage] = useState<string>("")
|
||||
const [loadingBackups, setLoadingBackups] = useState(false)
|
||||
const [creatingBackup, setCreatingBackup] = useState(false)
|
||||
|
||||
// Backup modal states
|
||||
const [showBackupModal, setShowBackupModal] = useState(false)
|
||||
const [backupMode, setBackupMode] = useState<string>("snapshot")
|
||||
const [backupProtected, setBackupProtected] = useState(false)
|
||||
const [backupNotification, setBackupNotification] = useState<string>("auto")
|
||||
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
|
||||
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLXCIPs = async () => {
|
||||
@@ -344,6 +412,11 @@ export function VirtualMachines() {
|
||||
setIsEditingNotes(false)
|
||||
setEditedNotes("")
|
||||
setDetailsLoading(true)
|
||||
|
||||
// Load backups immediately (independent of config)
|
||||
fetchBackupStorages()
|
||||
fetchVmBackups(vm.vmid)
|
||||
|
||||
try {
|
||||
const details = await fetchApi(`/api/vms/${vm.vmid}`)
|
||||
setVMDetails(details)
|
||||
@@ -362,6 +435,77 @@ export function VirtualMachines() {
|
||||
setCurrentView("main")
|
||||
}
|
||||
|
||||
// Backup functions
|
||||
const fetchBackupStorages = async () => {
|
||||
try {
|
||||
const response = await fetchApi("/api/backup-storages")
|
||||
if (response.storages) {
|
||||
setBackupStorages(response.storages)
|
||||
if (response.storages.length > 0 && !selectedBackupStorage) {
|
||||
setSelectedBackupStorage(response.storages[0].storage)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup storages:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVmBackups = async (vmid: number) => {
|
||||
setLoadingBackups(true)
|
||||
try {
|
||||
const response = await fetchApi(`/api/vms/${vmid}/backups`)
|
||||
if (response.backups) {
|
||||
setVmBackups(response.backups)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching VM backups:", error)
|
||||
setVmBackups([])
|
||||
} finally {
|
||||
setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openBackupModal = () => {
|
||||
// Reset modal to defaults
|
||||
setBackupMode("snapshot")
|
||||
setBackupProtected(false)
|
||||
setBackupNotification("auto")
|
||||
setBackupNotes("{{guestname}}")
|
||||
setBackupPbsChangeMode("default")
|
||||
// Auto-select first storage if none selected
|
||||
if (!selectedBackupStorage && backupStorages.length > 0) {
|
||||
setSelectedBackupStorage(backupStorages[0].storage)
|
||||
}
|
||||
setShowBackupModal(true)
|
||||
}
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
if (!selectedVM || !selectedBackupStorage) return
|
||||
|
||||
setCreatingBackup(true)
|
||||
setShowBackupModal(false)
|
||||
|
||||
try {
|
||||
await fetchApi(`/api/vms/${selectedVM.vmid}/backup`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
storage: selectedBackupStorage,
|
||||
mode: backupMode,
|
||||
compress: "zstd",
|
||||
protected: backupProtected,
|
||||
notification: backupNotification,
|
||||
notes: backupNotes,
|
||||
pbs_change_detection: backupPbsChangeMode
|
||||
}),
|
||||
})
|
||||
setTimeout(() => fetchVmBackups(selectedVM.vmid), 2000)
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error)
|
||||
} finally {
|
||||
setCreatingBackup(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVMControl = async (vmid: number, action: string) => {
|
||||
setControlLoading(true)
|
||||
try {
|
||||
@@ -380,7 +524,14 @@ export function VirtualMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// Open terminal for LXC container
|
||||
const openLxcTerminal = (vmid: number, vmName: string) => {
|
||||
setTerminalVmid(vmid)
|
||||
setTerminalVmName(vmName)
|
||||
setTerminalOpen(true)
|
||||
}
|
||||
|
||||
const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
try {
|
||||
const data = await fetchApi(`/api/vms/${vmid}/logs`)
|
||||
|
||||
@@ -453,10 +604,18 @@ export function VirtualMachines() {
|
||||
|
||||
const safeVMData = vmData || []
|
||||
|
||||
// Total allocated RAM for ALL VMs/LXCs (running + stopped)
|
||||
const totalAllocatedMemoryGB = useMemo(() => {
|
||||
return (safeVMData.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1)
|
||||
}, [safeVMData])
|
||||
|
||||
// Allocated RAM only for RUNNING VMs/LXCs (this is what actually matters for overcommit)
|
||||
const runningAllocatedMemoryGB = useMemo(() => {
|
||||
return (safeVMData
|
||||
.filter((vm) => vm.status === "running")
|
||||
.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1)
|
||||
}, [safeVMData])
|
||||
|
||||
const { data: systemData } = useSWR<{ memory_total: number; memory_used: number; memory_usage: number }>(
|
||||
"/api/system",
|
||||
fetcher,
|
||||
@@ -470,7 +629,9 @@ export function VirtualMachines() {
|
||||
const usedMemoryGB = systemData?.memory_used ?? null
|
||||
const memoryUsagePercent = systemData?.memory_usage ?? null
|
||||
const allocatedMemoryGB = Number.parseFloat(totalAllocatedMemoryGB)
|
||||
const isMemoryOvercommit = physicalMemoryGB !== null && allocatedMemoryGB > physicalMemoryGB
|
||||
const runningAllocatedGB = Number.parseFloat(runningAllocatedMemoryGB)
|
||||
// Overcommit warning should be based on RUNNING VMs allocation, not total
|
||||
const isMemoryOvercommit = physicalMemoryGB !== null && runningAllocatedGB > physicalMemoryGB
|
||||
|
||||
const getMemoryUsageColor = (percent: number | null) => {
|
||||
if (percent === null) return "bg-blue-500"
|
||||
@@ -490,8 +651,13 @@ export function VirtualMachines() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8 text-muted-foreground">Loading virtual machines...</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading virtual machines...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching VM and LXC container status</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -758,13 +924,21 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allocated RAM (configured) */}
|
||||
{/* Allocated RAM (configured) - Split into Running and Total */}
|
||||
<div className="pt-3 border-t border-border">
|
||||
{/* Layout para desktop (sin cambios) */}
|
||||
{/* Layout para desktop */}
|
||||
<div className="hidden lg:flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-foreground">{totalAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Allocated RAM</div>
|
||||
<div className="flex gap-6">
|
||||
{/* Running allocation - most important */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-foreground">{runningAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Running Allocated</div>
|
||||
</div>
|
||||
{/* Total allocation */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-muted-foreground">{totalAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Total Allocated</div>
|
||||
</div>
|
||||
</div>
|
||||
{physicalMemoryGB !== null && (
|
||||
<div>
|
||||
@@ -781,10 +955,20 @@ export function VirtualMachines() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layout para móvil (44.0 GB solo, Allocated RAM en otra línea, badge en tercera línea) */}
|
||||
<div className="lg:hidden space-y-1">
|
||||
<div className="text-lg font-semibold text-foreground">{totalAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Allocated RAM</div>
|
||||
{/* Layout para movil */}
|
||||
<div className="lg:hidden space-y-2">
|
||||
<div className="flex gap-4">
|
||||
{/* Running allocation */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-foreground">{runningAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Running</div>
|
||||
</div>
|
||||
{/* Total allocation */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-muted-foreground">{totalAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
{physicalMemoryGB !== null && (
|
||||
<div>
|
||||
{isMemoryOvercommit ? (
|
||||
@@ -1105,8 +1289,8 @@ export function VirtualMachines() {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4" style={{ maxHeight: 'calc(100vh - 280px)' }}>
|
||||
<div className="space-y-4">
|
||||
{selectedVM && (
|
||||
<>
|
||||
<div key={`metrics-${selectedVM.vmid}`}>
|
||||
@@ -1196,6 +1380,78 @@ export function VirtualMachines() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Backups Section */}
|
||||
<Card className="border border-border bg-card/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-amber-500/10">
|
||||
<Archive className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Backups</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
|
||||
onClick={openBackupModal}
|
||||
disabled={creatingBackup}
|
||||
>
|
||||
{creatingBackup ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
<span>Create Backup</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/50 mb-4" />
|
||||
|
||||
{/* Backup List */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-muted-foreground">Available backups</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">Loading backups...</span>
|
||||
</div>
|
||||
) : vmBackups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground">
|
||||
<Archive className="h-8 w-8 mb-2 opacity-30" />
|
||||
<span className="text-sm">No backups found</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[216px] overflow-y-auto">
|
||||
{vmBackups.map((backup, index) => (
|
||||
<div
|
||||
key={`backup-${backup.volid}-${index}`}
|
||||
className="flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{backup.date}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
|
||||
>
|
||||
{backup.storage}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-mono ml-2 flex-shrink-0">
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{detailsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
|
||||
) : vmDetails?.config ? (
|
||||
@@ -1203,9 +1459,12 @@ export function VirtualMachines() {
|
||||
<Card className="border border-border bg-card/50" key={`config-${selectedVM.vmid}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Resources
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Resources</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1734,9 +1993,21 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border bg-background px-6 py-4 mt-auto">
|
||||
{/* Terminal button for LXC containers - only when running */}
|
||||
{selectedVM?.type === "lxc" && selectedVM?.status === "running" && (
|
||||
<div className="mb-3">
|
||||
<Button
|
||||
className="w-full bg-zinc-600/20 border border-zinc-600/50 text-zinc-300 hover:bg-zinc-600/30"
|
||||
onClick={() => selectedVM && openLxcTerminal(selectedVM.vmid, selectedVM.name)}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
className="w-full bg-green-600/20 border border-green-600/50 text-green-400 hover:bg-green-600/30"
|
||||
disabled={selectedVM?.status === "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "start")}
|
||||
>
|
||||
@@ -1744,7 +2015,7 @@ export function VirtualMachines() {
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
disabled={selectedVM?.status !== "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "shutdown")}
|
||||
>
|
||||
@@ -1752,7 +2023,7 @@ export function VirtualMachines() {
|
||||
Shutdown
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
||||
disabled={selectedVM?.status !== "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "reboot")}
|
||||
>
|
||||
@@ -1760,7 +2031,7 @@ export function VirtualMachines() {
|
||||
Reboot
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30"
|
||||
disabled={selectedVM?.status !== "running" || controlLoading}
|
||||
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "stop")}
|
||||
>
|
||||
@@ -1782,6 +2053,173 @@ export function VirtualMachines() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Backup Configuration Modal */}
|
||||
<Dialog open={showBackupModal} onOpenChange={setShowBackupModal}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-amber-500">
|
||||
<Archive className="h-5 w-5" />
|
||||
Backup {selectedVM?.type?.toUpperCase()} {selectedVM?.vmid} ({selectedVM?.name})
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure backup options for this {selectedVM?.type === 'lxc' ? 'container' : 'virtual machine'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Storage & Mode Row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
Storage
|
||||
</Label>
|
||||
<Select value={selectedBackupStorage} onValueChange={setSelectedBackupStorage}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select storage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{backupStorages.map((storage) => (
|
||||
<SelectItem key={`modal-storage-${storage.storage}`} value={storage.storage}>
|
||||
{storage.storage} ({storage.avail_human} free)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm flex items-center gap-1.5">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Mode
|
||||
</Label>
|
||||
<Select value={backupMode} onValueChange={setBackupMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="snapshot">Snapshot</SelectItem>
|
||||
<SelectItem value="suspend">Suspend</SelectItem>
|
||||
<SelectItem value="stop">Stop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Row */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm flex items-center gap-1.5">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
Notification
|
||||
</Label>
|
||||
<Select value={backupNotification} onValueChange={setBackupNotification}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Use global settings</SelectItem>
|
||||
<SelectItem value="always">Always notify</SelectItem>
|
||||
<SelectItem value="failure">Notify on failure</SelectItem>
|
||||
<SelectItem value="never">Never notify</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Protected Checkbox */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="backup-protected"
|
||||
checked={backupProtected}
|
||||
onCheckedChange={(checked) => setBackupProtected(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="backup-protected" className="text-sm flex items-center gap-1.5 cursor-pointer">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Protected (prevent accidental deletion)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* PBS Change Detection Mode (only for LXC) */}
|
||||
{selectedVM?.type === 'lxc' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm flex items-center gap-1.5">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
PBS change detection mode
|
||||
<span className="text-xs text-muted-foreground ml-1">(for PBS storage)</span>
|
||||
</Label>
|
||||
<Select value={backupPbsChangeMode} onValueChange={setBackupPbsChangeMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="legacy">Legacy</SelectItem>
|
||||
<SelectItem value="data">Data</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm flex items-center gap-1.5">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Notes
|
||||
</Label>
|
||||
<Textarea
|
||||
value={backupNotes}
|
||||
onChange={(e) => setBackupNotes(e.target.value)}
|
||||
placeholder="{{guestname}}"
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{'Variables: {{cluster}}, {{guestname}}, {{node}}, {{vmid}}'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowBackupModal(false)}
|
||||
className="flex-1 bg-zinc-800/50 border-zinc-700 text-zinc-300 hover:bg-zinc-700/50"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={creatingBackup || !selectedBackupStorage}
|
||||
className="flex-1 bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30"
|
||||
>
|
||||
{creatingBackup ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* LXC Terminal Modal */}
|
||||
{terminalVmid !== null && (
|
||||
<LxcTerminalModal
|
||||
open={terminalOpen}
|
||||
onClose={() => {
|
||||
setTerminalOpen(false)
|
||||
setTerminalVmid(null)
|
||||
setTerminalVmName("")
|
||||
}}
|
||||
vmid={terminalVmid}
|
||||
vmName={terminalVmName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
||||
"_updated": "2026-03-20",
|
||||
|
||||
"groq": {
|
||||
"models": [
|
||||
"llama-3.3-70b-versatile",
|
||||
"llama-3.1-70b-versatile",
|
||||
"llama-3.1-8b-instant",
|
||||
"llama3-70b-8192",
|
||||
"llama3-8b-8192",
|
||||
"mixtral-8x7b-32768",
|
||||
"gemma2-9b-it"
|
||||
],
|
||||
"recommended": "llama-3.3-70b-versatile"
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"models": [
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-flash-lite-latest"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite"
|
||||
},
|
||||
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4o-mini"
|
||||
],
|
||||
"recommended": "gpt-4o-mini"
|
||||
},
|
||||
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest"
|
||||
],
|
||||
"recommended": "claude-3-5-haiku-latest"
|
||||
},
|
||||
|
||||
"openrouter": {
|
||||
"models": [
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
"anthropic/claude-3.5-haiku",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"google/gemini-flash-2.5-flash-lite",
|
||||
"openai/gpt-4o-mini",
|
||||
"mistralai/mistral-7b-instruct",
|
||||
"mistralai/mixtral-8x7b-instruct"
|
||||
],
|
||||
"recommended": "meta-llama/llama-3.3-70b-instruct"
|
||||
},
|
||||
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
"models": [],
|
||||
"recommended": ""
|
||||
}
|
||||
}
|
||||
+21
-33
@@ -19,29 +19,19 @@ export const API_PORT = process.env.NEXT_PUBLIC_API_PORT || "8008"
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
console.log("[v0] getApiBaseUrl: Running on server (SSR)")
|
||||
return ""
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
|
||||
console.log("[v0] getApiBaseUrl - protocol:", protocol, "hostname:", hostname, "port:", port)
|
||||
|
||||
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy
|
||||
// In this case, use relative URLs so the proxy handles routing
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
|
||||
console.log("[v0] getApiBaseUrl - isStandardPort:", isStandardPort)
|
||||
|
||||
if (isStandardPort) {
|
||||
// Behind a proxy - use relative URL
|
||||
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
|
||||
return ""
|
||||
} else {
|
||||
// Direct access - use explicit API port
|
||||
const baseUrl = `${protocol}//${hostname}:${API_PORT}`
|
||||
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
|
||||
return baseUrl
|
||||
return `${protocol}//${hostname}:${API_PORT}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +59,7 @@ export function getAuthToken(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
console.log(
|
||||
"[v0] getAuthToken called:",
|
||||
token ? `Token found (length: ${token.length})` : "No token found in localStorage",
|
||||
)
|
||||
return token
|
||||
return localStorage.getItem("proxmenux-auth-token")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,19 +81,13 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
console.log("[v0] fetchApi:", endpoint, "- Authorization header ADDED")
|
||||
} else {
|
||||
console.log("[v0] fetchApi:", endpoint, "- NO TOKEN - Request will fail if endpoint is protected")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
console.log("[v0] fetchApi:", endpoint, "- Response status:", response.status)
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
@@ -118,9 +97,18 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
console.error("[v0] fetchApi error for", endpoint, ":", error)
|
||||
throw error
|
||||
}
|
||||
// Check content type to ensure we're getting JSON
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text()
|
||||
console.error("[v0] fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
|
||||
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (jsonError) {
|
||||
console.error("[v0] fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
||||
throw new Error(`Invalid JSON response from ${endpoint}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.2-beta",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200.18 69.76">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path d="M114.26.13c-13.19,0-23.88,10.68-23.88,23.88s10.68,23.9,23.88,23.9,23.88-10.68,23.88-23.88h0c-.02-13.19-10.71-23.88-23.88-23.9ZM114.26,38.94c-8.24,0-14.93-6.69-14.93-14.93s6.69-14.93,14.93-14.93,14.93,6.69,14.93,14.93c-.02,8.24-6.71,14.93-14.93,14.93h0Z"/>
|
||||
<path d="M24.11,0C10.92-.11.13,10.47,0,23.66c-.13,13.19,10.47,23.98,23.66,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74-.11-8.24,6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v21.98h0c0,8.18-6.65,14.83-14.81,14.93-3.91-.04-7.63-1.59-10.39-4.38l-6.33,6.31c4.4,4.42,10.34,6.92,16.57,6.99h.32c13.02-.19,23.49-10.75,23.56-23.77v-22.69C47.65,10.35,37.05.02,24.11,0Z"/>
|
||||
<path d="M191.28,68.74V23.43c-.32-12.96-10.92-23.28-23.88-23.3-13.19-.13-23.98,10.47-24.11,23.66-.13,13.19,10.49,23.98,23.68,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74s6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v44.63h0l8.92.06Z"/>
|
||||
<path d="M54.8,47.9h8.92v-23.88c0-8.24,6.69-14.93,14.93-14.93,2.72,0,5.25.72,7.46,2l4.48-7.75c-3.5-2.02-7.58-3.19-11.92-3.19-13.19,0-23.88,10.68-23.88,23.88v23.88Z"/>
|
||||
<path d="M198.01.74c.68.38,1.21.91,1.59,1.59.38.68.57,1.42.57,2.25s-.19,1.57-.59,2.27c-.4.68-.93,1.23-1.61,1.61-.68.4-1.44.59-2.25.59s-1.57-.19-2.25-.59c-.68-.4-1.21-.93-1.59-1.61-.38-.68-.59-1.42-.59-2.25s.19-1.57.59-2.25c.38-.68.93-1.21,1.61-1.61s1.44-.59,2.27-.59c.83,0,1.57.19,2.25.59ZM197.57,7.75c.55-.32.98-.76,1.3-1.32.32-.55.47-1.17.47-1.85s-.15-1.3-.47-1.85-.74-.98-1.27-1.3c-.55-.32-1.17-.47-1.85-.47s-1.3.17-1.85.49c-.55.32-.98.76-1.3,1.32s-.47,1.17-.47,1.85.15,1.3.47,1.85c.32.55.74,1,1.27,1.32.55.32,1.15.49,1.83.49.7-.04,1.32-.21,1.87-.53ZM197.84,4.82c-.15.25-.38.45-.68.59l1.06,1.64h-1.32l-.91-1.42h-.87v1.42h-1.32V2.17h2.12c.66,0,1.19.15,1.57.47.38.32.57.74.57,1.27,0,.34-.08.66-.23.91ZM195.85,4.65c.3,0,.53-.06.68-.19.17-.13.25-.32.25-.55s-.08-.42-.25-.57-.4-.19-.68-.19h-.74v1.53h.74v-.02Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200.18 69.76">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M114.26.13c-13.19,0-23.88,10.68-23.88,23.88s10.68,23.9,23.88,23.9,23.88-10.68,23.88-23.88h0c-.02-13.19-10.71-23.88-23.88-23.9ZM114.26,38.94c-8.24,0-14.93-6.69-14.93-14.93s6.69-14.93,14.93-14.93,14.93,6.69,14.93,14.93c-.02,8.24-6.71,14.93-14.93,14.93h0Z"/>
|
||||
<path class="cls-1" d="M24.11,0C10.92-.11.13,10.47,0,23.66c-.13,13.19,10.47,23.98,23.66,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74-.11-8.24,6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v21.98h0c0,8.18-6.65,14.83-14.81,14.93-3.91-.04-7.63-1.59-10.39-4.38l-6.33,6.31c4.4,4.42,10.34,6.92,16.57,6.99h.32c13.02-.19,23.49-10.75,23.56-23.77v-22.69C47.65,10.35,37.05.02,24.11,0Z"/>
|
||||
<path class="cls-1" d="M191.28,68.74V23.43c-.32-12.96-10.92-23.28-23.88-23.3-13.19-.13-23.98,10.47-24.11,23.66-.13,13.19,10.49,23.98,23.68,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74s6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v44.63h0l8.92.06Z"/>
|
||||
<path class="cls-1" d="M54.8,47.9h8.92v-23.88c0-8.24,6.69-14.93,14.93-14.93,2.72,0,5.25.72,7.46,2l4.48-7.75c-3.5-2.02-7.58-3.19-11.92-3.19-13.19,0-23.88,10.68-23.88,23.88v23.88Z"/>
|
||||
<path class="cls-1" d="M198.01.74c.68.38,1.21.91,1.59,1.59.38.68.57,1.42.57,2.25s-.19,1.57-.59,2.27c-.4.68-.93,1.23-1.61,1.61-.68.4-1.44.59-2.25.59s-1.57-.19-2.25-.59c-.68-.4-1.21-.93-1.59-1.61-.38-.68-.59-1.42-.59-2.25s.19-1.57.59-2.25c.38-.68.93-1.21,1.61-1.61s1.44-.59,2.27-.59c.83,0,1.57.19,2.25.59ZM197.57,7.75c.55-.32.98-.76,1.3-1.32.32-.55.47-1.17.47-1.85s-.15-1.3-.47-1.85-.74-.98-1.27-1.3c-.55-.32-1.17-.47-1.85-.47s-1.3.17-1.85.49c-.55.32-.98.76-1.3,1.32s-.47,1.17-.47,1.85.15,1.3.47,1.85c.32.55.74,1,1.27,1.32.55.32,1.15.49,1.83.49.7-.04,1.32-.21,1.87-.53ZM197.84,4.82c-.15.25-.38.45-.68.59l1.06,1.64h-1.32l-.91-1.42h-.87v1.42h-1.32V2.17h2.12c.66,0,1.19.15,1.57.47.38.32.57.74.57,1.27,0,.34-.08.66-.23.91ZM195.85,4.65c.3,0,.53-.06.68-.19.17-.13.25-.32.25-.55s-.08-.42-.25-.57-.4-.19-.68-.19h-.74v1.53h.74v-.02Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,106 @@
|
||||
"""AI Providers for ProxMenux notification enhancement.
|
||||
|
||||
This module provides a pluggable architecture for different AI providers
|
||||
to enhance and translate notification messages.
|
||||
|
||||
Supported providers:
|
||||
- Groq: Fast inference, generous free tier (30 req/min)
|
||||
- OpenAI: Industry standard, widely used
|
||||
- Anthropic: Excellent for text generation, Claude Haiku is fast and affordable
|
||||
- Gemini: Google's model, free tier available, good quality/price ratio
|
||||
- Ollama: 100% local execution, no costs, complete privacy
|
||||
- OpenRouter: Aggregator with access to 100+ models using a single API key
|
||||
"""
|
||||
from .base import AIProvider, AIProviderError
|
||||
from .groq_provider import GroqProvider
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .anthropic_provider import AnthropicProvider
|
||||
from .gemini_provider import GeminiProvider
|
||||
from .ollama_provider import OllamaProvider
|
||||
from .openrouter_provider import OpenRouterProvider
|
||||
|
||||
PROVIDERS = {
|
||||
'groq': GroqProvider,
|
||||
'openai': OpenAIProvider,
|
||||
'anthropic': AnthropicProvider,
|
||||
'gemini': GeminiProvider,
|
||||
'ollama': OllamaProvider,
|
||||
'openrouter': OpenRouterProvider,
|
||||
}
|
||||
|
||||
# Provider metadata for UI display
|
||||
# Note: No hardcoded models - users load models dynamically from each provider
|
||||
PROVIDER_INFO = {
|
||||
'groq': {
|
||||
'name': 'Groq',
|
||||
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'openai': {
|
||||
'name': 'OpenAI',
|
||||
'description': 'Industry standard. Very accurate and widely used.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'anthropic': {
|
||||
'name': 'Anthropic (Claude)',
|
||||
'description': 'Excellent for writing and translation. Fast and affordable.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'gemini': {
|
||||
'name': 'Google Gemini',
|
||||
'description': 'Free tier available, very good quality/price ratio.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'ollama': {
|
||||
'name': 'Ollama (Local)',
|
||||
'description': '100% local execution. No costs, complete privacy, no internet required.',
|
||||
'requires_api_key': False,
|
||||
},
|
||||
'openrouter': {
|
||||
'name': 'OpenRouter',
|
||||
'description': 'Aggregator with access to 100+ models using a single API key. Maximum flexibility.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> AIProvider:
|
||||
"""Factory function to get provider instance.
|
||||
|
||||
Args:
|
||||
name: Provider name (groq, openai, anthropic, gemini, ollama, openrouter)
|
||||
**kwargs: Provider-specific arguments (api_key, model, base_url)
|
||||
|
||||
Returns:
|
||||
AIProvider instance
|
||||
|
||||
Raises:
|
||||
AIProviderError: If provider name is unknown
|
||||
"""
|
||||
if name not in PROVIDERS:
|
||||
raise AIProviderError(f"Unknown provider: {name}. Available: {list(PROVIDERS.keys())}")
|
||||
return PROVIDERS[name](**kwargs)
|
||||
|
||||
|
||||
def get_provider_info(name: str = None) -> dict:
|
||||
"""Get provider metadata for UI display.
|
||||
|
||||
Args:
|
||||
name: Optional provider name. If None, returns all providers info.
|
||||
|
||||
Returns:
|
||||
Provider info dict or dict of all providers
|
||||
"""
|
||||
if name:
|
||||
return PROVIDER_INFO.get(name, {})
|
||||
return PROVIDER_INFO
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AIProvider',
|
||||
'AIProviderError',
|
||||
'PROVIDERS',
|
||||
'PROVIDER_INFO',
|
||||
'get_provider',
|
||||
'get_provider_info',
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Anthropic (Claude) provider implementation.
|
||||
|
||||
Anthropic's Claude models are excellent for text generation and translation.
|
||||
Models use "-latest" aliases that auto-update to newest versions.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class AnthropicProvider(AIProvider):
|
||||
"""Anthropic provider using their Messages API."""
|
||||
|
||||
NAME = "anthropic"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://api.anthropic.com/v1/messages"
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
# Known stable model aliases (Anthropic doesn't have a public models list API)
|
||||
# These use "-latest" which auto-updates to the newest version
|
||||
KNOWN_MODELS = [
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest",
|
||||
]
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return known Anthropic model aliases.
|
||||
|
||||
Anthropic doesn't have a public models list API, but their "-latest"
|
||||
aliases auto-update to the newest versions, making them reliable choices.
|
||||
"""
|
||||
return self.KNOWN_MODELS
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Anthropic's API.
|
||||
|
||||
Note: Anthropic uses a different API format than OpenAI.
|
||||
The system prompt goes in a separate field, not in messages.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Anthropic")
|
||||
|
||||
# Anthropic uses a different format - system is a top-level field
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'system': system_prompt,
|
||||
'messages': [
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': self.api_key,
|
||||
'anthropic-version': self.API_VERSION,
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
# Anthropic returns content as array of content blocks
|
||||
content = result['content']
|
||||
if isinstance(content, list) and len(content) > 0:
|
||||
return content[0].get('text', '').strip()
|
||||
return str(content).strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Base class for AI providers."""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class AIProviderError(Exception):
|
||||
"""Exception for AI provider errors."""
|
||||
pass
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
"""Abstract base class for AI providers.
|
||||
|
||||
All provider implementations must inherit from this class and implement
|
||||
the generate() method.
|
||||
"""
|
||||
|
||||
# Provider metadata (override in subclasses)
|
||||
NAME = "base"
|
||||
REQUIRES_API_KEY = True
|
||||
|
||||
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
|
||||
"""Initialize the AI provider.
|
||||
|
||||
Args:
|
||||
api_key: API key for authentication (not required for local providers)
|
||||
model: Model name to use (required - user selects from loaded models)
|
||||
base_url: Base URL for API calls (used by Ollama and custom endpoints)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model # Model must be provided by user after loading from provider
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response from the AI model.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions for the model
|
||||
user_message: User message/query to process
|
||||
max_tokens: Maximum tokens in the response
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If there's an error communicating with the provider
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_connection(self) -> Dict[str, Any]:
|
||||
"""Test the connection to the AI provider.
|
||||
|
||||
Sends a simple test message to verify the provider is accessible
|
||||
and the API key is valid.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- success: bool indicating if connection succeeded
|
||||
- message: Human-readable status message
|
||||
- model: Model name being used
|
||||
"""
|
||||
try:
|
||||
response = self.generate(
|
||||
system_prompt="You are a test assistant. Respond with exactly: CONNECTION_OK",
|
||||
user_message="Test connection",
|
||||
max_tokens=20
|
||||
)
|
||||
if response:
|
||||
# Check if response contains our expected text
|
||||
if "CONNECTION_OK" in response.upper() or "CONNECTION" in response.upper():
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Connection successful',
|
||||
'model': self.model
|
||||
}
|
||||
# Even if different response, connection worked
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Connected (response received)',
|
||||
'model': self.model
|
||||
}
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'No response received from provider',
|
||||
'model': self.model
|
||||
}
|
||||
except AIProviderError as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': str(e),
|
||||
'model': self.model
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Unexpected error: {str(e)}',
|
||||
'model': self.model
|
||||
}
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available models from the provider.
|
||||
|
||||
Returns:
|
||||
List of model IDs available for use.
|
||||
Returns empty list if the provider doesn't support listing.
|
||||
"""
|
||||
# Default implementation - subclasses should override
|
||||
return []
|
||||
|
||||
def get_recommended_model(self) -> str:
|
||||
"""Get the recommended model for this provider.
|
||||
|
||||
Checks if the current model is available. If not, returns
|
||||
the first available model from the provider's model list.
|
||||
This is fully dynamic - no hardcoded fallback models.
|
||||
|
||||
Returns:
|
||||
Recommended model ID, or empty string if no models available
|
||||
"""
|
||||
available = self.list_models()
|
||||
if not available:
|
||||
# Can't get model list - keep current model and hope it works
|
||||
return self.model
|
||||
|
||||
# Check if current model is available
|
||||
if self.model and self.model in available:
|
||||
return self.model
|
||||
|
||||
# Current model not available - return first available model
|
||||
# Models are typically sorted, so first one is usually a good default
|
||||
return available[0]
|
||||
|
||||
def _make_request(self, url: str, payload: dict, headers: dict,
|
||||
timeout: int = 15) -> dict:
|
||||
"""Make HTTP request to AI provider API.
|
||||
|
||||
Args:
|
||||
url: API endpoint URL
|
||||
payload: JSON payload to send
|
||||
headers: HTTP headers
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
|
||||
Raises:
|
||||
AIProviderError: If request fails
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = ""
|
||||
try:
|
||||
error_body = e.read().decode('utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
raise AIProviderError(f"HTTP {e.code}: {error_body or e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise AIProviderError(f"Connection error: {e.reason}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise AIProviderError(f"Invalid JSON response: {e}")
|
||||
except Exception as e:
|
||||
raise AIProviderError(f"Request failed: {str(e)}")
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Google Gemini provider implementation.
|
||||
|
||||
Google's Gemini models offer a free tier and excellent quality/price ratio.
|
||||
Models are loaded dynamically from the API - no hardcoded model names.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class GeminiProvider(AIProvider):
|
||||
"""Google Gemini provider using the Generative Language API."""
|
||||
|
||||
NAME = "gemini"
|
||||
REQUIRES_API_KEY = True
|
||||
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
# Patterns to exclude from model list (experimental, preview, specialized)
|
||||
EXCLUDED_PATTERNS = [
|
||||
'preview', 'exp', 'experimental', 'computer-use',
|
||||
'deep-research', 'image', 'embedding', 'aqa', 'tts',
|
||||
'learnlm', 'imagen', 'veo'
|
||||
]
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available Gemini models that support generateContent.
|
||||
|
||||
Filters to only stable text generation models, excluding:
|
||||
- Preview/experimental models
|
||||
- Image generation models
|
||||
- Embedding models
|
||||
- Specialized models (computer-use, deep-research, etc.)
|
||||
|
||||
Returns:
|
||||
List of model IDs available for text generation.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{self.API_BASE}?key={self.api_key}"
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
models = []
|
||||
for model in data.get('models', []):
|
||||
model_name = model.get('name', '')
|
||||
# Extract just the model ID (e.g., "models/gemini-pro" -> "gemini-pro")
|
||||
if model_name.startswith('models/'):
|
||||
model_id = model_name[7:]
|
||||
else:
|
||||
model_id = model_name
|
||||
|
||||
# Only include models that support generateContent
|
||||
supported_methods = model.get('supportedGenerationMethods', [])
|
||||
if 'generateContent' not in supported_methods:
|
||||
continue
|
||||
|
||||
# Exclude experimental, preview, and specialized models
|
||||
model_lower = model_id.lower()
|
||||
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
|
||||
continue
|
||||
|
||||
models.append(model_id)
|
||||
|
||||
# Sort with recommended models first (flash-lite, flash, pro)
|
||||
def sort_key(m):
|
||||
m_lower = m.lower()
|
||||
if 'flash-lite' in m_lower:
|
||||
return (0, m) # Best for notifications (fast, cheap)
|
||||
if 'flash' in m_lower:
|
||||
return (1, m)
|
||||
if 'pro' in m_lower:
|
||||
return (2, m)
|
||||
return (3, m)
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
except Exception as e:
|
||||
print(f"[GeminiProvider] Failed to list models: {e}")
|
||||
return []
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Google's Gemini API.
|
||||
|
||||
Note: Gemini uses a different API format. System instructions
|
||||
go in a separate systemInstruction field.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Gemini")
|
||||
|
||||
url = f"{self.API_BASE}/{self.model}:generateContent?key={self.api_key}"
|
||||
|
||||
# Gemini uses a specific format with contents array
|
||||
payload = {
|
||||
'systemInstruction': {
|
||||
'parts': [{'text': system_prompt}]
|
||||
},
|
||||
'contents': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [{'text': user_message}]
|
||||
}
|
||||
],
|
||||
'generationConfig': {
|
||||
'maxOutputTokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
result = self._make_request(url, payload, headers)
|
||||
|
||||
try:
|
||||
# Gemini returns candidates array with content parts
|
||||
candidates = result.get('candidates', [])
|
||||
if candidates:
|
||||
content = candidates[0].get('content', {})
|
||||
parts = content.get('parts', [])
|
||||
if parts:
|
||||
return parts[0].get('text', '').strip()
|
||||
raise AIProviderError("No content in response")
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Groq AI provider implementation.
|
||||
|
||||
Groq provides fast inference with a generous free tier (30 requests/minute).
|
||||
Uses the OpenAI-compatible API format.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class GroqProvider(AIProvider):
|
||||
"""Groq AI provider using their OpenAI-compatible API."""
|
||||
|
||||
NAME = "groq"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
||||
MODELS_URL = "https://api.groq.com/openai/v1/models"
|
||||
|
||||
# Exclude non-chat models
|
||||
EXCLUDED_PATTERNS = ['whisper', 'tts', 'guard', 'tool-use']
|
||||
|
||||
# Recommended models (in priority order - versatile/large models first)
|
||||
RECOMMENDED_PREFIXES = ['llama-3.3', 'llama-3.1-70b', 'llama-3.1-8b', 'mixtral', 'gemma']
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available Groq models for chat completions.
|
||||
|
||||
Filters out non-chat models (whisper, guard, etc.)
|
||||
|
||||
Returns:
|
||||
List of model IDs suitable for chat completions.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
self.MODELS_URL,
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
method='GET'
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
models = []
|
||||
for model in data.get('data', []):
|
||||
model_id = model.get('id', '')
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
model_lower = model_id.lower()
|
||||
|
||||
# Exclude non-chat models
|
||||
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
|
||||
continue
|
||||
|
||||
models.append(model_id)
|
||||
|
||||
# Sort with recommended models first
|
||||
def sort_key(m):
|
||||
m_lower = m.lower()
|
||||
for i, prefix in enumerate(self.RECOMMENDED_PREFIXES):
|
||||
if m_lower.startswith(prefix):
|
||||
return (i, m)
|
||||
return (len(self.RECOMMENDED_PREFIXES), m)
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
except Exception as e:
|
||||
print(f"[GroqProvider] Failed to list models: {e}")
|
||||
return []
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Groq's API.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Groq")
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
return result['choices'][0]['message']['content'].strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Ollama provider implementation.
|
||||
|
||||
Ollama enables 100% local AI execution with no costs and complete privacy.
|
||||
No internet connection required - perfect for sensitive enterprise environments.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class OllamaProvider(AIProvider):
|
||||
"""Ollama provider for local AI execution."""
|
||||
|
||||
NAME = "ollama"
|
||||
REQUIRES_API_KEY = False
|
||||
DEFAULT_URL = "http://localhost:11434"
|
||||
|
||||
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
|
||||
"""Initialize Ollama provider.
|
||||
|
||||
Args:
|
||||
api_key: Not used for Ollama (local execution)
|
||||
model: Model name (user must select from loaded models)
|
||||
base_url: Ollama server URL (default: http://localhost:11434)
|
||||
"""
|
||||
super().__init__(api_key, model, base_url)
|
||||
# Use default URL if not provided
|
||||
if not self.base_url:
|
||||
self.base_url = self.DEFAULT_URL
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using local Ollama server.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length (maps to num_predict)
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If Ollama server is unreachable
|
||||
"""
|
||||
url = f"{self.base_url.rstrip('/')}/api/chat"
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'stream': False,
|
||||
'options': {
|
||||
'num_predict': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Cloud models (e.g., kimi-k2.5:cloud, minimax-m2.7:cloud) need longer timeout
|
||||
# because requests go through: ProxMenux -> Ollama -> Cloud Provider -> back
|
||||
is_cloud_model = ':cloud' in self.model.lower()
|
||||
timeout = 120 if is_cloud_model else 30 # 2 minutes for cloud, 30s for local
|
||||
|
||||
try:
|
||||
result = self._make_request(url, payload, headers, timeout=timeout)
|
||||
except AIProviderError as e:
|
||||
if "Connection" in str(e) or "refused" in str(e).lower():
|
||||
raise AIProviderError(
|
||||
f"Cannot connect to Ollama at {self.base_url}. "
|
||||
"Make sure Ollama is running (ollama serve)"
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
message = result.get('message', {})
|
||||
return message.get('content', '').strip()
|
||||
except (KeyError, AttributeError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
|
||||
def test_connection(self):
|
||||
"""Test connection to Ollama server.
|
||||
|
||||
Also checks if the specified model is available.
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# First check if server is running
|
||||
try:
|
||||
url = f"{self.base_url.rstrip('/')}/api/tags"
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
# Get full model names (with tags) for comparison
|
||||
full_model_names = [m.get('name', '') for m in data.get('models', [])]
|
||||
# Also get base names (without tags) for fallback matching
|
||||
base_model_names = [name.split(':')[0] for name in full_model_names]
|
||||
|
||||
# Check if the requested model matches any available model
|
||||
# Match by: exact name, base name, or requested model without tag
|
||||
requested_base = self.model.split(':')[0] if ':' in self.model else self.model
|
||||
|
||||
model_found = (
|
||||
self.model in full_model_names or # Exact match (e.g., "llama3.2:latest")
|
||||
self.model in base_model_names or # Base name match (e.g., "llama3.2")
|
||||
requested_base in base_model_names # Requested base matches available base
|
||||
)
|
||||
|
||||
if not model_found:
|
||||
display_models = full_model_names[:5] if full_model_names else ['none']
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"Model '{self.model}' not found. Available: {', '.join(display_models)}{'...' if len(full_model_names) > 5 else ''}",
|
||||
'model': self.model
|
||||
}
|
||||
except urllib.error.URLError:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"Cannot connect to Ollama at {self.base_url}. Make sure Ollama is running.",
|
||||
'model': self.model
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"Error checking Ollama: {str(e)}",
|
||||
'model': self.model
|
||||
}
|
||||
|
||||
# If server is up and model exists, do the actual test
|
||||
# For cloud models, we skip the full test (which sends a message)
|
||||
# because it would take too long. The model availability check above is sufficient.
|
||||
is_cloud_model = ':cloud' in self.model.lower()
|
||||
if is_cloud_model:
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"Cloud model '{self.model}' is available via Ollama",
|
||||
'model': self.model
|
||||
}
|
||||
|
||||
return super().test_connection()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""OpenAI provider implementation.
|
||||
|
||||
OpenAI is the industry standard for AI APIs.
|
||||
Models are loaded dynamically from the API.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class OpenAIProvider(AIProvider):
|
||||
"""OpenAI provider using their Chat Completions API.
|
||||
|
||||
Also compatible with OpenAI-compatible APIs like:
|
||||
- BytePlus/ByteDance (Kimi K2.5)
|
||||
- LocalAI
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- Together AI
|
||||
- Any OpenAI-compatible endpoint
|
||||
"""
|
||||
|
||||
NAME = "openai"
|
||||
REQUIRES_API_KEY = True
|
||||
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
|
||||
DEFAULT_MODELS_URL = "https://api.openai.com/v1/models"
|
||||
|
||||
# Models to exclude (not suitable for chat/text generation)
|
||||
EXCLUDED_PATTERNS = [
|
||||
'embedding', 'whisper', 'tts', 'dall-e', 'image',
|
||||
'instruct', 'realtime', 'audio', 'moderation',
|
||||
'search', 'code-search', 'text-similarity', 'babbage', 'davinci',
|
||||
'curie', 'ada', 'transcribe'
|
||||
]
|
||||
|
||||
# Recommended models for chat (in priority order)
|
||||
RECOMMENDED_PREFIXES = ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo']
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available OpenAI models for chat completions.
|
||||
|
||||
Filters to only chat-capable models, excluding:
|
||||
- Embedding models
|
||||
- Audio/speech models (whisper, tts)
|
||||
- Image models (dall-e)
|
||||
- Instruct models (different API)
|
||||
- Legacy models (babbage, davinci, etc.)
|
||||
|
||||
Returns:
|
||||
List of model IDs suitable for chat completions.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Determine models URL from base_url if set
|
||||
if self.base_url:
|
||||
base = self.base_url.rstrip('/')
|
||||
if not base.endswith('/v1'):
|
||||
base = f"{base}/v1"
|
||||
models_url = f"{base}/models"
|
||||
else:
|
||||
models_url = self.DEFAULT_MODELS_URL
|
||||
|
||||
req = urllib.request.Request(
|
||||
models_url,
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
method='GET'
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
models = []
|
||||
for model in data.get('data', []):
|
||||
model_id = model.get('id', '')
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
model_lower = model_id.lower()
|
||||
|
||||
# Must be a GPT model
|
||||
if 'gpt' not in model_lower:
|
||||
continue
|
||||
|
||||
# Exclude non-chat models
|
||||
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
|
||||
continue
|
||||
|
||||
models.append(model_id)
|
||||
|
||||
# Sort with recommended models first
|
||||
def sort_key(m):
|
||||
m_lower = m.lower()
|
||||
for i, prefix in enumerate(self.RECOMMENDED_PREFIXES):
|
||||
if m_lower.startswith(prefix):
|
||||
return (i, m)
|
||||
return (len(self.RECOMMENDED_PREFIXES), m)
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
except Exception as e:
|
||||
print(f"[OpenAIProvider] Failed to list models: {e}")
|
||||
return []
|
||||
|
||||
def _get_api_url(self) -> str:
|
||||
"""Get the API URL, using custom base_url if provided."""
|
||||
if self.base_url:
|
||||
# Ensure the URL ends with the correct path
|
||||
base = self.base_url.rstrip('/')
|
||||
if not base.endswith('/chat/completions'):
|
||||
if not base.endswith('/v1'):
|
||||
base = f"{base}/v1"
|
||||
base = f"{base}/chat/completions"
|
||||
return base
|
||||
return self.DEFAULT_API_URL
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using OpenAI's API or compatible endpoint.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for OpenAI")
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
api_url = self._get_api_url()
|
||||
result = self._make_request(api_url, payload, headers)
|
||||
|
||||
try:
|
||||
return result['choices'][0]['message']['content'].strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""OpenRouter provider implementation.
|
||||
|
||||
OpenRouter is an aggregator that provides access to 100+ AI models
|
||||
using a single API key. Maximum flexibility for choosing models.
|
||||
Uses OpenAI-compatible API format.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class OpenRouterProvider(AIProvider):
|
||||
"""OpenRouter provider for multi-model access."""
|
||||
|
||||
NAME = "openrouter"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||
|
||||
# Exclude non-text models
|
||||
EXCLUDED_PATTERNS = ['image', 'vision', 'audio', 'video', 'embedding', 'moderation']
|
||||
|
||||
# Recommended model prefixes (popular, reliable, good for notifications)
|
||||
RECOMMENDED_PREFIXES = [
|
||||
'meta-llama/llama-3', 'anthropic/claude', 'google/gemini',
|
||||
'openai/gpt', 'mistralai/mistral', 'mistralai/mixtral'
|
||||
]
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available OpenRouter models for chat completions.
|
||||
|
||||
OpenRouter has 300+ models. This filters to text generation models
|
||||
and prioritizes popular, reliable options.
|
||||
|
||||
Returns:
|
||||
List of model IDs suitable for text generation.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
self.MODELS_URL,
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
method='GET'
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
models = []
|
||||
for model in data.get('data', []):
|
||||
model_id = model.get('id', '')
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
model_lower = model_id.lower()
|
||||
|
||||
# Exclude non-text models
|
||||
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
|
||||
continue
|
||||
|
||||
models.append(model_id)
|
||||
|
||||
# Sort with recommended models first
|
||||
def sort_key(m):
|
||||
m_lower = m.lower()
|
||||
for i, prefix in enumerate(self.RECOMMENDED_PREFIXES):
|
||||
if m_lower.startswith(prefix):
|
||||
return (i, m)
|
||||
return (len(self.RECOMMENDED_PREFIXES), m)
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
except Exception as e:
|
||||
print(f"[OpenRouterProvider] Failed to list models: {e}")
|
||||
return []
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using OpenRouter's API.
|
||||
|
||||
OpenRouter uses OpenAI-compatible format with additional
|
||||
headers for app identification.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for OpenRouter")
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'HTTP-Referer': 'https://github.com/MacRimi/ProxMenux',
|
||||
'X-Title': 'ProxMenux Monitor',
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
return result['choices'][0]['message']['content'].strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
@@ -57,7 +57,9 @@ def load_auth_config():
|
||||
"configured": bool,
|
||||
"totp_enabled": bool, # 2FA enabled flag
|
||||
"totp_secret": str, # TOTP secret key
|
||||
"backup_codes": list # List of backup codes
|
||||
"backup_codes": list, # List of backup codes
|
||||
"api_tokens": list, # List of stored API token metadata
|
||||
"revoked_tokens": list # List of revoked token hashes
|
||||
}
|
||||
"""
|
||||
if not AUTH_CONFIG_FILE.exists():
|
||||
@@ -69,7 +71,9 @@ def load_auth_config():
|
||||
"configured": False,
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": []
|
||||
"backup_codes": [],
|
||||
"api_tokens": [],
|
||||
"revoked_tokens": []
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -81,6 +85,8 @@ def load_auth_config():
|
||||
config.setdefault("totp_enabled", False)
|
||||
config.setdefault("totp_secret", None)
|
||||
config.setdefault("backup_codes", [])
|
||||
config.setdefault("api_tokens", [])
|
||||
config.setdefault("revoked_tokens", [])
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"Error loading auth config: {e}")
|
||||
@@ -92,7 +98,9 @@ def load_auth_config():
|
||||
"configured": False,
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": []
|
||||
"backup_codes": [],
|
||||
"api_tokens": [],
|
||||
"revoked_tokens": []
|
||||
}
|
||||
|
||||
|
||||
@@ -141,11 +149,18 @@ def verify_token(token):
|
||||
"""
|
||||
Verify a JWT token
|
||||
Returns username if valid, None otherwise
|
||||
Also checks if the token has been revoked
|
||||
"""
|
||||
if not JWT_AVAILABLE or not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check if the token has been revoked
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
config = load_auth_config()
|
||||
if token_hash in config.get("revoked_tokens", []):
|
||||
return None
|
||||
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return payload.get('username')
|
||||
except jwt.ExpiredSignatureError:
|
||||
@@ -156,6 +171,88 @@ def verify_token(token):
|
||||
return None
|
||||
|
||||
|
||||
def store_api_token_metadata(token, token_name="API Token"):
|
||||
"""
|
||||
Store API token metadata (hash, name, creation date) for listing and revocation.
|
||||
The actual token is never stored - only a hash for identification.
|
||||
"""
|
||||
config = load_auth_config()
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_id = token_hash[:16]
|
||||
|
||||
token_entry = {
|
||||
"id": token_id,
|
||||
"name": token_name,
|
||||
"token_hash": token_hash,
|
||||
"token_prefix": token[:12] + "...",
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"expires_at": (datetime.utcnow() + timedelta(days=365)).isoformat() + "Z"
|
||||
}
|
||||
|
||||
config.setdefault("api_tokens", [])
|
||||
config["api_tokens"].append(token_entry)
|
||||
save_auth_config(config)
|
||||
return token_entry
|
||||
|
||||
|
||||
def list_api_tokens():
|
||||
"""
|
||||
List all stored API token metadata (no actual tokens are returned).
|
||||
Returns list of token entries with id, name, prefix, creation and expiration dates.
|
||||
"""
|
||||
config = load_auth_config()
|
||||
tokens = config.get("api_tokens", [])
|
||||
revoked = set(config.get("revoked_tokens", []))
|
||||
|
||||
result = []
|
||||
for t in tokens:
|
||||
entry = {
|
||||
"id": t.get("id"),
|
||||
"name": t.get("name", "API Token"),
|
||||
"token_prefix": t.get("token_prefix", "***"),
|
||||
"created_at": t.get("created_at"),
|
||||
"expires_at": t.get("expires_at"),
|
||||
"revoked": t.get("token_hash") in revoked
|
||||
}
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def revoke_api_token(token_id):
|
||||
"""
|
||||
Revoke an API token by its ID.
|
||||
Adds the token hash to the revoked list so it fails verification.
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
tokens = config.get("api_tokens", [])
|
||||
|
||||
target = None
|
||||
for t in tokens:
|
||||
if t.get("id") == token_id:
|
||||
target = t
|
||||
break
|
||||
|
||||
if not target:
|
||||
return False, "Token not found"
|
||||
|
||||
token_hash = target.get("token_hash")
|
||||
config.setdefault("revoked_tokens", [])
|
||||
|
||||
if token_hash in config["revoked_tokens"]:
|
||||
return False, "Token is already revoked"
|
||||
|
||||
config["revoked_tokens"].append(token_hash)
|
||||
|
||||
# Remove from the active tokens list
|
||||
config["api_tokens"] = [t for t in tokens if t.get("id") != token_id]
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "Token revoked successfully"
|
||||
else:
|
||||
return False, "Failed to save configuration"
|
||||
|
||||
|
||||
def get_auth_status():
|
||||
"""
|
||||
Get current authentication status
|
||||
@@ -243,6 +340,8 @@ def disable_auth():
|
||||
config["totp_enabled"] = False
|
||||
config["totp_secret"] = None
|
||||
config["backup_codes"] = []
|
||||
config["api_tokens"] = []
|
||||
config["revoked_tokens"] = []
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "Authentication disabled"
|
||||
@@ -472,6 +571,203 @@ def disable_totp(username, password):
|
||||
return False, "Failed to disable 2FA"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# SSL/HTTPS Certificate Management
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json"))
|
||||
|
||||
# Default Proxmox certificate paths
|
||||
PROXMOX_CERT_PATH = "/etc/pve/local/pve-ssl.pem"
|
||||
PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key"
|
||||
|
||||
|
||||
def load_ssl_config():
|
||||
"""Load SSL configuration from file"""
|
||||
if not SSL_CONFIG_FILE.exists():
|
||||
return {
|
||||
"enabled": False,
|
||||
"cert_path": "",
|
||||
"key_path": "",
|
||||
"source": "none" # "none", "proxmox", "custom"
|
||||
}
|
||||
|
||||
try:
|
||||
with open(SSL_CONFIG_FILE, 'r') as f:
|
||||
config = json.load(f)
|
||||
config.setdefault("enabled", False)
|
||||
config.setdefault("cert_path", "")
|
||||
config.setdefault("key_path", "")
|
||||
config.setdefault("source", "none")
|
||||
return config
|
||||
except Exception:
|
||||
return {
|
||||
"enabled": False,
|
||||
"cert_path": "",
|
||||
"key_path": "",
|
||||
"source": "none"
|
||||
}
|
||||
|
||||
|
||||
def save_ssl_config(config):
|
||||
"""Save SSL configuration to file"""
|
||||
try:
|
||||
SSL_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(SSL_CONFIG_FILE, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error saving SSL config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def detect_proxmox_certificates():
|
||||
"""
|
||||
Detect available Proxmox certificates.
|
||||
Returns dict with detection results.
|
||||
"""
|
||||
result = {
|
||||
"proxmox_available": False,
|
||||
"proxmox_cert": PROXMOX_CERT_PATH,
|
||||
"proxmox_key": PROXMOX_KEY_PATH,
|
||||
"cert_info": None
|
||||
}
|
||||
|
||||
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||
result["proxmox_available"] = True
|
||||
|
||||
# Try to get certificate info
|
||||
try:
|
||||
import subprocess
|
||||
cert_output = subprocess.run(
|
||||
["openssl", "x509", "-in", PROXMOX_CERT_PATH, "-noout", "-subject", "-enddate", "-issuer"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if cert_output.returncode == 0:
|
||||
lines = cert_output.stdout.strip().split('\n')
|
||||
info = {}
|
||||
for line in lines:
|
||||
if line.startswith("subject="):
|
||||
info["subject"] = line.replace("subject=", "").strip()
|
||||
elif line.startswith("notAfter="):
|
||||
info["expires"] = line.replace("notAfter=", "").strip()
|
||||
elif line.startswith("issuer="):
|
||||
issuer = line.replace("issuer=", "").strip()
|
||||
info["issuer"] = issuer
|
||||
info["is_self_signed"] = info.get("subject", "") == issuer
|
||||
result["cert_info"] = info
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_certificate_files(cert_path, key_path):
|
||||
"""
|
||||
Validate that cert and key files exist and are readable.
|
||||
Returns (valid: bool, message: str)
|
||||
"""
|
||||
if not cert_path or not key_path:
|
||||
return False, "Certificate and key paths are required"
|
||||
|
||||
if not os.path.isfile(cert_path):
|
||||
return False, f"Certificate file not found: {cert_path}"
|
||||
|
||||
if not os.path.isfile(key_path):
|
||||
return False, f"Key file not found: {key_path}"
|
||||
|
||||
# Verify files are readable
|
||||
try:
|
||||
with open(cert_path, 'r') as f:
|
||||
content = f.read(100)
|
||||
if "BEGIN CERTIFICATE" not in content and "BEGIN TRUSTED CERTIFICATE" not in content:
|
||||
return False, "Certificate file does not appear to be a valid PEM certificate"
|
||||
|
||||
with open(key_path, 'r') as f:
|
||||
content = f.read(100)
|
||||
if "BEGIN" not in content or "KEY" not in content:
|
||||
return False, "Key file does not appear to be a valid PEM key"
|
||||
except PermissionError:
|
||||
return False, "Cannot read certificate files. Check file permissions."
|
||||
except Exception as e:
|
||||
return False, f"Error reading certificate files: {str(e)}"
|
||||
|
||||
# Verify cert and key match
|
||||
try:
|
||||
import subprocess
|
||||
cert_mod = subprocess.run(
|
||||
["openssl", "x509", "-noout", "-modulus", "-in", cert_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
key_mod = subprocess.run(
|
||||
["openssl", "rsa", "-noout", "-modulus", "-in", key_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if cert_mod.returncode == 0 and key_mod.returncode == 0:
|
||||
if cert_mod.stdout.strip() != key_mod.stdout.strip():
|
||||
return False, "Certificate and key do not match"
|
||||
except Exception:
|
||||
pass # Non-critical, proceed anyway
|
||||
|
||||
return True, "Certificate files are valid"
|
||||
|
||||
|
||||
def configure_ssl(cert_path, key_path, source="custom"):
|
||||
"""
|
||||
Configure SSL with given certificate and key paths.
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
valid, message = validate_certificate_files(cert_path, key_path)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
config = {
|
||||
"enabled": True,
|
||||
"cert_path": cert_path,
|
||||
"key_path": key_path,
|
||||
"source": source
|
||||
}
|
||||
|
||||
if save_ssl_config(config):
|
||||
return True, "SSL configured successfully. Restart the monitor service to apply changes."
|
||||
else:
|
||||
return False, "Failed to save SSL configuration"
|
||||
|
||||
|
||||
def disable_ssl():
|
||||
"""Disable SSL and return to HTTP"""
|
||||
config = {
|
||||
"enabled": False,
|
||||
"cert_path": "",
|
||||
"key_path": "",
|
||||
"source": "none"
|
||||
}
|
||||
|
||||
if save_ssl_config(config):
|
||||
return True, "SSL disabled. Restart the monitor service to apply changes."
|
||||
else:
|
||||
return False, "Failed to save SSL configuration"
|
||||
|
||||
|
||||
def get_ssl_context():
|
||||
"""
|
||||
Get SSL context for Flask if SSL is configured and enabled.
|
||||
Returns tuple (cert_path, key_path) or None
|
||||
"""
|
||||
config = load_ssl_config()
|
||||
|
||||
if not config.get("enabled"):
|
||||
return None
|
||||
|
||||
cert_path = config.get("cert_path", "")
|
||||
key_path = config.get("key_path", "")
|
||||
|
||||
if cert_path and key_path and os.path.isfile(cert_path) and os.path.isfile(key_path):
|
||||
return (cert_path, key_path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def authenticate(username, password, totp_token=None):
|
||||
"""
|
||||
Authenticate a user with username, password, and optional TOTP
|
||||
@@ -490,12 +786,15 @@ def authenticate(username, password, totp_token=None):
|
||||
|
||||
if config.get("totp_enabled"):
|
||||
if not totp_token:
|
||||
# First step: password OK, now request TOTP code (not a failure)
|
||||
return False, None, True, "2FA code required"
|
||||
|
||||
# Verify TOTP token or backup code
|
||||
success, message = verify_totp(username, totp_token, use_backup=len(totp_token) == 9) # Backup codes are formatted XXXX-XXXX
|
||||
if not success:
|
||||
return False, None, True, message
|
||||
# TOTP code is wrong: return requires_totp=False so the caller
|
||||
# logs it as a real authentication failure for Fail2Ban
|
||||
return False, None, False, "Invalid 2FA code"
|
||||
|
||||
token = generate_token(username)
|
||||
if token:
|
||||
|
||||
@@ -89,6 +89,37 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
|
||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
||||
cp "$SCRIPT_DIR/notification_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_manager.py not found"
|
||||
cp "$SCRIPT_DIR/notification_channels.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_channels.py not found"
|
||||
cp "$SCRIPT_DIR/notification_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_templates.py not found"
|
||||
cp "$SCRIPT_DIR/notification_events.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_events.py not found"
|
||||
cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found"
|
||||
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
|
||||
cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found"
|
||||
|
||||
# Copy AI providers module for notification enhancement
|
||||
echo "📋 Copying AI providers module..."
|
||||
if [ -d "$SCRIPT_DIR/ai_providers" ]; then
|
||||
mkdir -p "$APP_DIR/usr/bin/ai_providers"
|
||||
cp "$SCRIPT_DIR/ai_providers/"*.py "$APP_DIR/usr/bin/ai_providers/"
|
||||
echo "✅ AI providers module copied"
|
||||
else
|
||||
echo "⚠️ ai_providers directory not found"
|
||||
fi
|
||||
|
||||
# Copy config files (verified AI models, etc.)
|
||||
echo "📋 Copying config files..."
|
||||
CONFIG_DIR="$APPIMAGE_ROOT/config"
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
mkdir -p "$APP_DIR/usr/bin/config"
|
||||
cp "$CONFIG_DIR/"*.json "$APP_DIR/usr/bin/config/" 2>/dev/null || true
|
||||
echo "✅ Config files copied"
|
||||
else
|
||||
echo "⚠️ config directory not found"
|
||||
fi
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
@@ -293,6 +324,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
||||
h11==0.9.0 || true
|
||||
|
||||
# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages)
|
||||
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
|
||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
|
||||
flask \
|
||||
flask-cors \
|
||||
@@ -310,6 +342,12 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
||||
simple-websocket>=0.10.0 \
|
||||
flask-sock>=0.6.0
|
||||
|
||||
# Phase 3b: Install gevent for SSL+WebSocket support (WSS)
|
||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
||||
gevent>=24.2.1 \
|
||||
gevent-websocket>=0.10.1 \
|
||||
greenlet>=3.0.0
|
||||
|
||||
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
||||
from typing import Tuple, Dict
|
||||
try:
|
||||
|
||||
@@ -3,11 +3,48 @@ Flask Authentication Routes
|
||||
Provides REST API endpoints for authentication management
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from flask import Blueprint, jsonify, request
|
||||
import auth_manager
|
||||
import jwt
|
||||
import datetime
|
||||
|
||||
# Dedicated logger for auth failures (Fail2Ban reads this file)
|
||||
auth_logger = logging.getLogger("proxmenux-auth")
|
||||
auth_logger.setLevel(logging.WARNING)
|
||||
|
||||
# Handler 1: File for Fail2Ban
|
||||
_auth_file_handler = logging.FileHandler("/var/log/proxmenux-auth.log")
|
||||
_auth_file_handler.setFormatter(logging.Formatter("%(asctime)s proxmenux-auth: %(message)s"))
|
||||
auth_logger.addHandler(_auth_file_handler)
|
||||
|
||||
# Handler 2: Syslog for JournalWatcher notifications
|
||||
# This sends to the systemd journal so notification_events.py can detect auth failures
|
||||
try:
|
||||
_auth_syslog_handler = logging.handlers.SysLogHandler(address='/dev/log', facility=logging.handlers.SysLogHandler.LOG_AUTH)
|
||||
_auth_syslog_handler.setFormatter(logging.Formatter("proxmenux-auth: %(message)s"))
|
||||
_auth_syslog_handler.ident = "proxmenux-auth"
|
||||
auth_logger.addHandler(_auth_syslog_handler)
|
||||
except Exception:
|
||||
pass # Syslog may not be available in all environments
|
||||
|
||||
|
||||
def _get_client_ip():
|
||||
"""Get the real client IP, supporting reverse proxies (X-Forwarded-For, X-Real-IP)"""
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
# First IP in the chain is the real client
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("X-Real-IP", "")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.remote_addr or "unknown"
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/api/auth/status', methods=['GET'])
|
||||
@@ -24,27 +61,132 @@ def auth_status():
|
||||
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/setup', methods=['POST'])
|
||||
def auth_setup():
|
||||
"""Set up authentication with username and password"""
|
||||
# -------------------------------------------------------------------
|
||||
# SSL/HTTPS Certificate Management
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@auth_bp.route('/api/ssl/status', methods=['GET'])
|
||||
def ssl_status():
|
||||
"""Get current SSL configuration status and detect available certificates"""
|
||||
try:
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
config = auth_manager.load_ssl_config()
|
||||
detection = auth_manager.detect_proxmox_certificates()
|
||||
|
||||
success, message = auth_manager.setup_auth(username, password)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"ssl_enabled": config.get("enabled", False),
|
||||
"source": config.get("source", "none"),
|
||||
"cert_path": config.get("cert_path", ""),
|
||||
"key_path": config.get("key_path", ""),
|
||||
"proxmox_available": detection.get("proxmox_available", False),
|
||||
"proxmox_cert": detection.get("proxmox_cert", ""),
|
||||
"proxmox_key": detection.get("proxmox_key", ""),
|
||||
"cert_info": detection.get("cert_info")
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
def _schedule_service_restart(delay=1.5):
|
||||
"""Schedule a restart of the monitor service via systemctl after a short delay.
|
||||
This gives time for the HTTP response to reach the client before the process restarts."""
|
||||
def _do_restart():
|
||||
time.sleep(delay)
|
||||
print("[ProxMenux] Restarting monitor service to apply SSL changes...")
|
||||
# Use systemctl restart which properly stops and starts the service.
|
||||
# This works because systemd manages proxmenux-monitor.service.
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["systemctl", "restart", "proxmenux-monitor"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Failed to restart via systemctl: {e}")
|
||||
# Fallback: try to restart the process directly
|
||||
os.kill(os.getpid(), 15) # SIGTERM
|
||||
|
||||
t = threading.Thread(target=_do_restart, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
@auth_bp.route('/api/ssl/configure', methods=['POST'])
|
||||
def ssl_configure():
|
||||
"""Configure SSL with Proxmox or custom certificates"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
source = data.get("source", "proxmox")
|
||||
auto_restart = data.get("auto_restart", True)
|
||||
|
||||
if source == "proxmox":
|
||||
cert_path = auth_manager.PROXMOX_CERT_PATH
|
||||
key_path = auth_manager.PROXMOX_KEY_PATH
|
||||
elif source == "custom":
|
||||
cert_path = data.get("cert_path", "")
|
||||
key_path = data.get("key_path", "")
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Invalid source. Use 'proxmox' or 'custom'."}), 400
|
||||
|
||||
success, message = auth_manager.configure_ssl(cert_path, key_path, source)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
if auto_restart:
|
||||
_schedule_service_restart()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "SSL enabled. The service is restarting...",
|
||||
"restarting": auto_restart,
|
||||
"new_protocol": "https"
|
||||
})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/ssl/disable', methods=['POST'])
|
||||
def ssl_disable():
|
||||
"""Disable SSL and return to HTTP"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
auto_restart = data.get("auto_restart", True)
|
||||
|
||||
success, message = auth_manager.disable_ssl()
|
||||
|
||||
if success:
|
||||
if auto_restart:
|
||||
_schedule_service_restart()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "SSL disabled. The service is restarting...",
|
||||
"restarting": auto_restart,
|
||||
"new_protocol": "http"
|
||||
})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/ssl/validate', methods=['POST'])
|
||||
def ssl_validate():
|
||||
"""Validate custom certificate and key file paths"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
cert_path = data.get("cert_path", "")
|
||||
key_path = data.get("key_path", "")
|
||||
|
||||
valid, message = auth_manager.validate_certificate_files(cert_path, key_path)
|
||||
|
||||
return jsonify({"success": valid, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/decline', methods=['POST'])
|
||||
def auth_decline():
|
||||
"""Decline authentication setup"""
|
||||
@@ -73,16 +215,50 @@ def auth_login():
|
||||
if success:
|
||||
return jsonify({"success": True, "token": token, "message": message})
|
||||
elif requires_totp:
|
||||
# First step: password OK, requesting TOTP code (not a failure)
|
||||
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 401
|
||||
# Authentication failure (wrong password or wrong TOTP code)
|
||||
client_ip = _get_client_ip()
|
||||
auth_logger.warning(
|
||||
"authentication failure; rhost=%s user=%s",
|
||||
client_ip, username or "unknown"
|
||||
)
|
||||
# If user submitted a TOTP token that was wrong, tell frontend
|
||||
# to keep showing the TOTP field (not go back to password step)
|
||||
is_totp_failure = totp_token and "2FA" in message
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": message,
|
||||
"requires_totp": is_totp_failure
|
||||
}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/setup', methods=['POST'])
|
||||
def auth_setup():
|
||||
"""Set up authentication with username and password (create user + enable auth)"""
|
||||
try:
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
success, message = auth_manager.setup_auth(username, password)
|
||||
|
||||
if success:
|
||||
# Generate a token so the user is logged in immediately
|
||||
token = auth_manager.generate_token(username)
|
||||
return jsonify({"success": True, "token": token, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "error": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/enable', methods=['POST'])
|
||||
def auth_enable():
|
||||
"""Enable authentication"""
|
||||
"""Enable authentication (must already be configured)"""
|
||||
try:
|
||||
success, message = auth_manager.enable_auth()
|
||||
|
||||
@@ -262,6 +438,9 @@ def generate_api_token():
|
||||
'iat': datetime.datetime.utcnow()
|
||||
}, auth_manager.JWT_SECRET, algorithm='HS256')
|
||||
|
||||
# Store token metadata for listing and revocation
|
||||
auth_manager.store_api_token_metadata(api_token, token_name)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"token": api_token,
|
||||
@@ -276,3 +455,35 @@ def generate_api_token():
|
||||
except Exception as e:
|
||||
print(f"[ERROR] generate_api_token: {str(e)}") # Log error for debugging
|
||||
return jsonify({"success": False, "message": f"Internal error: {str(e)}"}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/api-tokens', methods=['GET'])
|
||||
def list_api_tokens():
|
||||
"""List all generated API tokens (metadata only, no actual token values)"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if not token or not auth_manager.verify_token(token):
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
|
||||
tokens = auth_manager.list_api_tokens()
|
||||
return jsonify({"success": True, "tokens": tokens})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/api-tokens/<token_id>', methods=['DELETE'])
|
||||
def revoke_api_token_route(token_id):
|
||||
"""Revoke an API token by its ID"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if not token or not auth_manager.verify_token(token):
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
|
||||
success, message = auth_manager.revoke_api_token(token_id)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@@ -51,15 +51,74 @@ def get_system_info():
|
||||
|
||||
@health_bp.route('/api/health/acknowledge', methods=['POST'])
|
||||
def acknowledge_error():
|
||||
"""Acknowledge an error manually (user dismissed it)"""
|
||||
"""
|
||||
Acknowledge/dismiss an error manually.
|
||||
Returns details about the acknowledged error including original severity
|
||||
and suppression period info.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'error_key' not in data:
|
||||
return jsonify({'error': 'error_key is required'}), 400
|
||||
|
||||
error_key = data['error_key']
|
||||
health_persistence.acknowledge_error(error_key)
|
||||
return jsonify({'success': True, 'message': 'Error acknowledged'})
|
||||
result = health_persistence.acknowledge_error(error_key)
|
||||
|
||||
if result.get('success'):
|
||||
# Invalidate cached health results so next fetch reflects the dismiss
|
||||
# Use the error's category to clear the correct cache
|
||||
category = result.get('category', '')
|
||||
cache_key_map = {
|
||||
'logs': 'logs_analysis',
|
||||
'pve_services': 'pve_services',
|
||||
'updates': 'updates_check',
|
||||
'security': 'security_check',
|
||||
'temperature': 'cpu_check',
|
||||
'network': 'network_check',
|
||||
'disks': 'storage_check',
|
||||
'vms': 'vms_check',
|
||||
}
|
||||
cache_key = cache_key_map.get(category)
|
||||
if cache_key:
|
||||
health_monitor.last_check_times.pop(cache_key, None)
|
||||
health_monitor.cached_results.pop(cache_key, None)
|
||||
|
||||
# Also invalidate ALL background/overall caches so next fetch reflects dismiss
|
||||
for ck in ['_bg_overall', '_bg_detailed', 'overall_health']:
|
||||
health_monitor.last_check_times.pop(ck, None)
|
||||
health_monitor.cached_results.pop(ck, None)
|
||||
|
||||
# Use the per-record suppression hours from acknowledge_error()
|
||||
sup_hours = result.get('suppression_hours', 24)
|
||||
if sup_hours == -1:
|
||||
suppression_label = 'permanently'
|
||||
elif sup_hours >= 8760:
|
||||
suppression_label = f'{sup_hours // 8760} year(s)'
|
||||
elif sup_hours >= 720:
|
||||
suppression_label = f'{sup_hours // 720} month(s)'
|
||||
elif sup_hours >= 168:
|
||||
suppression_label = f'{sup_hours // 168} week(s)'
|
||||
elif sup_hours >= 72:
|
||||
suppression_label = f'{sup_hours // 24} day(s)'
|
||||
else:
|
||||
suppression_label = f'{sup_hours} hours'
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Error dismissed for {suppression_label}',
|
||||
'error_key': error_key,
|
||||
'original_severity': result.get('original_severity', 'WARNING'),
|
||||
'category': category,
|
||||
'suppression_hours': sup_hours,
|
||||
'suppression_label': suppression_label,
|
||||
'acknowledged_at': result.get('acknowledged_at')
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Error not found or already dismissed',
|
||||
'error_key': error_key
|
||||
}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -72,3 +131,328 @@ def get_active_errors():
|
||||
return jsonify({'errors': errors})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/dismissed', methods=['GET'])
|
||||
def get_dismissed_errors():
|
||||
"""
|
||||
Get dismissed errors that are still within their suppression period.
|
||||
These are shown as INFO items with a 'Dismissed' badge in the frontend.
|
||||
"""
|
||||
try:
|
||||
dismissed = health_persistence.get_dismissed_errors()
|
||||
return jsonify({'dismissed': dismissed})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/full', methods=['GET'])
|
||||
def get_full_health():
|
||||
"""
|
||||
Get complete health data in a single request: detailed status + active errors + dismissed.
|
||||
Uses background-cached results if fresh (< 6 min) for instant response,
|
||||
otherwise runs a fresh check.
|
||||
"""
|
||||
import time as _time
|
||||
try:
|
||||
# Try to use the background-cached detailed result for instant response
|
||||
bg_key = '_bg_detailed'
|
||||
bg_last = health_monitor.last_check_times.get(bg_key, 0)
|
||||
bg_age = _time.time() - bg_last
|
||||
|
||||
if bg_age < 360 and bg_key in health_monitor.cached_results:
|
||||
# Use cached result (at most ~5 min old)
|
||||
details = health_monitor.cached_results[bg_key]
|
||||
else:
|
||||
# No fresh cache, run live (first load or cache expired)
|
||||
details = health_monitor.get_detailed_status()
|
||||
|
||||
active_errors = health_persistence.get_active_errors()
|
||||
dismissed = health_persistence.get_dismissed_errors()
|
||||
custom_suppressions = health_persistence.get_custom_suppressions()
|
||||
|
||||
return jsonify({
|
||||
'health': details,
|
||||
'active_errors': active_errors,
|
||||
'dismissed': dismissed,
|
||||
'custom_suppressions': custom_suppressions,
|
||||
'timestamp': details.get('timestamp')
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/cleanup-orphans', methods=['POST'])
|
||||
def cleanup_orphan_errors():
|
||||
"""
|
||||
Clean up errors for devices that no longer exist in the system.
|
||||
Useful when USB drives or temporary devices are disconnected.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
try:
|
||||
cleaned = []
|
||||
# Get all active disk errors
|
||||
disk_errors = health_persistence.get_active_errors(category='disks')
|
||||
|
||||
for err in disk_errors:
|
||||
err_key = err.get('error_key', '')
|
||||
details = err.get('details', {})
|
||||
if isinstance(details, str):
|
||||
try:
|
||||
import json as _json
|
||||
details = _json.loads(details)
|
||||
except Exception:
|
||||
details = {}
|
||||
|
||||
device = details.get('device', '')
|
||||
base_disk = details.get('disk', '')
|
||||
|
||||
# Try to determine the device path
|
||||
dev_path = None
|
||||
if base_disk:
|
||||
dev_path = f'/dev/{base_disk}'
|
||||
elif device:
|
||||
dev_path = device if device.startswith('/dev/') else f'/dev/{device}'
|
||||
elif err_key.startswith('disk_'):
|
||||
# Extract device from error_key
|
||||
dev_name = err_key.replace('disk_fs_', '').replace('disk_', '')
|
||||
dev_name = re.sub(r'_.*$', '', dev_name) # Remove suffix
|
||||
if dev_name:
|
||||
dev_path = f'/dev/{dev_name}'
|
||||
|
||||
if dev_path:
|
||||
# Also check base disk (remove partition number)
|
||||
base_path = re.sub(r'\d+$', '', dev_path)
|
||||
if not os.path.exists(dev_path) and not os.path.exists(base_path):
|
||||
health_persistence.resolve_error(err_key, 'Device no longer present (manual cleanup)')
|
||||
cleaned.append({'error_key': err_key, 'device': dev_path})
|
||||
|
||||
# Also cleanup disk_observations for non-existent devices
|
||||
try:
|
||||
health_persistence.cleanup_orphan_observations()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'cleaned_count': len(cleaned),
|
||||
'cleaned_errors': cleaned
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/pending-notifications', methods=['GET'])
|
||||
def get_pending_notifications():
|
||||
"""
|
||||
Get events pending notification (for future Telegram/Gotify/Discord integration).
|
||||
This endpoint will be consumed by the Notification Service (Bloque A).
|
||||
"""
|
||||
try:
|
||||
pending = health_persistence.get_pending_notifications()
|
||||
return jsonify({'pending': pending, 'count': len(pending)})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/mark-notified', methods=['POST'])
|
||||
def mark_events_notified():
|
||||
"""
|
||||
Mark events as notified after notification was sent successfully.
|
||||
Used by the Notification Service (Bloque A) after sending alerts.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'event_ids' not in data:
|
||||
return jsonify({'error': 'event_ids array is required'}), 400
|
||||
|
||||
event_ids = data['event_ids']
|
||||
if not isinstance(event_ids, list):
|
||||
return jsonify({'error': 'event_ids must be an array'}), 400
|
||||
|
||||
health_persistence.mark_events_notified(event_ids)
|
||||
return jsonify({'success': True, 'marked_count': len(event_ids)})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/settings', methods=['GET'])
|
||||
def get_health_settings():
|
||||
"""
|
||||
Get per-category suppression duration settings.
|
||||
Returns all health categories with their current configured hours.
|
||||
"""
|
||||
try:
|
||||
categories = health_persistence.get_suppression_categories()
|
||||
return jsonify({'categories': categories})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/settings', methods=['POST'])
|
||||
def save_health_settings():
|
||||
"""
|
||||
Save per-category suppression duration settings.
|
||||
Expects JSON body with key-value pairs like: {"suppress_cpu": "168", "suppress_memory": "-1"}
|
||||
Valid values: 24, 72, 168, 720, 8760, -1 (permanent), or any positive integer for custom.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No settings provided'}), 400
|
||||
|
||||
valid_keys = set(health_persistence.CATEGORY_SETTING_MAP.values())
|
||||
updated = []
|
||||
|
||||
for key, value in data.items():
|
||||
if key not in valid_keys:
|
||||
continue
|
||||
|
||||
try:
|
||||
hours = int(value)
|
||||
# Validate: must be -1 (permanent) or positive
|
||||
if hours != -1 and hours < 1:
|
||||
continue
|
||||
health_persistence.set_setting(key, str(hours))
|
||||
updated.append(key)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Retroactively sync all existing dismissed errors
|
||||
# so changes are effective immediately, not just on next dismiss
|
||||
synced_count = health_persistence.sync_dismissed_suppression()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'updated': updated,
|
||||
'count': len(updated),
|
||||
'synced_dismissed': synced_count
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── Remote Storage Exclusions Endpoints ──
|
||||
|
||||
@health_bp.route('/api/health/remote-storages', methods=['GET'])
|
||||
def get_remote_storages():
|
||||
"""
|
||||
Get list of all remote storages with their exclusion status.
|
||||
Remote storages are those that can be offline (PBS, NFS, CIFS, etc.)
|
||||
"""
|
||||
try:
|
||||
from proxmox_storage_monitor import proxmox_storage_monitor
|
||||
|
||||
# Get current storage status
|
||||
storage_status = proxmox_storage_monitor.get_storage_status()
|
||||
all_storages = storage_status.get('available', []) + storage_status.get('unavailable', [])
|
||||
|
||||
# Filter to only remote types
|
||||
remote_types = health_persistence.REMOTE_STORAGE_TYPES
|
||||
remote_storages = [s for s in all_storages if s.get('type', '').lower() in remote_types]
|
||||
|
||||
# Get current exclusions
|
||||
exclusions = {e['storage_name']: e for e in health_persistence.get_excluded_storages()}
|
||||
|
||||
# Combine info
|
||||
result = []
|
||||
for storage in remote_storages:
|
||||
name = storage.get('name', '')
|
||||
exclusion = exclusions.get(name, {})
|
||||
result.append({
|
||||
'name': name,
|
||||
'type': storage.get('type', 'unknown'),
|
||||
'status': storage.get('status', 'unknown'),
|
||||
'total': storage.get('total', 0),
|
||||
'used': storage.get('used', 0),
|
||||
'available': storage.get('available', 0),
|
||||
'percent': storage.get('percent', 0),
|
||||
'exclude_health': exclusion.get('exclude_health', 0) == 1,
|
||||
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
|
||||
'excluded_at': exclusion.get('excluded_at'),
|
||||
'reason': exclusion.get('reason')
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'storages': result,
|
||||
'remote_types': list(remote_types)
|
||||
})
|
||||
except ImportError:
|
||||
return jsonify({'error': 'Storage monitor not available', 'storages': []}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/storage-exclusions', methods=['GET'])
|
||||
def get_storage_exclusions():
|
||||
"""Get all storage exclusions."""
|
||||
try:
|
||||
exclusions = health_persistence.get_excluded_storages()
|
||||
return jsonify({'exclusions': exclusions})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/storage-exclusions', methods=['POST'])
|
||||
def save_storage_exclusion():
|
||||
"""
|
||||
Add or update a storage exclusion.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"storage_name": "pbs-backup",
|
||||
"storage_type": "pbs",
|
||||
"exclude_health": true,
|
||||
"exclude_notifications": true,
|
||||
"reason": "PBS server is offline daily"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'storage_name' not in data:
|
||||
return jsonify({'error': 'storage_name is required'}), 400
|
||||
|
||||
storage_name = data['storage_name']
|
||||
storage_type = data.get('storage_type', 'unknown')
|
||||
exclude_health = data.get('exclude_health', True)
|
||||
exclude_notifications = data.get('exclude_notifications', True)
|
||||
reason = data.get('reason')
|
||||
|
||||
# Check if already excluded
|
||||
existing = health_persistence.get_excluded_storages()
|
||||
exists = any(e['storage_name'] == storage_name for e in existing)
|
||||
|
||||
if exists:
|
||||
# Update existing
|
||||
success = health_persistence.update_storage_exclusion(
|
||||
storage_name, exclude_health, exclude_notifications
|
||||
)
|
||||
else:
|
||||
# Add new
|
||||
success = health_persistence.exclude_storage(
|
||||
storage_name, storage_type, exclude_health, exclude_notifications, reason
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Storage {storage_name} exclusion saved',
|
||||
'storage_name': storage_name
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to save exclusion'}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/storage-exclusions/<storage_name>', methods=['DELETE'])
|
||||
def delete_storage_exclusion(storage_name):
|
||||
"""Remove a storage from the exclusion list."""
|
||||
try:
|
||||
success = health_persistence.remove_storage_exclusion(storage_name)
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Storage {storage_name} removed from exclusions'
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': 'Storage not found in exclusions'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
"""
|
||||
Flask routes for notification service configuration and management.
|
||||
Blueprint pattern matching flask_health_routes.py / flask_security_routes.py.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import time
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from flask import Blueprint, jsonify, request
|
||||
from notification_manager import notification_manager
|
||||
|
||||
|
||||
# ─── Webhook Hardening Helpers ───────────────────────────────────
|
||||
|
||||
class WebhookRateLimiter:
|
||||
"""Simple sliding-window rate limiter for the webhook endpoint."""
|
||||
|
||||
def __init__(self, max_requests: int = 60, window_seconds: int = 60):
|
||||
self._max = max_requests
|
||||
self._window = window_seconds
|
||||
self._timestamps: deque = deque()
|
||||
|
||||
def allow(self) -> bool:
|
||||
now = time.time()
|
||||
# Prune entries outside the window
|
||||
while self._timestamps and now - self._timestamps[0] > self._window:
|
||||
self._timestamps.popleft()
|
||||
if len(self._timestamps) >= self._max:
|
||||
return False
|
||||
self._timestamps.append(now)
|
||||
return True
|
||||
|
||||
|
||||
class ReplayCache:
|
||||
"""Bounded in-memory cache of recently seen request signatures (60s TTL)."""
|
||||
|
||||
_MAX_SIZE = 2000 # Hard cap to prevent memory growth
|
||||
|
||||
def __init__(self, ttl: int = 60):
|
||||
self._ttl = ttl
|
||||
self._seen: dict = {} # signature -> timestamp
|
||||
|
||||
def check_and_record(self, signature: str) -> bool:
|
||||
"""Return True if this signature was already seen (replay). Records it otherwise."""
|
||||
now = time.time()
|
||||
# Periodic cleanup
|
||||
if len(self._seen) > self._MAX_SIZE // 2:
|
||||
cutoff = now - self._ttl
|
||||
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
|
||||
if signature in self._seen and now - self._seen[signature] < self._ttl:
|
||||
return True # Replay detected
|
||||
self._seen[signature] = now
|
||||
return False
|
||||
|
||||
|
||||
# Module-level singletons (one per process)
|
||||
_webhook_limiter = WebhookRateLimiter(max_requests=60, window_seconds=60)
|
||||
_replay_cache = ReplayCache(ttl=60)
|
||||
|
||||
# Timestamp validation window (seconds)
|
||||
_TIMESTAMP_MAX_DRIFT = 60
|
||||
|
||||
notification_bp = Blueprint('notifications', __name__)
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/settings', methods=['GET'])
|
||||
def get_notification_settings():
|
||||
"""Get all notification settings for the UI."""
|
||||
try:
|
||||
settings = notification_manager.get_settings()
|
||||
return jsonify(settings)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/settings', methods=['POST'])
|
||||
def save_notification_settings():
|
||||
"""Save notification settings from the UI."""
|
||||
try:
|
||||
payload = request.get_json()
|
||||
if not payload:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
result = notification_manager.save_settings(payload)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/test', methods=['POST'])
|
||||
def test_notification():
|
||||
"""Send a test notification to one or all channels."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
channel = data.get('channel', 'all')
|
||||
|
||||
result = notification_manager.test_channel(channel)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
def load_verified_models():
|
||||
"""Load verified models from config file.
|
||||
|
||||
Checks multiple paths:
|
||||
1. Same directory as script (AppImage: /usr/bin/config/)
|
||||
2. Parent directory config folder (dev: AppImage/config/)
|
||||
"""
|
||||
try:
|
||||
# Try AppImage path first (scripts and config both in /usr/bin/)
|
||||
script_dir = Path(__file__).parent
|
||||
config_path = script_dir / 'config' / 'verified_ai_models.json'
|
||||
|
||||
if not config_path.exists():
|
||||
# Try development path (AppImage/scripts/ -> AppImage/config/)
|
||||
config_path = script_dir.parent / 'config' / 'verified_ai_models.json'
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
print(f"[flask_notification_routes] Config not found at {config_path}")
|
||||
except Exception as e:
|
||||
print(f"[flask_notification_routes] Failed to load verified models: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/provider-models', methods=['POST'])
|
||||
def get_provider_models():
|
||||
"""Fetch available models from AI provider, filtered by verified models list.
|
||||
|
||||
Only returns models that:
|
||||
1. Are available from the provider's API
|
||||
2. Are in our verified_ai_models.json list (tested to work)
|
||||
|
||||
Request body:
|
||||
{
|
||||
"provider": "gemini|groq|openai|openrouter|ollama|anthropic",
|
||||
"api_key": "your-api-key", // Not needed for ollama
|
||||
"ollama_url": "http://localhost:11434", // Only for ollama
|
||||
"openai_base_url": "https://custom.endpoint/v1" // Optional for openai
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true/false,
|
||||
"models": ["model1", "model2", ...],
|
||||
"recommended": "recommended-model",
|
||||
"message": "status message"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
provider = data.get('provider', '')
|
||||
api_key = data.get('api_key', '')
|
||||
ollama_url = data.get('ollama_url', 'http://localhost:11434')
|
||||
openai_base_url = data.get('openai_base_url', '')
|
||||
|
||||
if not provider:
|
||||
return jsonify({'success': False, 'models': [], 'message': 'Provider not specified'})
|
||||
|
||||
# Load verified models config
|
||||
verified_config = load_verified_models()
|
||||
provider_config = verified_config.get(provider, {})
|
||||
verified_models = set(provider_config.get('models', []))
|
||||
recommended = provider_config.get('recommended', '')
|
||||
|
||||
# Handle Ollama separately (local, no filtering)
|
||||
if provider == 'ollama':
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{ollama_url.rstrip('/')}/api/tags"
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
models = [m.get('name', '') for m in result.get('models', []) if m.get('name')]
|
||||
models = sorted(models)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': models,
|
||||
'recommended': models[0] if models else '',
|
||||
'message': f'Found {len(models)} local models'
|
||||
})
|
||||
|
||||
# Handle Anthropic - no models list API, return verified models directly
|
||||
if provider == 'anthropic':
|
||||
models = list(verified_models) if verified_models else [
|
||||
'claude-3-5-haiku-latest',
|
||||
'claude-3-5-sonnet-latest',
|
||||
'claude-3-opus-latest',
|
||||
]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': sorted(models),
|
||||
'recommended': recommended or models[0],
|
||||
'message': f'{len(models)} verified models'
|
||||
})
|
||||
|
||||
# For other providers, fetch from API and filter by verified list
|
||||
if not api_key:
|
||||
return jsonify({'success': False, 'models': [], 'message': 'API key required'})
|
||||
|
||||
from ai_providers import get_provider
|
||||
ai_provider = get_provider(
|
||||
provider,
|
||||
api_key=api_key,
|
||||
model='',
|
||||
base_url=openai_base_url if provider == 'openai' else None
|
||||
)
|
||||
|
||||
if not ai_provider:
|
||||
return jsonify({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
|
||||
|
||||
# Get all models from provider API
|
||||
api_models = ai_provider.list_models()
|
||||
|
||||
if not api_models:
|
||||
# API failed, fall back to verified list only
|
||||
if verified_models:
|
||||
models = sorted(verified_models)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': models,
|
||||
'recommended': recommended or models[0],
|
||||
'message': f'{len(models)} verified models (API unavailable)'
|
||||
})
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'models': [],
|
||||
'message': 'Could not retrieve models. Check your API key.'
|
||||
})
|
||||
|
||||
# Filter: only models that are BOTH in API and verified list
|
||||
if verified_models:
|
||||
api_models_set = set(api_models)
|
||||
filtered_models = [m for m in verified_models if m in api_models_set]
|
||||
|
||||
if not filtered_models:
|
||||
# No intersection - maybe verified list is outdated
|
||||
# Return verified list anyway (will fail on use if truly unavailable)
|
||||
filtered_models = list(verified_models)
|
||||
|
||||
# Sort with recommended first
|
||||
def sort_key(m):
|
||||
if m == recommended:
|
||||
return (0, m)
|
||||
return (1, m)
|
||||
|
||||
models = sorted(filtered_models, key=sort_key)
|
||||
else:
|
||||
# No verified list for this provider, return all from API
|
||||
models = sorted(api_models)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': models,
|
||||
'recommended': recommended if recommended in models else (models[0] if models else ''),
|
||||
'message': f'{len(models)} verified models available'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'models': [],
|
||||
'message': f'Error: {str(e)}'
|
||||
})
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/test-ai', methods=['POST'])
|
||||
def test_ai_connection():
|
||||
"""Test AI provider connection and configuration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"provider": "groq" | "openai" | "anthropic" | "gemini" | "ollama" | "openrouter",
|
||||
"api_key": "...",
|
||||
"model": "..." (optional),
|
||||
"ollama_url": "http://localhost:11434" (optional, for ollama)
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true/false,
|
||||
"message": "Connection successful" or error message,
|
||||
"model": "model used for test"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
provider = data.get('provider', 'groq')
|
||||
api_key = data.get('api_key', '')
|
||||
model = data.get('model', '')
|
||||
ollama_url = data.get('ollama_url', 'http://localhost:11434')
|
||||
openai_base_url = data.get('openai_base_url', '')
|
||||
|
||||
# Validate required fields
|
||||
if provider != 'ollama' and not api_key:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'API key is required',
|
||||
'model': ''
|
||||
}), 400
|
||||
|
||||
if provider == 'ollama' and not ollama_url:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Ollama URL is required',
|
||||
'model': ''
|
||||
}), 400
|
||||
|
||||
# Import and use the AI providers module
|
||||
import sys
|
||||
import os
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
from ai_providers import get_provider, AIProviderError
|
||||
|
||||
# Determine base_url based on provider
|
||||
if provider == 'ollama':
|
||||
base_url = ollama_url
|
||||
elif provider == 'openai':
|
||||
base_url = openai_base_url # Empty string means use default OpenAI API
|
||||
else:
|
||||
base_url = ''
|
||||
|
||||
try:
|
||||
ai_provider = get_provider(
|
||||
provider,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url
|
||||
)
|
||||
|
||||
result = ai_provider.test_connection()
|
||||
return jsonify(result)
|
||||
|
||||
except AIProviderError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': str(e),
|
||||
'model': model
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Unexpected error: {str(e)}',
|
||||
'model': ''
|
||||
}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/status', methods=['GET'])
|
||||
def get_notification_status():
|
||||
"""Get notification service status."""
|
||||
try:
|
||||
status = notification_manager.get_status()
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/history', methods=['GET'])
|
||||
def get_notification_history():
|
||||
"""Get notification history with optional filters."""
|
||||
try:
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
severity = request.args.get('severity', '')
|
||||
channel = request.args.get('channel', '')
|
||||
|
||||
result = notification_manager.get_history(limit, offset, severity, channel)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/history', methods=['DELETE'])
|
||||
def clear_notification_history():
|
||||
"""Clear all notification history."""
|
||||
try:
|
||||
result = notification_manager.clear_history()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/send', methods=['POST'])
|
||||
def send_notification():
|
||||
"""Send a notification via API (for testing or external triggers)."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
result = notification_manager.send_notification(
|
||||
event_type=data.get('event_type', 'custom'),
|
||||
severity=data.get('severity', 'INFO'),
|
||||
title=data.get('title', ''),
|
||||
message=data.get('message', ''),
|
||||
data=data.get('data', {}),
|
||||
source='api'
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── PVE config constants ──
|
||||
_PVE_ENDPOINT_ID = 'proxmenux-webhook'
|
||||
_PVE_MATCHER_ID = 'proxmenux-default'
|
||||
_PVE_WEBHOOK_URL = 'http://127.0.0.1:8008/api/notifications/webhook'
|
||||
_PVE_NOTIFICATIONS_CFG = '/etc/pve/notifications.cfg'
|
||||
_PVE_PRIV_CFG = '/etc/pve/priv/notifications.cfg'
|
||||
_PVE_OUR_HEADERS = {
|
||||
f'webhook: {_PVE_ENDPOINT_ID}',
|
||||
f'matcher: {_PVE_MATCHER_ID}',
|
||||
}
|
||||
|
||||
|
||||
def _pve_read_file(path):
|
||||
"""Read file, return (content, error). Content is '' if missing."""
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return f.read(), None
|
||||
except FileNotFoundError:
|
||||
return '', None
|
||||
except PermissionError:
|
||||
return None, f'Permission denied reading {path}'
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _pve_backup_file(path):
|
||||
"""Create timestamped backup if file exists. Never fails fatally."""
|
||||
import os, shutil
|
||||
from datetime import datetime
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup = f"{path}.proxmenux_backup_{ts}"
|
||||
shutil.copy2(path, backup)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pve_remove_our_blocks(text, headers_to_remove):
|
||||
"""Remove only blocks whose header line matches one of ours.
|
||||
|
||||
Preserves ALL other content byte-for-byte.
|
||||
A block = header line + indented continuation lines + trailing blank line.
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
cleaned = []
|
||||
skip_block = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped and not line[0:1].isspace() and ':' in stripped:
|
||||
if stripped in headers_to_remove:
|
||||
skip_block = True
|
||||
continue
|
||||
else:
|
||||
skip_block = False
|
||||
|
||||
if skip_block:
|
||||
if not stripped:
|
||||
skip_block = False
|
||||
continue
|
||||
elif line[0:1].isspace():
|
||||
continue
|
||||
else:
|
||||
skip_block = False
|
||||
|
||||
cleaned.append(line)
|
||||
|
||||
return ''.join(cleaned)
|
||||
|
||||
|
||||
def _build_webhook_fallback():
|
||||
"""Build fallback manual commands for webhook setup."""
|
||||
import base64
|
||||
body_tpl = '{"title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","timestamp":"{{ timestamp }}","fields":{{ json fields }}}'
|
||||
body_b64 = base64.b64encode(body_tpl.encode()).decode()
|
||||
return [
|
||||
"# 1. Append to END of /etc/pve/notifications.cfg",
|
||||
"# (do NOT delete existing content):",
|
||||
"",
|
||||
f"webhook: {_PVE_ENDPOINT_ID}",
|
||||
f"\tbody {body_b64}",
|
||||
f"\tmethod post",
|
||||
f"\turl {_PVE_WEBHOOK_URL}",
|
||||
"",
|
||||
f"matcher: {_PVE_MATCHER_ID}",
|
||||
f"\ttarget {_PVE_ENDPOINT_ID}",
|
||||
"\tmode all",
|
||||
"",
|
||||
"# 2. Append to /etc/pve/priv/notifications.cfg :",
|
||||
f"webhook: {_PVE_ENDPOINT_ID}",
|
||||
]
|
||||
|
||||
|
||||
def setup_pve_webhook_core() -> dict:
|
||||
"""Core logic to configure PVE webhook. Callable from anywhere.
|
||||
|
||||
Returns dict with 'configured', 'error', 'fallback_commands' keys.
|
||||
Idempotent: safe to call multiple times.
|
||||
"""
|
||||
import secrets as secrets_mod
|
||||
|
||||
result = {
|
||||
'configured': False,
|
||||
'endpoint_id': _PVE_ENDPOINT_ID,
|
||||
'matcher_id': _PVE_MATCHER_ID,
|
||||
'url': _PVE_WEBHOOK_URL,
|
||||
'fallback_commands': [],
|
||||
'error': None,
|
||||
}
|
||||
|
||||
try:
|
||||
# ── Step 1: Ensure webhook secret exists (for our own internal use) ──
|
||||
secret = notification_manager.get_webhook_secret()
|
||||
if not secret:
|
||||
secret = secrets_mod.token_urlsafe(32)
|
||||
notification_manager._save_setting('webhook_secret', secret)
|
||||
|
||||
# ── Step 2: Read main config ──
|
||||
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
|
||||
if err:
|
||||
result['error'] = err
|
||||
result['fallback_commands'] = _build_webhook_fallback()
|
||||
return result
|
||||
|
||||
# ── Step 3: Read priv config (to clean up any broken blocks we wrote before) ──
|
||||
priv_text, err = _pve_read_file(_PVE_PRIV_CFG)
|
||||
if err:
|
||||
priv_text = None
|
||||
|
||||
# ── Step 4: Create backups before ANY modification ──
|
||||
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
|
||||
if priv_text is not None:
|
||||
_pve_backup_file(_PVE_PRIV_CFG)
|
||||
|
||||
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
|
||||
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
|
||||
|
||||
if priv_text is not None:
|
||||
cleaned_priv = _pve_remove_our_blocks(priv_text, _PVE_OUR_HEADERS)
|
||||
|
||||
# ── Step 6: Build new blocks ──
|
||||
# Exact format from a real working PVE server:
|
||||
# webhook: name
|
||||
# \tmethod post
|
||||
# \turl http://...
|
||||
#
|
||||
# NO header lines -- localhost webhook doesn't need them.
|
||||
# PVE header format is: header name=X-Key,value=<base64>
|
||||
# PVE secret format is: secret name=key,value=<base64>
|
||||
# Neither is needed for localhost calls.
|
||||
|
||||
# PVE stores body as base64 in the config file.
|
||||
# {{ escape title/message }} -- JSON-safe escaping of quotes/newlines.
|
||||
# {{ json fields }} -- renders ALL PVE metadata as a JSON object
|
||||
# (type, hostname, job-id). This is a single Handlebars helper
|
||||
# that always works, even if fields is empty (renders {}).
|
||||
import base64
|
||||
body_template = '{"title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","timestamp":"{{ timestamp }}","fields":{{ json fields }}}'
|
||||
body_b64 = base64.b64encode(body_template.encode()).decode()
|
||||
|
||||
endpoint_block = (
|
||||
f"webhook: {_PVE_ENDPOINT_ID}\n"
|
||||
f"\tbody {body_b64}\n"
|
||||
f"\tmethod post\n"
|
||||
f"\turl {_PVE_WEBHOOK_URL}\n"
|
||||
)
|
||||
|
||||
matcher_block = (
|
||||
f"matcher: {_PVE_MATCHER_ID}\n"
|
||||
f"\ttarget {_PVE_ENDPOINT_ID}\n"
|
||||
f"\tmode all\n"
|
||||
)
|
||||
|
||||
# ── Step 7: Append our blocks to cleaned main config ──
|
||||
if cleaned_cfg and not cleaned_cfg.endswith('\n'):
|
||||
cleaned_cfg += '\n'
|
||||
if cleaned_cfg and not cleaned_cfg.endswith('\n\n'):
|
||||
cleaned_cfg += '\n'
|
||||
|
||||
new_cfg = cleaned_cfg + endpoint_block + '\n' + matcher_block
|
||||
|
||||
# ── Step 8: Write main config ──
|
||||
try:
|
||||
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||
f.write(new_cfg)
|
||||
except PermissionError:
|
||||
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
|
||||
result['fallback_commands'] = _build_webhook_fallback()
|
||||
return result
|
||||
except Exception as e:
|
||||
try:
|
||||
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||
f.write(cfg_text)
|
||||
except Exception:
|
||||
pass
|
||||
result['error'] = str(e)
|
||||
result['fallback_commands'] = _build_webhook_fallback()
|
||||
return result
|
||||
|
||||
# ── Step 9: Write priv config with our webhook entry ──
|
||||
# PVE REQUIRES a matching block in priv/notifications.cfg for every
|
||||
# webhook endpoint, even if it has no secrets. Without it PVE throws:
|
||||
# "Could not instantiate endpoint: private config does not exist"
|
||||
priv_block = (
|
||||
f"webhook: {_PVE_ENDPOINT_ID}\n"
|
||||
)
|
||||
|
||||
if priv_text is not None:
|
||||
# Start from cleaned priv (our old blocks removed)
|
||||
if cleaned_priv and not cleaned_priv.endswith('\n'):
|
||||
cleaned_priv += '\n'
|
||||
if cleaned_priv and not cleaned_priv.endswith('\n\n'):
|
||||
cleaned_priv += '\n'
|
||||
new_priv = cleaned_priv + priv_block
|
||||
else:
|
||||
new_priv = priv_block
|
||||
|
||||
try:
|
||||
with open(_PVE_PRIV_CFG, 'w') as f:
|
||||
f.write(new_priv)
|
||||
except PermissionError:
|
||||
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
|
||||
result['fallback_commands'] = _build_webhook_fallback()
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result['configured'] = True
|
||||
result['secret'] = secret
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
result['fallback_commands'] = _build_webhook_fallback()
|
||||
return result
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST'])
|
||||
def setup_proxmox_webhook():
|
||||
"""HTTP endpoint wrapper for webhook setup."""
|
||||
return jsonify(setup_pve_webhook_core()), 200
|
||||
|
||||
|
||||
def cleanup_pve_webhook_core() -> dict:
|
||||
"""Core logic to remove PVE webhook blocks. Callable from anywhere.
|
||||
|
||||
Returns dict with 'cleaned', 'error' keys.
|
||||
Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'.
|
||||
"""
|
||||
result = {'cleaned': False, 'error': None}
|
||||
|
||||
try:
|
||||
# Read both files
|
||||
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
|
||||
if err:
|
||||
result['error'] = err
|
||||
return result
|
||||
|
||||
priv_text, err = _pve_read_file(_PVE_PRIV_CFG)
|
||||
if err:
|
||||
priv_text = None
|
||||
|
||||
# Check if our blocks actually exist before doing anything
|
||||
has_our_blocks = any(
|
||||
h in cfg_text for h in [f'webhook: {_PVE_ENDPOINT_ID}', f'matcher: {_PVE_MATCHER_ID}']
|
||||
)
|
||||
has_priv_blocks = priv_text and f'webhook: {_PVE_ENDPOINT_ID}' in priv_text
|
||||
|
||||
if not has_our_blocks and not has_priv_blocks:
|
||||
result['cleaned'] = True
|
||||
return result
|
||||
|
||||
# Backup before modification
|
||||
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
|
||||
if priv_text is not None:
|
||||
_pve_backup_file(_PVE_PRIV_CFG)
|
||||
|
||||
# Remove our blocks
|
||||
if has_our_blocks:
|
||||
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
|
||||
try:
|
||||
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||
f.write(cleaned_cfg)
|
||||
except PermissionError:
|
||||
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
|
||||
return result
|
||||
except Exception as e:
|
||||
# Rollback
|
||||
try:
|
||||
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||
f.write(cfg_text)
|
||||
except Exception:
|
||||
pass
|
||||
result['error'] = str(e)
|
||||
return result
|
||||
|
||||
if has_priv_blocks and priv_text is not None:
|
||||
cleaned_priv = _pve_remove_our_blocks(priv_text, _PVE_OUR_HEADERS)
|
||||
try:
|
||||
with open(_PVE_PRIV_CFG, 'w') as f:
|
||||
f.write(cleaned_priv)
|
||||
except Exception:
|
||||
pass # Best-effort
|
||||
|
||||
result['cleaned'] = True
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/proxmox/cleanup-webhook', methods=['POST'])
|
||||
def cleanup_proxmox_webhook():
|
||||
"""HTTP endpoint wrapper for webhook cleanup."""
|
||||
return jsonify(cleanup_pve_webhook_core()), 200
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/proxmox/read-cfg', methods=['GET'])
|
||||
def read_pve_notification_cfg():
|
||||
"""Diagnostic: return raw content of PVE notification config files.
|
||||
|
||||
GET /api/notifications/proxmox/read-cfg
|
||||
Returns both notifications.cfg and priv/notifications.cfg content.
|
||||
"""
|
||||
import os
|
||||
|
||||
files = {
|
||||
'notifications_cfg': '/etc/pve/notifications.cfg',
|
||||
'priv_cfg': '/etc/pve/priv/notifications.cfg',
|
||||
}
|
||||
|
||||
# Also look for any backups we created
|
||||
backup_dir = '/etc/pve'
|
||||
priv_backup_dir = '/etc/pve/priv'
|
||||
|
||||
result = {}
|
||||
for key, path in files.items():
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
result[key] = {
|
||||
'path': path,
|
||||
'content': f.read(),
|
||||
'size': os.path.getsize(path),
|
||||
'error': None,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
result[key] = {'path': path, 'content': None, 'size': 0, 'error': 'file_not_found'}
|
||||
except PermissionError:
|
||||
result[key] = {'path': path, 'content': None, 'size': 0, 'error': 'permission_denied'}
|
||||
except Exception as e:
|
||||
result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)}
|
||||
|
||||
# Find backups
|
||||
backups = []
|
||||
for d in [backup_dir, priv_backup_dir]:
|
||||
try:
|
||||
for fname in sorted(os.listdir(d)):
|
||||
if 'proxmenux_backup' in fname:
|
||||
fpath = os.path.join(d, fname)
|
||||
try:
|
||||
with open(fpath, 'r') as f:
|
||||
backups.append({
|
||||
'path': fpath,
|
||||
'content': f.read(),
|
||||
'size': os.path.getsize(fpath),
|
||||
})
|
||||
except Exception:
|
||||
backups.append({'path': fpath, 'content': None, 'error': 'read_failed'})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result['backups'] = backups
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/proxmox/restore-cfg', methods=['POST'])
|
||||
def restore_pve_notification_cfg():
|
||||
"""Restore PVE notification config from our backup.
|
||||
|
||||
POST /api/notifications/proxmox/restore-cfg
|
||||
Finds the most recent proxmenux_backup and restores it.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
files_to_restore = {
|
||||
'/etc/pve': '/etc/pve/notifications.cfg',
|
||||
'/etc/pve/priv': '/etc/pve/priv/notifications.cfg',
|
||||
}
|
||||
|
||||
restored = []
|
||||
errors = []
|
||||
|
||||
for search_dir, target_path in files_to_restore.items():
|
||||
try:
|
||||
candidates = sorted([
|
||||
f for f in os.listdir(search_dir)
|
||||
if 'proxmenux_backup' in f and f.startswith('notifications.cfg')
|
||||
], reverse=True)
|
||||
|
||||
if candidates:
|
||||
backup_path = os.path.join(search_dir, candidates[0])
|
||||
shutil.copy2(backup_path, target_path)
|
||||
restored.append({'target': target_path, 'from_backup': backup_path})
|
||||
else:
|
||||
errors.append({'target': target_path, 'error': 'no_backup_found'})
|
||||
except Exception as e:
|
||||
errors.append({'target': target_path, 'error': str(e)})
|
||||
|
||||
return jsonify({
|
||||
'restored': restored,
|
||||
'errors': errors,
|
||||
'success': len(errors) == 0 and len(restored) > 0,
|
||||
}), 200
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/webhook', methods=['POST'])
|
||||
def proxmox_webhook():
|
||||
"""Receive native Proxmox VE notification webhooks (hardened).
|
||||
|
||||
Security layers:
|
||||
Localhost (127.0.0.1 / ::1): rate limiting only.
|
||||
PVE calls us on localhost and cannot send custom auth headers,
|
||||
so we trust the loopback interface (only local processes can reach it).
|
||||
Remote: rate limiting + shared secret + timestamp + replay + IP allowlist.
|
||||
"""
|
||||
_reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status)
|
||||
|
||||
client_ip = request.remote_addr or ''
|
||||
is_localhost = client_ip in ('127.0.0.1', '::1')
|
||||
|
||||
# ── Layer 1: Rate limiting (always) ──
|
||||
if not _webhook_limiter.allow():
|
||||
resp = jsonify({'accepted': False, 'error': 'rate_limited'})
|
||||
resp.headers['Retry-After'] = '60'
|
||||
return resp, 429
|
||||
|
||||
# ── Layers 2-5: Remote-only checks ──
|
||||
if not is_localhost:
|
||||
# Layer 2: Shared secret
|
||||
try:
|
||||
configured_secret = notification_manager.get_webhook_secret()
|
||||
except Exception:
|
||||
configured_secret = ''
|
||||
|
||||
if configured_secret:
|
||||
request_secret = request.headers.get('X-Webhook-Secret', '')
|
||||
if not request_secret:
|
||||
return _reject(401, 'missing_secret', 401)
|
||||
if not hmac.compare_digest(configured_secret, request_secret):
|
||||
return _reject(401, 'invalid_secret', 401)
|
||||
|
||||
# Layer 3: Anti-replay timestamp
|
||||
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
|
||||
if not ts_header:
|
||||
return _reject(401, 'missing_timestamp', 401)
|
||||
try:
|
||||
ts_value = int(ts_header)
|
||||
except (ValueError, TypeError):
|
||||
return _reject(401, 'invalid_timestamp', 401)
|
||||
if abs(time.time() - ts_value) > _TIMESTAMP_MAX_DRIFT:
|
||||
return _reject(401, 'timestamp_expired', 401)
|
||||
|
||||
# Layer 4: Replay cache
|
||||
raw_body = request.get_data(as_text=True) or ''
|
||||
signature = hashlib.sha256(f"{ts_value}:{raw_body}".encode(errors='replace')).hexdigest()
|
||||
if _replay_cache.check_and_record(signature):
|
||||
return _reject(409, 'replay_detected', 409)
|
||||
|
||||
# Layer 5: IP allowlist
|
||||
try:
|
||||
allowed_ips = notification_manager.get_webhook_allowed_ips()
|
||||
if allowed_ips and client_ip not in allowed_ips:
|
||||
return _reject(403, 'forbidden_ip', 403)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Parse and process payload ──
|
||||
try:
|
||||
content_type = request.content_type or ''
|
||||
raw_data = request.get_data(as_text=True) or ''
|
||||
|
||||
# Try JSON first
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
# If not JSON, try form data
|
||||
if not payload:
|
||||
payload = dict(request.form)
|
||||
|
||||
# If still empty, try parsing raw data as JSON (PVE may not set Content-Type)
|
||||
if not payload and raw_data:
|
||||
import json
|
||||
try:
|
||||
payload = json.loads(raw_data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# PVE's {{ message }} may contain unescaped newlines/quotes
|
||||
# that break JSON. Try to repair common issues.
|
||||
try:
|
||||
repaired = raw_data.replace('\n', '\\n').replace('\r', '\\r')
|
||||
payload = json.loads(repaired)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Try to extract fields with regex from broken JSON
|
||||
import re
|
||||
title_m = re.search(r'"title"\s*:\s*"([^"]*)"', raw_data)
|
||||
sev_m = re.search(r'"severity"\s*:\s*"([^"]*)"', raw_data)
|
||||
if title_m:
|
||||
payload = {
|
||||
'title': title_m.group(1),
|
||||
'body': raw_data[:1000],
|
||||
'severity': sev_m.group(1) if sev_m else 'info',
|
||||
'source': 'proxmox_hook',
|
||||
}
|
||||
|
||||
# If still empty, try to salvage data from raw body
|
||||
if not payload:
|
||||
if raw_data:
|
||||
# Last resort: treat raw text as the message body
|
||||
payload = {
|
||||
'title': 'PVE Notification',
|
||||
'body': raw_data[:1000],
|
||||
'severity': 'info',
|
||||
'source': 'proxmox_hook',
|
||||
}
|
||||
else:
|
||||
return _reject(400, 'empty_payload', 400)
|
||||
|
||||
result = notification_manager.process_webhook(payload)
|
||||
# Always return 200 to PVE -- a non-200 makes PVE report the webhook as broken.
|
||||
# The 'accepted' field in the JSON body indicates actual processing status.
|
||||
return jsonify(result), 200
|
||||
except Exception as e:
|
||||
# Still return 200 to avoid PVE flagging the webhook as broken
|
||||
return jsonify({'accepted': False, 'error': 'internal_error', 'detail': str(e)}), 200
|
||||
@@ -0,0 +1,545 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ProxMenux OCI Routes
|
||||
|
||||
REST API endpoints for OCI container app management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
import oci_manager
|
||||
from jwt_middleware import require_auth
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger("proxmenux.oci.routes")
|
||||
|
||||
# Blueprint
|
||||
oci_bp = Blueprint("oci", __name__, url_prefix="/api/oci")
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Catalog Endpoints
|
||||
# =================================================================
|
||||
|
||||
@oci_bp.route("/catalog", methods=["GET"])
|
||||
@require_auth
|
||||
def get_catalog():
|
||||
"""
|
||||
List all available apps from the catalog.
|
||||
|
||||
Returns:
|
||||
List of apps with basic info and installation status.
|
||||
"""
|
||||
try:
|
||||
apps = oci_manager.list_available_apps()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"apps": apps
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get catalog: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/catalog/<app_id>", methods=["GET"])
|
||||
@require_auth
|
||||
def get_app_definition(app_id: str):
|
||||
"""
|
||||
Get the full definition for a specific app.
|
||||
|
||||
Args:
|
||||
app_id: The app identifier
|
||||
|
||||
Returns:
|
||||
Full app definition including config schema.
|
||||
"""
|
||||
try:
|
||||
app_def = oci_manager.get_app_definition(app_id)
|
||||
|
||||
if not app_def:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"App '{app_id}' not found in catalog"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"app": app_def,
|
||||
"installed": oci_manager.is_installed(app_id)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get app definition: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/storages", methods=["GET"])
|
||||
@require_auth
|
||||
def get_storages():
|
||||
"""
|
||||
Get list of available storages for LXC rootfs.
|
||||
|
||||
Returns:
|
||||
List of storages with capacity info and recommendations.
|
||||
"""
|
||||
try:
|
||||
storages = oci_manager.get_available_storages()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"storages": storages
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get storages: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/catalog/<app_id>/schema", methods=["GET"])
|
||||
@require_auth
|
||||
def get_app_schema(app_id: str):
|
||||
"""
|
||||
Get only the config schema for an app.
|
||||
|
||||
Args:
|
||||
app_id: The app identifier
|
||||
|
||||
Returns:
|
||||
Config schema for building dynamic forms.
|
||||
"""
|
||||
try:
|
||||
app_def = oci_manager.get_app_definition(app_id)
|
||||
|
||||
if not app_def:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"App '{app_id}' not found in catalog"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"app_id": app_id,
|
||||
"name": app_def.get("name", app_id),
|
||||
"schema": app_def.get("config_schema", {})
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get app schema: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Installed Apps Endpoints
|
||||
# =================================================================
|
||||
|
||||
@oci_bp.route("/installed", methods=["GET"])
|
||||
@require_auth
|
||||
def list_installed():
|
||||
"""
|
||||
List all installed apps with their current status.
|
||||
|
||||
Returns:
|
||||
List of installed apps with status info.
|
||||
"""
|
||||
try:
|
||||
apps = oci_manager.list_installed_apps()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"instances": apps
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list installed apps: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>", methods=["GET"])
|
||||
@require_auth
|
||||
def get_installed_app(app_id: str):
|
||||
"""
|
||||
Get details of an installed app including current status.
|
||||
|
||||
Args:
|
||||
app_id: The app identifier
|
||||
|
||||
Returns:
|
||||
Installed app details with container info and status.
|
||||
"""
|
||||
try:
|
||||
app = oci_manager.get_installed_app(app_id)
|
||||
|
||||
if not app:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"App '{app_id}' is not installed"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"instance": app
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get installed app: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/logs", methods=["GET"])
|
||||
@require_auth
|
||||
def get_app_logs(app_id: str):
|
||||
"""
|
||||
Get recent logs from an app's container.
|
||||
|
||||
Args:
|
||||
app_id: The app identifier
|
||||
|
||||
Query params:
|
||||
lines: Number of lines to return (default 100)
|
||||
|
||||
Returns:
|
||||
Container logs.
|
||||
"""
|
||||
try:
|
||||
lines = request.args.get("lines", 100, type=int)
|
||||
result = oci_manager.get_app_logs(app_id, lines=lines)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify(result), 404 if "not installed" in result.get("message", "") else 500
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get app logs: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Deployment Endpoint
|
||||
# =================================================================
|
||||
|
||||
@oci_bp.route("/deploy", methods=["POST"])
|
||||
@require_auth
|
||||
def deploy_app():
|
||||
"""
|
||||
Deploy an OCI app with the given configuration.
|
||||
|
||||
Body:
|
||||
{
|
||||
"app_id": "secure-gateway",
|
||||
"config": {
|
||||
"auth_key": "tskey-auth-xxx",
|
||||
"hostname": "proxmox-gateway",
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
Deployment result with container ID if successful.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Request body is required"
|
||||
}), 400
|
||||
|
||||
app_id = data.get("app_id")
|
||||
config = data.get("config", {})
|
||||
|
||||
if not app_id:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "app_id is required"
|
||||
}), 400
|
||||
|
||||
logger.info(f"Deploy request: app_id={app_id}, config_keys={list(config.keys())}")
|
||||
|
||||
result = oci_manager.deploy_app(app_id, config, installed_by="web")
|
||||
|
||||
logger.info(f"Deploy result: {result}")
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to deploy app: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Lifecycle Action Endpoints
|
||||
# =================================================================
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/start", methods=["POST"])
|
||||
@require_auth
|
||||
def start_app(app_id: str):
|
||||
"""Start an installed app's container."""
|
||||
try:
|
||||
result = oci_manager.start_app(app_id)
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start app: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/stop", methods=["POST"])
|
||||
@require_auth
|
||||
def stop_app(app_id: str):
|
||||
"""Stop an installed app's container."""
|
||||
try:
|
||||
result = oci_manager.stop_app(app_id)
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop app: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/restart", methods=["POST"])
|
||||
@require_auth
|
||||
def restart_app(app_id: str):
|
||||
"""Restart an installed app's container."""
|
||||
try:
|
||||
result = oci_manager.restart_app(app_id)
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart app: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>", methods=["DELETE"])
|
||||
@require_auth
|
||||
def remove_app(app_id: str):
|
||||
"""
|
||||
Remove an installed app.
|
||||
|
||||
Query params:
|
||||
remove_data: If true, also remove persistent data (default false)
|
||||
"""
|
||||
try:
|
||||
remove_data = request.args.get("remove_data", "false").lower() == "true"
|
||||
result = oci_manager.remove_app(app_id, remove_data=remove_data)
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove app: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Configuration Update Endpoint
|
||||
# =================================================================
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/config", methods=["PUT"])
|
||||
@require_auth
|
||||
def update_app_config(app_id: str):
|
||||
"""
|
||||
Update an app's configuration and recreate the container.
|
||||
|
||||
Body:
|
||||
{
|
||||
"config": { ... new config values ... }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or "config" not in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "config is required in request body"
|
||||
}), 400
|
||||
|
||||
result = oci_manager.update_app_config(app_id, data["config"])
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update app config: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Utility Endpoints
|
||||
# =================================================================
|
||||
|
||||
@oci_bp.route("/networks", methods=["GET"])
|
||||
@require_auth
|
||||
def get_networks():
|
||||
"""
|
||||
Get available networks for VPN routing.
|
||||
|
||||
Returns:
|
||||
List of detected network interfaces with their subnets.
|
||||
"""
|
||||
try:
|
||||
networks = oci_manager.detect_networks()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"networks": networks
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to detect networks: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/runtime", methods=["GET"])
|
||||
@require_auth
|
||||
def get_runtime():
|
||||
"""
|
||||
Get container runtime information.
|
||||
|
||||
Returns:
|
||||
Runtime type (podman/docker), version, and availability.
|
||||
"""
|
||||
try:
|
||||
runtime_info = oci_manager.detect_runtime()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
**runtime_info
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to detect runtime: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/runtime/install-script", methods=["GET"])
|
||||
@require_auth
|
||||
def get_runtime_install_script():
|
||||
"""
|
||||
Get the path to the runtime installation script.
|
||||
|
||||
Returns:
|
||||
Script path for installing Podman.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check possible paths for the install script
|
||||
possible_paths = [
|
||||
"/usr/local/share/proxmenux/scripts/oci/install_runtime.sh",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "Scripts", "oci", "install_runtime.sh"),
|
||||
]
|
||||
|
||||
for script_path in possible_paths:
|
||||
if os.path.exists(script_path):
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"script_path": os.path.abspath(script_path)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Runtime installation script not found"
|
||||
}), 404
|
||||
|
||||
|
||||
@oci_bp.route("/status/<app_id>", methods=["GET"])
|
||||
@require_auth
|
||||
def get_app_status(app_id: str):
|
||||
"""
|
||||
Get the current status of an app's container.
|
||||
|
||||
Returns:
|
||||
Container state, health, and uptime.
|
||||
"""
|
||||
try:
|
||||
status = oci_manager.get_app_status(app_id)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"app_id": app_id,
|
||||
"status": status
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get app status: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@oci_bp.route("/installed/<app_id>/update-auth-key", methods=["POST"])
|
||||
@require_auth
|
||||
def update_auth_key(app_id: str):
|
||||
"""
|
||||
Update the Tailscale auth key for an installed gateway.
|
||||
|
||||
This is useful when the auth key expires and the gateway needs to re-authenticate.
|
||||
|
||||
Body:
|
||||
{
|
||||
"auth_key": "tskey-auth-xxx"
|
||||
}
|
||||
|
||||
Returns:
|
||||
Success status and message.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or "auth_key" not in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "auth_key is required in request body"
|
||||
}), 400
|
||||
|
||||
auth_key = data["auth_key"]
|
||||
|
||||
if not auth_key.startswith("tskey-"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Invalid auth key format. Should start with 'tskey-'"
|
||||
}), 400
|
||||
|
||||
result = oci_manager.update_auth_key(app_id, auth_key)
|
||||
status_code = 200 if result.get("success") else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update auth key: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ProxMenux Security Routes
|
||||
Flask blueprint for firewall management and security tool detection.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
security_bp = Blueprint('security', __name__)
|
||||
|
||||
try:
|
||||
import security_manager
|
||||
except ImportError:
|
||||
security_manager = None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Proxmox Firewall
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/firewall/status', methods=['GET'])
|
||||
def firewall_status():
|
||||
"""Get Proxmox firewall status, rules, and port 8008 status"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
status = security_manager.get_firewall_status()
|
||||
return jsonify({"success": True, **status})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/enable', methods=['POST'])
|
||||
def firewall_enable():
|
||||
"""Enable Proxmox firewall at host or cluster level"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
level = data.get("level", "host")
|
||||
success, message = security_manager.enable_firewall(level)
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/disable', methods=['POST'])
|
||||
def firewall_disable():
|
||||
"""Disable Proxmox firewall at host or cluster level"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
level = data.get("level", "host")
|
||||
success, message = security_manager.disable_firewall(level)
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/rules', methods=['POST'])
|
||||
def firewall_add_rule():
|
||||
"""Add a custom firewall rule"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
success, message = security_manager.add_firewall_rule(
|
||||
direction=data.get("direction", "IN"),
|
||||
action=data.get("action", "ACCEPT"),
|
||||
protocol=data.get("protocol", "tcp"),
|
||||
dport=data.get("dport", ""),
|
||||
sport=data.get("sport", ""),
|
||||
source=data.get("source", ""),
|
||||
dest=data.get("dest", ""),
|
||||
iface=data.get("iface", ""),
|
||||
comment=data.get("comment", ""),
|
||||
level=data.get("level", "host"),
|
||||
)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/rules', methods=['DELETE'])
|
||||
def firewall_delete_rule():
|
||||
"""Delete a firewall rule by index"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
rule_index = data.get("rule_index")
|
||||
level = data.get("level", "host")
|
||||
if rule_index is None:
|
||||
return jsonify({"success": False, "message": "rule_index is required"}), 400
|
||||
success, message = security_manager.delete_firewall_rule(int(rule_index), level)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/rules/edit', methods=['PUT'])
|
||||
def firewall_edit_rule():
|
||||
"""Edit an existing firewall rule (delete old + insert new at same position)"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
rule_index = data.get("rule_index")
|
||||
level = data.get("level", "host")
|
||||
new_rule = data.get("new_rule", {})
|
||||
if rule_index is None:
|
||||
return jsonify({"success": False, "message": "rule_index is required"}), 400
|
||||
|
||||
success, message = security_manager.edit_firewall_rule(
|
||||
rule_index=int(rule_index),
|
||||
level=level,
|
||||
direction=new_rule.get("direction", "IN"),
|
||||
action=new_rule.get("action", "ACCEPT"),
|
||||
protocol=new_rule.get("protocol", "tcp"),
|
||||
dport=new_rule.get("dport", ""),
|
||||
sport=new_rule.get("sport", ""),
|
||||
source=new_rule.get("source", ""),
|
||||
iface=new_rule.get("iface", ""),
|
||||
comment=new_rule.get("comment", ""),
|
||||
)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/monitor-port', methods=['POST'])
|
||||
def firewall_add_monitor_port():
|
||||
"""Add firewall rule to allow port 8008 for ProxMenux Monitor"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.add_monitor_port_rule()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/monitor-port', methods=['DELETE'])
|
||||
def firewall_remove_monitor_port():
|
||||
"""Remove the ProxMenux Monitor port 8008 rule"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.remove_monitor_port_rule()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Fail2Ban Detailed Management
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/details', methods=['GET'])
|
||||
def fail2ban_details():
|
||||
"""Get detailed Fail2Ban info: per-jail banned IPs, stats, config"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
details = security_manager.get_fail2ban_details()
|
||||
return jsonify({"success": True, **details})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/unban', methods=['POST'])
|
||||
def fail2ban_unban():
|
||||
"""Unban a specific IP from a Fail2Ban jail"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
jail = data.get("jail", "")
|
||||
ip = data.get("ip", "")
|
||||
success, message = security_manager.unban_ip(jail, ip)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/jail/config', methods=['PUT'])
|
||||
def fail2ban_jail_config():
|
||||
"""Update jail configuration (maxretry, bantime, findtime)"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
jail = data.get("jail", "")
|
||||
if not jail:
|
||||
return jsonify({"success": False, "message": "Jail name is required"}), 400
|
||||
success, message = security_manager.update_jail_config(
|
||||
jail,
|
||||
maxretry=data.get("maxretry"),
|
||||
bantime=data.get("bantime"),
|
||||
findtime=data.get("findtime"),
|
||||
)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST'])
|
||||
def fail2ban_apply_jails():
|
||||
"""Apply missing Fail2Ban jails (proxmox, proxmenux)"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message, applied = security_manager.apply_missing_jails()
|
||||
return jsonify({"success": success, "message": message, "applied": applied})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/activity', methods=['GET'])
|
||||
def fail2ban_activity():
|
||||
"""Get recent Fail2Ban log activity"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
events = security_manager.get_fail2ban_recent_activity()
|
||||
return jsonify({"success": True, "events": events})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Lynis Audit
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/lynis/run', methods=['POST'])
|
||||
def lynis_run_audit():
|
||||
"""Start a Lynis audit (runs in background)"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.run_lynis_audit()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/lynis/status', methods=['GET'])
|
||||
def lynis_audit_status():
|
||||
"""Get Lynis audit running status"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
status = security_manager.get_lynis_audit_status()
|
||||
return jsonify({"success": True, **status})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/lynis/report', methods=['GET'])
|
||||
def lynis_report():
|
||||
"""Get parsed Lynis audit report"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
report = security_manager.parse_lynis_report()
|
||||
if report:
|
||||
return jsonify({"success": True, "report": report})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "No report available. Run an audit first."})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/lynis/report', methods=['DELETE'])
|
||||
def lynis_report_delete():
|
||||
"""Delete Lynis audit report files"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
import os
|
||||
deleted = []
|
||||
for f in ["/var/log/lynis-report.dat", "/var/log/lynis.log", "/var/log/lynis-output.log"]:
|
||||
if os.path.isfile(f):
|
||||
os.remove(f)
|
||||
deleted.append(f)
|
||||
if deleted:
|
||||
return jsonify({"success": True, "message": f"Deleted: {', '.join(deleted)}"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "No report files found to delete"})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Security Tools Detection
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/tools', methods=['GET'])
|
||||
def security_tools():
|
||||
"""Detect installed security tools (Fail2Ban, Lynis, etc.)"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
tools = security_manager.detect_security_tools()
|
||||
return jsonify({"success": True, "tools": tools})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
+1440
-87
File diff suppressed because it is too large
Load Diff
@@ -181,11 +181,23 @@ def terminal_websocket(ws):
|
||||
except Exception:
|
||||
msg = None
|
||||
|
||||
if isinstance(msg, dict) and msg.get('type') == 'resize':
|
||||
cols = int(msg.get('cols', 120))
|
||||
rows = int(msg.get('rows', 30))
|
||||
set_winsize(master_fd, rows, cols)
|
||||
handled = True
|
||||
if isinstance(msg, dict):
|
||||
msg_type = msg.get('type')
|
||||
|
||||
# Handle ping messages (heartbeat to keep connection alive)
|
||||
if msg_type == 'ping':
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'pong'}))
|
||||
except:
|
||||
pass
|
||||
handled = True
|
||||
|
||||
# Handle resize messages
|
||||
elif msg_type == 'resize':
|
||||
cols = int(msg.get('cols', 120))
|
||||
rows = int(msg.get('rows', 30))
|
||||
set_winsize(master_fd, rows, cols)
|
||||
handled = True
|
||||
|
||||
if handled:
|
||||
# Control message processed, do not send to bash
|
||||
|
||||
+2835
-516
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,913 @@
|
||||
"""
|
||||
ProxMenux Notification Channels
|
||||
Provides transport adapters for Telegram, Gotify, and Discord.
|
||||
|
||||
Each channel implements send() and test() with:
|
||||
- Retry with exponential backoff (3 attempts)
|
||||
- Request timeout of 10s
|
||||
- Rate limiting (max 30 msg/min per channel)
|
||||
|
||||
Author: MacRimi
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
|
||||
|
||||
# ─── Rate Limiter ────────────────────────────────────────────────
|
||||
|
||||
class RateLimiter:
|
||||
"""Token-bucket rate limiter: max N messages per window."""
|
||||
|
||||
def __init__(self, max_calls: int = 30, window_seconds: int = 60):
|
||||
self.max_calls = max_calls
|
||||
self.window = window_seconds
|
||||
self._timestamps: deque = deque()
|
||||
|
||||
def allow(self) -> bool:
|
||||
now = time.monotonic()
|
||||
while self._timestamps and now - self._timestamps[0] > self.window:
|
||||
self._timestamps.popleft()
|
||||
if len(self._timestamps) >= self.max_calls:
|
||||
return False
|
||||
self._timestamps.append(now)
|
||||
return True
|
||||
|
||||
def wait_time(self) -> float:
|
||||
if not self._timestamps:
|
||||
return 0.0
|
||||
return max(0.0, self.window - (time.monotonic() - self._timestamps[0]))
|
||||
|
||||
|
||||
# ─── Base Channel ────────────────────────────────────────────────
|
||||
|
||||
class NotificationChannel(ABC):
|
||||
"""Abstract base for all notification channels."""
|
||||
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAYS = [2, 4, 8] # exponential backoff seconds
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
def __init__(self):
|
||||
self._rate_limiter = RateLimiter(max_calls=30, window_seconds=60)
|
||||
|
||||
@abstractmethod
|
||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""Send a notification. Returns {success, error, channel}."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""Send a test message. Returns (success, error_message)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
"""Check if config is valid without sending. Returns (valid, error)."""
|
||||
pass
|
||||
|
||||
def _http_request(self, url: str, data: bytes, headers: Dict[str, str],
|
||||
method: str = 'POST') -> Tuple[int, str]:
|
||||
"""Execute HTTP request with timeout. Returns (status_code, body)."""
|
||||
# Ensure User-Agent is set to avoid Cloudflare 1010 errors
|
||||
if 'User-Agent' not in headers:
|
||||
headers['User-Agent'] = 'ProxMenux-Monitor/1.1'
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.REQUEST_TIMEOUT) as resp:
|
||||
body = resp.read().decode('utf-8', errors='replace')
|
||||
return resp.status, body
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode('utf-8', errors='replace') if e.fp else str(e)
|
||||
return e.code, body
|
||||
except urllib.error.URLError as e:
|
||||
return 0, str(e.reason)
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
def _send_with_retry(self, send_fn) -> Dict[str, Any]:
|
||||
"""Wrap a send function with rate limiting and retry logic."""
|
||||
if not self._rate_limiter.allow():
|
||||
wait = self._rate_limiter.wait_time()
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Rate limited. Retry in {wait:.0f}s',
|
||||
'rate_limited': True
|
||||
}
|
||||
|
||||
last_error = ''
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
status, body = send_fn()
|
||||
if 200 <= status < 300:
|
||||
return {'success': True, 'error': None}
|
||||
last_error = f'HTTP {status}: {body[:200]}'
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
time.sleep(self.RETRY_DELAYS[attempt])
|
||||
|
||||
return {'success': False, 'error': last_error}
|
||||
|
||||
|
||||
# ─── Telegram ────────────────────────────────────────────────────
|
||||
|
||||
class TelegramChannel(NotificationChannel):
|
||||
"""Telegram Bot API channel using HTML parse mode."""
|
||||
|
||||
API_BASE = 'https://api.telegram.org/bot{token}/sendMessage'
|
||||
API_PHOTO = 'https://api.telegram.org/bot{token}/sendPhoto'
|
||||
MAX_LENGTH = 4096
|
||||
|
||||
SEVERITY_ICONS = {
|
||||
'CRITICAL': '\U0001F534', # red circle
|
||||
'WARNING': '\U0001F7E1', # yellow circle
|
||||
'INFO': '\U0001F535', # blue circle
|
||||
'OK': '\U0001F7E2', # green circle
|
||||
'UNKNOWN': '\u26AA', # white circle
|
||||
}
|
||||
|
||||
def __init__(self, bot_token: str, chat_id: str):
|
||||
super().__init__()
|
||||
token = bot_token.strip()
|
||||
# Strip 'bot' prefix if user included it (API_BASE already adds it)
|
||||
if token.lower().startswith('bot') and ':' in token[3:]:
|
||||
token = token[3:]
|
||||
self.bot_token = token
|
||||
self.chat_id = chat_id.strip()
|
||||
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
if not self.bot_token:
|
||||
return False, 'Bot token is required'
|
||||
if not self.chat_id:
|
||||
return False, 'Chat ID is required'
|
||||
if ':' not in self.bot_token:
|
||||
return False, 'Invalid bot token format (expected BOT_ID:TOKEN)'
|
||||
return True, ''
|
||||
|
||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
icon = self.SEVERITY_ICONS.get(severity, self.SEVERITY_ICONS['INFO'])
|
||||
html_msg = f"<b>{icon} {self._escape_html(title)}</b>\n\n{self._escape_html(message)}"
|
||||
|
||||
# Split long messages
|
||||
chunks = self._split_message(html_msg)
|
||||
result = {'success': True, 'error': None, 'channel': 'telegram'}
|
||||
|
||||
for chunk in chunks:
|
||||
res = self._send_with_retry(lambda c=chunk: self._post_message(c))
|
||||
if not res['success']:
|
||||
result = {**res, 'channel': 'telegram'}
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
def send_photo(self, photo_url: str, caption: str = '') -> Dict[str, Any]:
|
||||
"""Send a photo to Telegram chat."""
|
||||
url = self.API_PHOTO.format(token=self.bot_token)
|
||||
payload = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_url,
|
||||
}
|
||||
if caption:
|
||||
payload['caption'] = caption[:1024] # Telegram caption limit
|
||||
payload['parse_mode'] = 'HTML'
|
||||
|
||||
body = json.dumps(payload).encode()
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
result = self._send_with_retry(
|
||||
lambda: self._http_request(url, body, headers)
|
||||
)
|
||||
result['channel'] = 'telegram'
|
||||
return result
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
valid, err = self.validate_config()
|
||||
if not valid:
|
||||
return False, err
|
||||
|
||||
result = self.send(
|
||||
'ProxMenux Test',
|
||||
'Notification service is working correctly.\nThis is a test message from ProxMenux Monitor.',
|
||||
'INFO'
|
||||
)
|
||||
return result['success'], result.get('error', '')
|
||||
|
||||
def _post_message(self, text: str) -> Tuple[int, str]:
|
||||
url = self.API_BASE.format(token=self.bot_token)
|
||||
payload = json.dumps({
|
||||
'chat_id': self.chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_web_page_preview': True,
|
||||
}).encode('utf-8')
|
||||
|
||||
return self._http_request(url, payload, {'Content-Type': 'application/json'})
|
||||
|
||||
def _split_message(self, text: str) -> list:
|
||||
if len(text) <= self.MAX_LENGTH:
|
||||
return [text]
|
||||
chunks = []
|
||||
while text:
|
||||
if len(text) <= self.MAX_LENGTH:
|
||||
chunks.append(text)
|
||||
break
|
||||
split_at = text.rfind('\n', 0, self.MAX_LENGTH)
|
||||
if split_at == -1:
|
||||
split_at = self.MAX_LENGTH
|
||||
chunks.append(text[:split_at])
|
||||
text = text[split_at:].lstrip('\n')
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
def _escape_html(text: str) -> str:
|
||||
return (text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
|
||||
# ─── Gotify ──────────────────────────────────────────────────────
|
||||
|
||||
class GotifyChannel(NotificationChannel):
|
||||
"""Gotify push notification channel with priority mapping."""
|
||||
|
||||
PRIORITY_MAP = {
|
||||
'OK': 1,
|
||||
'INFO': 2,
|
||||
'UNKNOWN': 3,
|
||||
'WARNING': 5,
|
||||
'CRITICAL': 10,
|
||||
}
|
||||
|
||||
def __init__(self, server_url: str, app_token: str):
|
||||
super().__init__()
|
||||
self.server_url = server_url.rstrip('/').strip()
|
||||
self.app_token = app_token.strip()
|
||||
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
if not self.server_url:
|
||||
return False, 'Server URL is required'
|
||||
if not self.app_token:
|
||||
return False, 'Application token is required'
|
||||
if not self.server_url.startswith(('http://', 'https://')):
|
||||
return False, 'Server URL must start with http:// or https://'
|
||||
return True, ''
|
||||
|
||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
priority = self.PRIORITY_MAP.get(severity, 2)
|
||||
|
||||
result = self._send_with_retry(
|
||||
lambda: self._post_message(title, message, priority)
|
||||
)
|
||||
result['channel'] = 'gotify'
|
||||
return result
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
valid, err = self.validate_config()
|
||||
if not valid:
|
||||
return False, err
|
||||
|
||||
result = self.send(
|
||||
'ProxMenux Test',
|
||||
'Notification service is working correctly.\nThis is a test message from ProxMenux Monitor.',
|
||||
'INFO'
|
||||
)
|
||||
return result['success'], result.get('error', '')
|
||||
|
||||
def _post_message(self, title: str, message: str, priority: int) -> Tuple[int, str]:
|
||||
url = f"{self.server_url}/message?token={self.app_token}"
|
||||
payload = json.dumps({
|
||||
'title': title,
|
||||
'message': message,
|
||||
'priority': priority,
|
||||
'extras': {
|
||||
'client::display': {'contentType': 'text/markdown'}
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
return self._http_request(url, payload, {'Content-Type': 'application/json'})
|
||||
|
||||
|
||||
# ─── Discord ─────────────────────────────────────────────────────
|
||||
|
||||
class DiscordChannel(NotificationChannel):
|
||||
"""Discord webhook channel with color-coded embeds."""
|
||||
|
||||
MAX_EMBED_DESC = 2048
|
||||
|
||||
SEVERITY_COLORS = {
|
||||
'CRITICAL': 0xED4245, # red
|
||||
'WARNING': 0xFEE75C, # yellow
|
||||
'INFO': 0x5865F2, # blurple
|
||||
'OK': 0x57F287, # green
|
||||
'UNKNOWN': 0x99AAB5, # grey
|
||||
}
|
||||
|
||||
def __init__(self, webhook_url: str):
|
||||
super().__init__()
|
||||
self.webhook_url = webhook_url.strip()
|
||||
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
if not self.webhook_url:
|
||||
return False, 'Webhook URL is required'
|
||||
if 'discord.com/api/webhooks/' not in self.webhook_url:
|
||||
return False, 'Invalid Discord webhook URL'
|
||||
return True, ''
|
||||
|
||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
color = self.SEVERITY_COLORS.get(severity, 0x5865F2)
|
||||
|
||||
desc = message[:self.MAX_EMBED_DESC] if len(message) > self.MAX_EMBED_DESC else message
|
||||
|
||||
embed = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'color': color,
|
||||
'footer': {'text': 'ProxMenux Monitor'},
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
}
|
||||
|
||||
# Use structured fields from render_template if available
|
||||
rendered_fields = (data or {}).get('_rendered_fields', [])
|
||||
if rendered_fields:
|
||||
embed['fields'] = [
|
||||
{'name': name, 'value': val[:1024], 'inline': True}
|
||||
for name, val in rendered_fields[:25] # Discord limit: 25 fields
|
||||
]
|
||||
elif data:
|
||||
fields = []
|
||||
if data.get('category'):
|
||||
fields.append({'name': 'Category', 'value': data['category'], 'inline': True})
|
||||
if data.get('hostname'):
|
||||
fields.append({'name': 'Host', 'value': data['hostname'], 'inline': True})
|
||||
if data.get('severity'):
|
||||
fields.append({'name': 'Severity', 'value': data['severity'], 'inline': True})
|
||||
if fields:
|
||||
embed['fields'] = fields
|
||||
|
||||
result = self._send_with_retry(
|
||||
lambda: self._post_webhook(embed)
|
||||
)
|
||||
result['channel'] = 'discord'
|
||||
return result
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
valid, err = self.validate_config()
|
||||
if not valid:
|
||||
return False, err
|
||||
|
||||
result = self.send(
|
||||
'ProxMenux Test',
|
||||
'Notification service is working correctly.\nThis is a test message from ProxMenux Monitor.',
|
||||
'INFO'
|
||||
)
|
||||
return result['success'], result.get('error', '')
|
||||
|
||||
def _post_webhook(self, embed: Dict) -> Tuple[int, str]:
|
||||
payload = json.dumps({
|
||||
'username': 'ProxMenux',
|
||||
'embeds': [embed]
|
||||
}).encode('utf-8')
|
||||
|
||||
return self._http_request(
|
||||
self.webhook_url, payload, {'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
|
||||
# ─── Email Channel ──────────────────────────────────────────────
|
||||
|
||||
class EmailChannel(NotificationChannel):
|
||||
"""Email notification channel using SMTP (smtplib) or sendmail fallback.
|
||||
|
||||
Config keys:
|
||||
host, port, username, password, tls_mode (none|starttls|ssl),
|
||||
from_address, to_addresses (comma-separated), subject_prefix, timeout
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, str]):
|
||||
super().__init__()
|
||||
self.host = config.get('host', '')
|
||||
self.port = int(config.get('port', 587) or 587)
|
||||
self.username = config.get('username', '')
|
||||
self.password = config.get('password', '')
|
||||
self.tls_mode = config.get('tls_mode', 'starttls') # none | starttls | ssl
|
||||
self.from_address = config.get('from_address', '')
|
||||
self.to_addresses = self._parse_recipients(config.get('to_addresses', ''))
|
||||
self.subject_prefix = config.get('subject_prefix', '[ProxMenux]')
|
||||
self.timeout = int(config.get('timeout', 10) or 10)
|
||||
|
||||
@staticmethod
|
||||
def _parse_recipients(raw) -> list:
|
||||
if isinstance(raw, list):
|
||||
return [a.strip() for a in raw if a.strip()]
|
||||
return [addr.strip() for addr in str(raw).split(',') if addr.strip()]
|
||||
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
if not self.to_addresses:
|
||||
return False, 'No recipients configured'
|
||||
if not self.from_address:
|
||||
return False, 'No from address configured'
|
||||
# Must have SMTP host OR local sendmail available
|
||||
if not self.host:
|
||||
import os
|
||||
if not os.path.exists('/usr/sbin/sendmail'):
|
||||
return False, 'No SMTP host configured and /usr/sbin/sendmail not found'
|
||||
return True, ''
|
||||
|
||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
subject = f"{self.subject_prefix} [{severity}] {title}"
|
||||
|
||||
def _do_send():
|
||||
if self.host:
|
||||
return self._send_smtp(subject, message, severity, data)
|
||||
else:
|
||||
return self._send_sendmail(subject, message, severity, data)
|
||||
|
||||
return self._send_with_retry(_do_send)
|
||||
|
||||
def _send_smtp(self, subject: str, body: str, severity: str,
|
||||
data: Optional[Dict] = None) -> Tuple[int, str]:
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = self.from_address
|
||||
msg['To'] = ', '.join(self.to_addresses)
|
||||
msg.set_content(body)
|
||||
|
||||
# Add HTML alternative
|
||||
html_body = self._format_html(subject, body, severity, data)
|
||||
if html_body:
|
||||
msg.add_alternative(html_body, subtype='html')
|
||||
|
||||
server = None
|
||||
try:
|
||||
import ssl as _ssl
|
||||
|
||||
if self.tls_mode == 'ssl':
|
||||
ctx = _ssl.create_default_context()
|
||||
server = smtplib.SMTP_SSL(self.host, self.port,
|
||||
timeout=self.timeout, context=ctx)
|
||||
server.ehlo()
|
||||
else:
|
||||
server = smtplib.SMTP(self.host, self.port, timeout=self.timeout)
|
||||
server.ehlo()
|
||||
if self.tls_mode == 'starttls':
|
||||
ctx = _ssl.create_default_context()
|
||||
server.starttls(context=ctx)
|
||||
server.ehlo() # Re-identify after TLS -- server re-announces AUTH
|
||||
|
||||
if self.username and self.password:
|
||||
server.login(self.username, self.password)
|
||||
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
server = None
|
||||
return 200, 'OK'
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
return 0, f'SMTP authentication failed (check username/password or app-specific password): {e}'
|
||||
except smtplib.SMTPNotSupportedError as e:
|
||||
return 0, (f'SMTP AUTH not supported by server. '
|
||||
f'This may mean the server requires OAuth2 or an App Password '
|
||||
f'instead of regular credentials: {e}')
|
||||
except smtplib.SMTPConnectError as e:
|
||||
return 0, f'SMTP connection failed: {e}'
|
||||
except smtplib.SMTPException as e:
|
||||
return 0, f'SMTP error: {e}'
|
||||
except _ssl.SSLError as e:
|
||||
return 0, f'TLS/SSL error (check TLS mode and port): {e}'
|
||||
except (OSError, TimeoutError) as e:
|
||||
return 0, f'Connection error: {e}'
|
||||
finally:
|
||||
if server:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _send_sendmail(self, subject: str, body: str, severity: str,
|
||||
data: Optional[Dict] = None) -> Tuple[int, str]:
|
||||
import os
|
||||
import subprocess
|
||||
from email.message import EmailMessage
|
||||
|
||||
sendmail = '/usr/sbin/sendmail'
|
||||
if not os.path.exists(sendmail):
|
||||
return 0, 'sendmail not found at /usr/sbin/sendmail'
|
||||
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = self.from_address or 'proxmenux@localhost'
|
||||
msg['To'] = ', '.join(self.to_addresses)
|
||||
msg.set_content(body)
|
||||
|
||||
# Add HTML alternative
|
||||
html_body = self._format_html(subject, body, severity, data)
|
||||
if html_body:
|
||||
msg.add_alternative(html_body, subtype='html')
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sendmail, '-t', '-oi'],
|
||||
input=msg.as_string(), capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return 200, 'OK'
|
||||
return 0, f'sendmail failed (rc={proc.returncode}): {proc.stderr[:200]}'
|
||||
except subprocess.TimeoutExpired:
|
||||
return 0, 'sendmail timed out after 30s'
|
||||
except Exception as e:
|
||||
return 0, f'sendmail error: {e}'
|
||||
|
||||
# Severity -> accent colour + label
|
||||
_SEV_STYLE = {
|
||||
'CRITICAL': {'color': '#dc2626', 'bg': '#fef2f2', 'border': '#fecaca', 'label': 'Critical'},
|
||||
'WARNING': {'color': '#d97706', 'bg': '#fffbeb', 'border': '#fde68a', 'label': 'Warning'},
|
||||
'INFO': {'color': '#2563eb', 'bg': '#eff6ff', 'border': '#bfdbfe', 'label': 'Information'},
|
||||
'OK': {'color': '#16a34a', 'bg': '#f0fdf4', 'border': '#bbf7d0', 'label': 'Resolved'},
|
||||
}
|
||||
_SEV_DEFAULT = {'color': '#6b7280', 'bg': '#f9fafb', 'border': '#e5e7eb', 'label': 'Notice'}
|
||||
|
||||
# Group -> human-readable section header for the email
|
||||
_GROUP_LABELS = {
|
||||
'vm_ct': 'Virtual Machine / Container',
|
||||
'backup': 'Backup & Snapshot',
|
||||
'resources': 'System Resources',
|
||||
'storage': 'Storage',
|
||||
'network': 'Network',
|
||||
'security': 'Security',
|
||||
'cluster': 'Cluster',
|
||||
'services': 'System Services',
|
||||
'health': 'Health Monitor',
|
||||
'updates': 'System Updates',
|
||||
'other': 'System Notification',
|
||||
}
|
||||
|
||||
def _format_html(self, subject: str, body: str, severity: str,
|
||||
data: Optional[Dict] = None) -> str:
|
||||
"""Build a professional HTML email with structured data sections."""
|
||||
import html as html_mod
|
||||
import time as _time
|
||||
|
||||
data = data or {}
|
||||
sev = self._SEV_STYLE.get(severity, self._SEV_DEFAULT)
|
||||
|
||||
# Determine group for section header
|
||||
event_type = data.get('_event_type', '')
|
||||
group = data.get('_group', 'other')
|
||||
section_label = self._GROUP_LABELS.get(group, 'System Notification')
|
||||
|
||||
# Timestamp
|
||||
ts = data.get('timestamp', '') or _time.strftime('%Y-%m-%d %H:%M:%S UTC', _time.gmtime())
|
||||
|
||||
# ── Build structured detail rows from known data fields ──
|
||||
detail_rows = self._build_detail_rows(data, event_type, group, html_mod)
|
||||
|
||||
# ── Fallback: if no structured rows, render body text lines ──
|
||||
if not detail_rows:
|
||||
for line in body.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
# Try to split "Label: value" patterns
|
||||
if ':' in stripped:
|
||||
lbl, _, val = stripped.partition(':')
|
||||
if val.strip() and len(lbl) < 40:
|
||||
detail_rows.append((html_mod.escape(lbl.strip()), html_mod.escape(val.strip())))
|
||||
continue
|
||||
detail_rows.append(('', html_mod.escape(stripped)))
|
||||
|
||||
# ── Render detail rows as HTML table ──
|
||||
rows_html = ''
|
||||
for label, value in detail_rows:
|
||||
if label:
|
||||
rows_html += f'''<tr>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;font-weight:500;white-space:nowrap;vertical-align:top;border-bottom:1px solid #e5e7eb;">{label}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#111827;border-bottom:1px solid #e5e7eb;">{value}</td>
|
||||
</tr>'''
|
||||
else:
|
||||
# Full-width row (no label, just description text)
|
||||
rows_html += f'''<tr>
|
||||
<td colspan="2" style="padding:8px 12px;font-size:13px;color:#1f2937;border-bottom:1px solid #e5e7eb;">{value}</td>
|
||||
</tr>'''
|
||||
|
||||
# ── Reason / details block (long text, displayed separately) ──
|
||||
reason = data.get('reason', '')
|
||||
reason_html = ''
|
||||
if reason and len(reason) > 80:
|
||||
reason_html = f'''
|
||||
<div style="margin:16px 0 0;padding:12px 16px;border:1px solid #d1d5db;border-radius:6px;">
|
||||
<p style="margin:0 0 4px;font-size:11px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:0.05em;">Details</p>
|
||||
<p style="margin:0;font-size:13px;color:#1f2937;line-height:1.6;white-space:pre-wrap;">{html_mod.escape(reason)}</p>
|
||||
</div>'''
|
||||
|
||||
# ── Clean subject for display (remove prefix if present) ──
|
||||
display_title = subject
|
||||
for prefix in [self.subject_prefix, '[CRITICAL]', '[WARNING]', '[INFO]', '[OK]']:
|
||||
display_title = display_title.replace(prefix, '').strip()
|
||||
|
||||
return f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<div style="max-width:640px;margin:24px auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);border:1px solid #d1d5db;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="padding:20px 28px;background:#f8f9fa;border-bottom:1px solid {sev['border']};">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td>
|
||||
<h1 style="margin:0;font-size:18px;font-weight:700;color:#111827;letter-spacing:-0.02em;">ProxMenux Monitor</h1>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#4b5563;">{html_mod.escape(section_label)} Report</p>
|
||||
</td>
|
||||
<td style="text-align:right;vertical-align:top;">
|
||||
<span style="display:inline-block;padding:4px 12px;border-radius:4px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:{sev['color']};background:{sev['bg']};border:1px solid {sev['border']};">{sev['label'].upper()}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Title bar -->
|
||||
<div style="padding:16px 28px;background:{sev['bg']};border-bottom:1px solid {sev['border']};">
|
||||
<h2 style="margin:0;font-size:15px;font-weight:600;color:{sev['color']};">{html_mod.escape(display_title)}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding:24px 28px;">
|
||||
<!-- Metadata -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:16px;">
|
||||
<tr>
|
||||
<td style="font-size:12px;color:#4b5563;">
|
||||
Host: <strong style="color:#111827;">{html_mod.escape(data.get('hostname', ''))}</strong>
|
||||
</td>
|
||||
<td style="font-size:12px;color:#4b5563;text-align:right;">
|
||||
{html_mod.escape(ts)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Detail table -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #d1d5db;border-radius:6px;overflow:hidden;">
|
||||
{rows_html}
|
||||
</table>
|
||||
|
||||
{reason_html}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding:14px 28px;border-top:1px solid #d1d5db;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size:11px;color:#4b5563;">ProxMenux Notification Service</td>
|
||||
<td style="font-size:11px;color:#4b5563;text-align:right;">proxmenux.com</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@staticmethod
|
||||
def _build_detail_rows(data: Dict, event_type: str, group: str,
|
||||
html_mod) -> list:
|
||||
"""Build structured (label, value) rows from event data.
|
||||
|
||||
Returns list of (label_html, value_html) tuples.
|
||||
An empty label means a full-width descriptive row.
|
||||
"""
|
||||
esc = html_mod.escape
|
||||
rows = []
|
||||
|
||||
def _add(label: str, value, fmt: str = ''):
|
||||
"""Add a row if value is truthy."""
|
||||
v = str(value).strip() if value else ''
|
||||
if not v or v == '0' and label not in ('Failures',):
|
||||
return
|
||||
if fmt == 'severity':
|
||||
sev_colors = {
|
||||
'CRITICAL': '#dc2626', 'WARNING': '#d97706',
|
||||
'INFO': '#2563eb', 'OK': '#16a34a',
|
||||
}
|
||||
c = sev_colors.get(v, '#6b7280')
|
||||
rows.append((esc(label), f'<span style="color:{c};font-weight:600;">{esc(v)}</span>'))
|
||||
elif fmt == 'code':
|
||||
rows.append((esc(label), f'<code style="padding:2px 6px;background:#f3f4f6;border-radius:3px;font-family:monospace;font-size:12px;">{esc(v)}</code>'))
|
||||
elif fmt == 'bold':
|
||||
rows.append((esc(label), f'<strong>{esc(v)}</strong>'))
|
||||
else:
|
||||
rows.append((esc(label), esc(v)))
|
||||
|
||||
# ── Common fields present in most events ──
|
||||
|
||||
# ── VM / CT events ──
|
||||
if group == 'vm_ct':
|
||||
_add('VM/CT ID', data.get('vmid'), 'code')
|
||||
_add('Name', data.get('vmname'), 'bold')
|
||||
_add('Action', event_type.replace('_', ' ').replace('vm ', 'VM ').replace('ct ', 'CT ').title())
|
||||
_add('Target Node', data.get('target_node'))
|
||||
_add('Reason', data.get('reason'))
|
||||
|
||||
# ── Backup events ──
|
||||
elif group == 'backup':
|
||||
_add('VM/CT ID', data.get('vmid'), 'code')
|
||||
_add('Name', data.get('vmname'), 'bold')
|
||||
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
|
||||
'severity' if 'fail' in event_type else '')
|
||||
_add('Size', data.get('size'))
|
||||
_add('Duration', data.get('duration'))
|
||||
_add('Snapshot', data.get('snapshot_name'), 'code')
|
||||
# For backup_complete/fail with parsed body, add short reason only
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
# ── Resources ──
|
||||
elif group == 'resources':
|
||||
_add('Metric', event_type.replace('_', ' ').title())
|
||||
_add('Current Value', data.get('value'), 'bold')
|
||||
_add('Threshold', data.get('threshold'))
|
||||
_add('CPU Cores', data.get('cores'))
|
||||
_add('Memory', f"{data.get('used', '')} / {data.get('total', '')}" if data.get('used') else '')
|
||||
_add('Temperature', f"{data.get('value')}C" if 'temp' in event_type else '')
|
||||
|
||||
# ── Storage ──
|
||||
elif group == 'storage':
|
||||
if 'disk_space' in event_type:
|
||||
_add('Mount Point', data.get('mount'), 'code')
|
||||
_add('Usage', f"{data.get('used')}%", 'bold')
|
||||
_add('Available', data.get('available'))
|
||||
elif 'io_error' in event_type:
|
||||
_add('Device', data.get('device'), 'code')
|
||||
_add('Severity', data.get('severity', ''), 'severity')
|
||||
elif 'unavailable' in event_type:
|
||||
_add('Storage Name', data.get('storage_name'), 'bold')
|
||||
_add('Type', data.get('storage_type'), 'code')
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
# ── Network ──
|
||||
elif group == 'network':
|
||||
_add('Interface', data.get('interface'), 'code')
|
||||
_add('Latency', f"{data.get('value')}ms" if data.get('value') else '')
|
||||
_add('Threshold', f"{data.get('threshold')}ms" if data.get('threshold') else '')
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
# ── Security ──
|
||||
elif group == 'security':
|
||||
_add('Event', event_type.replace('_', ' ').title())
|
||||
_add('Source IP', data.get('source_ip'), 'code')
|
||||
_add('Username', data.get('username'), 'code')
|
||||
_add('Service', data.get('service'))
|
||||
_add('Jail', data.get('jail'), 'code')
|
||||
_add('Failures', data.get('failures'))
|
||||
_add('Change', data.get('change_details'))
|
||||
|
||||
# ── Cluster ──
|
||||
elif group == 'cluster':
|
||||
_add('Event', event_type.replace('_', ' ').title())
|
||||
_add('Node', data.get('node_name'), 'bold')
|
||||
_add('Quorum', data.get('quorum'))
|
||||
_add('Nodes Affected', data.get('entity_list'))
|
||||
|
||||
# ── Services ──
|
||||
elif group == 'services':
|
||||
_add('Service', data.get('service_name'), 'code')
|
||||
_add('Process', data.get('process'), 'code')
|
||||
_add('Event', event_type.replace('_', ' ').title())
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
# ── Health monitor ──
|
||||
elif group == 'health':
|
||||
_add('Category', data.get('category'), 'bold')
|
||||
_add('Severity', data.get('severity', ''), 'severity')
|
||||
if data.get('original_severity'):
|
||||
_add('Previous Severity', data.get('original_severity'), 'severity')
|
||||
_add('Duration', data.get('duration'))
|
||||
_add('Active Issues', data.get('count'))
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
# ── Updates ──
|
||||
elif group == 'updates':
|
||||
_add('Total Updates', data.get('total_count'), 'bold')
|
||||
_add('Security Updates', data.get('security_count'))
|
||||
_add('Proxmox Updates', data.get('pve_count'))
|
||||
_add('Kernel Updates', data.get('kernel_count'))
|
||||
imp = data.get('important_list', '')
|
||||
if imp and imp != 'none':
|
||||
# Render each package on its own line inside a single cell
|
||||
pkg_lines = [l.strip() for l in imp.split('\n') if l.strip()]
|
||||
if pkg_lines:
|
||||
pkg_html = '<br>'.join(
|
||||
f'<code style="padding:1px 5px;background:#f3f4f6;border-radius:3px;font-family:monospace;font-size:12px;">{esc(p)}</code>'
|
||||
for p in pkg_lines
|
||||
)
|
||||
rows.append((esc('Important Packages'), pkg_html))
|
||||
_add('Current Version', data.get('current_version'), 'code')
|
||||
_add('New Version', data.get('new_version'), 'code')
|
||||
|
||||
# ── Other / unknown ──
|
||||
else:
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
return rows
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
import socket as _socket
|
||||
hostname = _socket.gethostname().split('.')[0]
|
||||
result = self.send(
|
||||
'ProxMenux Test Notification',
|
||||
'This is a test notification from ProxMenux Monitor.\n'
|
||||
'If you received this, your email channel is working correctly.',
|
||||
'INFO',
|
||||
data={
|
||||
'hostname': hostname,
|
||||
'_event_type': 'webhook_test',
|
||||
'_group': 'other',
|
||||
'reason': 'Email notification channel connectivity verified successfully. '
|
||||
'You will receive alerts from ProxMenux Monitor at this address.',
|
||||
}
|
||||
)
|
||||
return result.get('success', False), result.get('error', '')
|
||||
|
||||
|
||||
# ─── Channel Factory ─────────────────────────────────────────────
|
||||
|
||||
CHANNEL_TYPES = {
|
||||
'telegram': {
|
||||
'name': 'Telegram',
|
||||
'config_keys': ['bot_token', 'chat_id'],
|
||||
'class': TelegramChannel,
|
||||
},
|
||||
'gotify': {
|
||||
'name': 'Gotify',
|
||||
'config_keys': ['url', 'token'],
|
||||
'class': GotifyChannel,
|
||||
},
|
||||
'discord': {
|
||||
'name': 'Discord',
|
||||
'config_keys': ['webhook_url'],
|
||||
'class': DiscordChannel,
|
||||
},
|
||||
'email': {
|
||||
'name': 'Email (SMTP)',
|
||||
'config_keys': ['host', 'port', 'username', 'password', 'tls_mode',
|
||||
'from_address', 'to_addresses', 'subject_prefix'],
|
||||
'class': EmailChannel,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[NotificationChannel]:
|
||||
"""Create a channel instance from type name and config dict.
|
||||
|
||||
Args:
|
||||
channel_type: 'telegram', 'gotify', or 'discord'
|
||||
config: Dict with channel-specific keys (see CHANNEL_TYPES)
|
||||
|
||||
Returns:
|
||||
Channel instance or None if creation fails
|
||||
"""
|
||||
try:
|
||||
if channel_type == 'telegram':
|
||||
return TelegramChannel(
|
||||
bot_token=config.get('bot_token', ''),
|
||||
chat_id=config.get('chat_id', '')
|
||||
)
|
||||
elif channel_type == 'gotify':
|
||||
return GotifyChannel(
|
||||
server_url=config.get('url', ''),
|
||||
app_token=config.get('token', '')
|
||||
)
|
||||
elif channel_type == 'discord':
|
||||
return DiscordChannel(
|
||||
webhook_url=config.get('webhook_url', '')
|
||||
)
|
||||
elif channel_type == 'email':
|
||||
return EmailChannel(config)
|
||||
except Exception as e:
|
||||
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ProxMenux - HTML Description Templates for OCI Containers
|
||||
==========================================================
|
||||
Generates beautiful HTML descriptions for the Proxmox Notes panel.
|
||||
Can be used from both Python (oci_manager.py) and bash scripts.
|
||||
|
||||
Usage from bash:
|
||||
python3 description_templates.py --app-id "secure-gateway" --hostname "my-gateway"
|
||||
|
||||
Usage from Python:
|
||||
from description_templates import generate_description
|
||||
html = generate_description(app_def, container_def, hostname)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Default paths
|
||||
CATALOG_PATH = Path(__file__).parent / "catalog.json"
|
||||
|
||||
|
||||
def get_shield_icon_svg(color: str = "#0EA5E9") -> str:
|
||||
"""Generate a shield icon SVG with checkmark."""
|
||||
return f"""<svg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='{color}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/><path d='M9 12l2 2 4-4'/></svg>"""
|
||||
|
||||
|
||||
def get_default_icon_svg(color: str = "#0EA5E9") -> str:
|
||||
"""Generate a default container icon SVG."""
|
||||
return f"""<svg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='{color}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z'/><polyline points='3.27 6.96 12 12.01 20.73 6.96'/><line x1='12' y1='22.08' x2='12' y2='12'/></svg>"""
|
||||
|
||||
|
||||
# Pre-defined icon types
|
||||
ICON_TYPES = {
|
||||
"shield": get_shield_icon_svg,
|
||||
"container": get_default_icon_svg,
|
||||
"default": get_default_icon_svg,
|
||||
}
|
||||
|
||||
|
||||
def generate_description(
|
||||
app_def: Dict,
|
||||
container_def: Optional[Dict] = None,
|
||||
hostname: str = "",
|
||||
extra_info: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML description for Proxmox Notes panel.
|
||||
|
||||
Args:
|
||||
app_def: Application definition from catalog
|
||||
container_def: Container definition (optional)
|
||||
hostname: Container hostname
|
||||
extra_info: Additional info to display (e.g., disk info)
|
||||
|
||||
Returns:
|
||||
HTML string for the description
|
||||
"""
|
||||
# Extract app info
|
||||
app_name = app_def.get("name", "ProxMenux App")
|
||||
app_subtitle = app_def.get("subtitle", "")
|
||||
app_color = app_def.get("color", "#0EA5E9")
|
||||
app_icon_type = app_def.get("icon_type", "default")
|
||||
doc_url = app_def.get("documentation_url", "https://macrimi.github.io/ProxMenux/")
|
||||
code_url = app_def.get("code_url", "https://github.com/MacRimi/ProxMenux")
|
||||
installer_url = app_def.get("installer_url", "")
|
||||
kofi_url = "https://ko-fi.com/macrimi"
|
||||
|
||||
# Get the icon SVG
|
||||
icon_func = ICON_TYPES.get(app_icon_type, ICON_TYPES["default"])
|
||||
icon_svg = icon_func(app_color)
|
||||
icon_data = "data:image/svg+xml," + urllib.parse.quote(icon_svg)
|
||||
|
||||
# Build badge buttons
|
||||
badges = []
|
||||
badges.append(f"<a href='{doc_url}' target='_blank'><img src='https://img.shields.io/badge/📚_Docs-blue' alt='Docs'></a>")
|
||||
badges.append(f"<a href='{code_url}' target='_blank'><img src='https://img.shields.io/badge/💻_Code-green' alt='Code'></a>")
|
||||
|
||||
if installer_url:
|
||||
badges.append(f"<a href='{installer_url}' target='_blank'><img src='https://img.shields.io/badge/📦_Installer-orange' alt='Installer'></a>")
|
||||
|
||||
badges.append(f"<a href='{kofi_url}' target='_blank'><img src='https://img.shields.io/badge/☕_Ko--fi-red' alt='Ko-fi'></a>")
|
||||
|
||||
badges_html = "\n".join(badges)
|
||||
|
||||
# Build footer info
|
||||
footer_parts = []
|
||||
if hostname:
|
||||
footer_parts.append(f"Hostname: {hostname}")
|
||||
if extra_info:
|
||||
footer_parts.append(extra_info)
|
||||
footer_html = "<br>".join(footer_parts) if footer_parts else ""
|
||||
|
||||
# Build the complete HTML
|
||||
html = f"""<div align='center'>
|
||||
<table style='width: 100%; border-collapse: collapse;'>
|
||||
<tr>
|
||||
<td style='width: 100px; vertical-align: middle;'>
|
||||
<img src="/images/design-mode/logo_desc.png" alt='ProxMenux Logo' style='height: 100px;'>
|
||||
</td>
|
||||
<td style='vertical-align: middle;'>
|
||||
<h1 style='margin: 0;'>{app_name}</h1>
|
||||
<p style='margin: 0;'>Created with ProxMenux</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style='margin: 15px 0; padding: 10px; background: #2d2d2d; border-radius: 8px; display: inline-block;'>
|
||||
<table style='border-collapse: collapse;'>
|
||||
<tr>
|
||||
<td style='vertical-align: middle; padding-right: 10px;'>
|
||||
<img src='{icon_data}' alt='Icon' style='height: 48px;'>
|
||||
</td>
|
||||
<td style='vertical-align: middle; text-align: left;'>
|
||||
<span style='font-size: 18px; font-weight: bold; color: {app_color};'>{app_name}</span><br>
|
||||
<span style='color: #9ca3af;'>{app_subtitle}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{badges_html}
|
||||
</p>
|
||||
"""
|
||||
|
||||
if footer_html:
|
||||
html += f"""
|
||||
<p style='color: #6b7280; font-size: 12px;'>
|
||||
{footer_html}
|
||||
</p>
|
||||
"""
|
||||
|
||||
html += "</div>"
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def generate_vm_description(
|
||||
vm_name: str,
|
||||
vm_version: str = "",
|
||||
doc_url: str = "",
|
||||
code_url: str = "",
|
||||
installer_url: str = "",
|
||||
extra_info: str = "",
|
||||
icon_url: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML description for VMs (like ZimaOS).
|
||||
|
||||
Args:
|
||||
vm_name: Name of the VM
|
||||
vm_version: Version string
|
||||
doc_url: Documentation URL
|
||||
code_url: Code repository URL
|
||||
installer_url: Installer URL
|
||||
extra_info: Additional info (e.g., disk info)
|
||||
icon_url: Custom icon URL for the VM
|
||||
|
||||
Returns:
|
||||
HTML string for the description
|
||||
"""
|
||||
# Build badge buttons
|
||||
badges = []
|
||||
if doc_url:
|
||||
badges.append(f"<a href='{doc_url}' target='_blank'><img src='https://img.shields.io/badge/📚_Docs-blue' alt='Docs'></a>")
|
||||
if code_url:
|
||||
badges.append(f"<a href='{code_url}' target='_blank'><img src='https://img.shields.io/badge/💻_Code-green' alt='Code'></a>")
|
||||
if installer_url:
|
||||
badges.append(f"<a href='{installer_url}' target='_blank'><img src='https://img.shields.io/badge/📦_Installer-orange' alt='Installer'></a>")
|
||||
badges.append("<a href='https://ko-fi.com/macrimi' target='_blank'><img src='https://img.shields.io/badge/☕_Ko--fi-red' alt='Ko-fi'></a>")
|
||||
|
||||
badges_html = "\n".join(badges)
|
||||
|
||||
# Version line
|
||||
version_html = f"<p style='margin: 0;'>{vm_version}</p>" if vm_version else ""
|
||||
|
||||
# Extra info
|
||||
extra_html = f"<p style='color: #6b7280; font-size: 12px;'>{extra_info}</p>" if extra_info else ""
|
||||
|
||||
html = f"""<div align='center'>
|
||||
<table style='width: 100%; border-collapse: collapse;'>
|
||||
<tr>
|
||||
<td style='width: 100px; vertical-align: middle;'>
|
||||
<img src="/images/design-mode/logo_desc.png" alt='ProxMenux Logo' style='height: 100px;'>
|
||||
</td>
|
||||
<td style='vertical-align: middle;'>
|
||||
<h1 style='margin: 0;'>{vm_name}</h1>
|
||||
<p style='margin: 0;'>Created with ProxMenux</p>
|
||||
{version_html}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
{badges_html}
|
||||
</p>
|
||||
|
||||
{extra_html}
|
||||
</div>"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def load_catalog() -> Dict:
|
||||
"""Load the OCI catalog."""
|
||||
if CATALOG_PATH.exists():
|
||||
with open(CATALOG_PATH) as f:
|
||||
return json.load(f)
|
||||
return {"apps": {}}
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI interface for generating descriptions."""
|
||||
parser = argparse.ArgumentParser(description="Generate HTML descriptions for Proxmox")
|
||||
parser.add_argument("--app-id", help="Application ID from catalog")
|
||||
parser.add_argument("--hostname", default="", help="Container hostname")
|
||||
parser.add_argument("--extra-info", default="", help="Additional info to display")
|
||||
parser.add_argument("--output", choices=["html", "encoded"], default="html",
|
||||
help="Output format: html or url-encoded")
|
||||
|
||||
# For VM descriptions (not from catalog)
|
||||
parser.add_argument("--vm-name", help="VM name (for non-catalog VMs)")
|
||||
parser.add_argument("--vm-version", default="", help="VM version")
|
||||
parser.add_argument("--doc-url", default="", help="Documentation URL")
|
||||
parser.add_argument("--code-url", default="", help="Code repository URL")
|
||||
parser.add_argument("--installer-url", default="", help="Installer URL")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.app_id:
|
||||
# Generate from catalog
|
||||
catalog = load_catalog()
|
||||
apps = catalog.get("apps", {})
|
||||
|
||||
if args.app_id not in apps:
|
||||
print(f"Error: App '{args.app_id}' not found in catalog", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
app_def = apps[args.app_id]
|
||||
html = generate_description(app_def, hostname=args.hostname, extra_info=args.extra_info)
|
||||
|
||||
elif args.vm_name:
|
||||
# Generate for VM
|
||||
html = generate_vm_description(
|
||||
vm_name=args.vm_name,
|
||||
vm_version=args.vm_version,
|
||||
doc_url=args.doc_url,
|
||||
code_url=args.code_url,
|
||||
installer_url=args.installer_url,
|
||||
extra_info=args.extra_info
|
||||
)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.output == "encoded":
|
||||
print(urllib.parse.quote(html))
|
||||
else:
|
||||
print(html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# ProxMenux Notification System - Complete Test Suite
|
||||
# ============================================================================
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x test_all_notifications.sh
|
||||
# ./test_all_notifications.sh # Run ALL tests (with 3s pause between)
|
||||
# ./test_all_notifications.sh system # Run only System category
|
||||
# ./test_all_notifications.sh vm_ct # Run only VM/CT category
|
||||
# ./test_all_notifications.sh backup # Run only Backup category
|
||||
# ./test_all_notifications.sh resources # Run only Resources category
|
||||
# ./test_all_notifications.sh storage # Run only Storage category
|
||||
# ./test_all_notifications.sh network # Run only Network category
|
||||
# ./test_all_notifications.sh security # Run only Security category
|
||||
# ./test_all_notifications.sh cluster # Run only Cluster category
|
||||
# ./test_all_notifications.sh burst # Run only Burst aggregation tests
|
||||
#
|
||||
# Each test sends a simulated webhook to the local notification endpoint.
|
||||
# Check your Telegram/Gotify/Discord/Email for the notifications.
|
||||
# ============================================================================
|
||||
|
||||
API="http://127.0.0.1:8008/api/notifications/webhook"
|
||||
PAUSE=3 # seconds between tests
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
BOLD='\033[1m'
|
||||
|
||||
test_count=0
|
||||
pass_count=0
|
||||
fail_count=0
|
||||
|
||||
send_test() {
|
||||
local name="$1"
|
||||
local payload="$2"
|
||||
test_count=$((test_count + 1))
|
||||
|
||||
echo -e "${CYAN} [$test_count] ${BOLD}$name${NC}"
|
||||
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "$API" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" 2>&1)
|
||||
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "202" ]; then
|
||||
echo -e " ${GREEN}HTTP $http_code${NC} - $body"
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
echo -e " ${RED}HTTP $http_code${NC} - $body"
|
||||
fail_count=$((fail_count + 1))
|
||||
fi
|
||||
|
||||
sleep "$PAUSE"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEM CATEGORY (group: system)
|
||||
# ============================================================================
|
||||
test_system() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} SYSTEM - Startup, shutdown, kernel${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. state_change (disabled by default -- test to verify it does NOT arrive)
|
||||
send_test "state_change (should NOT arrive - disabled by default)" \
|
||||
'{"type":"state_change","component":"health","severity":"warning","title":"overall changed to WARNING","body":"overall status changed from OK to WARNING."}'
|
||||
|
||||
# 2. new_error
|
||||
send_test "new_error" \
|
||||
'{"type":"new_error","component":"health","severity":"warning","title":"New WARNING - cpu","body":"CPU usage exceeds 90% for more than 5 minutes","category":"cpu"}'
|
||||
|
||||
# 3. error_resolved
|
||||
send_test "error_resolved" \
|
||||
'{"type":"error_resolved","component":"health","severity":"info","title":"Resolved - cpu","body":"CPU usage returned to normal.\nDuration: 15 minutes","category":"cpu","duration":"15 minutes"}'
|
||||
|
||||
# 4. error_escalated
|
||||
send_test "error_escalated" \
|
||||
'{"type":"error_escalated","component":"health","severity":"critical","title":"Escalated to CRITICAL - memory","body":"Memory usage exceeded 95% and swap is active","category":"memory"}'
|
||||
|
||||
# 5. system_shutdown
|
||||
send_test "system_shutdown" \
|
||||
'{"type":"system_shutdown","component":"system","severity":"warning","title":"System shutting down","body":"The system is shutting down.\nUser initiated shutdown."}'
|
||||
|
||||
# 6. system_reboot
|
||||
send_test "system_reboot" \
|
||||
'{"type":"system_reboot","component":"system","severity":"warning","title":"System rebooting","body":"The system is rebooting.\nKernel update applied."}'
|
||||
|
||||
# 7. system_problem
|
||||
send_test "system_problem" \
|
||||
'{"type":"system_problem","component":"system","severity":"critical","title":"System problem detected","body":"Kernel panic: Attempted to kill init! exitcode=0x00000009"}'
|
||||
|
||||
# 8. service_fail
|
||||
send_test "service_fail" \
|
||||
'{"type":"service_fail","component":"systemd","severity":"warning","title":"Service failed - pvedaemon","body":"Service pvedaemon has failed.\nUnit pvedaemon.service entered failed state.","service_name":"pvedaemon"}'
|
||||
|
||||
# 9. update_available (legacy, superseded by update_summary)
|
||||
send_test "update_available" \
|
||||
'{"type":"update_available","component":"apt","severity":"info","title":"Updates available","body":"Total updates: 12\nSecurity: 3\nProxmox: 5\nKernel: 1\nImportant: pve-manager (8.3.5 -> 8.4.1)","total_count":"12","security_count":"3","pve_count":"5","kernel_count":"1","important_list":"pve-manager (8.3.5 -> 8.4.1)"}'
|
||||
|
||||
# 10. update_complete
|
||||
send_test "update_complete" \
|
||||
'{"type":"update_complete","component":"apt","severity":"info","title":"Update completed","body":"12 packages updated successfully."}'
|
||||
|
||||
# 11. unknown_persistent
|
||||
send_test "unknown_persistent" \
|
||||
'{"type":"unknown_persistent","component":"health","severity":"warning","title":"Check unavailable - temperature","body":"Health check for temperature has been unavailable for 3+ cycles.\nSensor not responding.","category":"temperature"}'
|
||||
|
||||
# 12. health_persistent
|
||||
send_test "health_persistent" \
|
||||
'{"type":"health_persistent","component":"health","severity":"warning","title":"3 active health issue(s)","body":"The following health issues remain active:\n- CPU at 92%\n- Memory at 88%\n- Disk /dev/sda at 94%\n\nThis digest is sent once every 24 hours while issues persist.","count":"3"}'
|
||||
|
||||
# 13. health_issue_new
|
||||
send_test "health_issue_new" \
|
||||
'{"type":"health_issue_new","component":"health","severity":"warning","title":"New health issue - disk","body":"New WARNING issue detected:\nDisk /dev/sda usage at 94%","category":"disk"}'
|
||||
|
||||
# 14. health_issue_resolved
|
||||
send_test "health_issue_resolved" \
|
||||
'{"type":"health_issue_resolved","component":"health","severity":"info","title":"Resolved - disk","body":"disk issue has been resolved.\nDisk usage dropped to 72%.\nDuration: 3 hours","category":"disk","duration":"3 hours"}'
|
||||
|
||||
# 15. update_summary
|
||||
send_test "update_summary" \
|
||||
'{"type":"update_summary","component":"apt","severity":"info","title":"Updates available","body":"Total updates: 70\nSecurity updates: 9\nProxmox-related updates: 24\nKernel updates: 1\nImportant packages: pve-manager (8.3.5 -> 8.4.1), proxmox-ve (8.3.0 -> 8.4.0), qemu-server (8.3.8 -> 8.4.2)","total_count":"70","security_count":"9","pve_count":"24","kernel_count":"1","important_list":"pve-manager (8.3.5 -> 8.4.1), proxmox-ve (8.3.0 -> 8.4.0), qemu-server (8.3.8 -> 8.4.2)"}'
|
||||
|
||||
# 16. pve_update
|
||||
send_test "pve_update" \
|
||||
'{"type":"pve_update","component":"apt","severity":"info","title":"Proxmox VE 8.4.1 available","body":"Proxmox VE 8.3.5 -> 8.4.1\npve-manager 8.3.5 -> 8.4.1","current_version":"8.3.5","new_version":"8.4.1","version":"8.4.1","details":"pve-manager 8.3.5 -> 8.4.1"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# VM / CT CATEGORY (group: vm_ct)
|
||||
# ============================================================================
|
||||
test_vm_ct() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} VM / CT - Start, stop, crash, migration${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. vm_start
|
||||
send_test "vm_start" \
|
||||
'{"type":"vm_start","component":"qemu","severity":"info","title":"VM 100 started","body":"ubuntu-server (100) has been started.","vmid":"100","vmname":"ubuntu-server"}'
|
||||
|
||||
# 2. vm_stop
|
||||
send_test "vm_stop" \
|
||||
'{"type":"vm_stop","component":"qemu","severity":"info","title":"VM 100 stopped","body":"ubuntu-server (100) has been stopped.","vmid":"100","vmname":"ubuntu-server"}'
|
||||
|
||||
# 3. vm_shutdown
|
||||
send_test "vm_shutdown" \
|
||||
'{"type":"vm_shutdown","component":"qemu","severity":"info","title":"VM 100 shutdown","body":"ubuntu-server (100) has been shut down.","vmid":"100","vmname":"ubuntu-server"}'
|
||||
|
||||
# 4. vm_fail
|
||||
send_test "vm_fail" \
|
||||
'{"type":"vm_fail","component":"qemu","severity":"critical","title":"VM 100 FAILED","body":"ubuntu-server (100) has failed.\nKVM: internal error: unexpected exit to hypervisor","vmid":"100","vmname":"ubuntu-server","reason":"KVM: internal error: unexpected exit to hypervisor"}'
|
||||
|
||||
# 5. vm_restart
|
||||
send_test "vm_restart" \
|
||||
'{"type":"vm_restart","component":"qemu","severity":"info","title":"VM 100 restarted","body":"ubuntu-server (100) has been restarted.","vmid":"100","vmname":"ubuntu-server"}'
|
||||
|
||||
# 6. ct_start
|
||||
send_test "ct_start" \
|
||||
'{"type":"ct_start","component":"lxc","severity":"info","title":"CT 200 started","body":"nginx-proxy (200) has been started.","vmid":"200","vmname":"nginx-proxy"}'
|
||||
|
||||
# 7. ct_stop
|
||||
send_test "ct_stop" \
|
||||
'{"type":"ct_stop","component":"lxc","severity":"info","title":"CT 200 stopped","body":"nginx-proxy (200) has been stopped.","vmid":"200","vmname":"nginx-proxy"}'
|
||||
|
||||
# 8. ct_fail
|
||||
send_test "ct_fail" \
|
||||
'{"type":"ct_fail","component":"lxc","severity":"critical","title":"CT 200 FAILED","body":"nginx-proxy (200) has failed.\nContainer exited with error code 137","vmid":"200","vmname":"nginx-proxy","reason":"Container exited with error code 137"}'
|
||||
|
||||
# 9. migration_start
|
||||
send_test "migration_start" \
|
||||
'{"type":"migration_start","component":"qemu","severity":"info","title":"Migration started - 100","body":"ubuntu-server (100) migration to pve-node2 started.","vmid":"100","vmname":"ubuntu-server","target_node":"pve-node2"}'
|
||||
|
||||
# 10. migration_complete
|
||||
send_test "migration_complete" \
|
||||
'{"type":"migration_complete","component":"qemu","severity":"info","title":"Migration complete - 100","body":"ubuntu-server (100) migrated successfully to pve-node2.","vmid":"100","vmname":"ubuntu-server","target_node":"pve-node2"}'
|
||||
|
||||
# 11. migration_fail
|
||||
send_test "migration_fail" \
|
||||
'{"type":"migration_fail","component":"qemu","severity":"critical","title":"Migration FAILED - 100","body":"ubuntu-server (100) migration to pve-node2 failed.\nNetwork timeout during memory transfer","vmid":"100","vmname":"ubuntu-server","target_node":"pve-node2","reason":"Network timeout during memory transfer"}'
|
||||
|
||||
# 12. replication_fail
|
||||
send_test "replication_fail" \
|
||||
'{"type":"replication_fail","component":"replication","severity":"critical","title":"Replication FAILED - 100","body":"Replication of ubuntu-server (100) has failed.\nTarget storage unreachable","vmid":"100","vmname":"ubuntu-server","reason":"Target storage unreachable"}'
|
||||
|
||||
# 13. replication_complete
|
||||
send_test "replication_complete" \
|
||||
'{"type":"replication_complete","component":"replication","severity":"info","title":"Replication complete - 100","body":"Replication of ubuntu-server (100) completed successfully.","vmid":"100","vmname":"ubuntu-server"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# BACKUP CATEGORY (group: backup)
|
||||
# ============================================================================
|
||||
test_backup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} BACKUPS - Backup start, complete, fail${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. backup_start
|
||||
send_test "backup_start" \
|
||||
'{"type":"backup_start","component":"vzdump","severity":"info","title":"Backup started - 100","body":"Backup of ubuntu-server (100) has started.","vmid":"100","vmname":"ubuntu-server"}'
|
||||
|
||||
# 2. backup_complete
|
||||
send_test "backup_complete" \
|
||||
'{"type":"backup_complete","component":"vzdump","severity":"info","title":"Backup complete - 100","body":"Backup of ubuntu-server (100) completed successfully.\nSize: 12.4 GB","vmid":"100","vmname":"ubuntu-server","size":"12.4 GB"}'
|
||||
|
||||
# 3. backup_fail
|
||||
send_test "backup_fail" \
|
||||
'{"type":"backup_fail","component":"vzdump","severity":"critical","title":"Backup FAILED - 100","body":"Backup of ubuntu-server (100) has failed.\nStorage local-lvm is full","vmid":"100","vmname":"ubuntu-server","reason":"Storage local-lvm is full"}'
|
||||
|
||||
# 4. snapshot_complete
|
||||
send_test "snapshot_complete" \
|
||||
'{"type":"snapshot_complete","component":"qemu","severity":"info","title":"Snapshot created - 100","body":"Snapshot of ubuntu-server (100) created: pre-upgrade-2026","vmid":"100","vmname":"ubuntu-server","snapshot_name":"pre-upgrade-2026"}'
|
||||
|
||||
# 5. snapshot_fail
|
||||
send_test "snapshot_fail" \
|
||||
'{"type":"snapshot_fail","component":"qemu","severity":"critical","title":"Snapshot FAILED - 100","body":"Snapshot of ubuntu-server (100) failed.\nInsufficient space on storage","vmid":"100","vmname":"ubuntu-server","reason":"Insufficient space on storage"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# RESOURCES CATEGORY (group: resources)
|
||||
# ============================================================================
|
||||
test_resources() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} RESOURCES - CPU, memory, temperature${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. cpu_high
|
||||
send_test "cpu_high" \
|
||||
'{"type":"cpu_high","component":"health","severity":"warning","title":"High CPU usage (94%)","body":"CPU usage is at 94% on 16 cores.\nTop process: kvm (VM 100)","value":"94","cores":"16","details":"Top process: kvm (VM 100)"}'
|
||||
|
||||
# 2. ram_high
|
||||
send_test "ram_high" \
|
||||
'{"type":"ram_high","component":"health","severity":"warning","title":"High memory usage (91%)","body":"Memory usage: 58.2 GB / 64 GB (91%).\n4 VMs running, swap at 2.1 GB","value":"91","used":"58.2 GB","total":"64 GB","details":"4 VMs running, swap at 2.1 GB"}'
|
||||
|
||||
# 3. temp_high
|
||||
send_test "temp_high" \
|
||||
'{"type":"temp_high","component":"health","severity":"critical","title":"High temperature (89C)","body":"CPU temperature: 89C (threshold: 80C).\nCheck cooling system immediately","value":"89","threshold":"80","details":"Check cooling system immediately"}'
|
||||
|
||||
# 4. load_high
|
||||
send_test "load_high" \
|
||||
'{"type":"load_high","component":"health","severity":"warning","title":"High system load (24.5)","body":"System load average: 24.5 on 16 cores.\nI/O wait: 35%","value":"24.5","cores":"16","details":"I/O wait: 35%"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# STORAGE CATEGORY (group: storage)
|
||||
# ============================================================================
|
||||
test_storage() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} STORAGE - Disk space, I/O errors, SMART${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. disk_space_low
|
||||
send_test "disk_space_low" \
|
||||
'{"type":"disk_space_low","component":"storage","severity":"warning","title":"Low disk space on /var","body":"/var: 93% used (4.2 GB available).","mount":"/var","used":"93","available":"4.2 GB"}'
|
||||
|
||||
# 2. disk_io_error
|
||||
send_test "disk_io_error" \
|
||||
'{"type":"disk_io_error","component":"smart","severity":"critical","title":"Disk I/O error","body":"I/O error detected on /dev/sdb.\nSMART error: Current Pending Sector Count = 8","device":"/dev/sdb","reason":"SMART error: Current Pending Sector Count = 8"}'
|
||||
|
||||
# 3. burst_disk_io
|
||||
send_test "burst_disk_io" \
|
||||
'{"type":"burst_disk_io","component":"storage","severity":"critical","title":"5 disk I/O errors on /dev/sdb, /dev/sdc","body":"5 I/O errors detected in 60s.\nDevices: /dev/sdb, /dev/sdc","count":"5","window":"60s","entity_list":"/dev/sdb, /dev/sdc"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# NETWORK CATEGORY (group: network)
|
||||
# ============================================================================
|
||||
test_network() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} NETWORK - Connectivity, bond, latency${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. network_down
|
||||
send_test "network_down" \
|
||||
'{"type":"network_down","component":"network","severity":"critical","title":"Network connectivity lost","body":"Network connectivity check failed.\nGateway 192.168.1.1 unreachable. Bond vmbr0 degraded.","reason":"Gateway 192.168.1.1 unreachable. Bond vmbr0 degraded."}'
|
||||
|
||||
# 2. network_latency
|
||||
send_test "network_latency" \
|
||||
'{"type":"network_latency","component":"network","severity":"warning","title":"High network latency (450ms)","body":"Latency to gateway: 450ms (threshold: 100ms).","value":"450","threshold":"100"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# SECURITY CATEGORY (group: security)
|
||||
# ============================================================================
|
||||
test_security() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} SECURITY - Auth failures, fail2ban, firewall${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. auth_fail
|
||||
send_test "auth_fail" \
|
||||
'{"type":"auth_fail","component":"auth","severity":"warning","title":"Authentication failure","body":"Failed login attempt from 203.0.113.42.\nUser: root\nService: sshd","source_ip":"203.0.113.42","username":"root","service":"sshd"}'
|
||||
|
||||
# 2. ip_block
|
||||
send_test "ip_block" \
|
||||
'{"type":"ip_block","component":"security","severity":"info","title":"IP blocked by Fail2Ban","body":"IP 203.0.113.42 has been banned.\nJail: sshd\nFailures: 5","source_ip":"203.0.113.42","jail":"sshd","failures":"5"}'
|
||||
|
||||
# 3. firewall_issue
|
||||
send_test "firewall_issue" \
|
||||
'{"type":"firewall_issue","component":"firewall","severity":"warning","title":"Firewall issue detected","body":"Firewall rule conflict detected on vmbr0.\nRule 15 overlaps with rule 23, potentially blocking cluster traffic.","reason":"Firewall rule conflict detected on vmbr0. Rule 15 overlaps with rule 23."}'
|
||||
|
||||
# 4. user_permission_change
|
||||
send_test "user_permission_change" \
|
||||
'{"type":"user_permission_change","component":"auth","severity":"info","title":"User permission changed","body":"User: admin@pam\nChange: Added PVEAdmin role on /vms/100","username":"admin@pam","change_details":"Added PVEAdmin role on /vms/100"}'
|
||||
|
||||
# 5. burst_auth_fail
|
||||
send_test "burst_auth_fail" \
|
||||
'{"type":"burst_auth_fail","component":"security","severity":"warning","title":"8 auth failures in 2m","body":"8 authentication failures detected in 2m.\nSources: 203.0.113.42, 198.51.100.7, 192.0.2.15","count":"8","window":"2m","entity_list":"203.0.113.42, 198.51.100.7, 192.0.2.15"}'
|
||||
|
||||
# 6. burst_ip_block
|
||||
send_test "burst_ip_block" \
|
||||
'{"type":"burst_ip_block","component":"security","severity":"info","title":"Fail2Ban banned 4 IPs in 5m","body":"4 IPs banned by Fail2Ban in 5m.\nIPs: 203.0.113.42, 198.51.100.7, 192.0.2.15, 10.0.0.99","count":"4","window":"5m","entity_list":"203.0.113.42, 198.51.100.7, 192.0.2.15, 10.0.0.99"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# CLUSTER CATEGORY (group: cluster)
|
||||
# ============================================================================
|
||||
test_cluster() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} CLUSTER - Quorum, split-brain, HA fencing${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. split_brain
|
||||
send_test "split_brain" \
|
||||
'{"type":"split_brain","component":"cluster","severity":"critical","title":"SPLIT-BRAIN detected","body":"Cluster split-brain condition detected.\nQuorum status: No quorum - 1/3 nodes visible","quorum":"No quorum - 1/3 nodes visible"}'
|
||||
|
||||
# 2. node_disconnect
|
||||
send_test "node_disconnect" \
|
||||
'{"type":"node_disconnect","component":"corosync","severity":"critical","title":"Node disconnected","body":"Node pve-node3 has disconnected from the cluster.","node_name":"pve-node3"}'
|
||||
|
||||
# 3. node_reconnect
|
||||
send_test "node_reconnect" \
|
||||
'{"type":"node_reconnect","component":"corosync","severity":"info","title":"Node reconnected","body":"Node pve-node3 has reconnected to the cluster.","node_name":"pve-node3"}'
|
||||
|
||||
# 4. burst_cluster
|
||||
send_test "burst_cluster" \
|
||||
'{"type":"burst_cluster","component":"cluster","severity":"critical","title":"Cluster flapping detected (6 changes)","body":"Cluster state changed 6 times in 5m.\nNodes: pve-node2, pve-node3","count":"6","window":"5m","entity_list":"pve-node2, pve-node3"}'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# BURST AGGREGATION TESTS (send rapid events to trigger burst detection)
|
||||
# ============================================================================
|
||||
test_burst() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} BURST - Rapid events to trigger aggregation${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE} Sending 5 rapid auth_fail events (should trigger burst_auth_fail)...${NC}"
|
||||
for i in $(seq 1 5); do
|
||||
curl -s -X POST "$API" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"auth_fail\",\"component\":\"auth\",\"severity\":\"warning\",\"title\":\"Auth fail from 10.0.0.$i\",\"body\":\"Failed login from 10.0.0.$i\",\"source_ip\":\"10.0.0.$i\"}" > /dev/null
|
||||
echo -e " ${CYAN}Sent auth_fail $i/5${NC}"
|
||||
sleep 0.5
|
||||
done
|
||||
echo -e " ${GREEN}Done. Wait ~10s for burst aggregation...${NC}"
|
||||
sleep 10
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE} Sending 4 rapid disk_io_error events (should trigger burst_disk_io)...${NC}"
|
||||
for i in $(seq 1 4); do
|
||||
curl -s -X POST "$API" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"disk_io_error\",\"component\":\"smart\",\"severity\":\"critical\",\"title\":\"I/O error on /dev/sd${i}\",\"body\":\"Error on device\",\"device\":\"/dev/sd${i}\"}" > /dev/null
|
||||
echo -e " ${CYAN}Sent disk_io_error $i/4${NC}"
|
||||
sleep 0.5
|
||||
done
|
||||
echo -e " ${GREEN}Done. Wait ~10s for burst aggregation...${NC}"
|
||||
sleep 10
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE} Sending 3 rapid node_disconnect events (should trigger burst_cluster)...${NC}"
|
||||
for i in $(seq 1 3); do
|
||||
curl -s -X POST "$API" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"node_disconnect\",\"component\":\"corosync\",\"severity\":\"critical\",\"title\":\"Node pve-node$i disconnected\",\"body\":\"Node lost\",\"node_name\":\"pve-node$i\"}" > /dev/null
|
||||
echo -e " ${CYAN}Sent node_disconnect $i/3${NC}"
|
||||
sleep 0.5
|
||||
done
|
||||
echo -e " ${GREEN}Done. Wait ~10s for burst aggregation...${NC}"
|
||||
sleep 10
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}============================================================${NC}"
|
||||
echo -e "${BOLD} ProxMenux Notification System - Complete Test Suite${NC}"
|
||||
echo -e "${BOLD}============================================================${NC}"
|
||||
echo -e " API: $API"
|
||||
echo -e " Pause: ${PAUSE}s between tests"
|
||||
echo ""
|
||||
|
||||
# Check that the service is reachable
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8008/api/notifications/status" 2>/dev/null)
|
||||
if [ "$status" != "200" ]; then
|
||||
echo -e "${RED}ERROR: Notification service not reachable (HTTP $status)${NC}"
|
||||
echo -e " Make sure ProxMenux Monitor is running."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Service is reachable.${NC}"
|
||||
|
||||
# Parse argument
|
||||
category="${1:-all}"
|
||||
|
||||
case "$category" in
|
||||
system) test_system ;;
|
||||
vm_ct) test_vm_ct ;;
|
||||
backup) test_backup ;;
|
||||
resources) test_resources ;;
|
||||
storage) test_storage ;;
|
||||
network) test_network ;;
|
||||
security) test_security ;;
|
||||
cluster) test_cluster ;;
|
||||
burst) test_burst ;;
|
||||
all)
|
||||
test_system
|
||||
test_vm_ct
|
||||
test_backup
|
||||
test_resources
|
||||
test_storage
|
||||
test_network
|
||||
test_security
|
||||
test_cluster
|
||||
test_burst
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown category: $category${NC}"
|
||||
echo "Usage: $0 [system|vm_ct|backup|resources|storage|network|security|cluster|burst|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ============================================================================
|
||||
# SUMMARY
|
||||
# ============================================================================
|
||||
echo ""
|
||||
echo -e "${BOLD}============================================================${NC}"
|
||||
echo -e "${BOLD} SUMMARY${NC}"
|
||||
echo -e "${BOLD}============================================================${NC}"
|
||||
echo -e " Total tests: $test_count"
|
||||
echo -e " ${GREEN}Accepted:${NC} $pass_count"
|
||||
echo -e " ${RED}Rejected:${NC} $fail_count"
|
||||
echo ""
|
||||
echo -e " Check your notification channels for the messages."
|
||||
echo -e " Note: Some events may be filtered by your current settings"
|
||||
echo -e " (severity filter, disabled categories, disabled individual events)."
|
||||
echo ""
|
||||
echo -e " To check notification history (all events):"
|
||||
echo -e " ${CYAN}curl -s 'http://127.0.0.1:8008/api/notifications/history?limit=200' | python3 -m json.tool${NC}"
|
||||
echo ""
|
||||
echo -e " To count events by type:"
|
||||
echo -e " ${CYAN}curl -s 'http://127.0.0.1:8008/api/notifications/history?limit=200' | python3 -c \"import sys,json; h=json.load(sys.stdin)['history']; [print(f' {t}: {c}') for t,c in sorted(dict((e['event_type'],sum(1 for x in h if x['event_type']==e['event_type'])) for e in h).items())]\"${NC}
|
||||
echo ""
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to simulate a disk error and verify observation recording.
|
||||
Usage: python3 test_disk_observation.py [device_name] [error_type]
|
||||
|
||||
Examples:
|
||||
python3 test_disk_observation.py sdh io_error
|
||||
python3 test_disk_observation.py sdh smart_error
|
||||
python3 test_disk_observation.py sdh fs_error
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add possible module locations to path
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, script_dir)
|
||||
sys.path.insert(0, '/usr/local/share/proxmenux')
|
||||
sys.path.insert(0, '/tmp/.mount_ProxMeztyU13/usr/bin') # AppImage mount point
|
||||
|
||||
# Try to find the module
|
||||
for path in sys.path:
|
||||
if os.path.exists(os.path.join(path, 'health_persistence.py')):
|
||||
print(f"[INFO] Found health_persistence.py in: {path}")
|
||||
break
|
||||
|
||||
from health_persistence import HealthPersistence
|
||||
from datetime import datetime
|
||||
|
||||
def main():
|
||||
device_name = sys.argv[1] if len(sys.argv) > 1 else 'sdh'
|
||||
error_type = sys.argv[2] if len(sys.argv) > 2 else 'io_error'
|
||||
|
||||
# Known serial for sdh (WDC 2TB)
|
||||
serial_map = {
|
||||
'sdh': 'WD-WX72A30AA72R',
|
||||
'nvme0n1': '2241E675EA6C',
|
||||
'nvme1n1': '2241E675EBE6',
|
||||
'sda': '22440F443504',
|
||||
'sdb': 'WWZ1SJ18',
|
||||
'sdc': '52X0A0D9FZ1G',
|
||||
'sdd': '50026B7784446E63',
|
||||
'sde': '22440F442105',
|
||||
'sdf': 'WRQ0X2GP',
|
||||
'sdg': '23Q0A0MPFZ1G',
|
||||
}
|
||||
|
||||
serial = serial_map.get(device_name, None)
|
||||
|
||||
# Error messages by type
|
||||
error_messages = {
|
||||
'io_error': f'Test I/O error on /dev/{device_name}: sector read failed at LBA 12345678',
|
||||
'smart_error': f'/dev/{device_name}: SMART warning - 1 Currently unreadable (pending) sectors detected',
|
||||
'fs_error': f'EXT4-fs error (device {device_name}1): inode 123456: block 789012: error reading data',
|
||||
}
|
||||
|
||||
error_signatures = {
|
||||
'io_error': f'io_test_{device_name}',
|
||||
'smart_error': f'smart_test_{device_name}',
|
||||
'fs_error': f'fs_test_{device_name}',
|
||||
}
|
||||
|
||||
message = error_messages.get(error_type, f'Test error on /dev/{device_name}')
|
||||
signature = error_signatures.get(error_type, f'test_{device_name}')
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Testing Disk Observation Recording")
|
||||
print(f"{'='*60}")
|
||||
print(f"Device: /dev/{device_name}")
|
||||
print(f"Serial: {serial or 'Unknown'}")
|
||||
print(f"Error Type: {error_type}")
|
||||
print(f"Message: {message}")
|
||||
print(f"Signature: {signature}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# Initialize persistence
|
||||
hp = HealthPersistence()
|
||||
|
||||
# Record the observation
|
||||
print("[1] Recording observation...")
|
||||
hp.record_disk_observation(
|
||||
device_name=device_name,
|
||||
serial=serial,
|
||||
error_type=error_type,
|
||||
error_signature=signature,
|
||||
raw_message=message,
|
||||
severity='warning'
|
||||
)
|
||||
print(" OK - Observation recorded\n")
|
||||
|
||||
# Query observations for this device
|
||||
print("[2] Querying observations for this device...")
|
||||
observations = hp.get_disk_observations(device_name=device_name, serial=serial)
|
||||
|
||||
if observations:
|
||||
print(f" Found {len(observations)} observation(s):\n")
|
||||
for obs in observations:
|
||||
print(f" ID: {obs['id']}")
|
||||
print(f" Type: {obs['error_type']}")
|
||||
print(f" Signature: {obs['error_signature']}")
|
||||
print(f" Message: {obs['raw_message'][:80]}...")
|
||||
print(f" Severity: {obs['severity']}")
|
||||
print(f" First: {obs['first_occurrence']}")
|
||||
print(f" Last: {obs['last_occurrence']}")
|
||||
print(f" Count: {obs['occurrence_count']}")
|
||||
print(f" Dismissed: {obs['dismissed']}")
|
||||
print()
|
||||
else:
|
||||
print(" No observations found!\n")
|
||||
|
||||
# Also show the disk registry
|
||||
print("[3] Checking disk registry...")
|
||||
all_devices = hp.get_all_observed_devices()
|
||||
for dev in all_devices:
|
||||
if dev.get('device_name') == device_name or dev.get('serial') == serial:
|
||||
print(f" Found in registry:")
|
||||
print(f" ID: {dev.get('id')}")
|
||||
print(f" Device: {dev.get('device_name')}")
|
||||
print(f" Serial: {dev.get('serial')}")
|
||||
print(f" First seen: {dev.get('first_seen')}")
|
||||
print(f" Last seen: {dev.get('last_seen')}")
|
||||
print()
|
||||
|
||||
print(f"{'='*60}")
|
||||
print("Test complete! Check the Storage section in the UI.")
|
||||
print(f"The disk /dev/{device_name} should now show an observations badge.")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,732 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# ProxMenux - Real Proxmox Event Simulator
|
||||
# ============================================================================
|
||||
# This script triggers ACTUAL events on Proxmox so that PVE's notification
|
||||
# system fires real webhooks through the full pipeline:
|
||||
#
|
||||
# PVE event -> PVE notification -> webhook POST -> our pipeline -> Telegram
|
||||
#
|
||||
# Unlike test_all_notifications.sh (which injects directly via API), this
|
||||
# script makes Proxmox generate the events itself.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x test_real_events.sh
|
||||
# ./test_real_events.sh # interactive menu
|
||||
# ./test_real_events.sh disk # run disk tests only
|
||||
# ./test_real_events.sh backup # run backup tests only
|
||||
# ./test_real_events.sh all # run all tests
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
API="http://127.0.0.1:8008"
|
||||
LOG_FILE="/tmp/proxmenux_real_test_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
log() { echo -e "$1" | tee -a "$LOG_FILE"; }
|
||||
header() {
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" | tee -a "$LOG_FILE"
|
||||
echo -e "${BOLD} $1${NC}" | tee -a "$LOG_FILE"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
warn() { log "${YELLOW} [!] $1${NC}"; }
|
||||
ok() { log "${GREEN} [OK] $1${NC}"; }
|
||||
fail() { log "${RED} [FAIL] $1${NC}"; }
|
||||
info() { log "${CYAN} [i] $1${NC}"; }
|
||||
|
||||
confirm() {
|
||||
echo ""
|
||||
echo -e "${YELLOW} $1${NC}"
|
||||
echo -ne " Continue? [Y/n]: "
|
||||
read -r ans
|
||||
[[ -z "$ans" || "$ans" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
wait_webhook() {
|
||||
local seconds=${1:-10}
|
||||
log " Waiting ${seconds}s for webhook delivery..."
|
||||
sleep "$seconds"
|
||||
}
|
||||
|
||||
snapshot_history() {
|
||||
curl -s "${API}/api/notifications/history?limit=200" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
count = len(data.get('history', []))
|
||||
print(count)
|
||||
except:
|
||||
print(0)
|
||||
" 2>/dev/null || echo "0"
|
||||
}
|
||||
|
||||
check_new_events() {
|
||||
local before=$1
|
||||
local after
|
||||
after=$(snapshot_history)
|
||||
local diff=$((after - before))
|
||||
if [ "$diff" -gt 0 ]; then
|
||||
ok "Received $diff new notification(s) via webhook"
|
||||
# Show the latest events
|
||||
curl -s "${API}/api/notifications/history?limit=$((diff + 2))" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for h in data.get('history', [])[:$diff]:
|
||||
sev = h.get('severity', '?')
|
||||
icon = {'CRITICAL': ' RED', 'WARNING': ' YEL', 'INFO': ' BLU'}.get(sev, ' ???')
|
||||
print(f'{icon} {h[\"event_type\"]:25s} {h.get(\"title\", \"\")[:60]}')
|
||||
" 2>/dev/null | tee -a "$LOG_FILE"
|
||||
else
|
||||
warn "No new notifications detected (may need more time or check filters)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Pre-flight checks ──────────────────────────────────────────
|
||||
preflight() {
|
||||
header "Pre-flight Checks"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
fail "This script must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
ok "Running as root"
|
||||
|
||||
# Check ProxMenux is running
|
||||
if curl -s "${API}/api/health" >/dev/null 2>&1; then
|
||||
ok "ProxMenux Monitor is running"
|
||||
else
|
||||
fail "ProxMenux Monitor not reachable at ${API}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check webhook is configured by querying PVE directly
|
||||
if pvesh get /cluster/notifications/endpoints/webhook --output-format json 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
endpoints = json.load(sys.stdin)
|
||||
found = any('proxmenux' in e.get('name','').lower() for e in (endpoints if isinstance(endpoints, list) else [endpoints]))
|
||||
exit(0 if found else 1)
|
||||
" 2>/dev/null; then
|
||||
ok "PVE webhook endpoint 'proxmenux-webhook' is configured"
|
||||
else
|
||||
warn "PVE webhook may not be configured. Run setup from the UI first."
|
||||
if ! confirm "Continue anyway?"; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check notification config
|
||||
# API returns { config: { enabled: true/false/'true'/'false', ... }, success: true }
|
||||
if curl -s "${API}/api/notifications/settings" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
cfg = d.get('config', d)
|
||||
enabled = cfg.get('enabled', False)
|
||||
exit(0 if enabled is True or str(enabled).lower() == 'true' else 1)
|
||||
" 2>/dev/null; then
|
||||
ok "Notifications are enabled"
|
||||
else
|
||||
fail "Notifications are NOT enabled. Enable them in the UI first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Re-run webhook setup to ensure priv config and body template exist
|
||||
info "Re-configuring PVE webhook (ensures priv config + body template)..."
|
||||
local setup_result
|
||||
setup_result=$(curl -s -X POST "${API}/api/notifications/proxmox/setup-webhook" 2>/dev/null)
|
||||
if echo "$setup_result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('configured') else 1)" 2>/dev/null; then
|
||||
ok "PVE webhook re-configured successfully"
|
||||
else
|
||||
local setup_err
|
||||
setup_err=$(echo "$setup_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unknown'))" 2>/dev/null)
|
||||
warn "Webhook setup returned: ${setup_err}"
|
||||
warn "PVE webhook events may not work. Manual commands below:"
|
||||
echo "$setup_result" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
for cmd in d.get('fallback_commands', []):
|
||||
print(f' {cmd}')
|
||||
" 2>/dev/null
|
||||
if ! confirm "Continue anyway?"; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Find a VM/CT for testing
|
||||
VMID=""
|
||||
VMNAME=""
|
||||
VMTYPE=""
|
||||
|
||||
# Try to find a stopped CT first (safest)
|
||||
local cts
|
||||
cts=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null || echo "[]")
|
||||
|
||||
# Look for a stopped container
|
||||
VMID=$(echo "$cts" | python3 -c "
|
||||
import sys, json
|
||||
vms = json.load(sys.stdin)
|
||||
# Prefer stopped CTs, then stopped VMs
|
||||
for v in sorted(vms, key=lambda x: (0 if x.get('type')=='lxc' else 1, 0 if x.get('status')=='stopped' else 1)):
|
||||
if v.get('status') == 'stopped':
|
||||
print(v.get('vmid', ''))
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$VMID" ]; then
|
||||
VMTYPE=$(echo "$cts" | python3 -c "
|
||||
import sys, json
|
||||
vms = json.load(sys.stdin)
|
||||
for v in vms:
|
||||
if str(v.get('vmid')) == '$VMID':
|
||||
print(v.get('type', 'qemu'))
|
||||
break
|
||||
" 2>/dev/null)
|
||||
VMNAME=$(echo "$cts" | python3 -c "
|
||||
import sys, json
|
||||
vms = json.load(sys.stdin)
|
||||
for v in vms:
|
||||
if str(v.get('vmid')) == '$VMID':
|
||||
print(v.get('name', 'unknown'))
|
||||
break
|
||||
" 2>/dev/null)
|
||||
ok "Found stopped ${VMTYPE} for testing: ${VMID} (${VMNAME})"
|
||||
else
|
||||
warn "No stopped VM/CT found. Backup tests will use ID 0 (host backup)."
|
||||
fi
|
||||
|
||||
# List available storage
|
||||
info "Available storage:"
|
||||
pvesh get /storage --output-format json 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
stores = json.load(sys.stdin)
|
||||
for s in stores:
|
||||
sid = s.get('storage', '?')
|
||||
stype = s.get('type', '?')
|
||||
content = s.get('content', '?')
|
||||
print(f' {sid:20s} type={stype:10s} content={content}')
|
||||
" 2>/dev/null | tee -a "$LOG_FILE" || warn "Could not list storage"
|
||||
|
||||
echo ""
|
||||
log " Log file: ${LOG_FILE}"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# TEST CATEGORY: DISK ERRORS
|
||||
# ============================================================================
|
||||
test_disk() {
|
||||
header "DISK ERROR TESTS"
|
||||
|
||||
# ── Test D1: SMART error injection ──
|
||||
log ""
|
||||
log "${BOLD} Test D1: SMART error log injection${NC}"
|
||||
info "Writes a simulated SMART error to syslog so JournalWatcher catches it."
|
||||
info "This tests the journal -> notification_events -> pipeline flow."
|
||||
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
# Inject a realistic SMART error into the system journal
|
||||
logger -t kernel -p kern.err "ata1.00: exception Emask 0x0 SAct 0x0 SErr 0x0 action 0x6 frozen"
|
||||
sleep 1
|
||||
logger -t kernel -p kern.crit "ata1.00: failed command: READ FPDMA QUEUED"
|
||||
sleep 1
|
||||
logger -t smartd -p daemon.warning "Device: /dev/sda [SAT], 1 Currently unreadable (pending) sectors"
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
|
||||
# ── Test D2: ZFS error simulation ──
|
||||
log ""
|
||||
log "${BOLD} Test D2: ZFS scrub error simulation${NC}"
|
||||
|
||||
# Check if ZFS is available
|
||||
if command -v zpool >/dev/null 2>&1; then
|
||||
local zpools
|
||||
zpools=$(zpool list -H -o name 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$zpools" ]; then
|
||||
local pool
|
||||
pool=$(echo "$zpools" | head -1)
|
||||
info "ZFS pool found: ${pool}"
|
||||
info "Injecting ZFS checksum error into syslog (non-destructive)."
|
||||
|
||||
before=$(snapshot_history)
|
||||
|
||||
# Simulate ZFS error events via syslog (non-destructive)
|
||||
logger -t kernel -p kern.warning "ZFS: pool '${pool}' has experienced an error"
|
||||
sleep 1
|
||||
logger -t zfs-module -p daemon.err "CHECKSUM error on ${pool}:mirror-0/sda: zio error"
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
else
|
||||
warn "ZFS installed but no pools found. Skipping ZFS test."
|
||||
fi
|
||||
else
|
||||
warn "ZFS not installed. Skipping ZFS test."
|
||||
fi
|
||||
|
||||
# ── Test D3: Filesystem space pressure ──
|
||||
log ""
|
||||
log "${BOLD} Test D3: Disk space pressure simulation${NC}"
|
||||
info "Creates a large temporary file to fill disk, triggering space warnings."
|
||||
info "The Health Monitor should detect low disk space within ~60s."
|
||||
|
||||
# Check current free space on /
|
||||
local free_pct
|
||||
free_pct=$(df / | tail -1 | awk '{print 100-$5}' | tr -d '%')
|
||||
info "Current free space on /: ${free_pct}%"
|
||||
|
||||
if [ "$free_pct" -gt 15 ]; then
|
||||
info "Disk has ${free_pct}% free. Need to reduce below threshold for test."
|
||||
|
||||
# Calculate how much to fill (leave only 8% free)
|
||||
local total_k free_k fill_k
|
||||
total_k=$(df / | tail -1 | awk '{print $2}')
|
||||
free_k=$(df / | tail -1 | awk '{print $4}')
|
||||
fill_k=$((free_k - (total_k * 8 / 100)))
|
||||
|
||||
if [ "$fill_k" -gt 0 ] && [ "$fill_k" -lt 50000000 ]; then
|
||||
info "Will create ${fill_k}KB temp file to simulate low space."
|
||||
|
||||
if confirm "This will temporarily fill disk to ~92% on /. Safe to proceed?"; then
|
||||
before=$(snapshot_history)
|
||||
|
||||
dd if=/dev/zero of=/tmp/.proxmenux_disk_test bs=1024 count="$fill_k" 2>/dev/null || true
|
||||
ok "Temp file created. Disk pressure active."
|
||||
info "Waiting 90s for Health Monitor to detect low space..."
|
||||
|
||||
# Wait for health monitor polling cycle
|
||||
for i in $(seq 1 9); do
|
||||
echo -ne "\r Waiting... ${i}0/90s"
|
||||
sleep 10
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Clean up immediately
|
||||
rm -f /tmp/.proxmenux_disk_test
|
||||
ok "Temp file removed. Disk space restored."
|
||||
|
||||
check_new_events "$before"
|
||||
else
|
||||
warn "Skipped disk pressure test."
|
||||
fi
|
||||
else
|
||||
warn "Cannot safely fill disk (would need ${fill_k}KB). Skipping."
|
||||
fi
|
||||
else
|
||||
warn "Disk already at ${free_pct}% free. Health Monitor may already be alerting."
|
||||
fi
|
||||
|
||||
# ── Test D4: I/O error in syslog ──
|
||||
log ""
|
||||
log "${BOLD} Test D4: Generic I/O error injection${NC}"
|
||||
info "Injects I/O errors into syslog for JournalWatcher."
|
||||
|
||||
before=$(snapshot_history)
|
||||
|
||||
logger -t kernel -p kern.err "Buffer I/O error on dev sdb1, logical block 0, async page read"
|
||||
sleep 1
|
||||
logger -t kernel -p kern.err "EXT4-fs error (device sdb1): ext4_find_entry:1455: inode #2: comm ls: reading directory lblock 0"
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# TEST CATEGORY: BACKUP EVENTS
|
||||
# ============================================================================
|
||||
test_backup() {
|
||||
header "BACKUP EVENT TESTS"
|
||||
|
||||
local backup_storage=""
|
||||
|
||||
# Find backup-capable storage
|
||||
backup_storage=$(pvesh get /storage --output-format json 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
stores = json.load(sys.stdin)
|
||||
for s in stores:
|
||||
content = s.get('content', '')
|
||||
if 'backup' in content or 'vztmpl' in content:
|
||||
print(s.get('storage', ''))
|
||||
break
|
||||
# Fallback: try 'local'
|
||||
else:
|
||||
for s in stores:
|
||||
if s.get('storage') == 'local':
|
||||
print('local')
|
||||
break
|
||||
" 2>/dev/null || echo "local")
|
||||
|
||||
info "Using backup storage: ${backup_storage}"
|
||||
|
||||
# ── Test B1: Successful vzdump backup ──
|
||||
if [ -n "$VMID" ]; then
|
||||
log ""
|
||||
log "${BOLD} Test B1: Real vzdump backup (success)${NC}"
|
||||
info "Running a real vzdump backup of ${VMTYPE} ${VMID} (${VMNAME})."
|
||||
info "This triggers PVE's notification system with a real backup event."
|
||||
|
||||
if confirm "This will backup ${VMTYPE} ${VMID} to '${backup_storage}'. Proceed?"; then
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
# Use snapshot mode for VMs (non-disruptive), stop mode for CTs
|
||||
local bmode="snapshot"
|
||||
if [ "$VMTYPE" = "lxc" ]; then
|
||||
bmode="suspend"
|
||||
fi
|
||||
|
||||
info "Starting vzdump (mode=${bmode}, compress=zstd)..."
|
||||
if vzdump "$VMID" --storage "$backup_storage" --mode "$bmode" --compress zstd --notes-template "ProxMenux test backup" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
ok "vzdump completed successfully!"
|
||||
else
|
||||
warn "vzdump returned non-zero (check output above)"
|
||||
fi
|
||||
|
||||
wait_webhook 12
|
||||
check_new_events "$before"
|
||||
|
||||
# Clean up the test backup
|
||||
info "Cleaning up test backup file..."
|
||||
local latest_bak
|
||||
latest_bak=$(find "/var/lib/vz/dump/" -name "vzdump-*-${VMID}-*" -type f -newer /tmp/.proxmenux_bak_marker 2>/dev/null | head -1 || echo "")
|
||||
# Create a marker for cleanup
|
||||
touch /tmp/.proxmenux_bak_marker 2>/dev/null || true
|
||||
else
|
||||
warn "Skipped backup success test."
|
||||
fi
|
||||
|
||||
# ── Test B2: Failed vzdump backup ──
|
||||
log ""
|
||||
log "${BOLD} Test B2: vzdump backup failure (invalid storage)${NC}"
|
||||
info "Attempting backup to non-existent storage to trigger a backup failure event."
|
||||
|
||||
before=$(snapshot_history)
|
||||
|
||||
# This WILL fail because the storage doesn't exist
|
||||
info "Starting vzdump to fake storage (will fail intentionally)..."
|
||||
vzdump "$VMID" --storage "nonexistent_storage_12345" --mode snapshot 2>&1 | tail -5 | tee -a "$LOG_FILE" || true
|
||||
|
||||
warn "vzdump failed as expected (this is intentional)."
|
||||
|
||||
wait_webhook 12
|
||||
check_new_events "$before"
|
||||
|
||||
else
|
||||
warn "No VM/CT available for backup tests."
|
||||
info "You can create a minimal LXC container for testing:"
|
||||
info " pct create 9999 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst --storage local-lvm --memory 128 --cores 1"
|
||||
fi
|
||||
|
||||
# ── Test B3: Snapshot create/delete ──
|
||||
if [ -n "$VMID" ] && [ "$VMTYPE" = "qemu" ]; then
|
||||
log ""
|
||||
log "${BOLD} Test B3: VM Snapshot create & delete${NC}"
|
||||
info "Creating a snapshot of VM ${VMID} to test snapshot events."
|
||||
|
||||
if confirm "Create snapshot 'proxmenux_test' on VM ${VMID}?"; then
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
if qm snapshot "$VMID" proxmenux_test --description "ProxMenux test snapshot" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
ok "Snapshot created!"
|
||||
else
|
||||
warn "Snapshot creation returned non-zero"
|
||||
fi
|
||||
|
||||
wait_webhook 10
|
||||
check_new_events "$before"
|
||||
|
||||
# Clean up snapshot
|
||||
info "Cleaning up test snapshot..."
|
||||
qm delsnapshot "$VMID" proxmenux_test 2>/dev/null || true
|
||||
ok "Snapshot removed."
|
||||
fi
|
||||
elif [ -n "$VMID" ] && [ "$VMTYPE" = "lxc" ]; then
|
||||
log ""
|
||||
log "${BOLD} Test B3: CT Snapshot create & delete${NC}"
|
||||
info "Creating a snapshot of CT ${VMID}."
|
||||
|
||||
if confirm "Create snapshot 'proxmenux_test' on CT ${VMID}?"; then
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
if pct snapshot "$VMID" proxmenux_test --description "ProxMenux test snapshot" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
ok "Snapshot created!"
|
||||
else
|
||||
warn "Snapshot creation returned non-zero"
|
||||
fi
|
||||
|
||||
wait_webhook 10
|
||||
check_new_events "$before"
|
||||
|
||||
# Clean up
|
||||
info "Cleaning up test snapshot..."
|
||||
pct delsnapshot "$VMID" proxmenux_test 2>/dev/null || true
|
||||
ok "Snapshot removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Test B4: PVE scheduled backup notification ──
|
||||
log ""
|
||||
log "${BOLD} Test B4: Trigger PVE notification system directly${NC}"
|
||||
info "Using 'pvesh create /notifications/endpoints/...' to test PVE's own system."
|
||||
info "This sends a test notification through PVE, which should hit our webhook."
|
||||
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
# PVE 8.x has a test endpoint for notifications
|
||||
if pvesh create /notifications/targets/test --target proxmenux-webhook 2>&1 | tee -a "$LOG_FILE"; then
|
||||
ok "PVE test notification sent!"
|
||||
else
|
||||
# Try alternative method
|
||||
info "Direct test not available. Trying via API..."
|
||||
pvesh set /notifications/endpoints/webhook/proxmenux-webhook --test 1 2>/dev/null || \
|
||||
warn "Could not send PVE test notification (requires PVE 8.1+)"
|
||||
fi
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# TEST CATEGORY: VM/CT LIFECYCLE
|
||||
# ============================================================================
|
||||
test_vmct() {
|
||||
header "VM/CT LIFECYCLE TESTS"
|
||||
|
||||
if [ -z "$VMID" ]; then
|
||||
warn "No stopped VM/CT found for lifecycle tests."
|
||||
info "Create a minimal CT: pct create 9999 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst --storage local-lvm --memory 128 --cores 1"
|
||||
return
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "${BOLD} Test V1: Start ${VMTYPE} ${VMID} (${VMNAME})${NC}"
|
||||
|
||||
if confirm "Start ${VMTYPE} ${VMID}? It will be stopped again after the test."; then
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
if [ "$VMTYPE" = "lxc" ]; then
|
||||
pct start "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
else
|
||||
qm start "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
fi
|
||||
|
||||
ok "Start command sent."
|
||||
wait_webhook 10
|
||||
check_new_events "$before"
|
||||
|
||||
# Wait a moment
|
||||
sleep 5
|
||||
|
||||
# ── Test V2: Stop ──
|
||||
log ""
|
||||
log "${BOLD} Test V2: Stop ${VMTYPE} ${VMID}${NC}"
|
||||
|
||||
before=$(snapshot_history)
|
||||
|
||||
if [ "$VMTYPE" = "lxc" ]; then
|
||||
pct stop "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
else
|
||||
qm stop "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
fi
|
||||
|
||||
ok "Stop command sent."
|
||||
wait_webhook 10
|
||||
check_new_events "$before"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# TEST CATEGORY: SYSTEM EVENTS (via syslog injection)
|
||||
# ============================================================================
|
||||
test_system() {
|
||||
header "SYSTEM EVENT TESTS (syslog injection)"
|
||||
|
||||
# ── Test S1: Authentication failures ──
|
||||
log ""
|
||||
log "${BOLD} Test S1: SSH auth failure injection${NC}"
|
||||
info "Injecting SSH auth failure messages into syslog."
|
||||
|
||||
local before
|
||||
before=$(snapshot_history)
|
||||
|
||||
logger -t sshd -p auth.warning "Failed password for root from 192.168.1.200 port 44312 ssh2"
|
||||
sleep 2
|
||||
logger -t sshd -p auth.warning "Failed password for invalid user admin from 10.0.0.50 port 55123 ssh2"
|
||||
sleep 2
|
||||
logger -t sshd -p auth.warning "Failed password for root from 192.168.1.200 port 44315 ssh2"
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
|
||||
# ── Test S2: Firewall event ──
|
||||
log ""
|
||||
log "${BOLD} Test S2: Firewall drop event${NC}"
|
||||
|
||||
before=$(snapshot_history)
|
||||
|
||||
logger -t kernel -p kern.warning "pve-fw-reject: IN=vmbr0 OUT= MAC=00:11:22:33:44:55 SRC=10.0.0.99 DST=192.168.1.1 PROTO=TCP DPT=22 REJECT"
|
||||
sleep 2
|
||||
logger -t pvefw -p daemon.warning "firewall: blocked incoming connection from 10.0.0.99:45678 to 192.168.1.1:8006"
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
|
||||
# ── Test S3: Service failure ──
|
||||
log ""
|
||||
log "${BOLD} Test S3: Service failure injection${NC}"
|
||||
|
||||
before=$(snapshot_history)
|
||||
|
||||
logger -t systemd -p daemon.err "pvedaemon.service: Main process exited, code=exited, status=1/FAILURE"
|
||||
sleep 1
|
||||
logger -t systemd -p daemon.err "Failed to start Proxmox VE API Daemon."
|
||||
|
||||
wait_webhook 8
|
||||
check_new_events "$before"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# SUMMARY & REPORT
|
||||
# ============================================================================
|
||||
show_summary() {
|
||||
header "TEST SUMMARY"
|
||||
|
||||
info "Fetching full notification history..."
|
||||
echo ""
|
||||
|
||||
curl -s "${API}/api/notifications/history?limit=200" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
from collections import Counter
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
history = data.get('history', [])
|
||||
|
||||
if not history:
|
||||
print(' No notifications in history.')
|
||||
sys.exit(0)
|
||||
|
||||
# Group by event_type
|
||||
by_type = Counter(h['event_type'] for h in history)
|
||||
# Group by severity
|
||||
by_sev = Counter(h.get('severity', '?') for h in history)
|
||||
# Group by source
|
||||
by_src = Counter(h.get('source', '?') for h in history)
|
||||
|
||||
print(f' Total notifications: {len(history)}')
|
||||
print()
|
||||
|
||||
sev_icons = {'CRITICAL': '\033[0;31mCRITICAL\033[0m', 'WARNING': '\033[1;33mWARNING\033[0m', 'INFO': '\033[0;36mINFO\033[0m'}
|
||||
print(' By severity:')
|
||||
for sev, count in by_sev.most_common():
|
||||
icon = sev_icons.get(sev, sev)
|
||||
print(f' {icon}: {count}')
|
||||
|
||||
print()
|
||||
print(' By source:')
|
||||
for src, count in by_src.most_common():
|
||||
print(f' {src:20s}: {count}')
|
||||
|
||||
print()
|
||||
print(' By event type:')
|
||||
for etype, count in by_type.most_common():
|
||||
print(f' {etype:30s}: {count}')
|
||||
|
||||
print()
|
||||
print(' Latest 15 events:')
|
||||
for h in history[:15]:
|
||||
sev = h.get('severity', '?')
|
||||
icon = {'CRITICAL': ' \033[0;31mRED\033[0m', 'WARNING': ' \033[1;33mYEL\033[0m', 'INFO': ' \033[0;36mBLU\033[0m'}.get(sev, ' ???')
|
||||
ts = h.get('sent_at', '?')[:19]
|
||||
src = h.get('source', '?')[:12]
|
||||
print(f' {icon} {ts} {src:12s} {h[\"event_type\"]:25s} {h.get(\"title\", \"\")[:50]}')
|
||||
" 2>/dev/null | tee -a "$LOG_FILE"
|
||||
|
||||
echo ""
|
||||
info "Full log saved to: ${LOG_FILE}"
|
||||
echo ""
|
||||
info "To see all history:"
|
||||
echo -e " ${CYAN}curl -s '${API}/api/notifications/history?limit=200' | python3 -m json.tool${NC}"
|
||||
echo ""
|
||||
info "To check Telegram delivery, look at your Telegram bot chat."
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# INTERACTIVE MENU
|
||||
# ============================================================================
|
||||
show_menu() {
|
||||
echo ""
|
||||
echo -e "${BOLD} ProxMenux Real Event Test Suite${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Disk error tests (SMART, ZFS, I/O, space pressure)"
|
||||
echo -e " ${CYAN}2)${NC} Backup tests (vzdump success/fail, snapshots)"
|
||||
echo -e " ${CYAN}3)${NC} VM/CT lifecycle tests (start/stop real VMs)"
|
||||
echo -e " ${CYAN}4)${NC} System event tests (auth, firewall, service failures)"
|
||||
echo -e " ${CYAN}5)${NC} Run ALL tests"
|
||||
echo -e " ${CYAN}6)${NC} Show summary report"
|
||||
echo -e " ${CYAN}q)${NC} Exit"
|
||||
echo ""
|
||||
echo -ne " Select: "
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────
|
||||
main() {
|
||||
local mode="${1:-menu}"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}============================================================${NC}"
|
||||
echo -e "${BOLD} ProxMenux - Real Proxmox Event Simulator${NC}"
|
||||
echo -e "${BOLD}============================================================${NC}"
|
||||
echo -e " Tests REAL events through the full PVE -> webhook pipeline."
|
||||
echo -e " Log file: ${CYAN}${LOG_FILE}${NC}"
|
||||
echo ""
|
||||
|
||||
preflight
|
||||
|
||||
case "$mode" in
|
||||
disk) test_disk; show_summary ;;
|
||||
backup) test_backup; show_summary ;;
|
||||
vmct) test_vmct; show_summary ;;
|
||||
system) test_system; show_summary ;;
|
||||
all)
|
||||
test_disk
|
||||
test_backup
|
||||
test_vmct
|
||||
test_system
|
||||
show_summary
|
||||
;;
|
||||
menu|*)
|
||||
while true; do
|
||||
show_menu
|
||||
read -r choice
|
||||
case "$choice" in
|
||||
1) test_disk ;;
|
||||
2) test_backup ;;
|
||||
3) test_vmct ;;
|
||||
4) test_system ;;
|
||||
5) test_disk; test_backup; test_vmct; test_system; show_summary; break ;;
|
||||
6) show_summary ;;
|
||||
q|Q) echo " Bye!"; break ;;
|
||||
*) warn "Invalid option" ;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "${1:-menu}"
|
||||
@@ -57,20 +57,115 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
|
||||
|
||||
---
|
||||
|
||||
## 📌 System Requirements
|
||||
🖥 **Compatible with:**
|
||||
- Proxmox VE 8.x and 9.x
|
||||
|
||||
📦 **Dependencies:**
|
||||
- `bash`, `curl`, `wget`, `jq`, `whiptail`, `python3-venv` (These dependencies are installed automatically during setup.)
|
||||
- **Translations are handled in a Python virtual environment using `googletrans-env`.**
|
||||
## 🧪 Beta Program
|
||||
|
||||
Want to try the latest features before the official release and help shape the final version?
|
||||
|
||||
The **ProxMenux Beta Program** gives early access to new functionality — including the newest builds of ProxMenux Monitor — directly from the `develop` branch. Beta builds may contain bugs or incomplete features. Your feedback is what helps fix them before the stable release.
|
||||
|
||||
**Install the beta version:**
|
||||
|
||||
```bash
|
||||
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/install_proxmenux_beta.sh)"
|
||||
```
|
||||
|
||||
**What to expect:**
|
||||
|
||||
- You'll get new features and Monitor builds before anyone else
|
||||
- Some things may not work perfectly — that's expected and normal
|
||||
- When a stable release is published, ProxMenux will notify you on the next `menu` launch and offer to switch automatically
|
||||
|
||||
**How to report issues:**
|
||||
|
||||
Open a [GitHub Issue](https://github.com/MacRimi/ProxMenux/issues) and include:
|
||||
- What you did and what you expected to happen
|
||||
- Any error messages shown on screen
|
||||
- Logs from the Monitor if relevant:
|
||||
|
||||
```bash
|
||||
journalctl -u proxmenux-monitor -n 50
|
||||
```
|
||||
|
||||
> 💙 Thank you for being part of the beta program. Your help makes ProxMenux better for everyone.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🖥️ ProxMenux Monitor
|
||||
|
||||
ProxMenux Monitor is an integrated web dashboard that provides real-time visibility into your Proxmox infrastructure — accessible from any browser on your network, without needing a terminal.
|
||||
|
||||
**What it offers:**
|
||||
|
||||
- Real-time monitoring of CPU, RAM, disk usage and network traffic
|
||||
- Overview of running VMs and LXC containers with status indicators
|
||||
- Login authentication to protect access
|
||||
- Two-Factor Authentication (2FA) with TOTP support
|
||||
- Reverse proxy support (Nginx / Traefik)
|
||||
- Designed to work across desktop and mobile devices
|
||||
|
||||
**Access:**
|
||||
|
||||
Once installed, the dashboard is available at:
|
||||
|
||||
```
|
||||
http://<your-proxmox-ip>:8008
|
||||
```
|
||||
|
||||
The Monitor is installed automatically as part of the standard ProxMenux installation and runs as a systemd service (`proxmenux-monitor.service`) that starts automatically on boot.
|
||||
|
||||
**Useful commands:**
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status proxmenux-monitor
|
||||
|
||||
# View logs
|
||||
journalctl -u proxmenux-monitor -n 50
|
||||
|
||||
# Restart the service
|
||||
systemctl restart proxmenux-monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🔧 Dependencies
|
||||
|
||||
The following dependencies are installed automatically during setup:
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `dialog` | Interactive terminal menus |
|
||||
| `curl` | Downloads and connectivity checks |
|
||||
| `jq` | JSON processing |
|
||||
| `git` | Repository cloning and updates |
|
||||
| `python3` + `python3-venv` | Translation support *(Translation version only)* |
|
||||
| `googletrans` | Google Translate library *(Translation version only)* |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## ⭐ Support the Project!
|
||||
If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help others discover it!
|
||||
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions, bug reports and feature suggestions are welcome!
|
||||
|
||||
- 🐛 [Report a bug](https://github.com/MacRimi/ProxMenux/issues/new)
|
||||
- 💡 [Suggest a feature](https://github.com/MacRimi/ProxMenux/discussions)
|
||||
- 🔀 [Submit a pull request](https://github.com/MacRimi/ProxMenux/pulls)
|
||||
|
||||
If you find ProxMenux useful, consider giving it a ⭐ on GitHub — it helps others discover the project!
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#MacRimi/ProxMenux&Date)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.1.9.2
|
||||
Executable
+591
@@ -0,0 +1,591 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor - Beta Program Installer
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Subproject : ProxMenux Monitor Beta
|
||||
# Copyright : (c) 2024-2025 MacRimi
|
||||
# License : GPL-3.0 (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : Beta
|
||||
# Branch : develop
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script installs the BETA version of ProxMenux Monitor
|
||||
# from the develop branch on GitHub.
|
||||
#
|
||||
# Beta testers are expected to:
|
||||
# - Report bugs and unexpected behavior via GitHub Issues
|
||||
# - Provide feedback to help improve the final release
|
||||
#
|
||||
# Installs:
|
||||
# • dialog, curl, jq, git (system dependencies)
|
||||
# • ProxMenux core files (/usr/local/share/proxmenux)
|
||||
# • ProxMenux Monitor AppImage (Web dashboard on port 8008)
|
||||
# • Systemd service (auto-start on boot)
|
||||
#
|
||||
# Notes:
|
||||
# - Clones from the 'develop' branch
|
||||
# - Beta version file: beta_version.txt in the repository
|
||||
# - Transition to stable: re-run the official installer
|
||||
# ==========================================================
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
|
||||
MENU_SCRIPT="menu"
|
||||
|
||||
MONITOR_INSTALL_DIR="$BASE_DIR"
|
||||
MONITOR_SERVICE_FILE="/etc/systemd/system/proxmenux-monitor.service"
|
||||
MONITOR_PORT=8008
|
||||
|
||||
REPO_URL="https://github.com/MacRimi/ProxMenux.git"
|
||||
REPO_BRANCH="develop"
|
||||
TEMP_DIR="/tmp/proxmenux-beta-install-$$"
|
||||
|
||||
# ── Colors ─────────────────────────────────────────────────
|
||||
RESET="\033[0m"
|
||||
BOLD="\033[1m"
|
||||
WHITE="\033[38;5;15m"
|
||||
NEON_PURPLE_BLUE="\033[38;5;99m"
|
||||
DARK_GRAY="\033[38;5;244m"
|
||||
ORANGE="\033[38;5;208m"
|
||||
GN="\033[1;92m"
|
||||
YW="\033[33m"
|
||||
YWB="\033[1;33m"
|
||||
RD="\033[01;31m"
|
||||
BL="\033[36m"
|
||||
CL="\033[m"
|
||||
BGN="\e[1;32m"
|
||||
TAB=" "
|
||||
BFR="\\r\\033[K"
|
||||
HOLD="-"
|
||||
BOR=" | "
|
||||
CM="${GN}✓ ${CL}"
|
||||
|
||||
SPINNER_PID=""
|
||||
|
||||
# ── Spinner ────────────────────────────────────────────────
|
||||
spinner() {
|
||||
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
local spin_i=0
|
||||
printf "\e[?25l"
|
||||
while true; do
|
||||
printf "\r ${YW}%s${CL}" "${frames[spin_i]}"
|
||||
spin_i=$(( (spin_i + 1) % ${#frames[@]} ))
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
type_text() {
|
||||
local text="$1"
|
||||
local delay=0.04
|
||||
for ((i=0; i<${#text}; i++)); do
|
||||
echo -n "${text:$i:1}"
|
||||
sleep $delay
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
msg_info() {
|
||||
local msg="$1"
|
||||
echo -ne "${TAB}${YW}${HOLD}${msg}"
|
||||
spinner &
|
||||
SPINNER_PID=$!
|
||||
}
|
||||
|
||||
msg_ok() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
echo -e "${BFR}${TAB}${CM}${GN}${1}${CL}"
|
||||
}
|
||||
|
||||
msg_error() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
echo -e "${BFR}${TAB}${RD}[ERROR] ${1}${CL}"
|
||||
}
|
||||
|
||||
msg_warn() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
echo -e "${BFR}${TAB}${YWB}${1}${CL}"
|
||||
}
|
||||
|
||||
msg_title() {
|
||||
echo -e "\n"
|
||||
echo -e "${TAB}${BOLD}${HOLD}${BOR}${1}${BOR}${HOLD}${CL}"
|
||||
echo -e "\n"
|
||||
}
|
||||
|
||||
show_progress() {
|
||||
echo -e "\n${BOLD}${BL}${TAB}Installing ProxMenux Beta: Step ${1} of ${2}${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${YW}${HOLD}${3}${CL}"
|
||||
}
|
||||
|
||||
# ── Cleanup ────────────────────────────────────────────────
|
||||
cleanup() {
|
||||
if [ -d "$TEMP_DIR" ]; then
|
||||
rm -rf "$TEMP_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Logo ───────────────────────────────────────────────────
|
||||
show_proxmenux_logo() {
|
||||
clear
|
||||
|
||||
if [[ -z "$SSH_TTY" && -z "$(who am i | awk '{print $NF}' | grep -E '([0-9]{1,3}\.){3}[0-9]{1,3}')" ]]; then
|
||||
|
||||
LOGO=$(cat << "EOF"
|
||||
\e[0m\e[38;2;61;61;61m▆\e[38;2;60;60;60m▄\e[38;2;54;54;54m▂\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;54;54;54m▂\e[38;2;60;60;60m▄\e[38;2;61;61;61m▆\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[38;2;61;61;61;48;2;37;37;37m▇\e[0m\e[38;2;60;60;60m▅\e[38;2;56;56;56m▃\e[38;2;37;37;37m▁ \e[38;2;36;36;36m▁\e[38;2;56;56;56m▃\e[38;2;60;60;60m▅\e[38;2;61;61;61;48;2;37;37;37m▇\e[48;2;62;62;62m \e[0m\e[7m\e[38;2;60;60;60m▁\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[7m\e[38;2;61;61;61m▂\e[0m\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[38;2;60;60;60m▆\e[38;2;57;57;57m▄\e[38;2;48;48;48m▂\e[0m \e[38;2;47;47;47m▂\e[38;2;57;57;57m▄\e[38;2;60;60;60m▆\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[7m\e[38;2;60;60;60m▂\e[38;2;57;57;57m▄\e[38;2;47;47;47m▆\e[0m \e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;39;39;39m▇\e[38;2;57;57;57m▅\e[38;2;60;60;60m▃\e[0m\e[38;2;40;40;40;48;2;61;61;61m▁\e[48;2;62;62;62m \e[38;2;54;54;54;48;2;61;61;61m┊\e[48;2;62;62;62m \e[38;2;39;39;39;48;2;61;61;61m▁\e[0m\e[7m\e[38;2;60;60;60m▃\e[38;2;57;57;57m▅\e[38;2;38;38;38m▇\e[0m \e[38;2;193;60;2m▃\e[38;2;217;67;2m▅\e[38;2;225;70;2m▇\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[38;2;203;63;2m▄\e[38;2;147;45;1m▂\e[0m \e[7m\e[38;2;55;55;55m▆\e[38;2;60;60;60m▄\e[38;2;61;61;61m▂\e[38;2;60;60;60m▄\e[38;2;55;55;55m▆\e[0m \e[38;2;144;44;1m▂\e[38;2;202;62;2m▄\e[38;2;219;68;2m▆\e[38;2;231;72;3;48;2;226;70;2m┈\e[48;2;231;72;3m \e[48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;121;37;1m▉\e[0m\e[38;2;0;0;0;48;2;231;72;3m \e[0m\e[38;2;221;68;2m▇\e[38;2;208;64;2m▅\e[38;2;212;66;2m▂\e[38;2;123;37;0m▁\e[38;2;211;65;2m▂\e[38;2;207;64;2m▅\e[38;2;220;68;2m▇\e[48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m┈\e[0m\e[7m\e[38;2;221;68;2m▂\e[0m\e[38;2;44;13;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[7m\e[38;2;190;59;2m▅\e[38;2;216;67;2m▃\e[38;2;225;70;2m▁\e[0m\e[38;2;95;29;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;230;71;2m┈\e[48;2;231;72;3m \e[0m\e[7m\e[38;2;225;70;2m▁\e[38;2;216;67;2m▃\e[38;2;191;59;2m▅\e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[7m\e[38;2;172;53;1m▆\e[38;2;213;66;2m▄\e[38;2;219;68;2m▂\e[38;2;213;66;2m▄\e[38;2;174;54;2m▆\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[7m\e[38;2;52;52;52m▆\e[38;2;59;59;59m▄\e[38;2;61;61;61m▂\e[0m\e[38;2;31;31;31m▏ \e[0m \e[7m\e[38;2;228;71;2m▂\e[38;2;221;69;2m▄\e[38;2;196;60;2m▆\e[0m
|
||||
EOF
|
||||
)
|
||||
TEXT=(
|
||||
""
|
||||
""
|
||||
"${BOLD}ProxMenux${RESET}"
|
||||
""
|
||||
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
|
||||
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
|
||||
""
|
||||
"${BOLD}${YW} ★ BETA PROGRAM ★${RESET}"
|
||||
""
|
||||
""
|
||||
)
|
||||
mapfile -t logo_lines <<< "$LOGO"
|
||||
for i in {0..9}; do
|
||||
echo -e "${TAB}${logo_lines[i]} ${WHITE}│${RESET} ${TEXT[i]}"
|
||||
done
|
||||
echo -e
|
||||
|
||||
else
|
||||
|
||||
TEXT=(
|
||||
"" "" "" ""
|
||||
"${BOLD}ProxMenux${RESET}"
|
||||
""
|
||||
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
|
||||
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
|
||||
""
|
||||
"${BOLD}${YW} ★ BETA PROGRAM ★${RESET}"
|
||||
"" "" ""
|
||||
)
|
||||
LOGO=(
|
||||
"${DARK_GRAY}░░░░ ░░░░${RESET}"
|
||||
"${DARK_GRAY}░░░░░░░ ░░░░░░ ${RESET}"
|
||||
"${DARK_GRAY}░░░░░░░░░░░ ░░░░░░░ ${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░░░░ ░░░░░░ ${ORANGE}░░${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░░░░░ ${ORANGE}░░▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░ ${ORANGE}░▒▒▒▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░ ░▒▒▒▒▒▒▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░▒▒▒▒▒ ▒▒▒▒▒░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░░▒▒▒▒▒▒▒░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░${RESET}"
|
||||
"${DARK_GRAY} ░░ ${ORANGE}░░ ${RESET}"
|
||||
)
|
||||
for i in {0..12}; do
|
||||
echo -e "${TAB}${LOGO[i]} │${RESET} ${TEXT[i]}"
|
||||
done
|
||||
echo -e
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Beta welcome message ───────────────────────────────────
|
||||
show_beta_welcome() {
|
||||
local width=62
|
||||
local line
|
||||
line=$(printf '─%.0s' $(seq 1 $width))
|
||||
|
||||
echo -e "${TAB}${BOLD}${YW}┌${line}┐${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL}${BOLD} Welcome to the ProxMenux Monitor Beta Program ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}└${line}┘${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${WHITE}You are about to install a ${BOLD}pre-release (beta)${RESET}${WHITE} version of${CL}"
|
||||
echo -e "${TAB}${WHITE}ProxMenux Monitor, built from the ${BOLD}develop${RESET}${WHITE} branch.${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${GN}What this means for you:${CL}"
|
||||
echo -e "${TAB} ${GN}•${CL} You'll get the latest features before the official release."
|
||||
echo -e "${TAB} ${GN}•${CL} Some things may not work perfectly — that's expected."
|
||||
echo -e "${TAB} ${GN}•${CL} Your feedback is what makes the final version better."
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${YW}How to report issues:${CL}"
|
||||
echo -e "${TAB} ${YW}→${CL} Open a GitHub Issue at:"
|
||||
echo -e "${TAB} ${BL}https://github.com/MacRimi/ProxMenux/issues${CL}"
|
||||
echo -e "${TAB} ${YW}→${CL} Describe what happened, what you expected, and any"
|
||||
echo -e "${TAB} error messages you saw. Logs help a lot:"
|
||||
echo -e "${TAB} ${DARK_GRAY}journalctl -u proxmenux-monitor -n 50${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${NEON_PURPLE_BLUE}Thank you for being part of the beta program!${CL}"
|
||||
echo -e "${TAB}${DARK_GRAY}Your help is essential to deliver a stable and polished release.${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${YW}┌${line}┐${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} Press ${BOLD}${GN}[Enter]${CL} to continue with the beta installation, ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} or ${BOLD}${RD}[Ctrl+C]${CL} to cancel and exit. ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}└${line}┘${CL}"
|
||||
echo
|
||||
|
||||
read -r -p ""
|
||||
echo
|
||||
}
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
get_server_ip() {
|
||||
local ip
|
||||
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+')
|
||||
[ -z "$ip" ] && ip=$(hostname -I | awk '{print $1}')
|
||||
[ -z "$ip" ] && ip="localhost"
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
update_config() {
|
||||
local component="$1"
|
||||
local status="$2"
|
||||
local timestamp
|
||||
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
[ ! -f "$CONFIG_FILE" ] || ! jq empty "$CONFIG_FILE" >/dev/null 2>&1 && echo '{}' > "$CONFIG_FILE"
|
||||
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
if jq --arg comp "$component" --arg stat "$status" --arg time "$timestamp" \
|
||||
'.[$comp] = {status: $stat, timestamp: $time}' "$CONFIG_FILE" > "$tmp_file" 2>/dev/null; then
|
||||
mv "$tmp_file" "$CONFIG_FILE"
|
||||
else
|
||||
echo '{}' > "$CONFIG_FILE"
|
||||
fi
|
||||
[ -f "$tmp_file" ] && rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
cleanup_corrupted_files() {
|
||||
if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
|
||||
rm -f "$CONFIG_FILE"
|
||||
fi
|
||||
if [ -f "$CACHE_FILE" ] && ! jq empty "$CACHE_FILE" >/dev/null 2>&1; then
|
||||
rm -f "$CACHE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_latest_appimage() {
|
||||
local appimage_dir="$TEMP_DIR/AppImage"
|
||||
[ ! -d "$appimage_dir" ] && return 1
|
||||
local latest
|
||||
latest=$(find "$appimage_dir" -name "ProxMenux-*.AppImage" -type f | sort -V | tail -1)
|
||||
[ -z "$latest" ] && return 1
|
||||
echo "$latest"
|
||||
}
|
||||
|
||||
get_appimage_version() {
|
||||
local filename
|
||||
filename=$(basename "$1")
|
||||
echo "$filename" | grep -oP 'ProxMenux-\K[0-9]+\.[0-9]+\.[0-9]+'
|
||||
}
|
||||
|
||||
# ── Monitor install ────────────────────────────────────────
|
||||
install_proxmenux_monitor() {
|
||||
local appimage_source
|
||||
appimage_source=$(detect_latest_appimage)
|
||||
|
||||
if [ -z "$appimage_source" ] || [ ! -f "$appimage_source" ]; then
|
||||
msg_error "ProxMenux Monitor AppImage not found in $TEMP_DIR/AppImage/"
|
||||
msg_warn "Make sure the AppImage directory exists in the develop branch."
|
||||
update_config "proxmenux_monitor" "appimage_not_found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local appimage_version
|
||||
appimage_version=$(get_appimage_version "$appimage_source")
|
||||
|
||||
systemctl is-active --quiet proxmenux-monitor.service 2>/dev/null && \
|
||||
systemctl stop proxmenux-monitor.service
|
||||
|
||||
local service_exists=false
|
||||
[ -f "$MONITOR_SERVICE_FILE" ] && service_exists=true
|
||||
|
||||
local sha256_file="$TEMP_DIR/AppImage/ProxMenux-Monitor.AppImage.sha256"
|
||||
if [ -f "$sha256_file" ]; then
|
||||
msg_info "Verifying AppImage integrity..."
|
||||
local expected_hash actual_hash
|
||||
expected_hash=$(grep -Eo '^[a-f0-9]+' "$sha256_file" | tr -d '\n')
|
||||
actual_hash=$(sha256sum "$appimage_source" | awk '{print $1}')
|
||||
if [ "$expected_hash" != "$actual_hash" ]; then
|
||||
msg_error "SHA256 verification failed! The AppImage may be corrupted."
|
||||
return 1
|
||||
fi
|
||||
msg_ok "SHA256 verification passed."
|
||||
else
|
||||
msg_warn "SHA256 checksum file not found. Skipping verification."
|
||||
fi
|
||||
|
||||
msg_info "Installing ProxMenux Monitor (beta)..."
|
||||
mkdir -p "$MONITOR_INSTALL_DIR"
|
||||
local target_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
cp "$appimage_source" "$target_path"
|
||||
chmod +x "$target_path"
|
||||
msg_ok "ProxMenux Monitor beta v${appimage_version} installed."
|
||||
|
||||
if [ "$service_exists" = false ]; then
|
||||
return 0
|
||||
else
|
||||
systemctl start proxmenux-monitor.service
|
||||
sleep 2
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
update_config "proxmenux_monitor" "beta_updated"
|
||||
return 2
|
||||
else
|
||||
msg_warn "Service failed to restart. Check: journalctl -u proxmenux-monitor"
|
||||
update_config "proxmenux_monitor" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
create_monitor_service() {
|
||||
msg_info "Creating ProxMenux Monitor service..."
|
||||
local exec_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
|
||||
if [ -f "$TEMP_DIR/systemd/proxmenux-monitor.service" ]; then
|
||||
sed "s|ExecStart=.*|ExecStart=$exec_path|g" \
|
||||
"$TEMP_DIR/systemd/proxmenux-monitor.service" > "$MONITOR_SERVICE_FILE"
|
||||
msg_ok "Service file loaded from repository."
|
||||
else
|
||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard (Beta)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$MONITOR_INSTALL_DIR
|
||||
ExecStart=$exec_path
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=$MONITOR_PORT"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
msg_ok "Default service file created."
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmenux-monitor.service > /dev/null 2>&1
|
||||
systemctl start proxmenux-monitor.service > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
msg_ok "ProxMenux Monitor service started successfully."
|
||||
update_config "proxmenux_monitor" "beta_installed"
|
||||
return 0
|
||||
else
|
||||
msg_warn "ProxMenux Monitor service failed to start."
|
||||
echo -e "${TAB}${DARK_GRAY}Check logs : journalctl -u proxmenux-monitor -n 20${CL}"
|
||||
echo -e "${TAB}${DARK_GRAY}Check status: systemctl status proxmenux-monitor${CL}"
|
||||
update_config "proxmenux_monitor" "failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main install ───────────────────────────────────────────
|
||||
install_beta() {
|
||||
local total_steps=4
|
||||
local current_step=1
|
||||
|
||||
# ── Step 1: Dependencies ──────────────────────────────
|
||||
show_progress $current_step $total_steps "Installing system dependencies"
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
apt-get update > /dev/null 2>&1
|
||||
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed"
|
||||
else
|
||||
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
||||
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq \
|
||||
&& command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed_from_github"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually and re-run."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
update_config "jq" "already_installed"
|
||||
fi
|
||||
|
||||
local BASIC_DEPS=("dialog" "curl" "git")
|
||||
if [ -z "${APT_UPDATED:-}" ]; then
|
||||
apt-get update -y > /dev/null 2>&1 || true
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
|
||||
for pkg in "${BASIC_DEPS[@]}"; do
|
||||
if ! dpkg -l | grep -qw "$pkg"; then
|
||||
if apt-get install -y "$pkg" > /dev/null 2>&1; then
|
||||
update_config "$pkg" "installed"
|
||||
else
|
||||
msg_error "Failed to install $pkg. Please install it manually."
|
||||
update_config "$pkg" "failed"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
update_config "$pkg" "already_installed"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "Dependencies installed: jq, dialog, curl, git."
|
||||
|
||||
# ── Step 2: Clone develop branch ─────────────────────
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Cloning ProxMenux develop branch"
|
||||
|
||||
msg_info "Cloning branch '${REPO_BRANCH}' from repository..."
|
||||
if ! git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
|
||||
msg_error "Failed to clone branch '$REPO_BRANCH' from $REPO_URL"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "Repository cloned successfully (branch: ${REPO_BRANCH})."
|
||||
|
||||
# Read beta version if available
|
||||
local beta_version="unknown"
|
||||
if [ -f "$TEMP_DIR/beta_version.txt" ]; then
|
||||
beta_version=$(cat "$TEMP_DIR/beta_version.txt" | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
# ── Step 3: Files ─────────────────────────────────────
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Creating directories and copying files"
|
||||
|
||||
mkdir -p "$BASE_DIR" "$INSTALL_DIR"
|
||||
[ ! -f "$CONFIG_FILE" ] && echo '{}' > "$CONFIG_FILE"
|
||||
|
||||
# Preserve user/runtime directories that must never be overwritten
|
||||
mkdir -p "$BASE_DIR/oci"
|
||||
|
||||
cp "./scripts/utils.sh" "$UTILS_FILE"
|
||||
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE" 2>/dev/null || true
|
||||
|
||||
# Store beta version marker
|
||||
if [ -f "$TEMP_DIR/beta_version.txt" ]; then
|
||||
cp "$TEMP_DIR/beta_version.txt" "$BETA_VERSION_FILE"
|
||||
else
|
||||
echo "$beta_version" > "$BETA_VERSION_FILE"
|
||||
fi
|
||||
|
||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh" 2>/dev/null || true
|
||||
cp "./install_proxmenux_beta.sh" "$BASE_DIR/install_proxmenux_beta.sh" 2>/dev/null || true
|
||||
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
|
||||
if [ -d "./oci" ]; then
|
||||
mkdir -p "$BASE_DIR/oci"
|
||||
cp -r "./oci/"* "$BASE_DIR/oci/" 2>/dev/null || true
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
[ -f "$BASE_DIR/install_proxmenux.sh" ] && chmod +x "$BASE_DIR/install_proxmenux.sh"
|
||||
[ -f "$BASE_DIR/install_proxmenux_beta.sh" ] && chmod +x "$BASE_DIR/install_proxmenux_beta.sh"
|
||||
|
||||
# Store beta flag in config
|
||||
update_config "beta_program" "active"
|
||||
update_config "beta_version" "$beta_version"
|
||||
update_config "install_branch" "$REPO_BRANCH"
|
||||
|
||||
msg_ok "Files installed. Beta version: ${beta_version}."
|
||||
|
||||
# ── Step 4: Monitor ───────────────────────────────────
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor (beta)"
|
||||
|
||||
install_proxmenux_monitor
|
||||
local monitor_status=$?
|
||||
|
||||
if [ $monitor_status -eq 0 ]; then
|
||||
create_monitor_service
|
||||
elif [ $monitor_status -eq 2 ]; then
|
||||
msg_ok "ProxMenux Monitor beta updated successfully."
|
||||
fi
|
||||
|
||||
msg_ok "Beta installation completed."
|
||||
}
|
||||
|
||||
# ── Stable transition notice ───────────────────────────────
|
||||
check_stable_available() {
|
||||
# Called if a stable version is detected (future use by update logic)
|
||||
# When main's version.txt > beta_version.txt, the menu/updater can call this
|
||||
echo -e "\n${TAB}${BOLD}${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}"
|
||||
echo -e "${TAB}${BOLD}${GN} A stable release is now available!${CL}"
|
||||
echo -e "${TAB}${WHITE} To leave the beta program and switch to the stable version,${CL}"
|
||||
echo -e "${TAB}${WHITE} run the official installer:${CL}"
|
||||
echo -e ""
|
||||
echo -e "${TAB} ${YWB}bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)\"${CL}"
|
||||
echo -e ""
|
||||
echo -e "${TAB}${DARK_GRAY} This will cleanly replace your beta install with the stable release.${CL}"
|
||||
echo -e "${TAB}${BOLD}${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}\n"
|
||||
}
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo -e "${RD}[ERROR] This script must be run as root.${CL}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup_corrupted_files
|
||||
show_proxmenux_logo
|
||||
show_beta_welcome
|
||||
|
||||
msg_title "Installing ProxMenux Beta — branch: develop"
|
||||
install_beta
|
||||
|
||||
# Load utils if available
|
||||
[ -f "$UTILS_FILE" ] && source "$UTILS_FILE"
|
||||
|
||||
msg_title "ProxMenux Beta installed successfully"
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
local_ip=$(get_server_ip)
|
||||
echo -e "${GN}🌐 ProxMenux Monitor (beta) is running${CL}: ${BL}http://${local_ip}:${MONITOR_PORT}${CL}"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo -ne "${GN}"
|
||||
type_text "To run ProxMenux, execute this command in your terminal:"
|
||||
echo -e "${YWB} menu${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${DARK_GRAY}Report issues at: https://github.com/MacRimi/ProxMenux/issues${CL}"
|
||||
echo
|
||||
exit 0
|
||||
@@ -4,98 +4,157 @@
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/07/2025
|
||||
# Copyright : (c) 2024-2025 MacRimi
|
||||
# License : GPL-3.0 (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 18/03/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script serves as the main entry point for ProxMenux,
|
||||
# a menu-driven tool designed for Proxmox VE management.
|
||||
# - Displays the ProxMenux logo on startup.
|
||||
# - Loads necessary configurations and language settings.
|
||||
# - Checks for available updates and installs them if confirmed.
|
||||
# - Downloads and executes the latest main menu script.
|
||||
#
|
||||
# Key Features:
|
||||
# - Ensures ProxMenux is always up-to-date by fetching the latest version.
|
||||
# - Uses whiptail for interactive menus and language selection.
|
||||
# - Loads utility functions and translation support.
|
||||
# - Maintains a cache system to improve performance.
|
||||
# - Executes the ProxMenux main menu dynamically from the repository.
|
||||
#
|
||||
# This script ensures a streamlined and automated experience
|
||||
# for managing Proxmox VE using ProxMenux.
|
||||
# Main entry point for ProxMenux.
|
||||
# - Loads configuration and utility functions.
|
||||
# - Detects if running in Beta Program mode (develop branch).
|
||||
# - Checks for updates from the appropriate branch (main or develop).
|
||||
# - In beta mode: compares beta_version.txt; notifies when a stable
|
||||
# release is available and prompts the user to switch.
|
||||
# - Launches the main menu.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
# ── Configuration ──────────────────────────────────────────
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
REPO_DEVELOP="https://raw.githubusercontent.com/MacRimi/ProxMenux/develop"
|
||||
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
# ── Load utilities ─────────────────────────────────────────
|
||||
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
|
||||
|
||||
: "${LOCAL_SCRIPTS:=/usr/local/share/proxmenux/scripts}"
|
||||
|
||||
# =========================================================
|
||||
# ── Detect beta mode ───────────────────────────────────────
|
||||
# Returns 0 (true) if this install is part of the beta program.
|
||||
is_beta() {
|
||||
[[ -f "$CONFIG_FILE" ]] || return 1
|
||||
local beta_flag
|
||||
beta_flag=$(jq -r '.beta_program.status // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||
[[ "$beta_flag" == "active" ]]
|
||||
}
|
||||
|
||||
# ── Check for updates ──────────────────────────────────────
|
||||
check_updates() {
|
||||
local VERSION_URL INSTALL_URL INSTALL_SCRIPT
|
||||
local REMOTE_VERSION LOCAL_VERSION
|
||||
|
||||
VERSION_URL="$REPO_URL/version.txt"
|
||||
INSTALL_URL="$REPO_URL/install_proxmenux.sh"
|
||||
INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
if is_beta; then
|
||||
check_updates_beta
|
||||
else
|
||||
check_updates_stable
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Stable update check (main branch) ─────────────────────
|
||||
check_updates_stable() {
|
||||
local VERSION_URL="$REPO_MAIN/version.txt"
|
||||
local INSTALL_URL="$REPO_MAIN/install_proxmenux.sh"
|
||||
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
[[ ! -f "$LOCAL_VERSION_FILE" ]] && return 0
|
||||
|
||||
local REMOTE_VERSION LOCAL_VERSION
|
||||
REMOTE_VERSION="$(curl -fsSL "$VERSION_URL" 2>/dev/null | head -n 1)"
|
||||
[[ -z "$REMOTE_VERSION" ]] && return 0
|
||||
|
||||
|
||||
LOCAL_VERSION="$(head -n 1 "$LOCAL_VERSION_FILE" 2>/dev/null)"
|
||||
[[ -z "$LOCAL_VERSION" ]] && return 0
|
||||
|
||||
|
||||
[[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]] && return 0
|
||||
|
||||
|
||||
if whiptail --title "$(translate 'Update Available')" \
|
||||
--yesno "$(translate 'New version available') ($REMOTE_VERSION)\n\n$(translate 'Do you want to update now?')" \
|
||||
10 60 --defaultno; then
|
||||
|
||||
msg_warn "$(translate 'Starting ProxMenux update...')"
|
||||
|
||||
|
||||
if curl -fsSL "$INSTALL_URL" -o "$INSTALL_SCRIPT"; then
|
||||
chmod +x "$INSTALL_SCRIPT"
|
||||
|
||||
|
||||
bash "$INSTALL_SCRIPT" --update
|
||||
|
||||
return 0
|
||||
|
||||
fi
|
||||
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Beta update check (develop branch) ────────────────────
|
||||
check_updates_beta() {
|
||||
local BETA_VERSION_URL="$REPO_DEVELOP/beta_version.txt"
|
||||
local STABLE_VERSION_URL="$REPO_MAIN/version.txt"
|
||||
local INSTALL_BETA_URL="$REPO_DEVELOP/install_proxmenux_beta.sh"
|
||||
local INSTALL_STABLE_URL="$REPO_MAIN/install_proxmenux.sh"
|
||||
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux_beta.sh"
|
||||
|
||||
# ── 1. Check if a stable release has superseded the beta ──
|
||||
# If main's version.txt exists and is newer than local beta_version.txt,
|
||||
# the beta cycle is over and we invite the user to switch to stable.
|
||||
local STABLE_VERSION BETA_LOCAL_VERSION
|
||||
STABLE_VERSION="$(curl -fsSL "$STABLE_VERSION_URL" 2>/dev/null | head -n 1)"
|
||||
BETA_LOCAL_VERSION="$(head -n 1 "$BETA_VERSION_FILE" 2>/dev/null)"
|
||||
|
||||
if [[ -n "$STABLE_VERSION" && -n "$BETA_LOCAL_VERSION" ]]; then
|
||||
# Simple string comparison is enough if versions follow semver x.y.z
|
||||
if [[ "$STABLE_VERSION" != "$BETA_LOCAL_VERSION" ]] && \
|
||||
printf '%s\n' "$BETA_LOCAL_VERSION" "$STABLE_VERSION" | sort -V | tail -1 | grep -qx "$STABLE_VERSION"; then
|
||||
|
||||
# Stable is newer — offer migration out of beta
|
||||
if whiptail --title "🎉 Stable Release Available" \
|
||||
--yesno "A stable release of ProxMenux is now available!\n\nStable version : $STABLE_VERSION\nYour beta : $BETA_LOCAL_VERSION\n\nThe beta program for this cycle is complete.\nWould you like to switch to the stable release now?\n\n(Choosing 'No' keeps you on the beta for now.)" \
|
||||
16 68; then
|
||||
|
||||
msg_warn "Switching to stable release $STABLE_VERSION ..."
|
||||
|
||||
local tmp_installer="/tmp/install_proxmenux_stable_$$.sh"
|
||||
if curl -fsSL "$INSTALL_STABLE_URL" -o "$tmp_installer"; then
|
||||
chmod +x "$tmp_installer"
|
||||
bash "$tmp_installer"
|
||||
rm -f "$tmp_installer"
|
||||
else
|
||||
msg_error "Could not download the stable installer. Try manually:"
|
||||
echo
|
||||
echo " bash -c \"\$(wget -qLO - $INSTALL_STABLE_URL)\""
|
||||
echo
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
# User chose to stay on beta — continue normally
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. Check for a newer beta build on develop ─────────────
|
||||
[[ ! -f "$BETA_VERSION_FILE" ]] && return 0
|
||||
|
||||
local REMOTE_BETA_VERSION
|
||||
REMOTE_BETA_VERSION="$(curl -fsSL "$BETA_VERSION_URL" 2>/dev/null | head -n 1)"
|
||||
[[ -z "$REMOTE_BETA_VERSION" ]] && return 0
|
||||
[[ "$BETA_LOCAL_VERSION" = "$REMOTE_BETA_VERSION" ]] && return 0
|
||||
|
||||
if whiptail --title "Beta Update Available" \
|
||||
--yesno "A new beta build is available!\n\nInstalled beta : $BETA_LOCAL_VERSION\nNew beta build : $REMOTE_BETA_VERSION\n\nThis is a pre-release build from the develop branch.\nDo you want to update now?" \
|
||||
13 64 --defaultno; then
|
||||
|
||||
msg_warn "Updating to beta build $REMOTE_BETA_VERSION ..."
|
||||
|
||||
if curl -fsSL "$INSTALL_BETA_URL" -o "$INSTALL_SCRIPT"; then
|
||||
chmod +x "$INSTALL_SCRIPT"
|
||||
bash "$INSTALL_SCRIPT" --update
|
||||
else
|
||||
msg_error "Could not download the beta installer from the develop branch."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────
|
||||
main_menu() {
|
||||
local MAIN_MENU="$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
|
||||
exec bash "$MAIN_MENU"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2025-01-15T10:00:00Z",
|
||||
"apps": {
|
||||
"secure-gateway": {
|
||||
"id": "secure-gateway",
|
||||
"name": "Secure Gateway",
|
||||
"short_name": "VPN Gateway",
|
||||
"subtitle": "Tailscale VPN Gateway",
|
||||
"version": "1.0.0",
|
||||
"category": "security",
|
||||
"subcategory": "remote_access",
|
||||
"icon": "shield-check",
|
||||
"icon_type": "shield",
|
||||
"color": "#0EA5E9",
|
||||
|
||||
"summary": "Secure remote access without opening ports",
|
||||
"description": "Deploy a managed VPN gateway using Tailscale for zero-trust access to your Proxmox infrastructure. Access ProxMenux Monitor, Proxmox UI, VMs, and LXC containers from anywhere without exposing ports to the internet.",
|
||||
"documentation_url": "https://macrimi.github.io/ProxMenux/docs/secure-gateway",
|
||||
"code_url": "https://github.com/MacRimi/ProxMenux/tree/main/Scripts/oci",
|
||||
|
||||
"features": [
|
||||
"Zero-trust network access",
|
||||
"No port forwarding required",
|
||||
"End-to-end encryption",
|
||||
"Easy mobile access",
|
||||
"MagicDNS for easy hostname access",
|
||||
"Access control via Tailscale admin"
|
||||
],
|
||||
|
||||
"container": {
|
||||
"type": "lxc",
|
||||
"template": "alpine",
|
||||
"install_method": "apk",
|
||||
"packages": ["tailscale"],
|
||||
"services": ["tailscale"],
|
||||
"privileged": false,
|
||||
"memory": 256,
|
||||
"cores": 1,
|
||||
"disk_size": 2,
|
||||
"requires_ip_forward": true,
|
||||
"features": ["nesting=1"],
|
||||
"lxc_config": [
|
||||
"lxc.cgroup2.devices.allow: c 10:200 rwm",
|
||||
"lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
|
||||
]
|
||||
},
|
||||
|
||||
"volumes": {
|
||||
"state": {
|
||||
"container_path": "/var/lib/tailscale",
|
||||
"persistent": true,
|
||||
"description": "Tailscale state and keys"
|
||||
}
|
||||
},
|
||||
|
||||
"environment": [
|
||||
{
|
||||
"name": "TS_STATE_DIR",
|
||||
"value": "/var/lib/tailscale"
|
||||
},
|
||||
{
|
||||
"name": "TS_USERSPACE",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "TS_AUTHKEY",
|
||||
"value": "$auth_key"
|
||||
},
|
||||
{
|
||||
"name": "TS_HOSTNAME",
|
||||
"value": "$hostname"
|
||||
},
|
||||
{
|
||||
"name": "TS_ROUTES",
|
||||
"value": "$advertise_routes"
|
||||
},
|
||||
{
|
||||
"name": "TS_EXTRA_ARGS",
|
||||
"value": "$extra_args"
|
||||
}
|
||||
],
|
||||
|
||||
"config_schema": {
|
||||
"auth_key": {
|
||||
"type": "password",
|
||||
"label": "Tailscale Auth Key",
|
||||
"description": "Pre-authentication key from Tailscale admin console. Generate one at the link below.",
|
||||
"placeholder": "tskey-auth-xxxxx",
|
||||
"required": true,
|
||||
"sensitive": true,
|
||||
"env_var": "TS_AUTHKEY",
|
||||
"help_url": "https://login.tailscale.com/admin/settings/keys",
|
||||
"help_text": "Generate Auth Key"
|
||||
},
|
||||
"hostname": {
|
||||
"type": "text",
|
||||
"label": "Device Hostname",
|
||||
"description": "Name shown in Tailscale admin console",
|
||||
"placeholder": "proxmox-gateway",
|
||||
"default": "proxmox-gateway",
|
||||
"required": false,
|
||||
"env_var": "TS_HOSTNAME",
|
||||
"validation": {
|
||||
"pattern": "^[a-zA-Z0-9-]+$",
|
||||
"max_length": 63,
|
||||
"message": "Only letters, numbers, and hyphens allowed"
|
||||
}
|
||||
},
|
||||
"access_mode": {
|
||||
"type": "select",
|
||||
"label": "Access Scope",
|
||||
"description": "What should be accessible through this gateway",
|
||||
"default": "host_only",
|
||||
"required": true,
|
||||
"options": [
|
||||
{
|
||||
"value": "host_only",
|
||||
"label": "Proxmox Only",
|
||||
"description": "Access only this Proxmox server (UI and ProxMenux Monitor)"
|
||||
},
|
||||
{
|
||||
"value": "proxmox_network",
|
||||
"label": "Full Local Network",
|
||||
"description": "Access all devices on your local network (NAS, printers, VMs, etc.)"
|
||||
},
|
||||
{
|
||||
"value": "custom",
|
||||
"label": "Custom Subnets",
|
||||
"description": "Select specific subnets to expose"
|
||||
}
|
||||
]
|
||||
},
|
||||
"advertise_routes": {
|
||||
"type": "networks",
|
||||
"label": "Advertised Networks",
|
||||
"description": "Select networks to make accessible through the VPN",
|
||||
"required": false,
|
||||
"depends_on": {
|
||||
"field": "access_mode",
|
||||
"values": ["custom"]
|
||||
},
|
||||
"env_var": "TS_ROUTES",
|
||||
"env_format": "csv"
|
||||
},
|
||||
"exit_node": {
|
||||
"type": "boolean",
|
||||
"label": "Exit Node",
|
||||
"description": "Use this gateway as your internet exit point when away from home. All your internet traffic will appear to come from your Proxmox server's IP address.",
|
||||
"default": false,
|
||||
"required": false,
|
||||
"flag": "--advertise-exit-node",
|
||||
"warning": "Requires approval in Tailscale Admin. When enabled on your device, ALL internet traffic routes through your Proxmox server."
|
||||
},
|
||||
"accept_routes": {
|
||||
"type": "boolean",
|
||||
"label": "Accept Routes",
|
||||
"description": "Allow this gateway to access networks advertised by OTHER Tailscale nodes in your tailnet. Useful if you have multiple Tailscale subnet routers.",
|
||||
"default": false,
|
||||
"required": false,
|
||||
"flag": "--accept-routes"
|
||||
}
|
||||
},
|
||||
|
||||
"healthcheck": {
|
||||
"command": ["tailscale", "status", "--json"],
|
||||
"interval_seconds": 30,
|
||||
"timeout_seconds": 10,
|
||||
"retries": 3,
|
||||
"healthy_condition": "BackendState == Running"
|
||||
},
|
||||
|
||||
"requirements": {
|
||||
"min_memory_mb": 64,
|
||||
"min_disk_mb": 100,
|
||||
"proxmox_min_version": "9.1",
|
||||
"checks": [
|
||||
{
|
||||
"type": "proxmox_version",
|
||||
"min": "9.1",
|
||||
"message": "OCI containers require Proxmox VE 9.1+"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"security_notes": [
|
||||
"Requires NET_ADMIN capability for VPN tunneling",
|
||||
"Uses /dev/net/tun for network virtualization",
|
||||
"Auth key is stored encrypted at rest",
|
||||
"No ports are opened on the host firewall",
|
||||
"All traffic is end-to-end encrypted"
|
||||
],
|
||||
|
||||
"ui": {
|
||||
"wizard_steps": [
|
||||
{
|
||||
"id": "intro",
|
||||
"title": "Secure Remote Access",
|
||||
"description": "Set up secure VPN access to your Proxmox server"
|
||||
},
|
||||
{
|
||||
"id": "auth",
|
||||
"title": "Tailscale Authentication",
|
||||
"description": "Connect to your Tailscale account",
|
||||
"fields": ["auth_key", "hostname"]
|
||||
},
|
||||
{
|
||||
"id": "access",
|
||||
"title": "Access Scope",
|
||||
"description": "Choose what to make accessible",
|
||||
"fields": ["access_mode", "advertise_routes"]
|
||||
},
|
||||
{
|
||||
"id": "options",
|
||||
"title": "Advanced Options",
|
||||
"description": "Additional configuration",
|
||||
"fields": ["exit_node", "accept_routes"]
|
||||
},
|
||||
{
|
||||
"id": "deploy",
|
||||
"title": "Deploy Gateway",
|
||||
"description": "Review and deploy"
|
||||
}
|
||||
],
|
||||
"show_in_sections": ["security"],
|
||||
"dashboard_widget": false,
|
||||
"status_indicators": {
|
||||
"running": {
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"label": "Connected"
|
||||
},
|
||||
"stopped": {
|
||||
"color": "yellow",
|
||||
"icon": "pause-circle",
|
||||
"label": "Stopped"
|
||||
},
|
||||
"error": {
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"label": "Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"metadata": {
|
||||
"author": "ProxMenux",
|
||||
"license": "MIT",
|
||||
"upstream": "https://tailscale.com",
|
||||
"tags": ["vpn", "remote-access", "tailscale", "zero-trust", "security"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,861 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Complete Post-Installation Script with Registration
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 06/07/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
#
|
||||
# The script performs system optimizations including:
|
||||
# - Repository configuration and system upgrades
|
||||
# - Subscription banner removal and UI enhancements
|
||||
# - Advanced memory management and kernel optimizations
|
||||
# - Network stack tuning and security hardening
|
||||
# - Storage optimizations including log2ram for SSD protection
|
||||
# - System limits increases and entropy generation improvements
|
||||
# - Journald and logrotate optimizations for better log management
|
||||
# - Security enhancements including RPC disabling and time synchronization
|
||||
# - Bash environment customization and system monitoring setup
|
||||
#
|
||||
# Key Features:
|
||||
# - Zero-interaction automation: Runs completely unattended
|
||||
# - Intelligent hardware detection: Automatically detects SSD/NVMe for log2ram
|
||||
# - RAM-aware configurations: Adjusts settings based on available system memory
|
||||
# - Comprehensive error handling: Robust installation with fallback mechanisms
|
||||
# - Registration system: Tracks installed optimizations for easy management
|
||||
# - Reboot management: Intelligently handles reboot requirements
|
||||
# - Translation support: Multi-language compatible through ProxMenux framework
|
||||
# - Rollback compatibility: All optimizations can be reversed using the uninstall script
|
||||
#
|
||||
# This script is based on the post-install script customizable
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# Global variables
|
||||
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
RAM_SIZE_GB=$(( $(vmstat -s | grep -i "total memory" | xargs | cut -d" " -f 1) / 1024 / 1000))
|
||||
NECESSARY_REBOOT=0
|
||||
SCRIPT_TITLE="Customizable post-installation optimization script"
|
||||
|
||||
# ==========================================================
|
||||
# Tool registration system
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
lvm_repair_check() {
|
||||
msg_info "$(translate "Checking and repairing old LVM PV headers (if needed)...")"
|
||||
pvs_output=$(LC_ALL=C pvs -v 2>&1 | grep "old PV header")
|
||||
if [ -z "$pvs_output" ]; then
|
||||
msg_ok "$(translate "No PVs with old headers found.")"
|
||||
register_tool "lvm_repair" true
|
||||
return
|
||||
fi
|
||||
|
||||
declare -A vg_map
|
||||
while read -r line; do
|
||||
pv=$(echo "$line" | grep -o '/dev/[^ ]*')
|
||||
vg=$(pvs -o vg_name --noheadings "$pv" | awk '{print $1}')
|
||||
if [ -n "$vg" ]; then
|
||||
vg_map["$vg"]=1
|
||||
fi
|
||||
done <<< "$pvs_output"
|
||||
|
||||
for vg in "${!vg_map[@]}"; do
|
||||
msg_warn "$(translate "Old PV header(s) found in VG $vg. Updating metadata...")"
|
||||
vgck --updatemetadata "$vg"
|
||||
vgchange -ay "$vg"
|
||||
if [ $? -ne 0 ]; then
|
||||
msg_warn "$(translate "Metadata update failed for VG $vg. Review manually.")"
|
||||
else
|
||||
msg_ok "$(translate "Metadata updated successfully for VG $vg")"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "$(translate "LVM PV headers check completed")"
|
||||
register_tool "lvm_repair" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
cleanup_duplicate_repos() {
|
||||
local sources_file="/etc/apt/sources.list"
|
||||
local temp_file=$(mktemp)
|
||||
local cleaned_count=0
|
||||
declare -A seen_repos
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^deb ]]; then
|
||||
read -r _ url dist components <<< "$line"
|
||||
local key="${url}_${dist}"
|
||||
if [[ -v "seen_repos[$key]" ]]; then
|
||||
echo "# $line" >> "$temp_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
seen_repos[$key]="$components"
|
||||
fi
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
fi
|
||||
done < "$sources_file"
|
||||
|
||||
mv "$temp_file" "$sources_file"
|
||||
chmod 644 "$sources_file"
|
||||
|
||||
|
||||
local pve_files=(/etc/apt/sources.list.d/*proxmox*.list /etc/apt/sources.list.d/*pve*.list)
|
||||
local pve_content="deb http://download.proxmox.com/debian/pve ${OS_CODENAME} pve-no-subscription"
|
||||
local pve_public_repo="/etc/apt/sources.list.d/pve-public-repo.list"
|
||||
local pve_public_repo_exists=false
|
||||
|
||||
if [ -f "$pve_public_repo" ] && grep -q "^deb.*pve-no-subscription" "$pve_public_repo"; then
|
||||
pve_public_repo_exists=true
|
||||
fi
|
||||
|
||||
for file in "${pve_files[@]}"; do
|
||||
if [ -f "$file" ] && grep -q "^deb.*pve-no-subscription" "$file"; then
|
||||
if ! $pve_public_repo_exists && [[ "$file" == "$pve_public_repo" ]]; then
|
||||
sed -i 's/^# *deb/deb/' "$file"
|
||||
pve_public_repo_exists=true
|
||||
elif [[ "$file" != "$pve_public_repo" ]]; then
|
||||
sed -i 's/^deb/# deb/' "$file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
apt update
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
apt_upgrade() {
|
||||
|
||||
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
|
||||
if [ -f /etc/apt/sources.list.d/pve-enterprise.list ] && grep -q "^deb" /etc/apt/sources.list.d/pve-enterprise.list; then
|
||||
msg_info "$(translate "Disabling enterprise Proxmox repository...")"
|
||||
sed -i "s/^deb/#deb/g" /etc/apt/sources.list.d/pve-enterprise.list
|
||||
msg_ok "$(translate "Enterprise Proxmox repository disabled")"
|
||||
fi
|
||||
|
||||
|
||||
if [ -f /etc/apt/sources.list.d/ceph.list ] && grep -q "^deb" /etc/apt/sources.list.d/ceph.list; then
|
||||
msg_info "$(translate "Disabling enterprise Proxmox Ceph repository...")"
|
||||
sed -i "s/^deb/#deb/g" /etc/apt/sources.list.d/ceph.list
|
||||
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")"
|
||||
fi
|
||||
|
||||
|
||||
if [ ! -f /etc/apt/sources.list.d/pve-public-repo.list ] || ! grep -q "pve-no-subscription" /etc/apt/sources.list.d/pve-public-repo.list; then
|
||||
msg_info "$(translate "Enabling free public Proxmox repository...")"
|
||||
echo "deb http://download.proxmox.com/debian/pve ${OS_CODENAME} pve-no-subscription" > /etc/apt/sources.list.d/pve-public-repo.list
|
||||
msg_ok "$(translate "Free public Proxmox repository enabled")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
sources_file="/etc/apt/sources.list"
|
||||
need_update=false
|
||||
|
||||
|
||||
sed -i 's|ftp.es.debian.org|deb.debian.org|g' "$sources_file"
|
||||
|
||||
|
||||
if grep -q "^deb http://security.debian.org ${OS_CODENAME}-security main contrib" "$sources_file"; then
|
||||
sed -i "s|^deb http://security.debian.org ${OS_CODENAME}-security main contrib|deb http://security.debian.org/debian-security ${OS_CODENAME}-security main contrib non-free non-free-firmware|" "$sources_file"
|
||||
msg_ok "$(translate "Replaced security repository with full version")"
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
|
||||
if ! grep -q "deb http://security.debian.org/debian-security ${OS_CODENAME}-security" "$sources_file"; then
|
||||
echo "deb http://security.debian.org/debian-security ${OS_CODENAME}-security main contrib non-free non-free-firmware" >> "$sources_file"
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
|
||||
if ! grep -q "deb http://deb.debian.org/debian ${OS_CODENAME} " "$sources_file"; then
|
||||
echo "deb http://deb.debian.org/debian ${OS_CODENAME} main contrib non-free non-free-firmware" >> "$sources_file"
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
|
||||
if ! grep -q "deb http://deb.debian.org/debian ${OS_CODENAME}-updates" "$sources_file"; then
|
||||
echo "deb http://deb.debian.org/debian ${OS_CODENAME}-updates main contrib non-free non-free-firmware" >> "$sources_file"
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Debian repositories configured correctly")"
|
||||
|
||||
# ===================================================
|
||||
|
||||
|
||||
if [ ! -f /etc/apt/apt.conf.d/no-bookworm-firmware.conf ]; then
|
||||
msg_info "$(translate "Disabling non-free firmware warnings...")"
|
||||
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > /etc/apt/apt.conf.d/no-bookworm-firmware.conf
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
|
||||
msg_info "$(translate "Updating package lists...")"
|
||||
if apt-get update > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
msg_info "$(translate "Removing conflicting utilities...")"
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Conflicting utilities removed")"
|
||||
else
|
||||
msg_error "$(translate "Failed to remove conflicting utilities")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Performing packages upgrade...")"
|
||||
apt-get install pv -y > /dev/null 2>&1
|
||||
total_packages=$(apt-get -s dist-upgrade | grep "^Inst" | wc -l)
|
||||
|
||||
if [ "$total_packages" -eq 0 ]; then
|
||||
total_packages=1
|
||||
fi
|
||||
msg_ok "$(translate "Packages upgrade successful")"
|
||||
tput civis
|
||||
tput sc
|
||||
|
||||
|
||||
(
|
||||
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' dist-upgrade 2>&1 | \
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^(Setting up|Unpacking|Preparing to unpack|Processing triggers for) ]]; then
|
||||
|
||||
package_name=$(echo "$line" | sed -E 's/.*(Setting up|Unpacking|Preparing to unpack|Processing triggers for) ([^ ]+).*/\2/')
|
||||
|
||||
|
||||
[ -z "$package_name" ] && package_name="$(translate "Unknown")"
|
||||
|
||||
|
||||
tput rc
|
||||
tput ed
|
||||
|
||||
|
||||
row=$(( $(tput lines) - 6 ))
|
||||
tput cup $row 0; echo "$(translate "Installing packages...")"
|
||||
tput cup $((row + 1)) 0; echo "──────────────────────────────────────────────"
|
||||
tput cup $((row + 2)) 0; echo "Package: $package_name"
|
||||
tput cup $((row + 3)) 0; echo "Progress: [ ] 0%"
|
||||
tput cup $((row + 4)) 0; echo "──────────────────────────────────────────────"
|
||||
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
progress=$((i * 10))
|
||||
tput cup $((row + 3)) 9
|
||||
printf "[%-50s] %3d%%" "$(printf "#%.0s" $(seq 1 $((progress/2))))" "$progress"
|
||||
|
||||
done
|
||||
fi
|
||||
done
|
||||
)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
tput rc
|
||||
tput ed
|
||||
msg_ok "$(translate "System upgrade completed")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Installing additional Proxmox packages...")"
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install zfsutils-linux proxmox-backup-restore-image chrony > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Additional Proxmox packages installed")"
|
||||
else
|
||||
msg_error "$(translate "Failed to install additional Proxmox packages")"
|
||||
fi
|
||||
|
||||
lvm_repair_check
|
||||
|
||||
cleanup_duplicate_repos
|
||||
|
||||
msg_ok "$(translate "Proxmox update completed")"
|
||||
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
remove_subscription_banner() {
|
||||
msg_info "$(translate "Removing Proxmox subscription nag banner...")"
|
||||
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
|
||||
if [[ ! -f "$APT_HOOK" ]]; then
|
||||
cat <<'EOF' > "$APT_HOOK"
|
||||
DPkg::Post-Invoke { "dpkg -V proxmox-widget-toolkit | grep -q '/proxmoxlib\.js$'; if [ $? -eq 1 ]; then { echo 'Removing subscription nag from UI...'; sed -i '/.*data\.status.*{/{s/\!//;s/active/NoMoreNagging/;s/Active/NoMoreNagging/}' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js; rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz; }; fi"; };
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ -f "$JS_FILE" ]]; then
|
||||
sed -i '/.*data\.status.*{/{s/\!//;s/active/NoMoreNagging/;s/Active/NoMoreNagging/}' "$JS_FILE"
|
||||
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
|
||||
touch "$JS_FILE"
|
||||
fi
|
||||
|
||||
apt --reinstall install proxmox-widget-toolkit -y > /dev/null 2>&1
|
||||
|
||||
msg_ok "$(translate "Subscription nag banner removed successfully")"
|
||||
register_tool "subscription_banner" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
configure_time_sync() {
|
||||
msg_info "$(translate "Configuring system time settings...")"
|
||||
|
||||
this_ip=$(dig +short myip.opendns.com @resolver1.opendns.com)
|
||||
if [ -z "$this_ip" ]; then
|
||||
msg_warn "$(translate "Failed to obtain public IP address")"
|
||||
timezone="UTC"
|
||||
else
|
||||
|
||||
timezone=$(curl -s "https://ipapi.co/${this_ip}/timezone")
|
||||
if [ -z "$timezone" ]; then
|
||||
msg_warn "$(translate "Failed to determine timezone from IP address")"
|
||||
timezone="UTC"
|
||||
else
|
||||
msg_ok "$(translate "Found timezone $timezone for IP $this_ip")"
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Enabling automatic time synchronization...")"
|
||||
if timedatectl set-ntp true; then
|
||||
msg_ok "$(translate "Time settings configured - Timezone:") $timezone"
|
||||
register_tool "time_sync" true
|
||||
else
|
||||
msg_error "$(translate "Failed to enable automatic time synchronization")"
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
skip_apt_languages() {
|
||||
msg_info "$(translate "Configuring APT to skip downloading additional languages...")"
|
||||
local default_locale=""
|
||||
|
||||
if [ -f /etc/default/locale ]; then
|
||||
default_locale=$(grep '^LANG=' /etc/default/locale | cut -d= -f2 | tr -d '"')
|
||||
elif [ -f /etc/environment ]; then
|
||||
default_locale=$(grep '^LANG=' /etc/environment | cut -d= -f2 | tr -d '"')
|
||||
fi
|
||||
|
||||
default_locale="${default_locale:-en_US.UTF-8}"
|
||||
local normalized_locale=$(echo "$default_locale" | tr 'A-Z' 'a-z' | sed 's/utf-8/utf8/;s/-/_/')
|
||||
|
||||
if ! locale -a | grep -qi "^$normalized_locale$"; then
|
||||
if ! grep -qE "^${default_locale}[[:space:]]+UTF-8" /etc/locale.gen; then
|
||||
echo "$default_locale UTF-8" >> /etc/locale.gen
|
||||
fi
|
||||
locale-gen "$default_locale" > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
echo 'Acquire::Languages "none";' > /etc/apt/apt.conf.d/99-disable-translations
|
||||
|
||||
msg_ok "$(translate "APT configured to skip additional languages")"
|
||||
register_tool "apt_languages" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
optimize_journald() {
|
||||
msg_info "$(translate "Limiting size and optimizing journald...")"
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
cat <<EOF > /etc/systemd/journald.conf
|
||||
[Journal]
|
||||
Storage=persistent
|
||||
SplitMode=none
|
||||
RateLimitInterval=0
|
||||
RateLimitIntervalSec=0
|
||||
RateLimitBurst=0
|
||||
ForwardToSyslog=no
|
||||
ForwardToWall=yes
|
||||
Seal=no
|
||||
Compress=yes
|
||||
SystemMaxUse=64M
|
||||
RuntimeMaxUse=60M
|
||||
MaxLevelStore=warning
|
||||
MaxLevelSyslog=warning
|
||||
MaxLevelKMsg=warning
|
||||
MaxLevelConsole=notice
|
||||
MaxLevelWall=crit
|
||||
EOF
|
||||
|
||||
systemctl restart systemd-journald.service > /dev/null 2>&1
|
||||
journalctl --vacuum-size=64M --vacuum-time=1d > /dev/null 2>&1
|
||||
journalctl --rotate > /dev/null 2>&1
|
||||
|
||||
msg_ok "$(translate "Journald optimized - Max size: 64M")"
|
||||
register_tool "journald" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
optimize_logrotate() {
|
||||
msg_info "$(translate "Optimizing logrotate configuration...")"
|
||||
local logrotate_conf="/etc/logrotate.conf"
|
||||
local backup_conf="${logrotate_conf}.bak"
|
||||
|
||||
if ! grep -q "# ProxMenux optimized configuration" "$logrotate_conf"; then
|
||||
cp "$logrotate_conf" "$backup_conf"
|
||||
cat <<EOF > "$logrotate_conf"
|
||||
# ProxMenux optimized configuration
|
||||
daily
|
||||
su root adm
|
||||
rotate 7
|
||||
create
|
||||
compress
|
||||
size=10M
|
||||
delaycompress
|
||||
copytruncate
|
||||
include /etc/logrotate.d
|
||||
EOF
|
||||
systemctl restart logrotate > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Logrotate optimization completed")"
|
||||
register_tool "logrotate" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
increase_system_limits() {
|
||||
msg_info "$(translate "Increasing various system limits...")"
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
|
||||
cat > /etc/sysctl.d/99-maxwatches.conf << EOF
|
||||
# ProxMenux configuration
|
||||
fs.inotify.max_user_watches = 1048576
|
||||
fs.inotify.max_user_instances = 1048576
|
||||
fs.inotify.max_queued_events = 1048576
|
||||
EOF
|
||||
|
||||
|
||||
cat > /etc/security/limits.d/99-limits.conf << EOF
|
||||
# ProxMenux configuration
|
||||
* soft nproc 1048576
|
||||
* hard nproc 1048576
|
||||
* soft nofile 1048576
|
||||
* hard nofile 1048576
|
||||
root soft nproc unlimited
|
||||
root hard nproc unlimited
|
||||
root soft nofile unlimited
|
||||
root hard nofile unlimited
|
||||
EOF
|
||||
|
||||
|
||||
cat > /etc/sysctl.d/99-maxkeys.conf << EOF
|
||||
# ProxMenux configuration
|
||||
kernel.keys.root_maxkeys=1000000
|
||||
kernel.keys.maxkeys=1000000
|
||||
EOF
|
||||
|
||||
|
||||
for file in /etc/systemd/system.conf /etc/systemd/user.conf; do
|
||||
if ! grep -q "^DefaultLimitNOFILE=" "$file"; then
|
||||
echo "DefaultLimitNOFILE=256000" >> "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do
|
||||
if ! grep -q "^session required pam_limits.so" "$file"; then
|
||||
echo 'session required pam_limits.so' >> "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
if ! grep -q "ulimit -n 256000" /root/.profile; then
|
||||
echo "ulimit -n 256000" >> /root/.profile
|
||||
fi
|
||||
|
||||
|
||||
cat > /etc/sysctl.d/99-swap.conf << EOF
|
||||
# ProxMenux configuration
|
||||
vm.swappiness = 10
|
||||
vm.vfs_cache_pressure = 100
|
||||
EOF
|
||||
|
||||
|
||||
cat > /etc/sysctl.d/99-fs.conf << EOF
|
||||
# ProxMenux configuration
|
||||
fs.nr_open = 12000000
|
||||
fs.file-max = 9223372036854775807
|
||||
fs.aio-max-nr = 1048576
|
||||
EOF
|
||||
|
||||
msg_ok "$(translate "System limits increase completed.")"
|
||||
register_tool "system_limits" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
configure_entropy() {
|
||||
msg_info "$(translate "Configuring entropy generation to prevent slowdowns...")"
|
||||
|
||||
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install haveged > /dev/null 2>&1
|
||||
|
||||
cat <<EOF > /etc/default/haveged
|
||||
# -w sets low entropy watermark (in bits)
|
||||
DAEMON_ARGS="-w 1024"
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
systemctl enable haveged > /dev/null 2>&1
|
||||
|
||||
msg_ok "$(translate "Entropy generation configuration completed")"
|
||||
register_tool "entropy" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
optimize_memory_settings() {
|
||||
msg_info "$(translate "Optimizing memory settings...")"
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
cat <<EOF > /etc/sysctl.d/99-memory.conf
|
||||
# Balanced Memory Optimization
|
||||
vm.swappiness = 10
|
||||
vm.dirty_ratio = 15
|
||||
vm.dirty_background_ratio = 5
|
||||
vm.overcommit_memory = 1
|
||||
vm.max_map_count = 65530
|
||||
EOF
|
||||
|
||||
if [ -f /proc/sys/vm/compaction_proactiveness ]; then
|
||||
echo "vm.compaction_proactiveness = 20" >> /etc/sysctl.d/99-memory.conf
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Memory optimization completed.")"
|
||||
register_tool "memory_settings" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
configure_kernel_panic() {
|
||||
msg_info "$(translate "Configuring kernel panic behavior")"
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
cat <<EOF > /etc/sysctl.d/99-kernelpanic.conf
|
||||
# Enable restart on kernel panic, kernel oops and hardlockup
|
||||
kernel.core_pattern = /var/crash/core.%t.%p
|
||||
kernel.panic = 10
|
||||
kernel.panic_on_oops = 1
|
||||
kernel.hardlockup_panic = 1
|
||||
EOF
|
||||
|
||||
msg_ok "$(translate "Kernel panic behavior configuration completed")"
|
||||
register_tool "kernel_panic" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
force_apt_ipv4() {
|
||||
msg_info "$(translate "Configuring APT to use IPv4...")"
|
||||
|
||||
echo 'Acquire::ForceIPv4 "true";' > /etc/apt/apt.conf.d/99-force-ipv4
|
||||
|
||||
msg_ok "$(translate "APT IPv4 configuration completed")"
|
||||
register_tool "apt_ipv4" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
apply_network_optimizations() {
|
||||
msg_info "$(translate "Optimizing network settings...")"
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
cat <<EOF > /etc/sysctl.d/99-network.conf
|
||||
net.core.netdev_max_backlog=8192
|
||||
net.core.optmem_max=8192
|
||||
net.core.rmem_max=16777216
|
||||
net.core.somaxconn=8151
|
||||
net.core.wmem_max=16777216
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.all.accept_source_route = 0
|
||||
net.ipv4.conf.all.log_martians = 0
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.all.secure_redirects = 0
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.default.accept_redirects = 0
|
||||
net.ipv4.conf.default.accept_source_route = 0
|
||||
net.ipv4.conf.default.log_martians = 0
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
net.ipv4.conf.default.secure_redirects = 0
|
||||
net.ipv4.conf.default.send_redirects = 0
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
net.ipv4.icmp_ignore_bogus_error_responses = 1
|
||||
net.ipv4.ip_local_port_range=1024 65535
|
||||
net.ipv4.tcp_base_mss = 1024
|
||||
net.ipv4.tcp_challenge_ack_limit = 999999999
|
||||
net.ipv4.tcp_fin_timeout=10
|
||||
net.ipv4.tcp_keepalive_intvl=30
|
||||
net.ipv4.tcp_keepalive_probes=3
|
||||
net.ipv4.tcp_keepalive_time=240
|
||||
net.ipv4.tcp_limit_output_bytes=65536
|
||||
net.ipv4.tcp_max_syn_backlog=8192
|
||||
net.ipv4.tcp_max_tw_buckets = 1440000
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
net.ipv4.tcp_rfc1337=1
|
||||
net.ipv4.tcp_rmem=8192 87380 16777216
|
||||
net.ipv4.tcp_sack=1
|
||||
net.ipv4.tcp_slow_start_after_idle=0
|
||||
net.ipv4.tcp_syn_retries=3
|
||||
net.ipv4.tcp_synack_retries = 2
|
||||
net.ipv4.tcp_tw_recycle = 0
|
||||
net.ipv4.tcp_tw_reuse = 0
|
||||
net.ipv4.tcp_wmem=8192 65536 16777216
|
||||
net.netfilter.nf_conntrack_generic_timeout = 60
|
||||
net.netfilter.nf_conntrack_helper=0
|
||||
net.netfilter.nf_conntrack_max = 524288
|
||||
net.netfilter.nf_conntrack_tcp_timeout_established = 28800
|
||||
net.unix.max_dgram_qlen = 4096
|
||||
EOF
|
||||
|
||||
sysctl --system > /dev/null 2>&1
|
||||
|
||||
local interfaces_file="/etc/network/interfaces"
|
||||
if ! grep -q 'source /etc/network/interfaces.d/*' "$interfaces_file"; then
|
||||
echo "source /etc/network/interfaces.d/*" >> "$interfaces_file"
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Network optimization completed")"
|
||||
register_tool "network_optimization" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
disable_rpc() {
|
||||
msg_info "$(translate "Disabling portmapper/rpcbind for security...")"
|
||||
|
||||
systemctl disable rpcbind > /dev/null 2>&1
|
||||
systemctl stop rpcbind > /dev/null 2>&1
|
||||
|
||||
msg_ok "$(translate "portmapper/rpcbind has been disabled and removed")"
|
||||
register_tool "disable_rpc" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
customize_bashrc() {
|
||||
msg_info "$(translate "Customizing bashrc for root user...")"
|
||||
local bashrc="/root/.bashrc"
|
||||
local bash_profile="/root/.bash_profile"
|
||||
|
||||
if [ ! -f "${bashrc}.bak" ]; then
|
||||
cp "$bashrc" "${bashrc}.bak"
|
||||
fi
|
||||
|
||||
|
||||
cat >> "$bashrc" << 'EOF'
|
||||
|
||||
# ProxMenux customizations
|
||||
export HISTTIMEFORMAT="%d/%m/%y %T "
|
||||
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
|
||||
alias l='ls -CF'
|
||||
alias la='ls -A'
|
||||
alias ll='ls -alF'
|
||||
alias ls='ls --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
source /etc/profile.d/bash_completion.sh
|
||||
EOF
|
||||
|
||||
if ! grep -q "source /root/.bashrc" "$bash_profile"; then
|
||||
echo "source /root/.bashrc" >> "$bash_profile"
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Bashrc customization completed")"
|
||||
register_tool "bashrc_custom" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
|
||||
install_log2ram_auto() {
|
||||
msg_info "$(translate "Checking if system disk is SSD or M.2...")"
|
||||
|
||||
ROOT_PART=$(lsblk -no NAME,MOUNTPOINT | grep ' /$' | awk '{print $1}')
|
||||
SYSTEM_DISK=$(lsblk -no PKNAME /dev/$ROOT_PART 2>/dev/null)
|
||||
SYSTEM_DISK=${SYSTEM_DISK:-sda}
|
||||
|
||||
if [[ "$SYSTEM_DISK" == nvme* || "$(cat /sys/block/$SYSTEM_DISK/queue/rotational 2>/dev/null)" == "0" ]]; then
|
||||
msg_ok "$(translate "System disk ($SYSTEM_DISK) is SSD or M.2. Proceeding with log2ram setup.")"
|
||||
else
|
||||
msg_warn "$(translate "System disk ($SYSTEM_DISK) is not SSD/M.2. Skipping log2ram installation.")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up previous state
|
||||
rm -rf /tmp/log2ram
|
||||
rm -f /etc/systemd/system/log2ram*
|
||||
rm -f /etc/systemd/system/log2ram-daily.*
|
||||
rm -f /etc/cron.d/log2ram*
|
||||
rm -f /usr/sbin/log2ram
|
||||
rm -f /etc/log2ram.conf
|
||||
rm -f /usr/local/bin/log2ram-check.sh
|
||||
rm -rf /var/log.hdd
|
||||
systemctl daemon-reexec >/dev/null 2>&1
|
||||
systemctl daemon-reload >/dev/null 2>&1
|
||||
|
||||
msg_info "$(translate "Installing log2ram from GitHub...")"
|
||||
|
||||
git clone https://github.com/azlux/log2ram.git /tmp/log2ram >/dev/null 2>>/tmp/log2ram_install.log
|
||||
cd /tmp/log2ram || return 1
|
||||
bash install.sh >>/tmp/log2ram_install.log 2>&1
|
||||
|
||||
if [[ -f /etc/log2ram.conf ]] && systemctl list-units --all | grep -q log2ram; then
|
||||
msg_ok "$(translate "log2ram installed successfully")"
|
||||
else
|
||||
msg_error "$(translate "Failed to install log2ram. See /tmp/log2ram_install.log")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Detect RAM (in MB first for better accuracy)
|
||||
RAM_SIZE_MB=$(free -m | awk '/^Mem:/{print $2}')
|
||||
RAM_SIZE_GB=$((RAM_SIZE_MB / 1024))
|
||||
[[ -z "$RAM_SIZE_GB" || "$RAM_SIZE_GB" -eq 0 ]] && RAM_SIZE_GB=4
|
||||
|
||||
if (( RAM_SIZE_GB <= 8 )); then
|
||||
LOG2RAM_SIZE="128M"
|
||||
CRON_HOURS=1
|
||||
elif (( RAM_SIZE_GB <= 16 )); then
|
||||
LOG2RAM_SIZE="256M"
|
||||
CRON_HOURS=3
|
||||
else
|
||||
LOG2RAM_SIZE="512M"
|
||||
CRON_HOURS=6
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Detected RAM:") $RAM_SIZE_GB GB — $(translate "log2ram size set to:") $LOG2RAM_SIZE"
|
||||
|
||||
sed -i "s/^SIZE=.*/SIZE=$LOG2RAM_SIZE/" /etc/log2ram.conf
|
||||
rm -f /etc/cron.hourly/log2ram
|
||||
echo "0 */$CRON_HOURS * * * root /usr/sbin/log2ram write" > /etc/cron.d/log2ram
|
||||
msg_ok "$(translate "log2ram write scheduled every") $CRON_HOURS $(translate "hour(s)")"
|
||||
|
||||
cat << 'EOF' > /usr/local/bin/log2ram-check.sh
|
||||
#!/bin/bash
|
||||
CONF_FILE="/etc/log2ram.conf"
|
||||
SIZE_VALUE=$(grep '^SIZE=' "$CONF_FILE" | cut -d'=' -f2)
|
||||
# Convert to KB: handle M (megabytes) and G (gigabytes)
|
||||
if [[ "$SIZE_VALUE" == *"G" ]]; then
|
||||
LIMIT_KB=$(($(echo "$SIZE_VALUE" | tr -d 'G') * 1024 * 1024))
|
||||
else
|
||||
LIMIT_KB=$(($(echo "$SIZE_VALUE" | tr -d 'M') * 1024))
|
||||
fi
|
||||
USED_KB=$(df /var/log --output=used | tail -1)
|
||||
THRESHOLD=$(( LIMIT_KB * 90 / 100 ))
|
||||
if (( USED_KB > THRESHOLD )); then
|
||||
/usr/sbin/log2ram write
|
||||
fi
|
||||
EOF
|
||||
|
||||
chmod +x /usr/local/bin/log2ram-check.sh
|
||||
echo "*/5 * * * * root /usr/local/bin/log2ram-check.sh" > /etc/cron.d/log2ram-auto-sync
|
||||
msg_ok "$(translate "Auto-sync enabled when /var/log exceeds 90% of") $LOG2RAM_SIZE"
|
||||
|
||||
|
||||
register_tool "log2ram" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
|
||||
run_complete_optimization() {
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "ProxMenux Optimization Post-Installation")"
|
||||
|
||||
ensure_tools_json
|
||||
|
||||
apt_upgrade
|
||||
remove_subscription_banner
|
||||
configure_time_sync
|
||||
skip_apt_languages
|
||||
optimize_journald
|
||||
optimize_logrotate
|
||||
increase_system_limits
|
||||
configure_entropy
|
||||
optimize_memory_settings
|
||||
configure_kernel_panic
|
||||
force_apt_ipv4
|
||||
apply_network_optimizations
|
||||
disable_rpc
|
||||
customize_bashrc
|
||||
install_log2ram_auto
|
||||
|
||||
|
||||
echo -e
|
||||
msg_success "$(translate "Complete post-installation optimization finished!")"
|
||||
|
||||
if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then
|
||||
whiptail --title "Reboot Required" \
|
||||
--yesno "$(translate "Some changes require a reboot to take effect. Do you want to restart now?")" 10 60
|
||||
if [[ $? -eq 0 ]]; then
|
||||
msg_info "$(translate "Removing no longer required packages and purging old cached updates...")"
|
||||
apt-get -y autoremove >/dev/null 2>&1
|
||||
apt-get -y autoclean >/dev/null 2>&1
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
msg_warn "$(translate "Rebooting the system...")"
|
||||
reboot
|
||||
else
|
||||
msg_info "$(translate "Removing no longer required packages and purging old cached updates...")"
|
||||
apt-get -y autoremove >/dev/null 2>&1
|
||||
apt-get -y autoclean >/dev/null 2>&1
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
msg_info2 "$(translate "You can reboot later manually.")"
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_success "$(translate "All changes applied. No reboot required.")"
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
clear
|
||||
}
|
||||
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
run_complete_optimization
|
||||
fi
|
||||
@@ -1,11 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Global Share Functions (reusable)
|
||||
# File: scripts/global/share_common.func
|
||||
# ==========================================================
|
||||
#!/bin/bash
|
||||
# ProxMenux - Shared Common Functions
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# Last Updated: 29/01/2026
|
||||
# ============================================
|
||||
# Common functions shared across multiple scripts
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Ensure repositories are properly configured
|
||||
# ==========================================================
|
||||
ensure_repositories() {
|
||||
local pve_version need_update=false
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -z "$pve_version" ]]; then
|
||||
msg_error "$(translate 'Unable to detect Proxmox version.')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if (( pve_version >= 9 )); then
|
||||
# ===== PVE 9 (Debian 13 - trixie) =====
|
||||
# proxmox.sources (no-subscription) - create if missing
|
||||
if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then
|
||||
cat > /etc/apt/sources.list.d/proxmox.sources <<'EOF'
|
||||
Enabled: true
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: trixie
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
EOF
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
# debian.sources - create if missing
|
||||
if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||||
cat > /etc/apt/sources.list.d/debian.sources <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: trixie trixie-updates
|
||||
Components: main contrib non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: trixie-security
|
||||
Components: main contrib non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
else
|
||||
# ===== PVE 8 (Debian 12 - bookworm) =====
|
||||
local sources_file="/etc/apt/sources.list"
|
||||
|
||||
# Debian base (create or append minimal lines if missing)
|
||||
if ! grep -qE 'deb .* bookworm .* main' "$sources_file" 2>/dev/null; then
|
||||
{
|
||||
echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware"
|
||||
echo "deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware"
|
||||
echo "deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware"
|
||||
} >> "$sources_file"
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
# Proxmox no-subscription list (classic) if missing
|
||||
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
|
||||
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
|
||||
> /etc/apt/sources.list.d/pve-no-subscription.list
|
||||
need_update=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# apt-get update only if needed or lists are empty
|
||||
if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then
|
||||
msg_info "$(translate 'Updating APT package lists...')"
|
||||
apt-get update >/dev/null 2>&1 || apt-get update
|
||||
msg_ok "$(translate 'APT package lists updated')"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
if [[ -n "${__PROXMENUX_SHARE_COMMON__}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -41,7 +128,7 @@ pmx_share_map_set() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +207,7 @@ pmx_choose_or_create_group() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -179,7 +266,7 @@ pmx_ensure_host_group() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -214,7 +301,7 @@ pmx_prepare_host_shared_dir() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -257,7 +344,7 @@ pmx_select_host_mount_point() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -311,7 +398,7 @@ select_host_directory_() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -366,7 +453,7 @@ select_host_directory__() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
@@ -421,6 +508,9 @@ select_host_directory() {
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
select_lxc_container() {
|
||||
@@ -455,6 +545,14 @@ select_lxc_container() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
select_container_mount_point_() {
|
||||
local ctid="$1"
|
||||
local host_dir="$2"
|
||||
@@ -500,7 +598,7 @@ select_container_mount_point_() {
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - AMD GPU Tools Installer
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# Last Updated: 29/01/2026
|
||||
# ============================================
|
||||
# Installs amdgpu_top for monitoring AMD GPUs
|
||||
# https://github.com/Umio-Yasuno/amdgpu_top
|
||||
|
||||
SCRIPT_TITLE="AMD GPU Tools Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="c"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
LOG_FILE="/tmp/amd_gpu_tools_install.log"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language 2>/dev/null || true
|
||||
initialize_cache 2>/dev/null || true
|
||||
|
||||
# ==========================================================
|
||||
# AMD GPU detection
|
||||
# ==========================================================
|
||||
detect_amd_gpus() {
|
||||
local lspci_output
|
||||
lspci_output=$(lspci | grep -iE "(AMD|ATI)" \
|
||||
| grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
|
||||
|
||||
if [[ -z "$lspci_output" ]]; then
|
||||
AMD_GPU_PRESENT=false
|
||||
DETECTED_GPUS_TEXT="No AMD GPU detected on this system."
|
||||
else
|
||||
AMD_GPU_PRESENT=true
|
||||
DETECTED_GPUS_TEXT=""
|
||||
local i=1
|
||||
while IFS= read -r line; do
|
||||
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
|
||||
((i++))
|
||||
done <<< "$lspci_output"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Check if amdgpu_top is installed
|
||||
# ==========================================================
|
||||
check_amdgpu_top_installed() {
|
||||
if command -v amdgpu_top >/dev/null 2>&1; then
|
||||
AMDGPU_TOP_INSTALLED=true
|
||||
AMDGPU_TOP_VERSION=$(amdgpu_top --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
else
|
||||
AMDGPU_TOP_INSTALLED=false
|
||||
AMDGPU_TOP_VERSION=""
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Get latest amdgpu_top release from GitHub
|
||||
# ==========================================================
|
||||
get_latest_release() {
|
||||
local api_url="https://api.github.com/repos/Umio-Yasuno/amdgpu_top/releases/latest"
|
||||
|
||||
LATEST_VERSION=$(curl -sL "$api_url" | grep '"tag_name"' | head -n1 | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
|
||||
|
||||
if [[ -z "$LATEST_VERSION" ]]; then
|
||||
msg_error "$(translate 'Failed to get latest version from GitHub')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get the .deb download URL for amd64
|
||||
DEB_URL=$(curl -sL "$api_url" | grep -oP '"browser_download_url":\s*"\K[^"]+amd64\.deb' | head -n1)
|
||||
|
||||
if [[ -z "$DEB_URL" ]]; then
|
||||
msg_error "$(translate 'Failed to get .deb download URL')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Install dependencies
|
||||
# ==========================================================
|
||||
install_dependencies() {
|
||||
msg_info "$(translate 'Installing required dependencies...')"
|
||||
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
|
||||
# Install libdrm packages required for amdgpu_top
|
||||
if apt-get install -y libdrm-dev libdrm-amdgpu1 libdrm2 curl wget >>"$LOG_FILE" 2>&1; then
|
||||
msg_ok "$(translate 'Dependencies installed successfully')"
|
||||
return 0
|
||||
else
|
||||
msg_error "$(translate 'Failed to install dependencies')"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Install amdgpu_top
|
||||
# ==========================================================
|
||||
install_amdgpu_top() {
|
||||
local tmp_dir="/tmp/amdgpu_top_install"
|
||||
mkdir -p "$tmp_dir"
|
||||
|
||||
msg_info "$(translate 'Downloading amdgpu_top') ${LATEST_VERSION}..."
|
||||
|
||||
local deb_file="$tmp_dir/amdgpu_top.deb"
|
||||
|
||||
if ! wget -q -O "$deb_file" "$DEB_URL" >>"$LOG_FILE" 2>&1; then
|
||||
msg_error "$(translate 'Failed to download amdgpu_top')"
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Downloaded amdgpu_top') ${LATEST_VERSION}"
|
||||
|
||||
msg_info "$(translate 'Installing amdgpu_top...')"
|
||||
|
||||
if ! dpkg -i "$deb_file" >>"$LOG_FILE" 2>&1; then
|
||||
# Try to fix dependencies if dpkg failed
|
||||
apt-get install -f -y >>"$LOG_FILE" 2>&1
|
||||
if ! dpkg -i "$deb_file" >>"$LOG_FILE" 2>&1; then
|
||||
msg_error "$(translate 'Failed to install amdgpu_top')"
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'amdgpu_top installed successfully')"
|
||||
|
||||
# Clean up
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
# Update component status
|
||||
if type update_component_status &>/dev/null; then
|
||||
update_component_status "amdgpu_top" "installed" "$LATEST_VERSION" "gpu" '{"source":"github"}'
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Uninstall amdgpu_top
|
||||
# ==========================================================
|
||||
uninstall_amdgpu_top() {
|
||||
msg_info "$(translate 'Uninstalling amdgpu_top...')"
|
||||
|
||||
if dpkg -r amdgpu-top >>"$LOG_FILE" 2>&1 || apt-get remove -y amdgpu-top >>"$LOG_FILE" 2>&1; then
|
||||
msg_ok "$(translate 'amdgpu_top uninstalled successfully')"
|
||||
|
||||
if type update_component_status &>/dev/null; then
|
||||
update_component_status "amdgpu_top" "uninstalled" "" "gpu" '{}'
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
msg_error "$(translate 'Failed to uninstall amdgpu_top')"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Main execution
|
||||
# ==========================================================
|
||||
main() {
|
||||
# Show ProxMenux logo and title
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
# Detect AMD GPUs
|
||||
detect_amd_gpus
|
||||
|
||||
if ! $AMD_GPU_PRESENT; then
|
||||
msg_warn "$(translate 'No AMD GPU detected on this system.')"
|
||||
msg_info2 "$(translate 'This tool is designed for systems with AMD GPUs.')"
|
||||
msg_info2 "$(translate 'You can still install amdgpu_top if needed.')"
|
||||
echo ""
|
||||
else
|
||||
msg_ok "$(translate 'AMD GPU(s) detected:')"
|
||||
echo -e "$DETECTED_GPUS_TEXT"
|
||||
fi
|
||||
|
||||
# Check if already installed
|
||||
check_amdgpu_top_installed
|
||||
|
||||
if $AMDGPU_TOP_INSTALLED; then
|
||||
msg_ok "$(translate 'amdgpu_top is already installed:') $AMDGPU_TOP_VERSION"
|
||||
|
||||
# Check for updates
|
||||
if get_latest_release; then
|
||||
if [[ "$AMDGPU_TOP_VERSION" != *"$LATEST_VERSION"* ]]; then
|
||||
msg_info2 "$(translate 'A newer version is available:') $LATEST_VERSION"
|
||||
msg_info "$(translate 'Updating amdgpu_top...')"
|
||||
|
||||
if install_dependencies && install_amdgpu_top; then
|
||||
msg_ok "$(translate 'amdgpu_top updated to') $LATEST_VERSION"
|
||||
else
|
||||
msg_error "$(translate 'Failed to update amdgpu_top')"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'amdgpu_top is up to date')"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
msg_info2 "$(translate 'amdgpu_top is not installed')"
|
||||
msg_info "$(translate 'Starting installation...')"
|
||||
|
||||
# Get latest release info
|
||||
if ! get_latest_release; then
|
||||
msg_error "$(translate 'Failed to get release information from GitHub')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Latest version:') $LATEST_VERSION"
|
||||
|
||||
# Install dependencies
|
||||
if ! install_dependencies; then
|
||||
msg_error "$(translate 'Failed to install dependencies')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install amdgpu_top
|
||||
if ! install_amdgpu_top; then
|
||||
msg_error "$(translate 'Installation failed')"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
msg_ok "$(translate 'AMD GPU Tools installation completed!')"
|
||||
echo ""
|
||||
msg_info2 "$(translate 'You can now monitor your AMD GPU using:')"
|
||||
echo " amdgpu_top - $(translate 'TUI mode')"
|
||||
echo " amdgpu_top --json - $(translate 'JSON output for scripts')"
|
||||
echo " amdgpu_top --gui - $(translate 'GUI mode (if available)')"
|
||||
echo ""
|
||||
|
||||
# In web mode, don't wait for user input
|
||||
if ! is_web_mode 2>/dev/null; then
|
||||
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
|
||||
read -r
|
||||
else
|
||||
msg_success "$(translate 'Installation completed.')"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Intel GPU Tools Installer
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# Last Updated: 29/01/2026
|
||||
# ============================================
|
||||
# Installs intel-gpu-tools for monitoring Intel GPUs
|
||||
|
||||
SCRIPT_TITLE="Intel GPU Tools Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMMON_FUNC="$LOCAL_SCRIPTS/global/share_common.func"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
LOG_FILE="/tmp/intel_gpu_tools_install.log"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ -f "$COMMON_FUNC" ]]; then
|
||||
source "$COMMON_FUNC"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language 2>/dev/null || true
|
||||
initialize_cache 2>/dev/null || true
|
||||
|
||||
# ==========================================================
|
||||
# Intel GPU detection
|
||||
# ==========================================================
|
||||
detect_intel_gpus() {
|
||||
local lspci_output
|
||||
lspci_output=$(lspci | grep -iE "Intel.*VGA|Intel.*Display|Intel.*Graphics" || true)
|
||||
|
||||
if [[ -z "$lspci_output" ]]; then
|
||||
INTEL_GPU_PRESENT=false
|
||||
DETECTED_GPUS_TEXT="No Intel GPU detected on this system."
|
||||
else
|
||||
INTEL_GPU_PRESENT=true
|
||||
DETECTED_GPUS_TEXT=""
|
||||
local i=1
|
||||
while IFS= read -r line; do
|
||||
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
|
||||
((i++))
|
||||
done <<< "$lspci_output"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Check if intel-gpu-tools is installed
|
||||
# ==========================================================
|
||||
check_intel_gpu_tools_installed() {
|
||||
if command -v intel_gpu_top >/dev/null 2>&1; then
|
||||
INTEL_GPU_TOOLS_INSTALLED=true
|
||||
INTEL_GPU_TOOLS_VERSION=$(dpkg -s intel-gpu-tools 2>/dev/null | grep '^Version:' | awk '{print $2}' || echo "unknown")
|
||||
else
|
||||
INTEL_GPU_TOOLS_INSTALLED=false
|
||||
INTEL_GPU_TOOLS_VERSION=""
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Install intel-gpu-tools
|
||||
# ==========================================================
|
||||
install_intel_gpu_tools() {
|
||||
msg_info "$(translate 'Installing intel-gpu-tools...')"
|
||||
|
||||
if apt-get install -y intel-gpu-tools >>"$LOG_FILE" 2>&1; then
|
||||
msg_ok "$(translate 'intel-gpu-tools installed successfully')"
|
||||
|
||||
# Get installed version
|
||||
INTEL_GPU_TOOLS_VERSION=$(dpkg -s intel-gpu-tools 2>/dev/null | grep '^Version:' | awk '{print $2}' || echo "unknown")
|
||||
|
||||
# Update component status
|
||||
if type update_component_status &>/dev/null; then
|
||||
update_component_status "intel_gpu_tools" "installed" "$INTEL_GPU_TOOLS_VERSION" "gpu" '{"source":"apt"}'
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
msg_error "$(translate 'Failed to install intel-gpu-tools')"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Uninstall intel-gpu-tools
|
||||
# ==========================================================
|
||||
uninstall_intel_gpu_tools() {
|
||||
msg_info "$(translate 'Uninstalling intel-gpu-tools...')"
|
||||
|
||||
if apt-get remove -y intel-gpu-tools >>"$LOG_FILE" 2>&1; then
|
||||
msg_ok "$(translate 'intel-gpu-tools uninstalled successfully')"
|
||||
|
||||
if type update_component_status &>/dev/null; then
|
||||
update_component_status "intel_gpu_tools" "uninstalled" "" "gpu" '{}'
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
msg_error "$(translate 'Failed to uninstall intel-gpu-tools')"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Main execution
|
||||
# ==========================================================
|
||||
main() {
|
||||
# Show ProxMenux logo and title
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
# Detect Intel GPUs
|
||||
detect_intel_gpus
|
||||
|
||||
if ! $INTEL_GPU_PRESENT; then
|
||||
msg_warn "$(translate 'No Intel GPU detected on this system.')"
|
||||
msg_info2 "$(translate 'This tool is designed for systems with Intel GPUs.')"
|
||||
msg_info2 "$(translate 'You can still install intel-gpu-tools if needed.')"
|
||||
echo ""
|
||||
else
|
||||
msg_ok "$(translate 'Intel GPU(s) detected:')"
|
||||
echo -e "$DETECTED_GPUS_TEXT"
|
||||
fi
|
||||
|
||||
# Check if already installed
|
||||
check_intel_gpu_tools_installed
|
||||
|
||||
if $INTEL_GPU_TOOLS_INSTALLED; then
|
||||
msg_ok "$(translate 'intel-gpu-tools is already installed:') $INTEL_GPU_TOOLS_VERSION"
|
||||
|
||||
# Check for updates
|
||||
msg_info "$(translate 'Checking for updates...')"
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
|
||||
local available_version
|
||||
available_version=$(apt-cache policy intel-gpu-tools 2>/dev/null | grep 'Candidate:' | awk '{print $2}')
|
||||
|
||||
if [[ -n "$available_version" && "$available_version" != "$INTEL_GPU_TOOLS_VERSION" ]]; then
|
||||
msg_ok "$(translate 'A newer version is available:') $available_version"
|
||||
|
||||
if apt-get install -y intel-gpu-tools >>"$LOG_FILE" 2>&1; then
|
||||
INTEL_GPU_TOOLS_VERSION="$available_version"
|
||||
msg_ok "$(translate 'intel-gpu-tools updated to') $INTEL_GPU_TOOLS_VERSION"
|
||||
|
||||
if type update_component_status &>/dev/null; then
|
||||
update_component_status "intel_gpu_tools" "installed" "$INTEL_GPU_TOOLS_VERSION" "gpu" '{"source":"apt"}'
|
||||
fi
|
||||
else
|
||||
msg_error "$(translate 'Failed to update intel-gpu-tools')"
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'intel-gpu-tools is up to date')"
|
||||
fi
|
||||
else
|
||||
|
||||
# Ensure repositories are configured
|
||||
if type ensure_repositories &>/dev/null; then
|
||||
ensure_repositories
|
||||
fi
|
||||
|
||||
# Install intel-gpu-tools
|
||||
if ! install_intel_gpu_tools; then
|
||||
msg_error "$(translate 'Installation failed')"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
msg_ok "$(translate 'Intel GPU Tools installation completed!')"
|
||||
echo ""
|
||||
msg_info2 "$(translate 'You can now monitor your Intel GPU using:')"
|
||||
echo " intel_gpu_top - $(translate 'TUI mode (requires root)')"
|
||||
echo " intel_gpu_frequency - $(translate 'Show GPU frequency')"
|
||||
echo " intel_gpu_time - $(translate 'Show GPU time')"
|
||||
echo ""
|
||||
|
||||
# In web mode, don't wait for user input
|
||||
if ! is_web_mode 2>/dev/null; then
|
||||
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
|
||||
read -r
|
||||
else
|
||||
msg_success "$(translate 'Installation completed.')"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 04/04/2025
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
if ! command -v dialog &>/dev/null; then
|
||||
apt update -qq >/dev/null 2>&1
|
||||
apt install -y dialog >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
show_menu() {
|
||||
local TEMP_FILE
|
||||
TEMP_FILE=$(mktemp)
|
||||
|
||||
while true; do
|
||||
dialog --clear \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate "Main ProxMenux")" \
|
||||
--menu "$(translate "Select an option:")" 20 70 10 \
|
||||
1 "$(translate "Settings post-install Proxmox")" \
|
||||
2 "$(translate "Help and Info Commands")" \
|
||||
3 "$(translate "Hardware: GPUs and Coral-TPU")" \
|
||||
4 "$(translate "Create VM from template or script")" \
|
||||
5 "$(translate "Disk and Storage Manager")" \
|
||||
6 "$(translate "Proxmox VE Helper Scripts")" \
|
||||
7 "$(translate "Network Management")" \
|
||||
8 "$(translate "Utilities and Tools")" \
|
||||
9 "$(translate "Settings")" \
|
||||
0 "$(translate "Exit")" 2>"$TEMP_FILE"
|
||||
|
||||
local EXIT_STATUS=$?
|
||||
|
||||
if [[ $EXIT_STATUS -ne 0 ]]; then
|
||||
# ESC pressed or Cancel
|
||||
clear
|
||||
msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OPTION=$(<"$TEMP_FILE")
|
||||
|
||||
case $OPTION in
|
||||
1) exec bash "$LOCAL_SCRIPTS/menus/menu_post_install.sh" ;;
|
||||
2) bash "$LOCAL_SCRIPTS/help_info_menu.sh" ;;
|
||||
3) exec bash "$LOCAL_SCRIPTS/menus/hw_grafics_menu.sh" ;;
|
||||
4) exec bash "$LOCAL_SCRIPTS/menus/create_vm_menu.sh" ;;
|
||||
5) exec bash "$LOCAL_SCRIPTS/menus/storage_menu.sh" ;;
|
||||
6) exec bash "$LOCAL_SCRIPTS/menus/menu_Helper_Scripts.sh" ;;
|
||||
7) exec bash "$LOCAL_SCRIPTS/menus/network_menu.sh" ;;
|
||||
8) exec bash "$LOCAL_SCRIPTS/menus/utilities_menu.sh" ;;
|
||||
9) exec bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
|
||||
0) clear; msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"; rm -f "$TEMP_FILE"; exit 0 ;;
|
||||
*) msg_warn "$(translate "Invalid option")"; sleep 2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_menu
|
||||
@@ -6,8 +6,8 @@
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 14/11/2025
|
||||
# Version : 1.3
|
||||
# Last Updated: 14/03/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script provides a simple and efficient way to access and execute Proxmox VE scripts
|
||||
@@ -33,8 +33,9 @@ load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
# New unified cache — categories and mirror URLs are embedded,
|
||||
# metadata.json is no longer needed.
|
||||
HELPERS_JSON_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/refs/heads/main/json/helpers_cache.json"
|
||||
METADATA_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/frontend/public/json/metadata.json"
|
||||
|
||||
for cmd in curl jq dialog; do
|
||||
if ! command -v "$cmd" >/dev/null; then
|
||||
@@ -44,63 +45,78 @@ for cmd in curl jq dialog; do
|
||||
done
|
||||
|
||||
CACHE_JSON=$(curl -s "$HELPERS_JSON_URL")
|
||||
META_JSON=$(curl -s "$METADATA_URL")
|
||||
|
||||
# Validate that the JSON loaded correctly
|
||||
if ! echo "$CACHE_JSON" | jq -e 'if type == "array" and length > 0 then true else false end' >/dev/null 2>&1; then
|
||||
dialog --title "Helper Scripts" \
|
||||
--msgbox "Error: Could not load helpers cache.\nCheck your internet connection and try again.\n\nURL: $HELPERS_JSON_URL" 10 70
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build category map directly from the cache (id → name).
|
||||
# Uses transpose to pair categories[] and category_names[] arrays — no
|
||||
# dependency on metadata.json, which no longer exists upstream.
|
||||
# ---------------------------------------------------------------------------
|
||||
declare -A CATEGORY_NAMES
|
||||
while read -r id name; do
|
||||
CATEGORY_NAMES[$id]="$name"
|
||||
done < <(echo "$META_JSON" | jq -r '.categories[] | "\(.id)\t\(.name)"')
|
||||
while IFS=$'\t' read -r id name; do
|
||||
[[ -n "$id" && -n "$name" ]] && CATEGORY_NAMES["$id"]="$name"
|
||||
done < <(echo "$CACHE_JSON" | jq -r '
|
||||
[.[] | [.categories, .category_names] | transpose[] | @tsv]
|
||||
| unique[]')
|
||||
|
||||
# Count scripts per category (deduplicated by slug)
|
||||
declare -A CATEGORY_COUNT
|
||||
for id in $(echo "$CACHE_JSON" | jq -r '
|
||||
group_by(.slug) | map(.[0])[] | .categories[]'); do
|
||||
while read -r id; do
|
||||
((CATEGORY_COUNT[$id]++))
|
||||
done
|
||||
done < <(echo "$CACHE_JSON" | jq -r '
|
||||
group_by(.slug) | map(.[0])[] | .categories[]')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type label — updated to match new type values (lxc instead of ct)
|
||||
# ---------------------------------------------------------------------------
|
||||
get_type_label() {
|
||||
local type="$1"
|
||||
case "$type" in
|
||||
ct) echo $'\Z1LXC\Zn' ;;
|
||||
vm) echo $'\Z4VM\Zn' ;;
|
||||
pve) echo $'\Z3PVE\Zn' ;;
|
||||
addon) echo $'\Z2ADDON\Zn' ;;
|
||||
*) echo $'\Z7GEN\Zn' ;;
|
||||
lxc) echo $'\Z1LXC\Zn' ;;
|
||||
vm) echo $'\Z4VM\Zn' ;;
|
||||
pve) echo $'\Z3PVE\Zn' ;;
|
||||
addon) echo $'\Z2ADDON\Zn' ;;
|
||||
turnkey) echo $'\Z5TK\Zn' ;;
|
||||
*) echo $'\Z7GEN\Zn' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download and execute a script URL, with optional mirror fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
download_script() {
|
||||
local url="$1"
|
||||
local fallback_pve="${url/misc\/tools\/pve}"
|
||||
local fallback_addon="${url/misc\/tools\/addon}"
|
||||
local fallback_copydata="${url/misc\/tools\/copy-data}"
|
||||
|
||||
if curl --silent --head --fail "$url" >/dev/null; then
|
||||
bash <(curl -s "$url")
|
||||
elif curl --silent --head --fail "$fallback_pve" >/dev/null; then
|
||||
bash <(curl -s "$fallback_pve")
|
||||
elif curl --silent --head --fail "$fallback_addon" >/dev/null; then
|
||||
bash <(curl -s "$fallback_addon")
|
||||
elif curl --silent --head --fail "$fallback_copydata" >/dev/null; then
|
||||
bash <(curl -s "$fallback_copydata")
|
||||
bash <(curl -s "$url")
|
||||
else
|
||||
dialog --title "Helper Scripts" --msgbox "Error: Failed to download the script." 12 70
|
||||
dialog --title "Helper Scripts" --msgbox "$(translate "Error: Failed to download the script.")" 8 70
|
||||
fi
|
||||
}
|
||||
|
||||
RETURN_TO_MAIN=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format default credentials for display
|
||||
# ---------------------------------------------------------------------------
|
||||
format_credentials() {
|
||||
local script_info="$1"
|
||||
local credentials_info=""
|
||||
|
||||
|
||||
local has_credentials
|
||||
has_credentials=$(echo "$script_info" | base64 --decode | jq -r 'has("default_credentials")')
|
||||
|
||||
|
||||
if [[ "$has_credentials" == "true" ]]; then
|
||||
local username password
|
||||
username=$(echo "$script_info" | base64 --decode | jq -r '.default_credentials.username // empty')
|
||||
password=$(echo "$script_info" | base64 --decode | jq -r '.default_credentials.password // empty')
|
||||
|
||||
|
||||
if [[ -n "$username" && -n "$password" ]]; then
|
||||
credentials_info="Username: $username | Password: $password"
|
||||
elif [[ -n "$username" ]]; then
|
||||
@@ -109,30 +125,41 @@ format_credentials() {
|
||||
credentials_info="Password: $password"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo "$credentials_info"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run a script identified by its slug.
|
||||
#
|
||||
# A slug can have multiple entries when a script supports several OS variants
|
||||
# (e.g. Debian + Alpine). Each entry carries its own script_url / mirror and
|
||||
# the os field already normalised to lowercase by generate_helpers_cache.py.
|
||||
# The menu lets the user pick OS variant × source (GitHub / Mirror).
|
||||
# ---------------------------------------------------------------------------
|
||||
run_script_by_slug() {
|
||||
local slug="$1"
|
||||
local -a script_infos
|
||||
mapfile -t script_infos < <(echo "$CACHE_JSON" | jq -r --arg slug "$slug" '.[] | select(.slug == $slug) | @base64')
|
||||
mapfile -t script_infos < <(echo "$CACHE_JSON" | jq -r --arg slug "$slug" \
|
||||
'.[] | select(.slug == $slug) | @base64')
|
||||
|
||||
if [[ ${#script_infos[@]} -eq 0 ]]; then
|
||||
dialog --title "Helper Scripts" --msgbox "Error: No script data found for slug: $slug" 8 60
|
||||
dialog --title "Helper Scripts" \
|
||||
--msgbox "$(translate "Error: No script data found for slug:") $slug" 8 60
|
||||
return
|
||||
fi
|
||||
|
||||
decode() {
|
||||
echo "$1" | base64 --decode | jq -r "$2"
|
||||
}
|
||||
decode() { echo "$1" | base64 --decode | jq -r "$2"; }
|
||||
|
||||
local first="${script_infos[0]}"
|
||||
local name desc notes
|
||||
local name desc notes port website
|
||||
name=$(decode "$first" ".name")
|
||||
desc=$(decode "$first" ".desc")
|
||||
notes=$(decode "$first" ".notes | join(\"\n\")")
|
||||
notes=$(decode "$first" '.notes | join("\n")')
|
||||
port=$(decode "$first" ".port // 0")
|
||||
website=$(decode "$first" ".website // empty")
|
||||
|
||||
# Build notes block
|
||||
local notes_dialog=""
|
||||
if [[ -n "$notes" ]]; then
|
||||
while IFS= read -r line; do
|
||||
@@ -145,18 +172,21 @@ run_script_by_slug() {
|
||||
local credentials
|
||||
credentials=$(format_credentials "$first")
|
||||
|
||||
local msg="\Zb\Z4Descripción:\Zn\n$desc"
|
||||
[[ -n "$notes_dialog" ]] && msg+="\n\n\Zb\Z4Notes:\Zn\n$notes_dialog"
|
||||
[[ -n "$credentials" ]] && msg+="\n\n\Zb\Z4Default Credentials:\Zn\n$credentials"
|
||||
|
||||
# Add separator before menu options
|
||||
# Build info message
|
||||
local msg="\Zb\Z4$(translate "Description"):\Zn\n$desc"
|
||||
[[ -n "$notes_dialog" ]] && msg+="\n\n\Zb\Z4$(translate "Notes"):\Zn\n$notes_dialog"
|
||||
[[ -n "$credentials" ]] && msg+="\n\n\Zb\Z4$(translate "Default Credentials"):\Zn\n$credentials"
|
||||
[[ "$port" -gt 0 ]] && msg+="\n\n\Zb\Z4$(translate "Default Port"):\Zn $port"
|
||||
[[ -n "$website" ]] && msg+="\n\Zb\Z4$(translate "Website"):\Zn $website"
|
||||
|
||||
msg+="\n\n$(translate "Choose how to run the script:"):"
|
||||
|
||||
# Build menu: one or two entries per script_info (GH + optional Mirror)
|
||||
declare -a MENU_OPTS=()
|
||||
local idx=0
|
||||
for s in "${script_infos[@]}"; do
|
||||
local os script_url script_url_mirror script_name
|
||||
os=$(decode "$s" ".os // empty")
|
||||
os=$(decode "$s" '.os // empty')
|
||||
[[ -z "$os" ]] && os="$(translate "default")"
|
||||
script_name=$(decode "$s" ".name")
|
||||
script_url=$(decode "$s" ".script_url")
|
||||
@@ -196,7 +226,8 @@ run_script_by_slug() {
|
||||
if [[ -n "$mirror_url" ]]; then
|
||||
download_script "$mirror_url"
|
||||
else
|
||||
dialog --title "Helper Scripts" --msgbox "$(translate "Mirror URL not available for this script.")" 8 60
|
||||
dialog --title "Helper Scripts" \
|
||||
--msgbox "$(translate "Mirror URL not available for this script.")" 8 60
|
||||
RETURN_TO_MAIN=false
|
||||
return
|
||||
fi
|
||||
@@ -206,10 +237,10 @@ run_script_by_slug() {
|
||||
echo
|
||||
|
||||
if [[ -n "$desc" || -n "$notes" || -n "$credentials" ]]; then
|
||||
echo -e "$TAB\e[1;36mScript Information:\e[0m"
|
||||
echo -e "$TAB\e[1;36m$(translate "Script Information"):\e[0m"
|
||||
|
||||
if [[ -n "$notes" ]]; then
|
||||
echo -e "$TAB\e[1;33mNotes:\e[0m"
|
||||
echo -e "$TAB\e[1;33m$(translate "Notes"):\e[0m"
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
echo -e "$TAB• $line"
|
||||
@@ -218,26 +249,30 @@ run_script_by_slug() {
|
||||
fi
|
||||
|
||||
if [[ -n "$credentials" ]]; then
|
||||
echo -e "$TAB\e[1;32mDefault Credentials:\e[0m"
|
||||
echo -e "$TAB\e[1;32m$(translate "Default Credentials"):\e[0m"
|
||||
echo "$TAB$credentials"
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_success "Press Enter to return to the main menu..."
|
||||
msg_success "$(translate "Press Enter to return to the main menu...")"
|
||||
read -r
|
||||
RETURN_TO_MAIN=true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search / filter scripts by name or description
|
||||
# ---------------------------------------------------------------------------
|
||||
search_and_filter_scripts() {
|
||||
local search_term=""
|
||||
|
||||
|
||||
while true; do
|
||||
search_term=$(dialog --inputbox "Enter search term (leave empty to show all scripts):" \
|
||||
8 65 "$search_term" 3>&1 1>&2 2>&3)
|
||||
|
||||
search_term=$(dialog --inputbox \
|
||||
"$(translate "Enter search term (leave empty to show all scripts):"):" \
|
||||
8 65 "$search_term" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
|
||||
local filtered_json
|
||||
if [[ -z "$search_term" ]]; then
|
||||
filtered_json="$CACHE_JSON"
|
||||
@@ -250,12 +285,14 @@ search_and_filter_scripts() {
|
||||
(.desc | ascii_downcase | contains($term))
|
||||
)]')
|
||||
fi
|
||||
|
||||
|
||||
local count
|
||||
count=$(echo "$filtered_json" | jq 'group_by(.slug) | length')
|
||||
|
||||
if [[ $count -eq 0 ]]; then
|
||||
dialog --msgbox "No scripts found for: '$search_term'\n\nTry a different search term." 8 50
|
||||
|
||||
if [[ "$count" -eq 0 ]]; then
|
||||
dialog --msgbox \
|
||||
"$(translate "No scripts found for:") '$search_term'\n\n$(translate "Try a different search term.")" \
|
||||
8 50
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -263,43 +300,41 @@ search_and_filter_scripts() {
|
||||
declare -A index_to_slug
|
||||
local menu_items=()
|
||||
local i=1
|
||||
|
||||
|
||||
while IFS=$'\t' read -r slug name type; do
|
||||
index_to_slug[$i]="$slug"
|
||||
local label
|
||||
label=$(get_type_label "$type")
|
||||
local padded_name
|
||||
padded_name=$(printf "%-42s" "$name")
|
||||
local entry="$padded_name $label"
|
||||
menu_items+=("$i" "$entry")
|
||||
menu_items+=("$i" "$padded_name $label")
|
||||
((i++))
|
||||
done < <(echo "$filtered_json" | jq -r '
|
||||
group_by(.slug) | map(.[0]) | sort_by(.name)[] | [.slug, .name, .type] | @tsv')
|
||||
|
||||
group_by(.slug) | map(.[0]) | sort_by(.name)[]
|
||||
| [.slug, .name, .type] | @tsv')
|
||||
|
||||
menu_items+=("" "")
|
||||
menu_items+=("new_search" "New Search")
|
||||
menu_items+=("show_all" "Show All Scripts")
|
||||
|
||||
local title="Search Results"
|
||||
menu_items+=("new_search" "$(translate "New Search")")
|
||||
menu_items+=("show_all" "$(translate "Show All Scripts")")
|
||||
|
||||
local title
|
||||
if [[ -n "$search_term" ]]; then
|
||||
title="Search Results for: '$search_term' ($count found)"
|
||||
title="$(translate "Search Results for:") '$search_term' ($count $(translate "found"))"
|
||||
else
|
||||
title="All Available Scripts ($count total)"
|
||||
title="$(translate "All Available Scripts") ($count $(translate "total"))"
|
||||
fi
|
||||
|
||||
|
||||
local selected
|
||||
selected=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$title" \
|
||||
--menu "Select a script or action:" \
|
||||
--menu "$(translate "Select a script or action:"):" \
|
||||
22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
case "$selected" in
|
||||
"new_search")
|
||||
break
|
||||
break
|
||||
;;
|
||||
"show_all")
|
||||
search_term=""
|
||||
@@ -308,7 +343,7 @@ search_and_filter_scripts() {
|
||||
continue
|
||||
;;
|
||||
"back"|"")
|
||||
return
|
||||
return
|
||||
;;
|
||||
*)
|
||||
if [[ -n "${index_to_slug[$selected]}" ]]; then
|
||||
@@ -321,48 +356,64 @@ search_and_filter_scripts() {
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop — category list built from embedded category data.
|
||||
# We map scriptcatXXXXX IDs to short numeric indices so dialog doesn't show
|
||||
# the long ID string as the visible tag in the menu column.
|
||||
# ---------------------------------------------------------------------------
|
||||
while true; do
|
||||
MENU_ITEMS=()
|
||||
|
||||
MENU_ITEMS+=("search" "Search/Filter Scripts")
|
||||
MENU_ITEMS+=("search" "$(translate "Search/Filter Scripts")")
|
||||
MENU_ITEMS+=("" "")
|
||||
|
||||
for id in $(printf "%s\n" "${!CATEGORY_COUNT[@]}" | sort -n); do
|
||||
|
||||
# Map scriptcatXXXXX IDs to short numeric indices (1, 2, 3…) so dialog
|
||||
# doesn't render the long ID string as the visible tag column.
|
||||
declare -A CAT_IDX_TO_ID
|
||||
local_idx=1
|
||||
for id in $(printf "%s\n" "${!CATEGORY_COUNT[@]}" | sort); do
|
||||
CAT_IDX_TO_ID[$local_idx]="$id"
|
||||
name="${CATEGORY_NAMES[$id]:-Category $id}"
|
||||
count="${CATEGORY_COUNT[$id]}"
|
||||
padded_name=$(printf "%-35s" "$name")
|
||||
padded_count=$(printf "(%2d)" "$count")
|
||||
MENU_ITEMS+=("$id" "$padded_name $padded_count")
|
||||
MENU_ITEMS+=("$local_idx" "$padded_name $padded_count")
|
||||
((local_idx++))
|
||||
done
|
||||
|
||||
SELECTED=$(dialog --backtitle "ProxMenux" --title "Proxmox VE Helper-Scripts" --menu \
|
||||
"Select a category or search for scripts:" 20 70 14 \
|
||||
"${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
|
||||
dialog --clear --title "ProxMenux" \
|
||||
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
|
||||
SELECTED_IDX=$(dialog --backtitle "ProxMenux" \
|
||||
--title "Proxmox VE Helper-Scripts" \
|
||||
--menu "$(translate "Select a category or search for scripts:"):" \
|
||||
20 70 14 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
|
||||
dialog --clear --title "ProxMenux" \
|
||||
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
}
|
||||
|
||||
if [[ "$SELECTED" == "search" ]]; then
|
||||
|
||||
if [[ "$SELECTED_IDX" == "search" ]]; then
|
||||
search_and_filter_scripts
|
||||
continue
|
||||
fi
|
||||
|
||||
# Resolve numeric index back to the real category ID
|
||||
SELECTED="${CAT_IDX_TO_ID[$SELECTED_IDX]}"
|
||||
[[ -z "$SELECTED" ]] && continue
|
||||
|
||||
# ---- Scripts within the selected category --------------------------------
|
||||
while true; do
|
||||
declare -A INDEX_TO_SLUG
|
||||
SCRIPTS=()
|
||||
i=1
|
||||
|
||||
while IFS=$'\t' read -r slug name type; do
|
||||
INDEX_TO_SLUG[$i]="$slug"
|
||||
label=$(get_type_label "$type")
|
||||
padded_name=$(printf "%-42s" "$name")
|
||||
entry="$padded_name $label"
|
||||
SCRIPTS+=("$i" "$entry")
|
||||
SCRIPTS+=("$i" "$padded_name $label")
|
||||
((i++))
|
||||
done < <(echo "$CACHE_JSON" | jq -r --argjson id "$SELECTED" '
|
||||
done < <(echo "$CACHE_JSON" | jq -r --arg id "$SELECTED" '
|
||||
[
|
||||
.[]
|
||||
| select(.categories | index($id))
|
||||
.[]
|
||||
| select(.categories | index($id))
|
||||
| {slug, name, type}
|
||||
]
|
||||
| group_by(.slug)
|
||||
@@ -371,13 +422,14 @@ while true; do
|
||||
| [.slug, .name, .type]
|
||||
| @tsv')
|
||||
|
||||
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" --title "Scripts in ${CATEGORY_NAMES[$SELECTED]}" --menu \
|
||||
"Choose a script to execute:" 20 70 14 \
|
||||
"${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
|
||||
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \
|
||||
--menu "$(translate "Choose a script to execute:"):" \
|
||||
20 70 14 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
|
||||
|
||||
SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}"
|
||||
run_script_by_slug "$SCRIPT_SELECTED"
|
||||
|
||||
|
||||
[[ "$RETURN_TO_MAIN" == true ]] && { RETURN_TO_MAIN=false; break; }
|
||||
done
|
||||
done
|
||||
|
||||
@@ -238,8 +238,12 @@ Seal=no
|
||||
Compress=yes
|
||||
SystemMaxUse=64M
|
||||
RuntimeMaxUse=60M
|
||||
MaxLevelStore=warning
|
||||
MaxLevelSyslog=warning
|
||||
# MaxLevelStore=info allows ProxMenux Monitor to display system logs correctly.
|
||||
# Using "warning" causes the log viewer to show nearly identical entries across
|
||||
# all date ranges (1d/3d/7d) because most activity is info-level.
|
||||
# It also prevents Fail2Ban from detecting SSH/Proxmox auth failures via journal.
|
||||
MaxLevelStore=info
|
||||
MaxLevelSyslog=info
|
||||
MaxLevelKMsg=warning
|
||||
MaxLevelConsole=notice
|
||||
MaxLevelWall=crit
|
||||
@@ -787,8 +791,10 @@ Compress=yes
|
||||
SystemMaxUse=${USE_MB}M
|
||||
SystemKeepFree=${KEEP_MB}M
|
||||
RuntimeMaxUse=${RUNTIME_MB}M
|
||||
MaxLevelStore=warning
|
||||
MaxLevelSyslog=warning
|
||||
# MaxLevelStore=info: required for ProxMenux Monitor log display and Fail2Ban detection.
|
||||
# Using "warning" silently discards most system logs making date filters useless.
|
||||
MaxLevelStore=info
|
||||
MaxLevelSyslog=info
|
||||
MaxLevelKMsg=warning
|
||||
MaxLevelConsole=notice
|
||||
MaxLevelWall=crit
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Fail2Ban Installer & Configurator
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# ============================================
|
||||
# Hybrid script: works from terminal (dialog) and web panel (ScriptTerminalModal)
|
||||
|
||||
SCRIPT_TITLE="Fail2Ban Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Detection
|
||||
# ==========================================================
|
||||
detect_fail2ban() {
|
||||
FAIL2BAN_INSTALLED=false
|
||||
FAIL2BAN_VERSION=""
|
||||
FAIL2BAN_ACTIVE=false
|
||||
|
||||
if command -v fail2ban-client >/dev/null 2>&1; then
|
||||
FAIL2BAN_INSTALLED=true
|
||||
FAIL2BAN_VERSION=$(fail2ban-client --version 2>/dev/null | head -n1 | tr -d '[:space:]' || echo "unknown")
|
||||
|
||||
if systemctl is-active --quiet fail2ban 2>/dev/null; then
|
||||
FAIL2BAN_ACTIVE=true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Installation
|
||||
# ==========================================================
|
||||
install_fail2ban() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate "Installing and configuring Fail2Ban to protect Proxmox web interface and SSH...")"
|
||||
|
||||
# Ensure Debian repositories are available
|
||||
local deb_codename
|
||||
deb_codename=$(grep -oP '^VERSION_CODENAME=\K.*' /etc/os-release 2>/dev/null)
|
||||
|
||||
if ! grep -RqsE "debian.*(bookworm|trixie)" /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null; then
|
||||
msg_warn "$(translate "Debian repositories missing; creating default source file")"
|
||||
local src="/etc/apt/sources.list.d/debian.sources"
|
||||
cat > "$src" <<EOF
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian
|
||||
Suites: ${deb_codename} ${deb_codename}-updates
|
||||
Components: main contrib non-free non-free-firmware
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security
|
||||
Suites: ${deb_codename}-security
|
||||
Components: main contrib non-free non-free-firmware
|
||||
EOF
|
||||
msg_ok "$(translate "Debian repositories configured for ${deb_codename}")"
|
||||
fi
|
||||
|
||||
# Install Fail2Ban
|
||||
msg_info "$(translate "Installing Fail2Ban...")"
|
||||
if ! DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null 2>&1 || \
|
||||
! DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Failed to install Fail2Ban")"
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate "Fail2Ban installed successfully")"
|
||||
|
||||
# ── Ensure journald stores auth-level messages ──
|
||||
# Proxmox sets MaxLevelStore=warning by default, which silently drops
|
||||
# info/notice messages. SSH auth failures (PAM) are logged at info/notice,
|
||||
# so Fail2Ban with backend=systemd will never see them.
|
||||
local journald_conf="/etc/systemd/journald.conf"
|
||||
local journald_changed=false
|
||||
|
||||
if [[ -f "$journald_conf" ]]; then
|
||||
local current_max
|
||||
current_max=$(grep -i '^MaxLevelStore=' "$journald_conf" 2>/dev/null | tail -1 | cut -d= -f2 | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Levels that are too restrictive for auth logging (need at least info)
|
||||
case "$current_max" in
|
||||
emerg|alert|crit|err|warning)
|
||||
msg_warn "$(translate "journald MaxLevelStore is '${current_max}' - SSH auth failures are not being stored")"
|
||||
msg_info "$(translate "Updating journald to store info-level messages...")"
|
||||
|
||||
# Create a drop-in so we don't break other Proxmox settings
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
cat > /etc/systemd/journald.conf.d/proxmenux-loglevel.conf <<'JEOF'
|
||||
# ProxMenux: Allow auth/info messages so Fail2Ban can detect SSH failures
|
||||
# Proxmox default MaxLevelStore=warning drops PAM/SSH auth events
|
||||
[Journal]
|
||||
MaxLevelStore=info
|
||||
MaxLevelSyslog=info
|
||||
JEOF
|
||||
journald_changed=true
|
||||
msg_ok "$(translate "journald drop-in created: /etc/systemd/journald.conf.d/proxmenux-loglevel.conf")"
|
||||
;;
|
||||
*)
|
||||
msg_ok "$(translate "journald MaxLevelStore is adequate for auth logging")"
|
||||
;;
|
||||
esac
|
||||
|
||||
if $journald_changed; then
|
||||
systemctl restart systemd-journald
|
||||
sleep 1
|
||||
msg_ok "$(translate "journald restarted - auth messages will now be stored")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Journal-to-file logger services for Fail2Ban ──
|
||||
# Fail2Ban's systemd backend has a known issue: it cannot reliably read
|
||||
# journal entries in real-time from certain services (pvedaemon workers,
|
||||
# and intermittently sshd). The solution is to create small systemd services
|
||||
# that tail the journal and write to log files, then fail2ban monitors those
|
||||
# files with the reliable backend=auto.
|
||||
|
||||
# -- Proxmox UI auth logger (pvedaemon) --
|
||||
msg_info "$(translate "Creating Proxmox auth logger service...")"
|
||||
cat > /etc/systemd/system/proxmox-auth-logger.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Proxmox Auth Logger for Fail2Ban
|
||||
Documentation=https://github.com/MacRimi/ProxMenux
|
||||
After=pvedaemon.service
|
||||
PartOf=fail2ban.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/bin/bash -c 'journalctl -f _SYSTEMD_UNIT=pvedaemon.service -o short-iso --no-pager >> /var/log/proxmox-auth.log'
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
touch /var/log/proxmox-auth.log
|
||||
chmod 640 /var/log/proxmox-auth.log
|
||||
chown root:adm /var/log/proxmox-auth.log 2>/dev/null || true
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now proxmox-auth-logger.service >/dev/null 2>&1
|
||||
msg_ok "$(translate "Proxmox auth logger service created and started")"
|
||||
|
||||
# -- SSH auth logger --
|
||||
msg_info "$(translate "Creating SSH auth logger service...")"
|
||||
cat > /etc/systemd/system/ssh-auth-logger.service <<'EOF'
|
||||
[Unit]
|
||||
Description=SSH Auth Logger for Fail2Ban
|
||||
Documentation=https://github.com/MacRimi/ProxMenux
|
||||
After=ssh.service
|
||||
PartOf=fail2ban.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/bin/bash -c 'journalctl -f _SYSTEMD_UNIT=ssh.service -o short-iso --no-pager >> /var/log/ssh-auth.log'
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
touch /var/log/ssh-auth.log
|
||||
chmod 640 /var/log/ssh-auth.log
|
||||
chown root:adm /var/log/ssh-auth.log 2>/dev/null || true
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ssh-auth-logger.service >/dev/null 2>&1
|
||||
msg_ok "$(translate "SSH auth logger service created and started")"
|
||||
|
||||
# Configure Proxmox filter
|
||||
mkdir -p /etc/fail2ban/filter.d /etc/fail2ban/jail.d
|
||||
msg_info "$(translate "Configuring Proxmox filter...")"
|
||||
cat > /etc/fail2ban/filter.d/proxmox.conf <<'EOF'
|
||||
[Definition]
|
||||
# The proxmox-auth-logger service writes journal lines to /var/log/proxmox-auth.log
|
||||
# in short-iso format: 2026-02-10T19:36:08+01:00 host pvedaemon[PID]: message
|
||||
# Proxmox logs IPs as ::ffff:x.x.x.x (IPv4-mapped IPv6).
|
||||
failregex = authentication (failure|error); rhost=(::ffff:)?<HOST> user=.* msg=.*
|
||||
ignoreregex =
|
||||
datepattern = ^%%Y-%%m-%%dT%%H:%%M:%%S
|
||||
EOF
|
||||
msg_ok "$(translate "Proxmox filter configured")"
|
||||
|
||||
# Configure Proxmox jail (file-based backend)
|
||||
msg_info "$(translate "Configuring Proxmox jail...")"
|
||||
cat > /etc/fail2ban/jail.d/proxmox.conf <<'EOF'
|
||||
[proxmox]
|
||||
enabled = true
|
||||
port = 8006
|
||||
filter = proxmox
|
||||
backend = auto
|
||||
logpath = /var/log/proxmox-auth.log
|
||||
maxretry = 3
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
EOF
|
||||
msg_ok "$(translate "Proxmox jail configured")"
|
||||
|
||||
# Configure ProxMenux Monitor filter
|
||||
# This reads from a file written directly by the Flask app (not syslog/journal),
|
||||
# so it uses a datepattern that matches Python's logging format.
|
||||
msg_info "$(translate "Configuring ProxMenux Monitor filter...")"
|
||||
cat > /etc/fail2ban/filter.d/proxmenux.conf <<'EOF'
|
||||
[Definition]
|
||||
failregex = ^.*proxmenux-auth: authentication failure; rhost=<HOST> user=.*$
|
||||
ignoreregex =
|
||||
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
|
||||
EOF
|
||||
msg_ok "$(translate "ProxMenux Monitor filter configured")"
|
||||
|
||||
# Configure ProxMenux Monitor jail (port 8008 + http/https for reverse proxy)
|
||||
# Uses backend=auto with logpath because the Flask app writes directly to this file.
|
||||
msg_info "$(translate "Configuring ProxMenux Monitor jail...")"
|
||||
cat > /etc/fail2ban/jail.d/proxmenux.conf <<'EOF'
|
||||
[proxmenux]
|
||||
enabled = true
|
||||
port = 8008,http,https
|
||||
filter = proxmenux
|
||||
backend = auto
|
||||
logpath = /var/log/proxmenux-auth.log
|
||||
maxretry = 3
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
EOF
|
||||
msg_ok "$(translate "ProxMenux Monitor jail configured")"
|
||||
|
||||
# Ensure ProxMenux auth log exists (Flask writes here directly)
|
||||
touch /var/log/proxmenux-auth.log
|
||||
chmod 640 /var/log/proxmenux-auth.log 2>/dev/null || true
|
||||
|
||||
# Detect firewall backend (nftables preferred, fallback to iptables)
|
||||
local ban_action="iptables-multiport"
|
||||
local ban_action_all="iptables-allports"
|
||||
if command -v nft >/dev/null 2>&1 && nft list ruleset >/dev/null 2>&1; then
|
||||
ban_action="nftables"
|
||||
ban_action_all="nftables[type=allports]"
|
||||
msg_ok "$(translate "Detected nftables - using nftables ban action")"
|
||||
else
|
||||
msg_info "$(translate "nftables not available - using iptables ban action")"
|
||||
fi
|
||||
|
||||
# Configure global settings and SSH jail
|
||||
msg_info "$(translate "Configuring global Fail2Ban settings and SSH jail...")"
|
||||
cat > /etc/fail2ban/jail.local <<EOF
|
||||
[DEFAULT]
|
||||
ignoreip = 127.0.0.1/8 ::1
|
||||
ignoreself = true
|
||||
bantime = 86400
|
||||
maxretry = 2
|
||||
findtime = 1800
|
||||
backend = auto
|
||||
banaction = ${ban_action}
|
||||
banaction_allports = ${ban_action_all}
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
filter = sshd[mode=aggressive]
|
||||
backend = auto
|
||||
logpath = /var/log/ssh-auth.log
|
||||
maxretry = 2
|
||||
findtime = 3600
|
||||
bantime = 32400
|
||||
EOF
|
||||
msg_ok "$(translate "Global settings and SSH jail configured")"
|
||||
|
||||
# ── SSH Hardening: MaxAuthTries ──
|
||||
# Lynis (SSH-7408) recommends MaxAuthTries=3. With fail2ban maxretry=2,
|
||||
# SSH will never reach 3 attempts, but setting it satisfies the audit
|
||||
# and adds defense-in-depth. Backup original value for clean restore.
|
||||
local sshd_config="/etc/ssh/sshd_config"
|
||||
if [[ -f "$sshd_config" ]]; then
|
||||
# Save original MaxAuthTries value for restore on uninstall
|
||||
local original_max_auth
|
||||
original_max_auth=$(grep -i '^MaxAuthTries' "$sshd_config" 2>/dev/null | awk '{print $2}' || echo "6")
|
||||
if [[ -z "$original_max_auth" ]]; then
|
||||
original_max_auth="6"
|
||||
fi
|
||||
|
||||
# Store original value in our config directory
|
||||
echo "$original_max_auth" > "${BASE_DIR}/sshd_maxauthtries_backup"
|
||||
|
||||
msg_info "$(translate "Hardening SSH: setting MaxAuthTries to 3...")"
|
||||
if grep -qi '^MaxAuthTries' "$sshd_config"; then
|
||||
sed -i 's/^MaxAuthTries.*/MaxAuthTries 3/' "$sshd_config"
|
||||
elif grep -qi '^#MaxAuthTries' "$sshd_config"; then
|
||||
sed -i 's/^#MaxAuthTries.*/MaxAuthTries 3/' "$sshd_config"
|
||||
else
|
||||
echo "MaxAuthTries 3" >> "$sshd_config"
|
||||
fi
|
||||
|
||||
# Reload SSH to apply the change (reload, not restart, to keep existing sessions)
|
||||
systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true
|
||||
msg_ok "$(translate "SSH MaxAuthTries set to 3 (original: ${original_max_auth})")"
|
||||
fi
|
||||
|
||||
# Enable and restart the service (restart ensures new jails are loaded
|
||||
# even if fail2ban was already running from a previous install)
|
||||
systemctl daemon-reload
|
||||
systemctl enable fail2ban >/dev/null 2>&1
|
||||
systemctl restart fail2ban >/dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
# Verify
|
||||
if systemctl is-active --quiet fail2ban; then
|
||||
msg_ok "$(translate "Fail2Ban is running correctly")"
|
||||
else
|
||||
msg_error "$(translate "Fail2Ban is NOT running!")"
|
||||
journalctl -u fail2ban --no-pager -n 10
|
||||
fi
|
||||
|
||||
if fail2ban-client ping >/dev/null 2>&1; then
|
||||
msg_ok "$(translate "fail2ban-client successfully communicated with the server")"
|
||||
else
|
||||
msg_error "$(translate "fail2ban-client could not communicate with the server")"
|
||||
fi
|
||||
|
||||
update_component_status "fail2ban" "installed" "$(fail2ban-client --version 2>/dev/null | head -n1)" "security" '{}'
|
||||
|
||||
msg_success "$(translate "Fail2Ban installation and configuration completed successfully!")"
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Uninstall
|
||||
# ==========================================================
|
||||
uninstall_fail2ban() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate "Removing Fail2Ban...")"
|
||||
|
||||
systemctl stop fail2ban 2>/dev/null || true
|
||||
systemctl disable fail2ban 2>/dev/null || true
|
||||
|
||||
# Stop and remove the auth logger services
|
||||
systemctl stop proxmox-auth-logger.service 2>/dev/null || true
|
||||
systemctl disable proxmox-auth-logger.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/proxmox-auth-logger.service
|
||||
systemctl stop ssh-auth-logger.service 2>/dev/null || true
|
||||
systemctl disable ssh-auth-logger.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/ssh-auth-logger.service
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
rm -f /var/log/proxmox-auth.log /var/log/ssh-auth.log
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y fail2ban >/dev/null 2>&1
|
||||
rm -f /etc/fail2ban/jail.d/proxmox.conf
|
||||
rm -f /etc/fail2ban/jail.d/proxmenux.conf
|
||||
rm -f /etc/fail2ban/filter.d/proxmox.conf
|
||||
rm -f /etc/fail2ban/filter.d/proxmenux.conf
|
||||
rm -f /etc/fail2ban/jail.local
|
||||
|
||||
# ── Restore SSH MaxAuthTries to original value ──
|
||||
local sshd_config="/etc/ssh/sshd_config"
|
||||
local backup_file="${BASE_DIR}/sshd_maxauthtries_backup"
|
||||
if [[ -f "$backup_file" && -f "$sshd_config" ]]; then
|
||||
local original_val
|
||||
original_val=$(cat "$backup_file" 2>/dev/null | tr -d '[:space:]')
|
||||
if [[ -n "$original_val" ]]; then
|
||||
msg_info "$(translate "Restoring SSH MaxAuthTries to ${original_val}...")"
|
||||
if grep -qi '^MaxAuthTries' "$sshd_config"; then
|
||||
sed -i "s/^MaxAuthTries.*/MaxAuthTries ${original_val}/" "$sshd_config"
|
||||
fi
|
||||
systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true
|
||||
msg_ok "$(translate "SSH MaxAuthTries restored to ${original_val}")"
|
||||
fi
|
||||
rm -f "$backup_file"
|
||||
fi
|
||||
|
||||
# Remove journald drop-in and restore original log level
|
||||
if [[ -f /etc/systemd/journald.conf.d/proxmenux-loglevel.conf ]]; then
|
||||
rm -f /etc/systemd/journald.conf.d/proxmenux-loglevel.conf
|
||||
systemctl restart systemd-journald 2>/dev/null || true
|
||||
msg_ok "$(translate "journald log level restored")"
|
||||
fi
|
||||
|
||||
update_component_status "fail2ban" "removed" "" "security" '{}'
|
||||
|
||||
msg_ok "$(translate "Fail2Ban has been removed")"
|
||||
msg_success "$(translate "Uninstallation completed. Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Main
|
||||
# ==========================================================
|
||||
main() {
|
||||
detect_fail2ban
|
||||
|
||||
if $FAIL2BAN_INSTALLED; then
|
||||
# Already installed - show action menu
|
||||
local action_text
|
||||
action_text="\n$(translate 'Fail2Ban is currently installed.')\n"
|
||||
action_text+="$(translate 'Version:') $FAIL2BAN_VERSION\n"
|
||||
action_text+="$(translate 'Status:') $(if $FAIL2BAN_ACTIVE; then translate 'Active'; else translate 'Inactive'; fi)\n\n"
|
||||
action_text+="$(translate 'What would you like to do?')"
|
||||
|
||||
local ACTION
|
||||
ACTION=$(hybrid_menu "$(translate 'Fail2Ban Management')" "$action_text" 18 70 3 \
|
||||
"reinstall" "$(translate 'Reinstall and reconfigure Fail2Ban')" \
|
||||
"remove" "$(translate 'Uninstall Fail2Ban')" \
|
||||
"cancel" "$(translate 'Cancel')" \
|
||||
) || ACTION="cancel"
|
||||
|
||||
case "$ACTION" in
|
||||
reinstall)
|
||||
if hybrid_yesno "$(translate 'Reinstall Fail2Ban')" \
|
||||
"\n\n$(translate 'This will reinstall and reconfigure Fail2Ban with the default ProxMenux settings. Continue?')" 12 70; then
|
||||
install_fail2ban
|
||||
fi
|
||||
;;
|
||||
remove)
|
||||
if hybrid_yesno "$(translate 'Remove Fail2Ban')" \
|
||||
"\n\n$(translate 'This will completely remove Fail2Ban and its configuration. Continue?')" 12 70; then
|
||||
uninstall_fail2ban
|
||||
fi
|
||||
;;
|
||||
cancel|*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Not installed - confirm and install
|
||||
local info_text
|
||||
info_text="\n$(translate 'Fail2Ban is not installed on this system.')\n\n"
|
||||
info_text+="$(translate 'This will install and configure Fail2Ban with:')\n\n"
|
||||
info_text+=" - $(translate 'SSH protection (aggressive mode)') (max 2 $(translate 'retries'), 9h $(translate 'ban'))\n"
|
||||
info_text+=" - $(translate 'Proxmox web interface protection') ($(translate 'port') 8006, max 3 $(translate 'retries'), 1h $(translate 'ban'))\n"
|
||||
info_text+=" - $(translate 'ProxMenux Monitor protection') ($(translate 'port') 8008 + $(translate 'reverse proxy'), max 3 $(translate 'retries'), 1h $(translate 'ban'))\n"
|
||||
info_text+=" - $(translate 'Auto-detected firewall backend (nftables/iptables)')\n"
|
||||
info_text+=" - $(translate 'Adjusts journald log level if needed (Proxmox defaults may block auth logs)')\n"
|
||||
info_text+=" - $(translate 'SSH hardening: MaxAuthTries set to 3 (Lynis recommendation)')\n\n"
|
||||
info_text+="$(translate 'Do you want to proceed?')"
|
||||
|
||||
if hybrid_yesno "$(translate 'Install Fail2Ban')" "$info_text" 20 70; then
|
||||
install_fail2ban
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Lynis Security Audit Tool Installer
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# ============================================
|
||||
# Hybrid script: works from terminal (dialog) and web panel (ScriptTerminalModal)
|
||||
|
||||
SCRIPT_TITLE="Lynis Security Audit Tool Installer"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Detection
|
||||
# ==========================================================
|
||||
detect_lynis() {
|
||||
LYNIS_INSTALLED=false
|
||||
LYNIS_VERSION=""
|
||||
LYNIS_CMD=""
|
||||
|
||||
for path in /usr/local/bin/lynis /opt/lynis/lynis /usr/bin/lynis; do
|
||||
if [[ -f "$path" ]] && [[ -x "$path" ]]; then
|
||||
LYNIS_CMD="$path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$LYNIS_CMD" ]]; then
|
||||
LYNIS_INSTALLED=true
|
||||
LYNIS_VERSION=$("$LYNIS_CMD" show version 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Installation
|
||||
# ==========================================================
|
||||
install_lynis() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate "Installing latest Lynis security scan tool...")"
|
||||
|
||||
# Install git if needed
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
msg_info "$(translate "Installing Git as a prerequisite...")"
|
||||
apt-get update -qq >/dev/null 2>&1
|
||||
apt-get install -y git >/dev/null 2>&1
|
||||
msg_ok "$(translate "Git installed")"
|
||||
fi
|
||||
|
||||
# Remove old installation if present
|
||||
if [[ -d /opt/lynis ]]; then
|
||||
msg_info "$(translate "Removing previous Lynis installation...")"
|
||||
rm -rf /opt/lynis >/dev/null 2>&1
|
||||
msg_ok "$(translate "Previous installation removed")"
|
||||
fi
|
||||
|
||||
# Clone from GitHub
|
||||
msg_info "$(translate "Cloning Lynis from GitHub...")"
|
||||
if git clone --quiet https://github.com/CISOfy/lynis.git /opt/lynis >/dev/null 2>&1; then
|
||||
# Create wrapper script
|
||||
cat << 'EOF' > /usr/local/bin/lynis
|
||||
#!/bin/bash
|
||||
cd /opt/lynis && ./lynis "$@"
|
||||
EOF
|
||||
chmod +x /usr/local/bin/lynis
|
||||
msg_ok "$(translate "Lynis installed successfully from GitHub")"
|
||||
else
|
||||
msg_error "$(translate "Failed to clone Lynis from GitHub")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify
|
||||
if /usr/local/bin/lynis show version >/dev/null 2>&1; then
|
||||
local version
|
||||
version=$(/usr/local/bin/lynis show version 2>/dev/null)
|
||||
update_component_status "lynis" "installed" "$version" "security" '{}'
|
||||
msg_ok "$(translate "Lynis version:") $version"
|
||||
msg_success "$(translate "Lynis is ready to use")"
|
||||
else
|
||||
msg_warn "$(translate "Lynis installation could not be verified")"
|
||||
fi
|
||||
|
||||
msg_info2 "$(translate "You can run a security audit with:")"
|
||||
echo -e " lynis audit system"
|
||||
echo ""
|
||||
msg_success "$(translate "Installation completed. Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Update
|
||||
# ==========================================================
|
||||
update_lynis() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate "Updating Lynis to the latest version...")"
|
||||
|
||||
if [[ -d /opt/lynis/.git ]]; then
|
||||
cd /opt/lynis
|
||||
msg_info "$(translate "Pulling latest changes from GitHub...")"
|
||||
if git pull --quiet >/dev/null 2>&1; then
|
||||
local version
|
||||
version=$(/usr/local/bin/lynis show version 2>/dev/null)
|
||||
update_component_status "lynis" "installed" "$version" "security" '{}'
|
||||
msg_ok "$(translate "Lynis updated to version:") $version"
|
||||
else
|
||||
msg_error "$(translate "Failed to update Lynis")"
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "Lynis was not installed from Git. Reinstalling...")"
|
||||
install_lynis
|
||||
return
|
||||
fi
|
||||
|
||||
msg_success "$(translate "Update completed. Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Run Audit
|
||||
# ==========================================================
|
||||
run_audit() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate "Running Lynis security audit...")"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$LYNIS_CMD" ]]; then
|
||||
msg_error "$(translate "Lynis command not found")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Run the audit
|
||||
"$LYNIS_CMD" audit system --no-colors 2>&1
|
||||
|
||||
echo ""
|
||||
msg_success "$(translate "Audit completed. Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Uninstall
|
||||
# ==========================================================
|
||||
uninstall_lynis() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate "Removing Lynis...")"
|
||||
|
||||
rm -rf /opt/lynis 2>/dev/null
|
||||
rm -f /usr/local/bin/lynis 2>/dev/null
|
||||
|
||||
update_component_status "lynis" "removed" "" "security" '{}'
|
||||
|
||||
msg_ok "$(translate "Lynis has been removed")"
|
||||
msg_success "$(translate "Uninstallation completed. Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Main
|
||||
# ==========================================================
|
||||
main() {
|
||||
detect_lynis
|
||||
|
||||
if $LYNIS_INSTALLED; then
|
||||
# Already installed - show action menu
|
||||
local action_text
|
||||
action_text="\n$(translate 'Lynis is currently installed.')\n"
|
||||
action_text+="$(translate 'Version:') $LYNIS_VERSION\n\n"
|
||||
action_text+="$(translate 'What would you like to do?')"
|
||||
|
||||
local ACTION
|
||||
ACTION=$(hybrid_menu "$(translate 'Lynis Management')" "$action_text" 20 70 5 \
|
||||
"audit" "$(translate 'Run security audit now')" \
|
||||
"update" "$(translate 'Update Lynis to latest version')" \
|
||||
"reinstall" "$(translate 'Reinstall Lynis')" \
|
||||
"remove" "$(translate 'Uninstall Lynis')" \
|
||||
"cancel" "$(translate 'Cancel')" \
|
||||
) || ACTION="cancel"
|
||||
|
||||
case "$ACTION" in
|
||||
audit)
|
||||
run_audit
|
||||
;;
|
||||
update)
|
||||
update_lynis
|
||||
;;
|
||||
reinstall)
|
||||
if hybrid_yesno "$(translate 'Reinstall Lynis')" \
|
||||
"\n\n$(translate 'This will remove and reinstall Lynis from the latest GitHub source. Continue?')" 12 70; then
|
||||
install_lynis
|
||||
fi
|
||||
;;
|
||||
remove)
|
||||
if hybrid_yesno "$(translate 'Remove Lynis')" \
|
||||
"\n\n$(translate 'This will completely remove Lynis from the system. Continue?')" 12 70; then
|
||||
uninstall_lynis
|
||||
fi
|
||||
;;
|
||||
cancel|*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Not installed - confirm and install
|
||||
local info_text
|
||||
info_text="\n$(translate 'Lynis is not installed on this system.')\n\n"
|
||||
info_text+="$(translate 'Lynis is a security auditing tool that performs comprehensive system scans including:')\n\n"
|
||||
info_text+=" - $(translate 'System hardening scoring (0-100)')\n"
|
||||
info_text+=" - $(translate 'Vulnerability detection')\n"
|
||||
info_text+=" - $(translate 'Configuration analysis')\n"
|
||||
info_text+=" - $(translate 'Compliance checking (PCI-DSS, HIPAA, etc.)')\n\n"
|
||||
info_text+="$(translate 'It will be installed from the official GitHub repository.')\n\n"
|
||||
info_text+="$(translate 'Do you want to proceed?')"
|
||||
|
||||
if hybrid_yesno "$(translate 'Install Lynis')" "$info_text" 22 70; then
|
||||
install_lynis
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - UUP Dump ISO Creator
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script is part of the ProxMenux tools for Proxmox VE.
|
||||
# It allows downloading and converting official Windows ISO images
|
||||
# from UUP Dump using a shared link (with ID, pack, and edition).
|
||||
#
|
||||
# Key features:
|
||||
# - Automatically installs and verifies required dependencies (aria2c, cabextract, wimlib-imagex…)
|
||||
# - Downloads the selected Windows edition from UUP Dump using aria2
|
||||
# - Converts the downloaded files into a bootable ISO
|
||||
# - Stores the resulting ISO in the default template path (/var/lib/vz/template/iso)
|
||||
# - Provides a graphical prompt via whiptail for user-friendly usage
|
||||
#
|
||||
# This tool simplifies the creation of official Windows ISOs
|
||||
# for use in virtual machines within Proxmox VE.
|
||||
# ==========================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
detect_iso_dir() {
|
||||
for store in $(pvesm status -content iso | awk 'NR>1 {print $1}'); do
|
||||
for ext in iso img; do
|
||||
volid=$(pvesm list "$store" --content iso | awk -v ext="$ext" 'NR>1 && $2 ~ ext {print $1; exit}')
|
||||
if [[ -n "$volid" ]]; then
|
||||
path=$(pvesm path "$volid" 2>/dev/null)
|
||||
dir=$(dirname "$path")
|
||||
[[ -d "$dir" ]] && echo "$dir" && return 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ -d /var/lib/vz/template/iso ]]; then
|
||||
echo "/var/lib/vz/template/iso"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
function run_uupdump_creator() {
|
||||
|
||||
|
||||
local DEPS=(curl aria2 cabextract wimtools genisoimage chntpw)
|
||||
local CMDS=(curl aria2c cabextract wimlib-imagex genisoimage chntpw)
|
||||
local MISSING=()
|
||||
local FAILED=()
|
||||
|
||||
for i in "${!CMDS[@]}"; do
|
||||
if ! command -v "${CMDS[$i]}" &>/dev/null; then
|
||||
MISSING+=("${DEPS[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
msg_info "$(translate "Installing dependencies: ${MISSING[*]}")"
|
||||
apt-get update -qq >/dev/null 2>&1
|
||||
if ! apt-get install -y "${MISSING[@]}" >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Failed to install: ${MISSING[*]}")"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
for i in "${!CMDS[@]}"; do
|
||||
if ! command -v "${CMDS[$i]}" &>/dev/null; then
|
||||
FAILED+=("${CMDS[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#FAILED[@]} -eq 0 ]]; then
|
||||
msg_ok "$(translate "All dependencies installed and verified.")"
|
||||
else
|
||||
msg_error "$(translate "Missing commands after installation: ${FAILED[*]}")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
ISO_DIR=$(detect_iso_dir)
|
||||
if [[ -z "$ISO_DIR" ]]; then
|
||||
msg_error "$(translate "Could not determine a valid ISO storage directory.")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
mkdir -p "$ISO_DIR"
|
||||
|
||||
TMP_DIR=$(dialog --inputbox "Enter temporary folder path (default: /root/uup-temp):" 10 60 "/root/uup-temp" 3>&1 1>&2 2>&3)
|
||||
if [[ $? -ne 0 || -z "$TMP_DIR" ]]; then
|
||||
TMP_DIR="/root/uup-temp"
|
||||
fi
|
||||
|
||||
OUT_DIR="$ISO_DIR"
|
||||
CONVERTER="/root/uup-converter"
|
||||
|
||||
mkdir -p "$TMP_DIR" "$OUT_DIR"
|
||||
cd "$TMP_DIR" || exit 1
|
||||
|
||||
|
||||
UUP_URL=$(whiptail --inputbox "$(translate "Paste the UUP Dump URL here")" 10 90 3>&1 1>&2 2>&3)
|
||||
if [[ $? -ne 0 || -z "$UUP_URL" ]]; then
|
||||
msg_warn "$(translate "Cancelled by user or empty URL.")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! "$UUP_URL" =~ id=.+\&pack=.+\&edition=.+ ]]; then
|
||||
msg_error "$(translate "The URL does not contain the required parameters (id, pack, edition).")"
|
||||
sleep 2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
BUILD_ID=$(echo "$UUP_URL" | grep -oP 'id=\K[^&]+')
|
||||
LANG=$(echo "$UUP_URL" | grep -oP 'pack=\K[^&]+')
|
||||
EDITION=$(echo "$UUP_URL" | grep -oP 'edition=\K[^&]+')
|
||||
ARCH="amd64"
|
||||
|
||||
echo -e "\n${BGN}=============== UUP Dump Creator ===============${CL}"
|
||||
echo -e " ${BGN}🆔 ID:${CL} ${DGN}$BUILD_ID${CL}"
|
||||
echo -e " ${BGN}🌐 Language:${CL} ${DGN}$LANG${CL}"
|
||||
echo -e " ${BGN}💿 Edition:${CL} ${DGN}$EDITION${CL}"
|
||||
echo -e " ${BGN}🖥️ Architecture:${CL} ${DGN}$ARCH${CL}"
|
||||
echo -e "${BGN}===============================================${CL}\n"
|
||||
|
||||
|
||||
if [[ ! -f "$CONVERTER/convert.sh" ]]; then
|
||||
echo "📦 $(translate "Downloading UUP converter...")"
|
||||
mkdir -p "$CONVERTER"
|
||||
cd "$CONVERTER" || exit 1
|
||||
wget -q https://git.uupdump.net/uup-dump/converter/archive/refs/heads/master.tar.gz -O converter.tar.gz
|
||||
tar -xzf converter.tar.gz --strip-components=1
|
||||
chmod +x convert.sh
|
||||
cd "$TMP_DIR" || exit 1
|
||||
fi
|
||||
|
||||
|
||||
cat > uup_download_linux.sh <<EOF
|
||||
#!/bin/bash
|
||||
mkdir -p files
|
||||
echo "https://git.uupdump.net/uup-dump/converter/archive/refs/heads/master.tar.gz" > files/converter_multi
|
||||
|
||||
for prog in aria2c cabextract wimlib-imagex chntpw; do
|
||||
which \$prog &>/dev/null || { echo "\$prog not found."; exit 1; }
|
||||
done
|
||||
which genisoimage &>/dev/null || which mkisofs &>/dev/null || { echo "genisoimage/mkisofs not found."; exit 1; }
|
||||
|
||||
destDir="UUPs"
|
||||
tempScript="aria2_script.\$RANDOM.txt"
|
||||
|
||||
aria2c --no-conf --console-log-level=warn --log-level=info --log="aria2_download.log" \
|
||||
-x16 -s16 -j2 --allow-overwrite=true --auto-file-renaming=false -d"files" -i"files/converter_multi" || exit 1
|
||||
|
||||
aria2c --no-conf --console-log-level=warn --log-level=info --log="aria2_download.log" \
|
||||
-o"\$tempScript" --allow-overwrite=true --auto-file-renaming=false \
|
||||
"https://uupdump.net/get.php?id=$BUILD_ID&pack=$LANG&edition=$EDITION&aria2=2" || exit 1
|
||||
|
||||
grep '#UUPDUMP_ERROR:' "\$tempScript" && { echo "❌ Error generating UUP download list."; exit 1; }
|
||||
|
||||
aria2c --no-conf --console-log-level=warn --log-level=info --log="aria2_download.log" \
|
||||
-x16 -s16 -j5 -c -R -d"\$destDir" -i"\$tempScript" || exit 1
|
||||
EOF
|
||||
|
||||
chmod +x uup_download_linux.sh
|
||||
|
||||
|
||||
|
||||
# ==========================
|
||||
./uup_download_linux.sh
|
||||
# ==========================
|
||||
|
||||
|
||||
|
||||
UUP_FOLDER=$(find "$TMP_DIR" -type d -name "UUPs" | head -n1)
|
||||
[[ -z "$UUP_FOLDER" ]] && msg_error "$(translate "No UUP folder found.")" && exit 1
|
||||
|
||||
|
||||
echo -e "\n${GN}=======================================${CL}"
|
||||
echo -e " 💿 ${GN}Starting ISO conversion...${CL}"
|
||||
echo -e "${GN}=======================================${CL}\n"
|
||||
|
||||
"$CONVERTER/convert.sh" wim "$UUP_FOLDER" 1
|
||||
|
||||
|
||||
ISO_FILE=$(find "$TMP_DIR" "$CONVERTER" "$UUP_FOLDER" -maxdepth 1 -iname "*.iso" | head -n1)
|
||||
if [[ -f "$ISO_FILE" ]]; then
|
||||
mv "$ISO_FILE" "$OUT_DIR/"
|
||||
msg_ok "$(translate "ISO created successfully:") $OUT_DIR/$(basename "$ISO_FILE")"
|
||||
|
||||
|
||||
msg_ok "$(translate "Cleaning temporary files...")"
|
||||
rm -rf "$TMP_DIR" "$CONVERTER"
|
||||
|
||||
export OS_TYPE="windows"
|
||||
export LANGUAGE=C
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
|
||||
else
|
||||
msg_warn "$(translate "No ISO was generated.")"
|
||||
rm -rf "$TMP_DIR" "$CONVERTER"
|
||||
export LANGUAGE=C
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
load_language
|
||||
initialize_cache
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
VMID="105"
|
||||
|
||||
echo -e "\n=== 📦 Discos físicos detectados (lsblk) ==="
|
||||
lsblk -dn -o PATH,TYPE | awk '$2 == "disk" {print $1}'
|
||||
|
||||
echo -e "\n=== 📌 Discos montados ==="
|
||||
mount | grep /dev/sd || echo "Ninguno"
|
||||
|
||||
echo -e "\n=== 🧱 Discos en uso por LVM (pvs) ==="
|
||||
pvs --noheadings -o pv_name | xargs -n1 readlink -f | sort -u || echo "Ninguno"
|
||||
|
||||
echo -e "\n=== ⚠ RAID activos (mdstat) ==="
|
||||
awk '/^md/ {for (i=4; i<=NF; i++) print $i}' /proc/mdstat || echo "Ninguno"
|
||||
|
||||
echo -e "\n=== 💻 Discos ya asignados a la VM ID $VMID ==="
|
||||
qm config "$VMID" | grep -E '^(scsi|sata|virtio|ide)[0-9]+:' | awk -F ':' '{print $2}' | cut -d',' -f1
|
||||
|
||||
echo -e "\n=== 🧪 FSTYPE de cada disco ==="
|
||||
for disk in $(lsblk -dn -o PATH,TYPE | awk '$2 == "disk" {print $1}'); do
|
||||
echo -e "\n→ $disk"
|
||||
lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2
|
||||
done
|
||||
|
||||
|
||||
|
||||
echo "📋 Analizando discos físicos..."
|
||||
ACTIVE_MD_DEVICES=$(awk '/^md/ {for (i=4; i<=NF; i++) print $i}' /proc/mdstat)
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name | xargs -n1 readlink -f | sed 's/ *$//' | sort -u)
|
||||
MOUNTED_DISKS=$(mount | grep /dev/sd | awk '{print $1}' | sort -u)
|
||||
|
||||
for DISK in $(lsblk -dn -o PATH,TYPE | awk '$2 == "disk" {print $1}'); do
|
||||
echo -e "\n🔍 Disco: $DISK"
|
||||
echo " - En LVM: $(echo "$LVM_DEVICES" | grep -Fxq "$DISK" && echo 'SÍ' || echo 'NO')"
|
||||
echo " - Es ZVOL (zd*): $( [[ $(basename "$DISK") == zd* ]] && echo 'SÍ' || echo 'NO')"
|
||||
echo " - Ya está en la VM: $(qm config "$VMID" | grep -q "$DISK" && echo 'SÍ' || echo 'NO')"
|
||||
|
||||
IS_MOUNTED=false
|
||||
IS_RAID=false
|
||||
IS_RAID_ACTIVE=false
|
||||
IS_ZFS=false
|
||||
|
||||
while read -r part fstype; do
|
||||
full_path="/dev/$part"
|
||||
real_path=$(readlink -f "$full_path")
|
||||
|
||||
[[ -z "$fstype" ]] && continue
|
||||
|
||||
echo " ➤ Partición: $part ($fstype)"
|
||||
if echo "$MOUNTED_DISKS" | grep -q "$full_path"; then
|
||||
echo " ⛔ Montado en el sistema"
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
if echo "$LVM_DEVICES" | grep -Fxq "$real_path"; then
|
||||
echo " ⛔ En uso por LVM"
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
if [[ "$fstype" == "linux_raid_member" ]]; then
|
||||
IS_RAID=true
|
||||
if echo "$ACTIVE_MD_DEVICES" | grep -q "$part"; then
|
||||
IS_RAID_ACTIVE=true
|
||||
echo " ⛔ RAID activo"
|
||||
else
|
||||
echo " ⚠ RAID pasivo"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$fstype" == "zfs_member" ]]; then
|
||||
IS_ZFS=true
|
||||
echo " ⛔ ZFS detectado"
|
||||
fi
|
||||
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
|
||||
|
||||
echo "Resumen:"
|
||||
echo " - Montado: $IS_MOUNTED"
|
||||
echo " - RAID activo: $IS_RAID_ACTIVE"
|
||||
echo " - RAID pasivo: $IS_RAID"
|
||||
echo " - ZFS: $IS_ZFS"
|
||||
done
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
VMID="105"
|
||||
|
||||
echo -e "\n=== 📦 Discos físicos detectados (lsblk) ==="
|
||||
lsblk -dn -o PATH,TYPE | awk '$2 == "disk" {print $1}'
|
||||
|
||||
echo -e "\n=== 📌 Discos montados ==="
|
||||
mount | grep /dev/sd || echo "Ninguno"
|
||||
|
||||
echo -e "\n=== 🧱 Discos en uso por LVM (pvs) ==="
|
||||
pvs --noheadings -o pv_name | xargs -n1 readlink -f | sort -u || echo "Ninguno"
|
||||
|
||||
echo -e "\n=== ⚠ RAID activos (mdstat) ==="
|
||||
awk '/^md/ {for (i=4; i<=NF; i++) print $i}' /proc/mdstat || echo "Ninguno"
|
||||
|
||||
echo -e "\n=== 💻 Discos ya asignados a la VM ID $VMID ==="
|
||||
qm config "$VMID" | grep -E '^(scsi|sata|virtio|ide)[0-9]+:' | awk -F ':' '{print $2}' | cut -d',' -f1
|
||||
|
||||
echo -e "\n=== 🧪 FSTYPE de cada disco ==="
|
||||
for disk in $(lsblk -dn -o PATH,TYPE | awk '$2 == "disk" {print $1}'); do
|
||||
echo -e "\n→ $disk"
|
||||
lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2
|
||||
done
|
||||
@@ -1,327 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/06/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script provides a simple and efficient way to access and execute Proxmox VE scripts
|
||||
# from the Community Scripts project (https://community-scripts.github.io/ProxmoxVE/).
|
||||
#
|
||||
# It serves as a convenient tool to run key automation scripts that simplify system management,
|
||||
# continuing the great work and legacy of tteck in making Proxmox VE more accessible.
|
||||
# A streamlined solution for executing must-have tools in Proxmox VE.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
HELPERS_JSON_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/refs/heads/main/json/helpers_cache.json"
|
||||
METADATA_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/frontend/public/json/metadata.json"
|
||||
|
||||
for cmd in curl jq dialog; do
|
||||
if ! command -v "$cmd" >/dev/null; then
|
||||
echo "Missing required command: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
CACHE_JSON=$(curl -s "$HELPERS_JSON_URL")
|
||||
META_JSON=$(curl -s "$METADATA_URL")
|
||||
|
||||
declare -A CATEGORY_NAMES
|
||||
while read -r id name; do
|
||||
CATEGORY_NAMES[$id]="$name"
|
||||
done < <(echo "$META_JSON" | jq -r '.categories[] | "\(.id)\t\(.name)"')
|
||||
|
||||
declare -A CATEGORY_COUNT
|
||||
for id in $(echo "$CACHE_JSON" | jq -r '.[].categories[]'); do
|
||||
((CATEGORY_COUNT[$id]++))
|
||||
done
|
||||
|
||||
get_type_label() {
|
||||
local type="$1"
|
||||
case "$type" in
|
||||
ct) echo $'\Z1LXC\Zn' ;;
|
||||
vm) echo $'\Z4VM\Zn' ;;
|
||||
pve) echo $'\Z3PVE\Zn' ;;
|
||||
addon) echo $'\Z2ADDON\Zn' ;;
|
||||
*) echo $'\Z7GEN\Zn' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_script() {
|
||||
local url="$1"
|
||||
local fallback_pve="${url/misc\/tools\/pve}"
|
||||
local fallback_addon="${url/misc\/tools\/addon}"
|
||||
local fallback_copydata="${url/misc\/tools\/copy-data}"
|
||||
|
||||
if curl --silent --head --fail "$url" >/dev/null; then
|
||||
bash <(curl -s "$url")
|
||||
elif curl --silent --head --fail "$fallback_pve" >/dev/null; then
|
||||
bash <(curl -s "$fallback_pve")
|
||||
elif curl --silent --head --fail "$fallback_addon" >/dev/null; then
|
||||
bash <(curl -s "$fallback_addon")
|
||||
elif curl --silent --head --fail "$fallback_copydata" >/dev/null; then
|
||||
bash <(curl -s "$fallback_copydata")
|
||||
else
|
||||
dialog --title "Helper Scripts" --msgbox "Error: Failed to download the script." 12 70
|
||||
fi
|
||||
}
|
||||
|
||||
RETURN_TO_MAIN=false
|
||||
|
||||
format_credentials() {
|
||||
local script_info="$1"
|
||||
local credentials_info=""
|
||||
|
||||
local has_credentials
|
||||
has_credentials=$(echo "$script_info" | base64 --decode | jq -r 'has("default_credentials")')
|
||||
|
||||
if [[ "$has_credentials" == "true" ]]; then
|
||||
local username password
|
||||
username=$(echo "$script_info" | base64 --decode | jq -r '.default_credentials.username // empty')
|
||||
password=$(echo "$script_info" | base64 --decode | jq -r '.default_credentials.password // empty')
|
||||
|
||||
if [[ -n "$username" && -n "$password" ]]; then
|
||||
credentials_info="Username: $username | Password: $password"
|
||||
elif [[ -n "$username" ]]; then
|
||||
credentials_info="Username: $username"
|
||||
elif [[ -n "$password" ]]; then
|
||||
credentials_info="Password: $password"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$credentials_info"
|
||||
}
|
||||
|
||||
|
||||
run_script_by_slug() {
|
||||
local slug="$1"
|
||||
local script_info
|
||||
script_info=$(echo "$CACHE_JSON" | jq -r --arg slug "$slug" '.[] | select(.slug == $slug) | @base64')
|
||||
|
||||
decode() {
|
||||
echo "$1" | base64 --decode | jq -r "$2"
|
||||
}
|
||||
|
||||
local name desc script_url notes
|
||||
name=$(decode "$script_info" ".name")
|
||||
desc=$(decode "$script_info" ".desc")
|
||||
script_url=$(decode "$script_info" ".script_url")
|
||||
notes=$(decode "$script_info" ".notes | join(\"\n\")")
|
||||
|
||||
|
||||
local notes_dialog=""
|
||||
if [[ -n "$notes" ]]; then
|
||||
while IFS= read -r line; do
|
||||
notes_dialog+="• $line\n"
|
||||
done <<< "$notes"
|
||||
notes_dialog="${notes_dialog%\\n}"
|
||||
fi
|
||||
|
||||
|
||||
local credentials
|
||||
credentials=$(format_credentials "$script_info")
|
||||
|
||||
|
||||
local msg="\Zb\Z4Descripción:\Zn\n$desc"
|
||||
[[ -n "$notes_dialog" ]] && msg+="\n\n\Zb\Z4Notes:\Zn\n$notes_dialog"
|
||||
[[ -n "$credentials" ]] && msg+="\n\n\Zb\Z4Default Credentials:\Zn\n$credentials"
|
||||
|
||||
dialog --clear --colors --backtitle "ProxMenux" --title "$name" --yesno "$msg\n\nExecute this script?" 22 85
|
||||
if [[ $? -eq 0 ]]; then
|
||||
download_script "$script_url"
|
||||
echo
|
||||
echo
|
||||
|
||||
if [[ -n "$desc" || -n "$notes" || -n "$credentials" ]]; then
|
||||
echo -e "$TAB\e[1;36mScript Information:\e[0m"
|
||||
|
||||
|
||||
|
||||
if [[ -n "$notes" ]]; then
|
||||
echo -e "$TAB\e[1;33mNotes:\e[0m"
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
echo -e "$TAB• $line"
|
||||
done <<< "$notes"
|
||||
echo
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$credentials" ]]; then
|
||||
echo -e "$TAB\e[1;32mDefault Credentials:\e[0m"
|
||||
echo "$TAB$credentials"
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_success "Press Enter to return to the main menu..."
|
||||
read -r
|
||||
RETURN_TO_MAIN=true
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
search_and_filter_scripts() {
|
||||
local search_term=""
|
||||
|
||||
while true; do
|
||||
search_term=$(dialog --inputbox "Enter search term (leave empty to show all scripts):" \
|
||||
8 65 "$search_term" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
local filtered_json
|
||||
if [[ -z "$search_term" ]]; then
|
||||
filtered_json="$CACHE_JSON"
|
||||
else
|
||||
local search_lower
|
||||
search_lower=$(echo "$search_term" | tr '[:upper:]' '[:lower:]')
|
||||
filtered_json=$(echo "$CACHE_JSON" | jq --arg term "$search_lower" '
|
||||
[.[] | select(
|
||||
(.name | ascii_downcase | contains($term)) or
|
||||
(.desc | ascii_downcase | contains($term))
|
||||
)]')
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(echo "$filtered_json" | jq length)
|
||||
|
||||
if [[ $count -eq 0 ]]; then
|
||||
dialog --msgbox "No scripts found for: '$search_term'\n\nTry a different search term." 8 50
|
||||
continue
|
||||
fi
|
||||
|
||||
while true; do
|
||||
declare -A index_to_slug
|
||||
local menu_items=()
|
||||
local i=1
|
||||
|
||||
while IFS=$'\t' read -r slug name type; do
|
||||
index_to_slug[$i]="$slug"
|
||||
local label
|
||||
label=$(get_type_label "$type")
|
||||
local padded_name
|
||||
padded_name=$(printf "%-42s" "$name")
|
||||
local entry="$padded_name $label"
|
||||
menu_items+=("$i" "$entry")
|
||||
((i++))
|
||||
done < <(echo "$filtered_json" | jq -r '
|
||||
sort_by(.name)[] | [.slug, .name, .type] | @tsv')
|
||||
|
||||
menu_items+=("" "")
|
||||
menu_items+=("new_search" "New Search")
|
||||
menu_items+=("show_all" "Show All Scripts")
|
||||
|
||||
local title="Search Results"
|
||||
if [[ -n "$search_term" ]]; then
|
||||
title="Search Results for: '$search_term' ($count found)"
|
||||
else
|
||||
title="All Available Scripts ($count total)"
|
||||
fi
|
||||
|
||||
local selected
|
||||
selected=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$title" \
|
||||
--menu "Select a script or action:" \
|
||||
22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
case "$selected" in
|
||||
"new_search")
|
||||
break
|
||||
;;
|
||||
"show_all")
|
||||
search_term=""
|
||||
filtered_json="$CACHE_JSON"
|
||||
count=$(echo "$filtered_json" | jq length)
|
||||
continue
|
||||
;;
|
||||
"back"|"")
|
||||
return
|
||||
;;
|
||||
*)
|
||||
if [[ -n "${index_to_slug[$selected]}" ]]; then
|
||||
run_script_by_slug "${index_to_slug[$selected]}"
|
||||
[[ "$RETURN_TO_MAIN" == true ]] && { RETURN_TO_MAIN=false; return; }
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
while true; do
|
||||
MENU_ITEMS=()
|
||||
|
||||
MENU_ITEMS+=("search" "Search/Filter Scripts")
|
||||
MENU_ITEMS+=("" "")
|
||||
|
||||
for id in $(printf "%s\n" "${!CATEGORY_COUNT[@]}" | sort -n); do
|
||||
name="${CATEGORY_NAMES[$id]:-Category $id}"
|
||||
count="${CATEGORY_COUNT[$id]}"
|
||||
padded_name=$(printf "%-35s" "$name")
|
||||
padded_count=$(printf "(%2d)" "$count")
|
||||
MENU_ITEMS+=("$id" "$padded_name $padded_count")
|
||||
done
|
||||
|
||||
SELECTED=$(dialog --backtitle "ProxMenux" --title "Proxmox VE Helper-Scripts" --menu \
|
||||
"Select a category or search for scripts:" 20 70 14 \
|
||||
"${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
|
||||
dialog --title "Proxmox VE Helper-Scripts" \
|
||||
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
|
||||
clear
|
||||
break
|
||||
}
|
||||
|
||||
if [[ "$SELECTED" == "search" ]]; then
|
||||
search_and_filter_scripts
|
||||
continue
|
||||
fi
|
||||
|
||||
while true; do
|
||||
declare -A INDEX_TO_SLUG
|
||||
SCRIPTS=()
|
||||
i=1
|
||||
while IFS=$'\t' read -r slug name type; do
|
||||
INDEX_TO_SLUG[$i]="$slug"
|
||||
label=$(get_type_label "$type")
|
||||
padded_name=$(printf "%-42s" "$name")
|
||||
entry="$padded_name $label"
|
||||
SCRIPTS+=("$i" "$entry")
|
||||
((i++))
|
||||
done < <(echo "$CACHE_JSON" | jq -r --argjson id "$SELECTED" \
|
||||
'[.[] | select(.categories | index($id)) | {slug, name, type}] | sort_by(.name)[] | [.slug, .name, .type] | @tsv')
|
||||
|
||||
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" --title "Scripts in ${CATEGORY_NAMES[$SELECTED]}" --menu \
|
||||
"Choose a script to execute:" 20 70 14 \
|
||||
"${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
|
||||
|
||||
SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}"
|
||||
run_script_by_slug "$SCRIPT_SELECTED"
|
||||
|
||||
[[ "$RETURN_TO_MAIN" == true ]] && { RETURN_TO_MAIN=false; break; }
|
||||
done
|
||||
done
|
||||
@@ -1,355 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script allows users to assign physical disks to existing
|
||||
# Proxmox virtual machines (VMs) through an interactive menu.
|
||||
# - Detects the system disk and excludes it from selection.
|
||||
# - Lists all available VMs for the user to choose from.
|
||||
# - Identifies and displays unassigned physical disks.
|
||||
# - Allows the user to select multiple disks and attach them to a VM.
|
||||
# - Supports interface types: SATA, SCSI, VirtIO, and IDE.
|
||||
# - Ensures that disks are not already assigned to active VMs.
|
||||
# - Warns about disk sharing between multiple VMs to avoid data corruption.
|
||||
# - Configures the selected disks for the VM and verifies the assignment.
|
||||
#
|
||||
# The goal of this script is to simplify the process of assigning
|
||||
# physical disks to Proxmox VMs, reducing manual configurations
|
||||
# and preventing potential errors.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
# Function to get detailed disk information
|
||||
get_disk_info() {
|
||||
local disk=$1
|
||||
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
|
||||
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
|
||||
echo "$MODEL" "$SIZE"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# Display list of available VMs
|
||||
VM_LIST=$(qm list | awk 'NR>1 {print $1, $2}')
|
||||
if [ -z "$VM_LIST" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VMs available in the system.")" 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Select VM
|
||||
VMID=$(whiptail --title "$(translate "Select VM")" --menu "$(translate "Select the VM to which you want to add disks:")" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$VMID" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VM was selected.")" 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VMID=$(echo "$VMID" | tr -d '"')
|
||||
|
||||
|
||||
|
||||
#clear
|
||||
msg_ok "$(translate "VM selected successfully.")"
|
||||
|
||||
|
||||
VM_STATUS=$(qm status "$VMID" | awk '{print $2}')
|
||||
if [ "$VM_STATUS" == "running" ]; then
|
||||
whiptail --title "$(translate "Warning")" --msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" 12 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
##########################################
|
||||
|
||||
msg_info "$(translate "Detecting available disks...")"
|
||||
|
||||
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
|
||||
|
||||
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
|
||||
|
||||
ZFS_DISKS=""
|
||||
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
|
||||
|
||||
for entry in $ZFS_RAW; do
|
||||
|
||||
path=""
|
||||
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
|
||||
if [ -e "/dev/disk/by-id/$entry" ]; then
|
||||
path=$(readlink -f "/dev/disk/by-id/$entry")
|
||||
fi
|
||||
elif [[ "$entry" == /dev/* ]]; then
|
||||
path="$entry"
|
||||
fi
|
||||
|
||||
|
||||
if [ -n "$path" ]; then
|
||||
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
|
||||
if [ -n "$base_disk" ]; then
|
||||
ZFS_DISKS+="/dev/$base_disk"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
|
||||
|
||||
|
||||
is_disk_in_use() {
|
||||
local disk="$1"
|
||||
|
||||
|
||||
while read -r part fstype; do
|
||||
case "$fstype" in
|
||||
zfs_member|linux_raid_member)
|
||||
return 0 ;;
|
||||
esac
|
||||
|
||||
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
|
||||
return 0
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
|
||||
|
||||
|
||||
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
FREE_DISKS=()
|
||||
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name | xargs -n1 readlink -f | sort -u)
|
||||
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
|
||||
|
||||
while read -r DISK; do
|
||||
# Saltar ZVOLs
|
||||
[[ "$DISK" =~ /dev/zd ]] && continue
|
||||
|
||||
INFO=($(get_disk_info "$DISK"))
|
||||
MODEL="${INFO[@]::${#INFO[@]}-1}"
|
||||
SIZE="${INFO[-1]}"
|
||||
LABEL=""
|
||||
SHOW_DISK=true
|
||||
|
||||
IS_MOUNTED=false
|
||||
IS_RAID=false
|
||||
IS_ZFS=false
|
||||
IS_LVM=false
|
||||
|
||||
while read -r part fstype; do
|
||||
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
|
||||
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
|
||||
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
|
||||
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
|
||||
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
# RAID activo → no mostrar
|
||||
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
|
||||
if grep -q "active raid" /proc/mdstat; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# ZFS no mostrar nunca
|
||||
if $IS_ZFS; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
# Si está montado → ocultar
|
||||
if $IS_MOUNTED; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
# Ya asignado a la VM actual
|
||||
if qm config "$VMID" | grep -q "$DISK"; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if $SHOW_DISK; then
|
||||
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ with partitions"
|
||||
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
|
||||
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
|
||||
|
||||
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
|
||||
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
|
||||
fi
|
||||
done < <(lsblk -dn -e 7,11 -o PATH)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
|
||||
cleanup
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this VM.")" 8 40
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Available disks detected.")"
|
||||
|
||||
|
||||
|
||||
######################################################
|
||||
|
||||
|
||||
|
||||
|
||||
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
|
||||
TOTAL_WIDTH=$((MAX_WIDTH + 20))
|
||||
|
||||
if [ $TOTAL_WIDTH -lt 70 ]; then
|
||||
TOTAL_WIDTH=70
|
||||
fi
|
||||
|
||||
|
||||
SELECTED=$(whiptail --title "$(translate "Select Disks")" --checklist \
|
||||
"$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$SELECTED" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 $TOTAL_WIDTH
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Disks selected successfully.")"
|
||||
|
||||
|
||||
INTERFACE=$(whiptail --title "$(translate "Interface Type")" --menu "$(translate "Select the interface type for all disks:")" 15 40 4 \
|
||||
"sata" "$(translate "Add as SATA")" \
|
||||
"scsi" "$(translate "Add as SCSI")" \
|
||||
"virtio" "$(translate "Add as VirtIO")" \
|
||||
"ide" "$(translate "Add as IDE")" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$INTERFACE" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No interface type was selected for the disks.")" 8 40
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Interface type selected: $INTERFACE")"
|
||||
|
||||
DISKS_ADDED=0
|
||||
ERROR_MESSAGES=""
|
||||
SUCCESS_MESSAGES=""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Processing selected disks...")"
|
||||
|
||||
for DISK in $SELECTED; do
|
||||
DISK=$(echo "$DISK" | tr -d '"')
|
||||
DISK_INFO=$(get_disk_info "$DISK")
|
||||
|
||||
ASSIGNED_TO=""
|
||||
RUNNING_VMS=""
|
||||
|
||||
while read -r VM_ID VM_NAME; do
|
||||
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then
|
||||
ASSIGNED_TO+="$VM_ID $VM_NAME\n"
|
||||
VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}')
|
||||
if [ "$VM_STATUS" == "running" ]; then
|
||||
RUNNING_VMS+="$VM_ID $VM_NAME\n"
|
||||
fi
|
||||
fi
|
||||
done < <(qm list | awk 'NR>1 {print $1, $2}')
|
||||
|
||||
if [ -n "$RUNNING_VMS" ]; then
|
||||
ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s):")\\n$RUNNING_VMS\\n\\n"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
if [ -n "$ASSIGNED_TO" ]; then
|
||||
cleanup
|
||||
whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70
|
||||
if [ $? -ne 0 ]; then
|
||||
sleep 1
|
||||
exec "$0"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
INDEX=0
|
||||
while qm config "$VMID" | grep -q "${INTERFACE}${INDEX}"; do
|
||||
((INDEX++))
|
||||
done
|
||||
|
||||
RESULT=$(qm set "$VMID" -${INTERFACE}${INDEX} "$DISK" 2>&1)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to VM") $VMID."
|
||||
if [ -n "$ASSIGNED_TO" ]; then
|
||||
MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following VM(s):")\\n$ASSIGNED_TO"
|
||||
MESSAGE+="\\n$(translate "Make sure not to start VMs that share this disk at the same time to avoid data corruption.")"
|
||||
fi
|
||||
SUCCESS_MESSAGES+="$MESSAGE\\n\\n"
|
||||
((DISKS_ADDED++))
|
||||
else
|
||||
ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to VM") $VMID.\\n$(translate "Error:") $RESULT\\n\\n"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "$(translate "Disk processing completed.")"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if [ -n "$SUCCESS_MESSAGES" ]; then
|
||||
MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l)
|
||||
whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70
|
||||
fi
|
||||
|
||||
if [ -n "$ERROR_MESSAGES" ]; then
|
||||
whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70
|
||||
fi
|
||||
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.1
|
||||
# Last Updated: 30/04/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script allows users to repair or verify network configuration in Proxmox.
|
||||
# It avoids making changes if the system is already connected to the internet.
|
||||
# ==========================================================
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
detect_physical_interfaces() {
|
||||
physical_interfaces=$(ip -o link show | awk -F': ' '$2 !~ /^(lo|veth|dummy|bond|tap|fw|vmbr|br)/ {print $2}')
|
||||
whiptail --title "$(translate 'Network Interfaces')" --msgbox "$physical_interfaces" 10 78
|
||||
}
|
||||
|
||||
get_relevant_interfaces() {
|
||||
echo $(ip -o link show | awk -F': ' '$2 !~ /^(lo|veth|dummy)/ {print $2}')
|
||||
}
|
||||
|
||||
check_and_fix_bridges() {
|
||||
local output=""
|
||||
output+="$(translate 'Checking bridges')\n\n"
|
||||
|
||||
bridges=$(grep "^auto vmbr" /etc/network/interfaces | awk '{print $2}')
|
||||
|
||||
for bridge in $bridges; do
|
||||
old_port=$(grep -A1 "iface $bridge" /etc/network/interfaces | grep "bridge-ports" | awk '{print $2}')
|
||||
|
||||
if ! ip link show "$old_port" &>/dev/null; then
|
||||
output+="$(translate 'Bridge port missing'): $bridge - $old_port\n"
|
||||
|
||||
new_port=$(whiptail --title "$(translate 'Missing Port Detected')" --menu "$(translate 'The bridge') $bridge $(translate 'is using a missing port') ($old_port).\n\n$(translate 'Select a replacement interface:')" 20 60 10 $(echo "$physical_interfaces" | tr ' ' '\n' | grep -v "vmbr" | awk '{print $1 " " $1}') 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -n "$new_port" ]; then
|
||||
sed -i "/iface $bridge/,/bridge-ports/ s/bridge-ports.*/bridge-ports $new_port/" /etc/network/interfaces
|
||||
output+="$(translate 'Bridge port updated'): $bridge - $old_port -> $new_port\n"
|
||||
else
|
||||
output+="$(translate 'No replacement selected. Skipping update for') $bridge\n"
|
||||
fi
|
||||
else
|
||||
output+="$(translate 'Bridge port OK'): $bridge - $old_port\n"
|
||||
fi
|
||||
done
|
||||
|
||||
whiptail --title "$(translate 'Checking Bridges')" --msgbox "$output" 20 78
|
||||
}
|
||||
|
||||
clean_nonexistent_interfaces() {
|
||||
local output=""
|
||||
output+="$(translate 'Cleaning interfaces')\n\n"
|
||||
configured_interfaces=$(grep "^iface" /etc/network/interfaces | awk '{print $2}' | grep -v "lo")
|
||||
for iface in $configured_interfaces; do
|
||||
if [[ ! $iface =~ ^(vmbr|bond) ]] && ! ip link show "$iface" &>/dev/null; then
|
||||
sed -i "/iface $iface/,/^$/d" /etc/network/interfaces
|
||||
output+="$(translate 'Interface removed'): $iface\n"
|
||||
fi
|
||||
done
|
||||
whiptail --title "$(translate 'Cleaning Interfaces')" --msgbox "$output" 15 78
|
||||
}
|
||||
|
||||
configure_physical_interfaces() {
|
||||
local output=""
|
||||
output+="$(translate 'Configuring interfaces')\n\n"
|
||||
for iface in $physical_interfaces; do
|
||||
if ! grep -q "iface $iface" /etc/network/interfaces; then
|
||||
echo -e "\niface $iface inet manual" >> /etc/network/interfaces
|
||||
output+="$(translate 'Interface added'): $iface\n"
|
||||
fi
|
||||
done
|
||||
whiptail --title "$(translate 'Configuring Interfaces')" --msgbox "$output" 15 78
|
||||
}
|
||||
|
||||
restart_networking() {
|
||||
if (whiptail --title "$(translate 'Restarting Network')" --yesno "$(translate 'Do you want to restart the network service?')" 10 60); then
|
||||
clear
|
||||
msg_info "$(translate 'The network service is about to restart. You may experience a brief disconnection.')"
|
||||
systemctl restart networking
|
||||
if [ $? -eq 0 ]; then
|
||||
msg_ok "$(translate 'Network service restarted successfully')"
|
||||
else
|
||||
msg_error "$(translate 'Failed to restart network service')"
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'Network restart canceled')"
|
||||
fi
|
||||
}
|
||||
|
||||
check_network_connectivity() {
|
||||
if ping -c 4 8.8.8.8 &> /dev/null; then
|
||||
msg_ok "$(translate 'Network connectivity OK')"
|
||||
return 0
|
||||
else
|
||||
msg_error "$(translate 'Network connectivity failed')"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
show_ip_info() {
|
||||
whiptail --title "$(translate 'IP Information')" --infobox "$(translate 'Gathering IP information...')" 8 78
|
||||
local ip_info=""
|
||||
ip_info+="$(translate 'IP Information')\n\n"
|
||||
|
||||
local interfaces=$(get_relevant_interfaces)
|
||||
|
||||
for interface in $interfaces; do
|
||||
local interface_ip=$(ip -4 addr show $interface 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}')
|
||||
if [ -n "$interface_ip" ]; then
|
||||
ip_info+="$interface: $interface_ip\n"
|
||||
else
|
||||
ip_info+="$interface: $(translate 'No IP assigned')\n"
|
||||
fi
|
||||
done
|
||||
|
||||
whiptail --title "$(translate 'Result')" --msgbox "${ip_info}\n\n$(translate 'IP information gathering completed')\n\n$(translate 'Press Enter to continue')" 20 78
|
||||
}
|
||||
|
||||
repair_network() {
|
||||
if check_network_connectivity; then
|
||||
msg_ok "$(translate 'Network is already working. No repair needed.')"
|
||||
whiptail --title "$(translate 'Network Status')" --msgbox "$(translate 'Network is already connected. No action will be taken.')" 10 78
|
||||
return
|
||||
fi
|
||||
|
||||
whiptail --title "$(translate 'Network Repair Started')" --infobox "$(translate 'Repairing network...')" 8 78
|
||||
echo -ne "${TAB}${YW}-$(translate 'Repairing network...') ${CL}"
|
||||
sleep 3
|
||||
detect_physical_interfaces
|
||||
clean_nonexistent_interfaces
|
||||
check_and_fix_bridges
|
||||
configure_physical_interfaces
|
||||
restart_networking
|
||||
|
||||
if check_network_connectivity; then
|
||||
show_ip_info
|
||||
msg_ok "$(translate 'Network repair completed successfully')"
|
||||
else
|
||||
msg_error "$(translate 'Network repair failed')"
|
||||
fi
|
||||
|
||||
whiptail --title "$(translate 'Result')" --msgbox "$(translate 'Repair process completed')\n\n$(translate 'Press Enter to continue')" 10 78
|
||||
}
|
||||
|
||||
verify_network() {
|
||||
whiptail --title "$(translate 'Network Verification Started')" --infobox "$(translate 'Verifying network...')" 8 78
|
||||
echo -ne "${TAB}${YW}-$(translate 'Verifying network...') ${CL}"
|
||||
detect_physical_interfaces
|
||||
show_ip_info
|
||||
if check_network_connectivity; then
|
||||
msg_ok "$(translate 'Network verification completed successfully')"
|
||||
else
|
||||
msg_error "$(translate 'Network verification failed')"
|
||||
fi
|
||||
whiptail --title "$(translate 'Result')" --msgbox "$(translate 'Verification process completed')\n\n$(translate 'Press Enter to continue')" 10 78
|
||||
}
|
||||
|
||||
show_main_menu() {
|
||||
while true; do
|
||||
OPTION=$(whiptail --title "$(translate 'Network Repair Menu')" --menu "$(translate 'Choose an option:')" 15 60 4 \
|
||||
"1" "$(translate 'Repair Network')" \
|
||||
"2" "$(translate 'Verify Network')" \
|
||||
"3" "$(translate 'Show IP Information')" \
|
||||
"4" "$(translate "Return to Main Menu")" 3>&1 1>&2 2>&3)
|
||||
|
||||
case $OPTION in
|
||||
1) repair_network ;;
|
||||
2) verify_network ;;
|
||||
3) show_ip_info ;;
|
||||
4) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;;
|
||||
*) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
show_main_menu
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==============================================
|
||||
# ProxMenux - Menú de Restauración de Backups
|
||||
# ==============================================
|
||||
|
||||
BACKUP_DIR="/root/backups"
|
||||
PBS_REPO="root@pbs@192.168.100.10:host-backups"
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
main_menu() {
|
||||
OPTION=$(whiptail --title "Restaurar Backup del Host" --menu "¿Desde dónde quieres restaurar?" 15 60 5 \\
|
||||
"1" "Restaurar desde archivo local (.tar.gz)" \\
|
||||
"2" "Restaurar desde PBS (.pxar)" \\
|
||||
"3" "Salir" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$OPTION" in
|
||||
"1") restore_from_local ;;
|
||||
"2") restore_from_pbs ;;
|
||||
"3") clear; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
restore_from_local() {
|
||||
mapfile -t TAR_FILES < <(find "$BACKUP_DIR" -name "*.tar.gz" 2>/dev/null)
|
||||
|
||||
if [ ${#TAR_FILES[@]} -eq 0 ]; then
|
||||
whiptail --msgbox "No se encontraron archivos .tar.gz en $BACKUP_DIR" 10 60
|
||||
return
|
||||
fi
|
||||
|
||||
MENU_ITEMS=()
|
||||
for f in "${TAR_FILES[@]}"; do
|
||||
MENU_ITEMS+=("$f" "")
|
||||
done
|
||||
|
||||
SELECTED_TAR=$(whiptail --title "Selecciona backup local" --menu "Elige el archivo a restaurar:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
mapfile -t FILE_CONTENT < <(tar -tzf "$SELECTED_TAR")
|
||||
MENU_CONTENT=()
|
||||
for item in "${FILE_CONTENT[@]}"; do
|
||||
MENU_CONTENT+=("$item" "OFF")
|
||||
done
|
||||
|
||||
SELECTED_DIRS=$(whiptail --title "Contenido del backup" --checklist "Selecciona qué restaurar (Espacio = seleccionar):" 20 80 15 \\
|
||||
"ALL" "Restaurar todo el contenido" OFF \\
|
||||
"${MENU_CONTENT[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
if echo "$SELECTED_DIRS" | grep -q "ALL"; then
|
||||
tar -xzf "$SELECTED_TAR" -C /
|
||||
whiptail --msgbox "Restauración completa realizada con éxito." 10 60
|
||||
else
|
||||
for item in $SELECTED_DIRS; do
|
||||
item_cleaned=$(echo "$item" | tr -d '"')
|
||||
tar -xzf "$SELECTED_TAR" -C / "$item_cleaned"
|
||||
done
|
||||
whiptail --msgbox "Restauración parcial realizada con éxito." 10 60
|
||||
fi
|
||||
}
|
||||
|
||||
restore_from_pbs() {
|
||||
mapfile -t BACKUPS < <(proxmox-backup-client list --repository "$PBS_REPO" | grep "$HOSTNAME" | awk '{print $3}')
|
||||
|
||||
if [ ${#BACKUPS[@]} -eq 0 ]; then
|
||||
whiptail --msgbox "No se encontraron backups de $HOSTNAME en PBS." 10 60
|
||||
return
|
||||
fi
|
||||
|
||||
MENU_ITEMS=()
|
||||
for backup in "${BACKUPS[@]}"; do
|
||||
MENU_ITEMS+=("$backup" "")
|
||||
done
|
||||
|
||||
SELECTED_BACKUP=$(whiptail --title "Seleccionar backup en PBS" --menu "Elige un snapshot para restaurar:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
mapfile -t FILES < <(proxmox-backup-client catalog --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP" | awk '{print $1}' | grep ".pxar")
|
||||
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
whiptail --msgbox "No se encontraron archivos .pxar en ese snapshot." 10 60
|
||||
return
|
||||
fi
|
||||
|
||||
FILE_OPTIONS=("ALL" "Restaurar todos los archivos" OFF)
|
||||
for file in "${FILES[@]}"; do
|
||||
FILE_OPTIONS+=("$file" "OFF")
|
||||
done
|
||||
|
||||
SELECTED_FILES=$(whiptail --title "Contenido del snapshot PBS" --checklist "Selecciona qué restaurar:" 20 80 15 "${FILE_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
RESTORE_DIR="/tmp/pbs-restore-${SELECTED_BACKUP}"
|
||||
mkdir -p "$RESTORE_DIR"
|
||||
|
||||
if echo "$SELECTED_FILES" | grep -q "ALL"; then
|
||||
for file in "${FILES[@]}"; do
|
||||
proxmox-backup-client restore "$file" "$RESTORE_DIR/$(basename "$file" .pxar)" --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP"
|
||||
done
|
||||
whiptail --msgbox "Restauración completa a $RESTORE_DIR." 10 60
|
||||
else
|
||||
for file in $SELECTED_FILES; do
|
||||
file_cleaned=$(echo "$file" | tr -d '"')
|
||||
proxmox-backup-client restore "$file_cleaned" "$RESTORE_DIR/$(basename "$file_cleaned" .pxar)" --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP"
|
||||
done
|
||||
whiptail --msgbox "Restauración parcial a $RESTORE_DIR." 10 60
|
||||
fi
|
||||
}
|
||||
|
||||
# Lanzar menú principal
|
||||
while true; do main_menu; done
|
||||
""")
|
||||
|
||||
unified_script_path = Path("/mnt/data/restore-unified-menu.sh")
|
||||
unified_script_path.write_text(unified_restore_script)
|
||||
unified_script_path.chmod(0o755)
|
||||
unified_script_path
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ========================================
|
||||
# ProxMenux - Menú completo de restauración
|
||||
# ========================================
|
||||
|
||||
BACKUP_DIR="/root/backups"
|
||||
PBS_REPO="root@pbs@192.168.100.10:host-backups"
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
main_menu() {
|
||||
OPTION=$(whiptail --title "Restaurar Backup del Host" --menu "Selecciona el origen del backup:" 15 60 4 \\
|
||||
"1" "Restaurar desde archivo local (.tar.gz)" \\
|
||||
"2" "Restaurar desde PBS (.pxar)" \\
|
||||
"3" "Salir" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$OPTION" in
|
||||
"1") local_restore_menu ;;
|
||||
"2") pbs_restore_menu ;;
|
||||
"3") clear; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
local_restore_menu() {
|
||||
OPTION=$(whiptail --title "Restaurar desde archivo local" --menu "Selecciona el tipo de restauración:" 15 60 2 \\
|
||||
"1" "Restauración completa del sistema" \\
|
||||
"2" "Restauración manual (archivos o directorios)" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$OPTION" in
|
||||
"1") restore_local_full ;;
|
||||
"2") restore_local_manual ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pbs_restore_menu() {
|
||||
OPTION=$(whiptail --title "Restaurar desde PBS" --menu "Selecciona el tipo de restauración:" 15 60 2 \\
|
||||
"1" "Restauración completa del sistema" \\
|
||||
"2" "Restauración manual (archivos o directorios)" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$OPTION" in
|
||||
"1") restore_pbs_full ;;
|
||||
"2") restore_pbs_manual ;;
|
||||
esac
|
||||
}
|
||||
|
||||
restore_local_full() {
|
||||
mapfile -t TAR_FILES < <(find "$BACKUP_DIR" -name "*.tar.gz" 2>/dev/null)
|
||||
[[ ${#TAR_FILES[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron backups en $BACKUP_DIR" 10 60 && return
|
||||
|
||||
MENU_ITEMS=()
|
||||
for f in "${TAR_FILES[@]}"; do MENU_ITEMS+=("$f" ""); done
|
||||
|
||||
SELECTED=$(whiptail --title "Seleccionar backup" --menu "Elige el archivo para restaurar completamente:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
CONFIRM=$(whiptail --title "Confirmar restauración" --yesno "¿Deseas sobrescribir el sistema con este backup?" 10 60)
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
tar -xzf "$SELECTED" -C /
|
||||
whiptail --msgbox "Restauración completa realizada con éxito." 10 60
|
||||
}
|
||||
|
||||
restore_local_manual() {
|
||||
mapfile -t TAR_FILES < <(find "$BACKUP_DIR" -name "*.tar.gz" 2>/dev/null)
|
||||
[[ ${#TAR_FILES[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron backups en $BACKUP_DIR" 10 60 && return
|
||||
|
||||
MENU_ITEMS=()
|
||||
for f in "${TAR_FILES[@]}"; do MENU_ITEMS+=("$f" ""); done
|
||||
|
||||
SELECTED=$(whiptail --title "Seleccionar backup" --menu "Elige el archivo a examinar:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
mapfile -t CONTENT < <(tar -tzf "$SELECTED")
|
||||
MENU_CONTENT=()
|
||||
for item in "${CONTENT[@]}"; do MENU_CONTENT+=("$item" "OFF"); done
|
||||
|
||||
SELECTED_ITEMS=$(whiptail --title "Seleccionar contenido" --checklist "Selecciona qué restaurar:" 20 80 15 "${MENU_CONTENT[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
for item in $SELECTED_ITEMS; do
|
||||
CLEAN_ITEM=$(echo "$item" | tr -d '"')
|
||||
tar -xzf "$SELECTED" -C / "$CLEAN_ITEM"
|
||||
done
|
||||
|
||||
whiptail --msgbox "Restauración parcial realizada con éxito." 10 60
|
||||
}
|
||||
|
||||
restore_pbs_full() {
|
||||
mapfile -t BACKUPS < <(proxmox-backup-client list --repository "$PBS_REPO" | grep "$HOSTNAME" | awk '{print $3}')
|
||||
[[ ${#BACKUPS[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron backups en PBS." 10 60 && return
|
||||
|
||||
MENU_ITEMS=()
|
||||
for backup in "${BACKUPS[@]}"; do MENU_ITEMS+=("$backup" ""); done
|
||||
|
||||
SELECTED_BACKUP=$(whiptail --title "Snapshot PBS" --menu "Selecciona el snapshot para restaurar:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
mapfile -t FILES < <(proxmox-backup-client catalog --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP" | awk '{print $1}' | grep ".pxar")
|
||||
[[ ${#FILES[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron archivos .pxar." 10 60 && return
|
||||
|
||||
FILE_OPTIONS=()
|
||||
for file in "${FILES[@]}"; do FILE_OPTIONS+=("$file" "OFF"); done
|
||||
SELECTED_FILE=$(whiptail --title "Archivo .pxar" --radiolist "Selecciona el archivo para restaurar completamente:" 20 80 10 "${FILE_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
FILE_CLEAN=$(echo "$SELECTED_FILE" | tr -d '"')
|
||||
|
||||
CONFIRM=$(whiptail --title "Confirmar restauración" --yesno "¿Deseas sobrescribir el sistema con ${FILE_CLEAN}?" 10 70)
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
proxmox-backup-client restore "$FILE_CLEAN" / --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP"
|
||||
whiptail --msgbox "Restauración completa realizada con éxito." 10 60
|
||||
}
|
||||
|
||||
restore_pbs_manual() {
|
||||
mapfile -t BACKUPS < <(proxmox-backup-client list --repository "$PBS_REPO" | grep "$HOSTNAME" | awk '{print $3}')
|
||||
[[ ${#BACKUPS[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron backups en PBS." 10 60 && return
|
||||
|
||||
MENU_ITEMS=()
|
||||
for backup in "${BACKUPS[@]}"; do MENU_ITEMS+=("$backup" ""); done
|
||||
|
||||
SELECTED_BACKUP=$(whiptail --title "Snapshot PBS" --menu "Selecciona el snapshot para explorar:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
mapfile -t FILES < <(proxmox-backup-client catalog --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP" | awk '{print $1}' | grep ".pxar")
|
||||
[[ ${#FILES[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron archivos .pxar." 10 60 && return
|
||||
|
||||
FILE_OPTIONS=()
|
||||
for file in "${FILES[@]}"; do FILE_OPTIONS+=("$file" "OFF"); done
|
||||
SELECTED_FILE=$(whiptail --title "Archivo .pxar" --radiolist "Selecciona el archivo para restaurar parcialmente:" 20 80 10 "${FILE_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
FILE_CLEAN=$(echo "$SELECTED_FILE" | tr -d '"')
|
||||
|
||||
TMP_DIR="/tmp/restore-${RANDOM}"
|
||||
mkdir -p "$TMP_DIR"
|
||||
proxmox-backup-client restore "$FILE_CLEAN" "$TMP_DIR" --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP"
|
||||
|
||||
mapfile -t CONTENT < <(cd "$TMP_DIR" && find . -type f -o -type d)
|
||||
RESTORE_ITEMS=()
|
||||
for entry in "${CONTENT[@]}"; do RESTORE_ITEMS+=("$entry" "OFF"); done
|
||||
|
||||
SELECTED_ITEMS=$(whiptail --title "Contenido del backup" --checklist "Selecciona qué restaurar en el sistema:" 20 80 15 "${RESTORE_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
for item in $SELECTED_ITEMS; do
|
||||
CLEAN_ITEM=$(echo "$item" | tr -d '"')
|
||||
cp -r "$TMP_DIR/$CLEAN_ITEM" "/$CLEAN_ITEM"
|
||||
done
|
||||
|
||||
rm -rf "$TMP_DIR"
|
||||
whiptail --msgbox "Restauración parcial completada y archivos temporales eliminados." 10 60
|
||||
}
|
||||
|
||||
while true; do main_menu; done
|
||||
""")
|
||||
|
||||
script_path = Path("/mnt/data/proxmox-restore-menu.sh")
|
||||
script_path.write_text(full_menu_script)
|
||||
script_path.chmod(0o755)
|
||||
script_path
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ================================
|
||||
# ProxMenux - Restauración completa desde PBS con autoreparación
|
||||
# ================================
|
||||
|
||||
PBS_REPO="root@pbs@192.168.100.10:host-backups"
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
restore_pbs_full() {
|
||||
mapfile -t BACKUPS < <(proxmox-backup-client list --repository "$PBS_REPO" | grep "$HOSTNAME" | awk '{print $3}')
|
||||
[[ ${#BACKUPS[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron backups en PBS." 10 60 && return
|
||||
|
||||
MENU_ITEMS=()
|
||||
for backup in "${BACKUPS[@]}"; do MENU_ITEMS+=("$backup" ""); done
|
||||
|
||||
SELECTED_BACKUP=$(whiptail --title "Snapshot PBS" --menu "Selecciona el snapshot para restaurar:" 20 70 10 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
mapfile -t FILES < <(proxmox-backup-client catalog --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP" | awk '{print $1}' | grep ".pxar")
|
||||
[[ ${#FILES[@]} -eq 0 ]] && whiptail --msgbox "No se encontraron archivos .pxar." 10 60 && return
|
||||
|
||||
FILE_OPTIONS=()
|
||||
for file in "${FILES[@]}"; do FILE_OPTIONS+=("$file" "OFF"); done
|
||||
SELECTED_FILE=$(whiptail --title "Archivo .pxar" --radiolist "Selecciona el archivo para restaurar completamente:" 20 80 10 "${FILE_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
FILE_CLEAN=$(echo "$SELECTED_FILE" | tr -d '"')
|
||||
|
||||
CONFIRM=$(whiptail --title "Confirmar restauración" --yesno "¿Deseas sobrescribir el sistema con ${FILE_CLEAN}?\nEsto restaurará todos los archivos y puede requerir reinstalar GRUB y el kernel." 12 70)
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
# Restauración principal
|
||||
proxmox-backup-client restore "$FILE_CLEAN" / --repository "$PBS_REPO" --backup-id "$HOSTNAME" --backup-time "$SELECTED_BACKUP"
|
||||
RESTORE_STATUS=$?
|
||||
|
||||
if [ $RESTORE_STATUS -eq 0 ]; then
|
||||
whiptail --msgbox "Restauración completa realizada con éxito. Ahora se ejecutarán pasos de autoreparación (GRUB, kernel, DKMS)..." 10 70
|
||||
|
||||
# Reparación post-restauración
|
||||
{
|
||||
echo "[INFO] Reinstalando GRUB en /dev/sda..."
|
||||
grub-install /dev/sda && update-grub
|
||||
|
||||
echo "[INFO] Reinstalando kernel actual..."
|
||||
apt install --reinstall -y pve-kernel-$(uname -r)
|
||||
|
||||
echo "[INFO] Reconstruyendo módulos DKMS..."
|
||||
dkms autoinstall || true
|
||||
} >> /var/log/proxmox-restore.log 2>&1
|
||||
|
||||
whiptail --yesno "Restauración y autoreparación completadas.\n¿Deseas reiniciar ahora el sistema?" 10 60 && reboot
|
||||
else
|
||||
whiptail --msgbox "Error durante la restauración. Verifica los logs para más detalles." 10 60
|
||||
fi
|
||||
}
|
||||
|
||||
restore_pbs_full
|
||||
"""
|
||||
|
||||
pbs_autorepair_path.write_text(pbs_restore_autorepair_script)
|
||||
pbs_autorepair_path.chmod(0o755)
|
||||
pbs_autorepair_path
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# =======================================
|
||||
# ProxMenux - Backup Menu for Proxmox VE
|
||||
# =======================================
|
||||
|
||||
# CONFIGURACIÓN DINÁMICA
|
||||
# Solicitar datos de conexión a PBS por separado y construir el repositorio final
|
||||
PBS_REPO_FILE="/etc/proxmenux/pbs-repo.conf"
|
||||
|
||||
if [[ -f "$PBS_REPO_FILE" ]]; then
|
||||
PBS_REPO=$(tr -d '
|
||||
[:space:]' < "$PBS_REPO_FILE")
|
||||
else
|
||||
PBS_USER=$(whiptail --inputbox "Introduce el nombre de usuario para el PBS:" 10 60 "root" 3>&1 1>&2 2>&3) || exit
|
||||
PBS_HOST=$(whiptail --inputbox "Introduce la IP o nombre del host del PBS:" 10 60 "192.168.0.42" 3>&1 1>&2 2>&3) || exit
|
||||
PBS_DATASTORE=$(whiptail --inputbox "Introduce el nombre del datastore PBS:" 10 60 "t6pbs" 3>&1 1>&2 2>&3) || exit
|
||||
|
||||
PBS_REPO="${PBS_USER}@pam@${PBS_HOST}:${PBS_DATASTORE}"
|
||||
mkdir -p "$(dirname "$PBS_REPO_FILE")"
|
||||
echo "$PBS_REPO" > "$PBS_REPO_FILE"
|
||||
fi
|
||||
|
||||
HOSTNAME=$(hostname)
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
|
||||
SNAPSHOT="${HOSTNAME}-${TIMESTAMP}"
|
||||
BACKUP_DIR="/var/backups/proxmox-host/tar"
|
||||
|
||||
declare -A BACKUP_PATHS=(
|
||||
[etc-pve]="/etc/pve"
|
||||
[etc-network]="/etc/network"
|
||||
[var-lib-pve-cluster]="/var/lib/pve-cluster"
|
||||
[root-dir]="/root"
|
||||
[etc-ssh]="/etc/ssh"
|
||||
[home]="/home"
|
||||
[local-bin]="/usr/local/bin"
|
||||
[cron]="/etc/cron.d"
|
||||
[custom-systemd]="/etc/systemd/system"
|
||||
)
|
||||
|
||||
main_menu() {
|
||||
OPTION=$(whiptail --title "Proxmox Host Backup" --menu "Elige una opción de respaldo:" 20 78 10 \
|
||||
"1" "Backup rápido personalizado (tar.gz, local)" \
|
||||
"2" "Backup rápido personalizado (PBS)" \
|
||||
"3" "Backup completo del sistema (tar.gz, local)" \
|
||||
"4" "Backup completo del sistema (PBS)" \
|
||||
"5" "Backup mínimo automático (tar.gz, local)" \
|
||||
"6" "Salir" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$OPTION" in
|
||||
"1") backup_local_tar_checklist ;;
|
||||
"2") backup_modular_pbs_checklist ;;
|
||||
"3") backup_full_local_root ;;
|
||||
"4") backup_full_pbs_root ;;
|
||||
"5") backup_min_local_tar ;;
|
||||
"6") clear; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
backup_local_tar_checklist() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
MENU_OPTIONS=("ALL" "Respaldar todos los directorios sugeridos" OFF)
|
||||
for name in "${!BACKUP_PATHS[@]}"; do
|
||||
MENU_OPTIONS+=("$name" "${BACKUP_PATHS[$name]}" OFF)
|
||||
done
|
||||
|
||||
CHOICES=$(whiptail --checklist "Selecciona los directorios a respaldar:" 20 78 12 "${MENU_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
SELECTED=()
|
||||
if echo "$CHOICES" | grep -q "ALL"; then
|
||||
for path in "${BACKUP_PATHS[@]}"; do SELECTED+=("$path"); done
|
||||
else
|
||||
for choice in $CHOICES; do
|
||||
key=$(echo "$choice" | tr -d '"')
|
||||
SELECTED+=("${BACKUP_PATHS[$key]}")
|
||||
done
|
||||
fi
|
||||
|
||||
BACKUP_FILE="${BACKUP_DIR}/${HOSTNAME}-local-backup-${TIMESTAMP}.tar.gz"
|
||||
tar --exclude="$BACKUP_FILE" -czf "$BACKUP_FILE" --absolute-names "${SELECTED[@]}" && \
|
||||
echo -e "\nBackup guardado en: $BACKUP_FILE" || \
|
||||
echo -e "\nError al crear el backup local."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_modular_pbs_checklist() {
|
||||
MENU_OPTIONS=("ALL" "Respaldar todos los directorios sugeridos" OFF)
|
||||
for name in "${!BACKUP_PATHS[@]}"; do
|
||||
MENU_OPTIONS+=("$name" "${BACKUP_PATHS[$name]}" OFF)
|
||||
done
|
||||
|
||||
CHOICES=$(whiptail --checklist "Selecciona qué enviar al PBS:" 20 78 12 "${MENU_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
SELECTED=()
|
||||
if echo "$CHOICES" | grep -q "ALL"; then
|
||||
for name in "${!BACKUP_PATHS[@]}"; do
|
||||
safe_name=$(echo "$name" | tr '.-' '_')
|
||||
SELECTED+=("${safe_name}.pxar:${BACKUP_PATHS[$name]}")
|
||||
done
|
||||
else
|
||||
for choice in $CHOICES; do
|
||||
key=$(echo "$choice" | tr -d '"')
|
||||
safe_key=$(echo "$key" | tr '.-' '_')
|
||||
SELECTED+=("${safe_key}.pxar:${BACKUP_PATHS[$key]}")
|
||||
done
|
||||
fi
|
||||
|
||||
for entry in "${SELECTED[@]}"; do
|
||||
if [[ "$entry" =~ ^[a-zA-Z0-9_-]+\.pxar:/.* ]]; then
|
||||
echo ">> Enviando: $entry"
|
||||
echo ">> REPO: '$PBS_REPO'"
|
||||
proxmox-backup-client backup "$entry" \
|
||||
--repository "$PBS_REPO" \
|
||||
--backup-type host \
|
||||
--backup-id "${HOSTNAME}-$(echo "$entry" | cut -d'.' -f1)" \
|
||||
--backup-time "$(date +%s)" \
|
||||
--incremental true
|
||||
else
|
||||
echo ">> Saltado (mal formado): $entry"
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "\nBackup modular al PBS finalizado."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_full_local_root() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="${BACKUP_DIR}/${HOSTNAME}-full-backup-${TIMESTAMP}.tar.gz"
|
||||
echo "Creando backup completo local (excluyendo /proc, /sys, /dev, /run, /mnt, /tmp)..."
|
||||
tar --exclude="$BACKUP_DIR" --exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run --exclude=/mnt --exclude=/tmp \
|
||||
-czf "$BACKUP_FILE" / && \
|
||||
echo -e "\nBackup completo guardado en: $BACKUP_FILE" || \
|
||||
echo -e "\nError durante el backup completo."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_full_pbs_root() {
|
||||
proxmox-backup-client backup \
|
||||
--include-dev /boot/efi \
|
||||
--include-dev /etc/pve \
|
||||
root-${HOSTNAME}.pxar:/ \
|
||||
--repository "$PBS_REPO" \
|
||||
--backup-type host \
|
||||
--backup-id "$HOSTNAME" \
|
||||
--backup-time "$(date +%s)" && \
|
||||
echo -e "
|
||||
Backup completo al PBS finalizado correctamente." || \
|
||||
echo -e "
|
||||
Error durante el backup completo."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
echo -e "\nBackup completo al PBS finalizado correctamente." || \
|
||||
echo -e "\nError durante el backup completo."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_min_local_tar() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="${BACKUP_DIR}/${HOSTNAME}-minimal-${TIMESTAMP}.tar.gz"
|
||||
tar --exclude="$BACKUP_FILE" -czf "$BACKUP_FILE" --absolute-names /etc/pve /etc/network /var/lib/pve-cluster /root && \
|
||||
echo -e "\nBackup mínimo guardado en: $BACKUP_FILE" || \
|
||||
echo -e "\nError durante el backup mínimo."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
main_menu
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# =======================================
|
||||
# ProxMenux - Backup Menu for Proxmox VE
|
||||
# =======================================
|
||||
|
||||
# CONFIGURACIÓN
|
||||
PBS_REPO="root@pbs@192.168.100.10:host-backups" # Cambiar IP/datastore si es necesario
|
||||
HOSTNAME=$(hostname)
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
|
||||
SNAPSHOT="${HOSTNAME}-${TIMESTAMP}"
|
||||
|
||||
# LISTA DE DIRECTORIOS RECOMENDADOS
|
||||
declare -A BACKUP_PATHS=(
|
||||
[etc-pve]="/etc/pve"
|
||||
[etc-network]="/etc/network"
|
||||
[var-lib-pve-cluster]="/var/lib/pve-cluster"
|
||||
[root-dir]="/root"
|
||||
[etc-ssh]="/etc/ssh"
|
||||
[home]="/home"
|
||||
[local-bin]="/usr/local/bin"
|
||||
[cron]="/etc/cron.d"
|
||||
[custom-systemd]="/etc/systemd/system"
|
||||
)
|
||||
|
||||
main_menu() {
|
||||
OPTION=$(whiptail --title "Proxmox Host Backup" --menu "Elige una opción de respaldo:" 20 78 10 \
|
||||
"1" "Backup rápido personalizado (tar.gz, local)" \
|
||||
"2" "Backup completo del sistema (PBS, backup-client)" \
|
||||
"3" "Backup modular al PBS (checklist)" \
|
||||
"4" "Backup mínimo automático (tar.gz, local)" \
|
||||
"5" "Salir" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$OPTION" in
|
||||
"1") backup_local_tar_checklist ;;
|
||||
"2") backup_full_pbs_root ;;
|
||||
"3") backup_modular_pbs_checklist ;;
|
||||
"4") backup_min_local_tar ;;
|
||||
"5") clear; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
backup_local_tar_checklist() {
|
||||
BACKUP_DIR=$(whiptail --inputbox "¿Dónde guardar el backup local? (por defecto /root/backups)" 10 60 3>&1 1>&2 2>&3)
|
||||
BACKUP_DIR="${BACKUP_DIR:-/root/backups}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
MENU_OPTIONS=("ALL" "Respaldar todos los directorios sugeridos" OFF)
|
||||
for name in "${!BACKUP_PATHS[@]}"; do
|
||||
MENU_OPTIONS+=("$name" "${BACKUP_PATHS[$name]}" OFF)
|
||||
done
|
||||
|
||||
CHOICES=$(whiptail --checklist "Selecciona los directorios a respaldar:" 20 78 12 "${MENU_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
SELECTED=()
|
||||
if echo "$CHOICES" | grep -q "ALL"; then
|
||||
for path in "${BACKUP_PATHS[@]}"; do SELECTED+=("$path"); done
|
||||
else
|
||||
for choice in $CHOICES; do
|
||||
key=$(echo "$choice" | tr -d '"')
|
||||
SELECTED+=("${BACKUP_PATHS[$key]}")
|
||||
done
|
||||
fi
|
||||
|
||||
BACKUP_FILE="${BACKUP_DIR}/${HOSTNAME}-local-backup-${TIMESTAMP}.tar.gz"
|
||||
tar -czf "$BACKUP_FILE" --absolute-names "${SELECTED[@]}" && \
|
||||
echo -e "\\nBackup guardado en: $BACKUP_FILE" || \
|
||||
echo -e "\\nError al crear el backup local."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_full_pbs_root() {
|
||||
proxmox-backup-client backup \\
|
||||
--include-dev /boot/efi \\
|
||||
--include-dev /etc/pve \\
|
||||
root-${HOSTNAME}.pxar:/ \\
|
||||
--repository "$PBS_REPO" && \
|
||||
echo -e "\\nBackup completo al PBS finalizado correctamente." || \
|
||||
echo -e "\\nError durante el backup completo."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_modular_pbs_checklist() {
|
||||
MENU_OPTIONS=("ALL" "Respaldar todos los directorios sugeridos" OFF)
|
||||
for name in "${!BACKUP_PATHS[@]}"; do
|
||||
MENU_OPTIONS+=("$name" "${BACKUP_PATHS[$name]}" OFF)
|
||||
done
|
||||
|
||||
CHOICES=$(whiptail --checklist "Selecciona qué enviar al PBS:" 20 78 12 "${MENU_OPTIONS[@]}" 3>&1 1>&2 2>&3) || return
|
||||
|
||||
SELECTED=()
|
||||
if echo "$CHOICES" | grep -q "ALL"; then
|
||||
for name in "${!BACKUP_PATHS[@]}"; do
|
||||
SELECTED+=("${name}.pxar:${BACKUP_PATHS[$name]}")
|
||||
done
|
||||
else
|
||||
for choice in $CHOICES; do
|
||||
key=$(echo "$choice" | tr -d '"')
|
||||
SELECTED+=("${key}.pxar:${BACKUP_PATHS[$key]}")
|
||||
done
|
||||
fi
|
||||
|
||||
for entry in "${SELECTED[@]}"; do
|
||||
proxmox-backup-client backup "$entry" \\
|
||||
--repository "$PBS_REPO" \\
|
||||
--backup-type host \\
|
||||
--backup-id "$HOSTNAME" \\
|
||||
--backup-time "$TIMESTAMP"
|
||||
done
|
||||
echo -e "\\nBackup modular al PBS finalizado."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
backup_min_local_tar() {
|
||||
BACKUP_DIR="/root/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="${BACKUP_DIR}/${HOSTNAME}-minimal-${TIMESTAMP}.tar.gz"
|
||||
tar -czf "$BACKUP_FILE" --absolute-names \
|
||||
/etc/pve /etc/network /var/lib/pve-cluster /root && \
|
||||
echo -e "\\nBackup mínimo guardado en: $BACKUP_FILE" || \
|
||||
echo -e "\\nError durante el backup mínimo."
|
||||
read -p "Pulsa ENTER para continuar..."
|
||||
}
|
||||
|
||||
# Lanzar menú principal
|
||||
while true; do main_menu; done
|
||||
""")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
backup_script_path = Path("/mnt/data/proxmox_host_backup_menu.sh")
|
||||
backup_script_path.write_text(menu_script)
|
||||
backup_script_path.chmod(0o755)
|
||||
backup_script_path
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ================================================
|
||||
# ProxMenux - Create VM Entry Point
|
||||
# ================================================
|
||||
# Author : MacRimi
|
||||
# ================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
VM_CONFIG="./vm_configurator.sh"
|
||||
DISK_SELECTOR="./disk_selector.sh"
|
||||
VM_CREATOR="./vm_creator.sh"
|
||||
#LINUX_ISO="./select_linux_iso.sh"
|
||||
GUEST_AGENT="./guest_agent_config.sh"
|
||||
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# Load modules
|
||||
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
|
||||
[[ -f "$VM_CONFIG" ]] && source "$VM_CONFIG"
|
||||
[[ -f "$DISK_SELECTOR" ]] && source "$DISK_SELECTOR"
|
||||
[[ -f "$VM_CREATOR" ]] && source "$VM_CREATOR"
|
||||
[[ -f "$LINUX_ISO" ]] && source "$LINUX_ISO"
|
||||
[[ -f "$GUEST_AGENT" ]] && source "$GUEST_AGENT"
|
||||
|
||||
|
||||
|
||||
|
||||
function header_info() {
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
echo -e "${BL}╔═══════════════════════════════════════════════╗${CL}"
|
||||
echo -e "${BL}║ ║${CL}"
|
||||
echo -e "${BL}║${YWB} ProxMenux VM Creator ${BL}║${CL}"
|
||||
echo -e "${BL}║ ║${CL}"
|
||||
echo -e "${BL}╚═══════════════════════════════════════════════╝${CL}"
|
||||
echo -e
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# MAIN EXECUTION
|
||||
# ==========================================================
|
||||
|
||||
header_info
|
||||
echo -e "\n Loading..."
|
||||
sleep 1
|
||||
|
||||
# Step 1 - Select OS Type
|
||||
OS_TYPE=$(whiptail --title "ProxMenux" --menu "$(translate "Select the type of system to install")" 15 60 4 \
|
||||
"nas" "$(translate "Create VM System NAS")" \
|
||||
"windows" "$(translate "Create VM System Windows")" \
|
||||
"linux" "$(translate "Create VM System Linux")" \
|
||||
"lite" "$(translate "Create VM System Others (based Linux)")" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ -z "$OS_TYPE" ]] && clear && exit
|
||||
|
||||
|
||||
if [[ "$OS_TYPE" == "nas" ]]; then
|
||||
header_info
|
||||
source ./select_nas_iso.sh
|
||||
select_nas_iso || exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Si es Windows, invocar selección de ISO
|
||||
if [[ "$OS_TYPE" == "windows" ]]; then
|
||||
header_info
|
||||
source ./select_windows_iso.sh
|
||||
select_windows_iso || exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Si es Linux, invocar selección de ISO
|
||||
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||
header_info
|
||||
source ./select_linux_iso.sh
|
||||
select_linux_iso || exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Step 2 - Default or Advanced config
|
||||
if (whiptail --title "ProxMenux" --yesno "$(translate "Use Default Settings?")" --no-button "$(translate "Advanced")" 10 60); then
|
||||
header_info
|
||||
load_default_vm_config "$OS_TYPE"
|
||||
apply_default_vm_config
|
||||
else
|
||||
header_info
|
||||
echo -e "${CUS}$(translate "Using advanced configuration")${CL}"
|
||||
configure_vm_advanced "$OS_TYPE"
|
||||
fi
|
||||
|
||||
# Step 3 - Disk selection
|
||||
select_disk_type
|
||||
if [[ -z "$DISK_TYPE" ]]; then
|
||||
msg_error "$(translate "Disk type selection failed or cancelled")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4 - Create VM
|
||||
create_vm
|
||||
|
||||
# Step 5 - Guest Agent integration
|
||||
configure_guest_agent
|
||||
@@ -1,280 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==========================================================
|
||||
# Disk Selector Module - ProxMenux
|
||||
# ==========================================================
|
||||
# Reutiliza la lógica original de selección de discos
|
||||
# virtuales y físicos con integración de traducciones
|
||||
# ==========================================================
|
||||
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
function select_disk_type() {
|
||||
DISK_TYPE=$(whiptail --backtitle "ProxMenux" --title "DISK TYPE" --menu "$(translate "Choose disk type:")" 12 58 2 \
|
||||
"virtual" "$(translate "Create virtual disk")" \
|
||||
"passthrough" "$(translate "Use physical disk passthrough")" \
|
||||
--ok-button "Select" --cancel-button "Cancel" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ -z "$DISK_TYPE" ]] && return 1
|
||||
|
||||
if [[ "$DISK_TYPE" == "virtual" ]]; then
|
||||
select_virtual_disk
|
||||
else
|
||||
select_passthrough_disk
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Select Virtual Disks
|
||||
# ==========================================================
|
||||
function select_virtual_disk() {
|
||||
|
||||
VIRTUAL_DISKS=()
|
||||
|
||||
# Loop to add multiple disks
|
||||
local add_more_disks=true
|
||||
while $add_more_disks; do
|
||||
|
||||
msg_info "Detecting available storage volumes..."
|
||||
|
||||
# Get list of available storage
|
||||
STORAGE_MENU=()
|
||||
while read -r line; do
|
||||
TAG=$(echo $line | awk '{print $1}')
|
||||
TYPE=$(echo $line | awk '{print $2}')
|
||||
FREE=$(echo $line | numfmt --field 4-6 --from-unit=K --to=iec --format "%.2f" | awk '{printf( "%9sB", $6)}')
|
||||
ITEM=$(printf "%-15s %-10s %-15s" "$TAG" "$TYPE" "$FREE")
|
||||
STORAGE_MENU+=("$TAG" "$ITEM" "OFF")
|
||||
done < <(pvesm status -content images | awk 'NR>1')
|
||||
|
||||
# Check that storage is available
|
||||
VALID=$(pvesm status -content images | awk 'NR>1')
|
||||
if [ -z "$VALID" ]; then
|
||||
msg_error "Unable to detect a valid storage location."
|
||||
sleep 2
|
||||
select_disk_type
|
||||
fi
|
||||
|
||||
|
||||
# Select storage
|
||||
if [ $((${#STORAGE_MENU[@]} / 3)) -eq 1 ]; then
|
||||
STORAGE=${STORAGE_MENU[0]}
|
||||
msg_ok "Using ${CL}${BL}$STORAGE${CL} ${GN}for Storage Location."
|
||||
else
|
||||
|
||||
kill $SPINNER_PID > /dev/null
|
||||
STORAGE=$(whiptail --backtitle "ProxMenuX" --title "$(translate "Select Storage Volume")" --radiolist \
|
||||
"$(translate "Choose the storage volume for the virtual disk:\n")" 20 78 10 \
|
||||
"${STORAGE_MENU[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$STORAGE" ]; then
|
||||
if [ ${#VIRTUAL_DISKS[@]} -eq 0 ]; then
|
||||
msg_error "No storage selected. At least one disk is required."
|
||||
select_disk_type
|
||||
else
|
||||
add_more_disks=false
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
fi
|
||||
|
||||
# Request disk size
|
||||
DISK_SIZE=$(whiptail --backtitle "ProxMenuX" --inputbox "$(translate "System Disk Size (GB)")" 8 58 32 --title "VIRTUAL DISK" --cancel-button Cancel 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
if [ ${#VIRTUAL_DISKS[@]} -eq 0 ]; then
|
||||
msg_error "Disk size not specified. At least one disk is required."
|
||||
sleep 2
|
||||
select_disk_type
|
||||
|
||||
else
|
||||
add_more_disks=false
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$DISK_SIZE" ]; then
|
||||
DISK_SIZE="32"
|
||||
fi
|
||||
|
||||
# Store the configuration in the disk list
|
||||
VIRTUAL_DISKS+=("${STORAGE}:${DISK_SIZE}")
|
||||
|
||||
|
||||
# Ask if you want to create another disk
|
||||
if ! whiptail --backtitle "ProxMenuX" --title "$(translate "Add Another Disk")" \
|
||||
--yesno "$(translate "Do you want to add another virtual disk?")" 8 58; then
|
||||
add_more_disks=false
|
||||
fi
|
||||
done
|
||||
|
||||
# Show summary of the created disks
|
||||
if [ ${#VIRTUAL_DISKS[@]} -gt 0 ]; then
|
||||
|
||||
msg_ok "Virtual Disks Created:"
|
||||
for i in "${!VIRTUAL_DISKS[@]}"; do
|
||||
echo -e "${TAB}${BL}- Disk $((i+1)): ${VIRTUAL_DISKS[$i]}GB${CL}"
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
export VIRTUAL_DISKS
|
||||
|
||||
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# Select Physical Disks
|
||||
# ==========================================================
|
||||
function select_passthrough_disk() {
|
||||
|
||||
msg_info "$(translate "Detecting available disks...")"
|
||||
|
||||
FREE_DISKS=()
|
||||
|
||||
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
|
||||
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
|
||||
|
||||
ZFS_DISKS=""
|
||||
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
|
||||
|
||||
for entry in $ZFS_RAW; do
|
||||
path=""
|
||||
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
|
||||
if [ -e "/dev/disk/by-id/$entry" ]; then
|
||||
path=$(readlink -f "/dev/disk/by-id/$entry")
|
||||
fi
|
||||
elif [[ "$entry" == /dev/* ]]; then
|
||||
path="$entry"
|
||||
fi
|
||||
|
||||
if [ -n "$path" ]; then
|
||||
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
|
||||
if [ -n "$base_disk" ]; then
|
||||
ZFS_DISKS+="/dev/$base_disk"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u)
|
||||
|
||||
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
|
||||
|
||||
while read -r DISK; do
|
||||
[[ "$DISK" =~ /dev/zd ]] && continue
|
||||
|
||||
INFO=($(lsblk -dn -o MODEL,SIZE "$DISK"))
|
||||
MODEL="${INFO[@]::${#INFO[@]}-1}"
|
||||
SIZE="${INFO[-1]}"
|
||||
LABEL=""
|
||||
SHOW_DISK=true
|
||||
|
||||
IS_MOUNTED=false
|
||||
IS_RAID=false
|
||||
IS_ZFS=false
|
||||
IS_LVM=false
|
||||
|
||||
while read -r part fstype; do
|
||||
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
|
||||
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
|
||||
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
|
||||
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
|
||||
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
USED_BY=""
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
CONFIG_DATA=$(cat /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
|
||||
|
||||
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
|
||||
USED_BY="⚠ $(translate "In use")"
|
||||
else
|
||||
for SYMLINK in /dev/disk/by-id/*; do
|
||||
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
|
||||
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
|
||||
USED_BY="⚠ $(translate "In use")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)" && grep -q "active raid" /proc/mdstat; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if $IS_ZFS || $IS_MOUNTED || [[ "$ZFS_DISKS" == *"$DISK"* ]]; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if $SHOW_DISK; then
|
||||
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
|
||||
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
|
||||
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
|
||||
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
|
||||
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
|
||||
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
|
||||
fi
|
||||
done < <(lsblk -dn -e 7,11 -o PATH)
|
||||
|
||||
|
||||
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
|
||||
cleanup
|
||||
whiptail --title "Error" --msgbox "$(translate "No disks available for this VM.")" 8 40
|
||||
select_disk_type
|
||||
return
|
||||
fi
|
||||
|
||||
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
|
||||
TOTAL_WIDTH=$((MAX_WIDTH + 20))
|
||||
[ $TOTAL_WIDTH -lt 50 ] && TOTAL_WIDTH=50
|
||||
cleanup
|
||||
SELECTED_DISKS=$(whiptail --title "Select Disks" --checklist \
|
||||
"$(translate "Select the disks you want to use (use spacebar to select):")" 20 $TOTAL_WIDTH 10 \
|
||||
"${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$SELECTED_DISKS" ]; then
|
||||
msg_error "Disk not specified. At least one disk is required."
|
||||
sleep 2
|
||||
select_disk_type
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
msg_ok "Disk passthrough selected:"
|
||||
PASSTHROUGH_DISKS=()
|
||||
for DISK in $(echo "$SELECTED_DISKS" | tr -d '"'); do
|
||||
DISK_INFO=$(lsblk -ndo MODEL,SIZE "$DISK" | xargs)
|
||||
echo -e "${TAB}${CL}${BL}- $DISK $DISK_INFO${GN}${CL}"
|
||||
PASSTHROUGH_DISKS+=("$DISK")
|
||||
done
|
||||
|
||||
|
||||
}
|
||||
# ==========================================================
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==========================================================
|
||||
# Guest Agent Configurator - ProxMenux
|
||||
# ==========================================================
|
||||
# Añade soporte al QEMU Guest Agent y dispositivos útiles.
|
||||
# Se adapta según el sistema operativo.
|
||||
# ==========================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
|
||||
function configure_guest_agent() {
|
||||
if [[ -z "$VMID" ]]; then
|
||||
msg_error "$(translate "No VMID defined. Cannot apply guest agent config.")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Adding QEMU Guest Agent support...")"
|
||||
|
||||
# Habilitar el agente en la VM
|
||||
qm set "$VMID" -agent enabled=1 >/dev/null 2>&1
|
||||
|
||||
# Añadir canal de comunicación virtio
|
||||
qm set "$VMID" -chardev socket,id=qga0,path=/var/run/qemu-server/$VMID.qga,server=on,wait=off >/dev/null 2>&1
|
||||
qm set "$VMID" -device virtio-serial-pci -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 >/dev/null 2>&1
|
||||
|
||||
msg_ok "$(translate "Guest Agent configuration applied")"
|
||||
|
||||
if [[ "$OS_TYPE" == "windows" ]]; then
|
||||
echo -e "${YW}$(translate "Reminder: You must install the QEMU Guest Agent inside the Windows VM")${NC}"
|
||||
echo -e "${YW}$(translate "Tip: Also mount the VirtIO ISO for drivers and guest agent installer")${NC}"
|
||||
echo -e "${TAB}- https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/"
|
||||
elif [[ "$OS_TYPE" == "linux" || "$OS_TYPE" == "lite" ]]; then
|
||||
echo -e "${YW}$(translate "Tip: You can install the QEMU Guest Agent inside the VM with:")${NC}"
|
||||
echo -e "${TAB}apt install qemu-guest-agent -y && systemctl enable --now qemu-guest-agent"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==============================================================
|
||||
# ProxMenux - Linux ISO Selector (No download yet)
|
||||
# ==============================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
|
||||
function select_linux_iso() {
|
||||
|
||||
ISO_DIR="/var/lib/vz/template/iso"
|
||||
mkdir -p "$ISO_DIR"
|
||||
|
||||
|
||||
DISTROS=(
|
||||
"Ubuntu 22.04 LTS Desktop|Desktop|ProxMenux|https://releases.ubuntu.com/22.04/ubuntu-22.04.4-desktop-amd64.iso"
|
||||
"Ubuntu 20.04 LTS Desktop|Desktop|ProxMenux|https://releases.ubuntu.com/20.04/ubuntu-20.04.6-desktop-amd64.iso"
|
||||
"Ubuntu 22.04 LTS Server (CLI)|CLI|ProxMenux|https://releases.ubuntu.com/22.04/ubuntu-22.04.4-live-server-amd64.iso"
|
||||
"Ubuntu 20.04 LTS Server (CLI)|CLI|ProxMenux|https://releases.ubuntu.com/20.04/ubuntu-20.04.6-live-server-amd64.iso"
|
||||
"Debian 12 Gnome (Desktop)|Desktop|ProxMenux|https://cdimage.debian.org/debian-cd/current/amd64/iso-dvd/debian-12.5.0-amd64-DVD-1.iso"
|
||||
"Debian 11 Gnome (Desktop)|Desktop|ProxMenux|https://cdimage.debian.org/debian-cd/11.9.0/amd64/iso-dvd/debian-11.9.0-amd64-DVD-1.iso"
|
||||
"Debian 12 Netinst (CLI)|CLI|ProxMenux|https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.5.0-amd64-netinst.iso"
|
||||
"Debian 11 Netinst (CLI)|CLI|ProxMenux|https://cdimage.debian.org/debian-cd/11.9.0/amd64/iso-cd/debian-11.9.0-amd64-netinst.iso"
|
||||
"Fedora Workstation 39|Desktop|ProxMenux|https://download.fedoraproject.org/pub/fedora/linux/releases/39/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-39-1.5.iso"
|
||||
"Fedora Workstation 38|Desktop|ProxMenux|https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso"
|
||||
"Rocky Linux 9.3 Gnome|Desktop|ProxMenux|https://download.rockylinux.org/pub/rocky/9.3/isos/x86_64/Rocky-9.3-x86_64-boot.iso"
|
||||
"Rocky Linux 8.9 Gnome|Desktop|ProxMenux|https://download.rockylinux.org/pub/rocky/8.9/isos/x86_64/Rocky-8.9-x86_64-boot.iso"
|
||||
"Linux Mint 21.3 Cinnamon|Desktop|ProxMenux|https://mirrors.edge.kernel.org/linuxmint/stable/21.3/linuxmint-21.3-cinnamon-64bit.iso"
|
||||
"Linux Mint 21.2 Cinnamon|Desktop|ProxMenux|https://mirrors.edge.kernel.org/linuxmint/stable/21.2/linuxmint-21.2-cinnamon-64bit.iso"
|
||||
"openSUSE Leap 15.5|Desktop|ProxMenux|https://download.opensuse.org/distribution/leap/15.5/iso/openSUSE-Leap-15.5-DVD-x86_64.iso"
|
||||
"openSUSE Leap 15.4|Desktop|ProxMenux|https://download.opensuse.org/distribution/leap/15.4/iso/openSUSE-Leap-15.4-DVD-x86_64.iso"
|
||||
"Alpine Linux 3.19|CLI|ProxMenux|https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-standard-3.19.1-x86_64.iso"
|
||||
"Kali Linux 2024.1|Desktop|ProxMenux|https://cdimage.kali.org/kali-2024.1/kali-linux-2024.1-installer-amd64.iso"
|
||||
"Manjaro 23.1 GNOME|Desktop|ProxMenux|https://download.manjaro.org/gnome/23.1/manjaro-gnome-23.1-231017-linux65.iso"
|
||||
"Arch Linux (automatizado)|Cloud-ini|Helper Scripts|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh"
|
||||
"Debian 12 (automatizado)|Cloud-ini|Helper Scripts|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh"
|
||||
"Ubuntu 22.04 (automatizado)|Cloud-ini|Helper Scripts|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2204-vm.sh"
|
||||
"Ubuntu 24.04 (automatizado)|Cloud-ini|Helper Scripts|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2404-vm.sh"
|
||||
"Ubuntu 24.10 (automatizado)|Cloud-ini|Helper Scripts|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2410-vm.sh"
|
||||
)
|
||||
|
||||
MENU_OPTIONS=()
|
||||
INDEX=0
|
||||
for entry in "${DISTROS[@]}"; do
|
||||
IFS='|' read -r NAME TYPE SOURCE URL <<< "$entry"
|
||||
LINE=$(printf "%-35s │ %-10s │ %s" "$NAME" "$TYPE" "$SOURCE")
|
||||
MENU_OPTIONS+=("$INDEX" "$LINE")
|
||||
URLS[$INDEX]="$entry"
|
||||
((INDEX++))
|
||||
done
|
||||
|
||||
HEADER="%-41s │ %-10s │ %s"
|
||||
HEADER_TEXT=$(printf "$HEADER" " Versión" "Tipo" "Fuente")
|
||||
|
||||
CHOICE=$(whiptail --title "ProxMenux - Linux ISO" \
|
||||
--menu "$(translate "Select the Linux distribution to install"):\n\n$HEADER_TEXT" 20 80 10 \
|
||||
"${MENU_OPTIONS[@]}" \
|
||||
3>&1 1>&2 2>&3)
|
||||
|
||||
[[ $? -ne 0 ]] && echo "Cancelled" && exit 1
|
||||
|
||||
SELECTED="${URLS[$CHOICE]}"
|
||||
IFS='|' read -r ISO_NAME ISO_TYPE SOURCE ISO_URL <<< "$SELECTED"
|
||||
ISO_FILE=$(basename "$ISO_URL")
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
|
||||
# Exportar para que los use el script principal
|
||||
export ISO_NAME
|
||||
export ISO_TYPE
|
||||
export ISO_URL
|
||||
export ISO_FILE
|
||||
export ISO_PATH
|
||||
|
||||
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==============================================================
|
||||
# ProxMenux - NAS ISO Selector
|
||||
# ==============================================================
|
||||
|
||||
# Configuracion Base
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ISO_DIR="/var/lib/vz/template/iso"
|
||||
mkdir -p "$ISO_DIR"
|
||||
|
||||
function select_nas_iso() {
|
||||
|
||||
NAS_OPTIONS=(
|
||||
"1" "$(translate "Synology DSM VM")"
|
||||
"2" "$(translate "TrueNAS SCALE VM 24.04.2.5") (Dragonfish)"
|
||||
"3" "$(translate "TrueNAS CORE VM (FreeBSD based)")"
|
||||
"4" "$(translate "OpenMediaVault VM (Debian based)")"
|
||||
"5" "$(translate "Rockstor VM (openSUSE based)")"
|
||||
)
|
||||
|
||||
NAS_TYPE=$(whiptail --title "ProxMenux - NAS Systems" --menu "$(translate "Select the NAS system to install")" 20 70 6 \
|
||||
"${NAS_OPTIONS[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ $? -ne 0 ]] && echo "Cancelled." && exit 1
|
||||
|
||||
case "$NAS_TYPE" in
|
||||
1)
|
||||
bash <(curl -s "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/scripts/vm/synology.sh")
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
ISO_NAME="TrueNAS SCALE 24.04.2.5 (Dragonfish)"
|
||||
ISO_URL="https://download.truenas.com/TrueNAS-SCALE-Dragonfish/24.04.2.5/TrueNAS-SCALE-24.04.2.5.iso"
|
||||
ISO_FILE="TrueNAS-SCALE-24.04.2.5.iso"
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
;;
|
||||
3)
|
||||
LATEST_ISO=$(wget -qO- https://download.freenas.org/latest/x64/ | grep -oP 'href="\K[^"]+\.iso' | head -n1)
|
||||
ISO_NAME="TrueNAS CORE (Latest)"
|
||||
ISO_URL="https://download.freenas.org/latest/x64/$LATEST_ISO"
|
||||
ISO_FILE=$(basename "$LATEST_ISO")
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
;;
|
||||
4)
|
||||
ISO_NAME="OpenMediaVault"
|
||||
ISO_URL="https://downloads.sourceforge.net/project/openmediavault/7.2.0/openmediavault_7.2.0-amd64.iso"
|
||||
ISO_FILE="openmediavault_7.2.0-amd64.iso"
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
;;
|
||||
5)
|
||||
ISO_NAME="Rockstor"
|
||||
ISO_URL="https://rockstor.com/downloads/installer/leap/15.6/x86_64/Rockstor-Leap15.6-generic.x86_64-5.0.15-0.install.iso"
|
||||
ISO_FILE="Rockstor-Leap15.6-generic.x86_64-5.0.15-0.install.iso"
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
;;
|
||||
esac
|
||||
|
||||
export ISO_NAME
|
||||
export ISO_URL
|
||||
export ISO_FILE
|
||||
export ISO_PATH
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==============================================================
|
||||
# ProxMenux - Windows ISO Selector
|
||||
# ==============================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ISO_DIR="/var/lib/vz/template/iso"
|
||||
mkdir -p "$ISO_DIR"
|
||||
|
||||
|
||||
|
||||
|
||||
function select_windows_iso() {
|
||||
local CHOICE=$(whiptail --title "ProxMenux - Windows ISO" --menu "$(translate "Select how to provide the Windows ISO")" 15 60 2 \
|
||||
"1" "$(translate "Use existing ISO from storage")" \
|
||||
"2" "$(translate "Download ISO using UUP Dump")" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ $? -ne 0 ]] && msg_error "$(translate "Operation cancelled.")" && exit 1
|
||||
|
||||
case "$CHOICE" in
|
||||
1)
|
||||
select_existing_iso
|
||||
;;
|
||||
2)
|
||||
if [[ -f ./uupdump_creator.sh ]]; then
|
||||
source ./uupdump_creator.sh
|
||||
run_uupdump_creator || exit 1
|
||||
detect_latest_iso_created
|
||||
else
|
||||
msg_error "$(translate "UUP Dump script not found.")"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
function select_existing_iso() {
|
||||
ISO_LIST=()
|
||||
while read -r line; do
|
||||
FILENAME=$(basename "$line")
|
||||
SIZE=$(du -h "$line" | cut -f1)
|
||||
ISO_LIST+=("$FILENAME" "$SIZE")
|
||||
done < <(find "$ISO_DIR" -type f -iname "*.iso" ! -iname "virtio*" | sort)
|
||||
|
||||
if [[ ${#ISO_LIST[@]} -eq 0 ]]; then
|
||||
msg_error "$(translate "No ISO images found in $ISO_DIR.")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ISO_FILE=$(whiptail --title "ProxMenux - Windows ISO" --menu "$(translate "Choose a Windows ISO to use:")" 20 70 10 "${ISO_LIST[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ -z "$ISO_FILE" ]]; then
|
||||
msg_error "$(translate "No ISO selected.")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
ISO_NAME="$ISO_FILE"
|
||||
|
||||
export ISO_PATH ISO_FILE ISO_NAME
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function detect_latest_iso_created() {
|
||||
ISO_FILE=$(find "$ISO_DIR" -maxdepth 1 -type f -iname "*.iso" ! -iname "virtio*" -printf "%T@ %p\n" | sort -n | awk '{print $2}' | tail -n 1)
|
||||
|
||||
if [[ -z "$ISO_FILE" ]]; then
|
||||
msg_error "$(translate "No ISO file detected after UUP Dump process.")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ISO_NAME=$(basename "$ISO_FILE")
|
||||
ISO_PATH="$ISO_FILE"
|
||||
|
||||
export ISO_PATH ISO_FILE ISO_NAME
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==============================================================
|
||||
# ProxMenux - Windows ISO Creator from UUP Dump
|
||||
# ==============================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
function run_uupdump_creator() {
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
|
||||
# Configuración de carpetas
|
||||
TMP_DIR="/root/uup-temp"
|
||||
OUT_DIR="/var/lib/vz/template/iso"
|
||||
CONVERTER="/root/uup-converter"
|
||||
|
||||
mkdir -p "$TMP_DIR" "$OUT_DIR"
|
||||
cd "$TMP_DIR" || exit 1
|
||||
|
||||
# Solicitar URL UUP Dump al usuario
|
||||
UUP_URL=$(whiptail --inputbox "$(translate "Paste the UUP Dump URL here")" 10 90 3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && msg_error "$(translate "Cancelled by user.")" && exit 1
|
||||
|
||||
# Validar que la URL tenga los parámetros necesarios
|
||||
if [[ ! "$UUP_URL" =~ id=.+\&pack=.+\&edition=.+ ]]; then
|
||||
msg_error "$(translate "The URL does not contain the required parameters (id, pack, edition).")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extraer parámetros de la URL
|
||||
BUILD_ID=$(echo "$UUP_URL" | grep -oP 'id=\K[^&]+')
|
||||
LANG=$(echo "$UUP_URL" | grep -oP 'pack=\K[^&]+')
|
||||
EDITION=$(echo "$UUP_URL" | grep -oP 'edition=\K[^&]+')
|
||||
ARCH="amd64"
|
||||
|
||||
echo -e "\n${BGN}=============== UUP Dump Creator ===============${CL}"
|
||||
echo -e " ${BGN}🆔 ID:${CL} ${DGN}$BUILD_ID${CL}"
|
||||
echo -e " ${BGN}🌐 Language:${CL} ${DGN}$LANG${CL}"
|
||||
echo -e " ${BGN}💿 Edition:${CL} ${DGN}$EDITION${CL}"
|
||||
echo -e " ${BGN}🖥️ Architecture:${CL} ${DGN}$ARCH${CL}"
|
||||
echo -e "${BGN}===============================================${CL}\n"
|
||||
|
||||
# Descargar el conversor si no existe
|
||||
if [[ ! -f "$CONVERTER/convert.sh" ]]; then
|
||||
echo "📦 $(translate "Downloading UUP converter...")"
|
||||
mkdir -p "$CONVERTER"
|
||||
cd "$CONVERTER" || exit 1
|
||||
wget -q https://git.uupdump.net/uup-dump/converter/archive/refs/heads/master.tar.gz -O converter.tar.gz
|
||||
tar -xzf converter.tar.gz --strip-components=1
|
||||
chmod +x convert.sh
|
||||
cd "$TMP_DIR" || exit 1
|
||||
fi
|
||||
|
||||
# Crear script de descarga uup_download_linux.sh
|
||||
cat > uup_download_linux.sh <<EOF
|
||||
#!/bin/bash
|
||||
mkdir -p files
|
||||
echo "https://git.uupdump.net/uup-dump/converter/archive/refs/heads/master.tar.gz" > files/converter_multi
|
||||
|
||||
for prog in aria2c cabextract wimlib-imagex chntpw; do
|
||||
which \$prog &>/dev/null || { echo "\$prog not found."; exit 1; }
|
||||
done
|
||||
which genisoimage &>/dev/null || which mkisofs &>/dev/null || { echo "genisoimage/mkisofs not found."; exit 1; }
|
||||
|
||||
destDir="UUPs"
|
||||
tempScript="aria2_script.\$RANDOM.txt"
|
||||
|
||||
aria2c --no-conf --console-log-level=warn --log-level=info --log="aria2_download.log" \
|
||||
-x16 -s16 -j2 --allow-overwrite=true --auto-file-renaming=false -d"files" -i"files/converter_multi" || exit 1
|
||||
|
||||
aria2c --no-conf --console-log-level=warn --log-level=info --log="aria2_download.log" \
|
||||
-o"\$tempScript" --allow-overwrite=true --auto-file-renaming=false \
|
||||
"https://uupdump.net/get.php?id=$BUILD_ID&pack=$LANG&edition=$EDITION&aria2=2" || exit 1
|
||||
|
||||
grep '#UUPDUMP_ERROR:' "\$tempScript" && { echo "❌ Error generating UUP download list."; exit 1; }
|
||||
|
||||
aria2c --no-conf --console-log-level=warn --log-level=info --log="aria2_download.log" \
|
||||
-x16 -s16 -j5 -c -R -d"\$destDir" -i"\$tempScript" || exit 1
|
||||
EOF
|
||||
|
||||
chmod +x uup_download_linux.sh
|
||||
|
||||
# Ejecutar la descarga de archivos UUP
|
||||
./uup_download_linux.sh
|
||||
|
||||
# Buscar carpeta UUPs descargada
|
||||
UUP_FOLDER=$(find "$TMP_DIR" -type d -name "UUPs" | head -n1)
|
||||
[[ -z "$UUP_FOLDER" ]] && msg_error "$(translate "No UUP folder found.")" && exit 1
|
||||
|
||||
# Iniciar conversión a ISO
|
||||
echo -e "\n${GN}=======================================${CL}"
|
||||
echo -e " 💿 ${GN}Starting ISO conversion...${CL}"
|
||||
echo -e "${GN}=======================================${CL}\n"
|
||||
|
||||
"$CONVERTER/convert.sh" wim "$UUP_FOLDER" 1
|
||||
|
||||
# Buscar la ISO generada
|
||||
ISO_FILE=$(find "$TMP_DIR" "$CONVERTER" "$UUP_FOLDER" -maxdepth 1 -iname "*.iso" | head -n1)
|
||||
if [[ -f "$ISO_FILE" ]]; then
|
||||
mv "$ISO_FILE" "$OUT_DIR/"
|
||||
msg_ok "$(translate "ISO created successfully:") $OUT_DIR/$(basename "$ISO_FILE")"
|
||||
|
||||
# Limpiar temporales
|
||||
msg_ok "$(translate "Cleaning temporary files...")"
|
||||
rm -rf "$TMP_DIR" "$CONVERTER"
|
||||
|
||||
export LANGUAGE=C
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
else
|
||||
msg_warn "$(translate "No ISO was generated.")"
|
||||
|
||||
export LANGUAGE=C
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
fi
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user