Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bda862e4d3 | |||
| 6887dc291b | |||
| 29daddfc3b | |||
| 9c44ba6d7f | |||
| c61a740725 | |||
| a9d3d86394 | |||
| 9b0b2663ec | |||
| 0b04034141 | |||
| 0a635ab3aa | |||
| 175d2b6329 | |||
| 3d6c4151b8 | |||
| 5f893e3b20 | |||
| 1296cf6a32 | |||
| 31e5335aac | |||
| b6d4ca5269 | |||
| 7e7522608a | |||
| 43957f65d2 | |||
| 09ece0a77e | |||
| 06ba38aa15 | |||
| abd878fe6c | |||
| f4d47bbd45 | |||
| b675fddc92 | |||
| 0a70ed2143 | |||
| 4e07279a54 | |||
| 020cdec912 | |||
| 4771b2ead0 | |||
| 87c829e100 | |||
| e6093f9fe2 | |||
| 956786a859 | |||
| 6a0169e4d8 | |||
| 92959945f3 | |||
| b2269187d8 | |||
| 05f46969c4 | |||
| ab168b440e | |||
| 2e866d7a95 | |||
| b372131978 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: [tolaleng]
|
github: #[tolaleng]
|
||||||
patreon: # Replace with a single Patreon username
|
patreon: # Replace with a single Patreon username
|
||||||
open_collective: #checkcle
|
open_collective: #checkcle
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ body:
|
|||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
description: Please confirm the following before submitting:
|
description: |
|
||||||
|
Please confirm the following before submitting:
|
||||||
options:
|
options:
|
||||||
- label: I have searched existing issues to avoid creating duplicates.
|
- label: I have searched existing issues to avoid creating duplicates.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ body:
|
|||||||
id: checklist
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
description: Before submitting, please confirm:
|
description: |
|
||||||
|
Before submitting, please confirm:
|
||||||
options:
|
options:
|
||||||
- label: I have searched existing issues to make sure this feature hasn’t been requested yet.
|
- label: I have searched existing issues to make sure this feature hasn’t been requested yet.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ body:
|
|||||||
id: checklist
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
description: Before submitting, please confirm the following:
|
description: |
|
||||||
|
Before submitting, please confirm the following:
|
||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and discussions for a solution.
|
- label: I have searched existing issues and discussions for a solution.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -128,19 +128,9 @@ docker run -d \
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
🙏 We're incredibly thankful to our sponsors. Your contributions enable us to maintain infrastructure (hosting, domains), run tests, and continue developing valuable features.
|
Sponsorships will no longer be accepted. From now on, support will only be accepted in the form of ecosystem and community partnerships that provide infrastructure such as cloud servers, domains, or hosting credits.
|
||||||
|
|
||||||
### 🥈 Silver Uptime
|
If you’re a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io
|
||||||
|
|
||||||
<a href="https://github.com/michaelortnerit">
|
|
||||||
<img src="https://avatars.githubusercontent.com/u/135371107?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### 🧡 Ping Supporter
|
|
||||||
|
|
||||||
<a href="https://github.com/samang-dauth">
|
|
||||||
<img src="https://avatars.githubusercontent.com/u/4575656?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### 🤝 Ecosystem & Community Partner
|
### 🤝 Ecosystem & Community Partner
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
@@ -151,6 +141,11 @@ docker run -d \
|
|||||||
alt="GitBook Logo" />
|
alt="GitBook Logo" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="https://www.cloudflare.com">
|
||||||
|
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
|
||||||
|
height="60"
|
||||||
|
alt="Cloudflare Logo" />
|
||||||
|
</a>
|
||||||
<a href="https://m.do.co/c/0c27ef82475f">
|
<a href="https://m.do.co/c/0c27ef82475f">
|
||||||
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
|
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
|
||||||
height="50"
|
height="50"
|
||||||
@@ -158,12 +153,6 @@ docker run -d \
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -1,25 +1,29 @@
|
|||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CheckCle</title>
|
<title>CheckCle - Dev</title>
|
||||||
<meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
<meta name="description"
|
||||||
|
content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||||
<meta name="author" content="Tola Leng" />
|
<meta name="author" content="Tola Leng" />
|
||||||
|
|
||||||
<meta property="og:title" content="checkcle-an-open-source" />
|
<meta property="og:title" content="checkcle-an-open-source" />
|
||||||
<meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
<meta property="og:description"
|
||||||
|
content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@checkcle_oss" />
|
<meta name="twitter:site" content="@checkcle_oss" />
|
||||||
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Generated
+184
@@ -8,6 +8,7 @@
|
|||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "2.2.7",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.0",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
@@ -644,6 +645,21 @@
|
|||||||
"@floating-ui/utils": "^0.2.8"
|
"@floating-ui/utils": "^0.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/react": {
|
||||||
|
"version": "0.26.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||||
|
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.1.2",
|
||||||
|
"@floating-ui/utils": "^0.2.8",
|
||||||
|
"tabbable": "^6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/react-dom": {
|
"node_modules/@floating-ui/react-dom": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||||
@@ -663,6 +679,26 @@
|
|||||||
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
|
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@headlessui/react": {
|
||||||
|
"version": "2.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.7.tgz",
|
||||||
|
"integrity": "sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.26.16",
|
||||||
|
"@react-aria/focus": "^3.20.2",
|
||||||
|
"@react-aria/interactions": "^3.25.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.9",
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.9.0",
|
"version": "3.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
|
||||||
@@ -2280,6 +2316,103 @@
|
|||||||
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
|
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-aria/focus": {
|
||||||
|
"version": "3.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.1.tgz",
|
||||||
|
"integrity": "sha512-hmH1IhHlcQ2lSIxmki1biWzMbGgnhdxJUM0MFfzc71Rv6YAzhlx4kX3GYn4VNcjCeb6cdPv4RZ5vunV4kgMZYQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-aria/interactions": "^3.25.5",
|
||||||
|
"@react-aria/utils": "^3.30.1",
|
||||||
|
"@react-types/shared": "^3.32.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"clsx": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-aria/interactions": {
|
||||||
|
"version": "3.25.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.5.tgz",
|
||||||
|
"integrity": "sha512-EweYHOEvMwef/wsiEqV73KurX/OqnmbzKQa2fLxdULbec5+yDj6wVGaRHIzM4NiijIDe+bldEl5DG05CAKOAHA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-aria/ssr": "^3.9.10",
|
||||||
|
"@react-aria/utils": "^3.30.1",
|
||||||
|
"@react-stately/flags": "^3.1.2",
|
||||||
|
"@react-types/shared": "^3.32.0",
|
||||||
|
"@swc/helpers": "^0.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-aria/ssr": {
|
||||||
|
"version": "3.9.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||||
|
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-aria/utils": {
|
||||||
|
"version": "3.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.1.tgz",
|
||||||
|
"integrity": "sha512-zETcbDd6Vf9GbLndO6RiWJadIZsBU2MMm23rBACXLmpRztkrIqPEb2RVdlLaq1+GklDx0Ii6PfveVjx+8S5U6A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-aria/ssr": "^3.9.10",
|
||||||
|
"@react-stately/flags": "^3.1.2",
|
||||||
|
"@react-stately/utils": "^3.10.8",
|
||||||
|
"@react-types/shared": "^3.32.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"clsx": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-stately/flags": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-stately/utils": {
|
||||||
|
"version": "3.10.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
|
||||||
|
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-types/shared": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.20.0",
|
"version": "1.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
|
||||||
@@ -2729,6 +2862,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/types": {
|
"node_modules/@swc/types": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz",
|
||||||
@@ -2793,6 +2935,33 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.13.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||||
@@ -6289,6 +6458,12 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tabbable": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
|
||||||
@@ -6568,6 +6743,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "2.2.7",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.0",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
|
"framer-motion": "12.23.12",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"jspdf": "^3.0.1",
|
"jspdf": "^3.0.1",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
|
|||||||
@@ -153,7 +153,9 @@
|
|||||||
"google_chat",
|
"google_chat",
|
||||||
"email",
|
"email",
|
||||||
"ntfy",
|
"ntfy",
|
||||||
"pushover"
|
"pushover",
|
||||||
|
"notifiarr",
|
||||||
|
"gotify"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -338,6 +340,20 @@
|
|||||||
"system": false,
|
"system": false,
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3401535476",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "server_url",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "autodate2990389176",
|
"id": "autodate2990389176",
|
||||||
@@ -4823,6 +4839,169 @@
|
|||||||
"indexes": [],
|
"indexes": [],
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1753185970",
|
||||||
|
"listRule": "",
|
||||||
|
"viewRule": "",
|
||||||
|
"createRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"name": "ssl_history",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2812878347",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "cert_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1881225410",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "days_before_expiry",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3062427817",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "days_before_warning",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text98176952",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "template_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text4011498884",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "notification_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select3839288252",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "warning_status",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"sent",
|
||||||
|
"pending",
|
||||||
|
"failed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2670434693",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "expiry_status",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"sent",
|
||||||
|
"pending",
|
||||||
|
"failed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select3824245652",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "critical_status",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"sent",
|
||||||
|
"pending",
|
||||||
|
"failed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [],
|
||||||
|
"system": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "pbc_351174173",
|
"id": "pbc_351174173",
|
||||||
"listRule": "",
|
"listRule": "",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
+135
-41
@@ -1,37 +1,40 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from "./contexts/LanguageContext";
|
||||||
import { SidebarProvider } from './contexts/SidebarContext';
|
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||||
|
|
||||||
import Index from './pages/Index';
|
import Index from "./pages/Index";
|
||||||
import Login from './pages/Login';
|
import Login from "./pages/Login";
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from "./pages/Dashboard";
|
||||||
import InstanceMonitoring from './pages/InstanceMonitoring';
|
import InstanceMonitoring from "./pages/InstanceMonitoring";
|
||||||
import ContainerMonitoring from './pages/ContainerMonitoring';
|
import ContainerMonitoring from "./pages/ContainerMonitoring";
|
||||||
import ServiceDetail from './pages/ServiceDetail';
|
import ServiceDetail from "./pages/ServiceDetail";
|
||||||
import SslDomain from './pages/SslDomain';
|
import SslDomain from "./pages/SslDomain";
|
||||||
import ScheduleIncident from './pages/ScheduleIncident';
|
import ScheduleIncident from "./pages/ScheduleIncident";
|
||||||
import OperationalPage from './pages/OperationalPage';
|
import OperationalPage from "./pages/OperationalPage";
|
||||||
import RegionalMonitoring from './pages/RegionalMonitoring';
|
import RegionalMonitoring from "./pages/RegionalMonitoring";
|
||||||
import Settings from './pages/Settings';
|
import Settings from "./pages/Settings";
|
||||||
import Profile from './pages/Profile';
|
import Profile from "./pages/Profile";
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from "./pages/NotFound";
|
||||||
import PublicStatusPage from './pages/PublicStatusPage';
|
import PublicStatusPage from "./pages/PublicStatusPage";
|
||||||
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
import { ProtectedRoute } from "./components/auth/ProtectedRoute";
|
||||||
import ServerDetail from './pages/ServerDetail';
|
import ServerDetail from "./pages/ServerDetail";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [queryClient] = useState(() => new QueryClient({
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 60 * 1000
|
staleTime: 60 * 1000,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -46,19 +49,110 @@ function App() {
|
|||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
|
<Route
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
path="/"
|
||||||
<Route path="/instance-monitoring" element={<ProtectedRoute><InstanceMonitoring /></ProtectedRoute>} />
|
element={
|
||||||
<Route path="/server-detail/:serverId" element={<ProtectedRoute><ServerDetail /></ProtectedRoute>} />
|
<ProtectedRoute>
|
||||||
<Route path="/container-monitoring" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
|
<Index />
|
||||||
<Route path="/container-monitoring/:serverId" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
|
</ProtectedRoute>
|
||||||
<Route path="/service/:id" element={<ProtectedRoute><ServiceDetail /></ProtectedRoute>} />
|
}
|
||||||
<Route path="/ssl-domain" element={<ProtectedRoute><SslDomain /></ProtectedRoute>} />
|
/>
|
||||||
<Route path="/schedule-incident" element={<ProtectedRoute><ScheduleIncident /></ProtectedRoute>} />
|
<Route
|
||||||
<Route path="/operational-page" element={<ProtectedRoute><OperationalPage /></ProtectedRoute>} />
|
path="/dashboard"
|
||||||
<Route path="/regional-monitoring" element={<ProtectedRoute><RegionalMonitoring /></ProtectedRoute>} />
|
element={
|
||||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
<ProtectedRoute>
|
||||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/instance-monitoring"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<InstanceMonitoring />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/server-detail/:serverId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ServerDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/container-monitoring"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ContainerMonitoring />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/container-monitoring/:serverId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ContainerMonitoring />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/service/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ServiceDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ssl-domain"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SslDomain />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schedule-incident"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ScheduleIncident />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/operational-page"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<OperationalPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/regional-monitoring"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<RegionalMonitoring />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Settings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Profile />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
@@ -15,17 +14,24 @@ interface DashboardContentProps {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
export const DashboardContent = ({
|
||||||
|
services,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
}: DashboardContentProps) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
// Filter services based on search term and type filter
|
// Filter services based on search term and type filter
|
||||||
const filteredServices = services.filter(service => {
|
const filteredServices = services.filter((service) => {
|
||||||
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch =
|
||||||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
|
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
|
(service.url &&
|
||||||
|
service.url.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
const matchesFilter =
|
||||||
|
filter === "all" || service.type.toLowerCase() === filter.toLowerCase();
|
||||||
return matchesSearch && matchesFilter;
|
return matchesSearch && matchesFilter;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,21 +39,23 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
||||||
<p>Error loading service data.</p>
|
<p>Error loading service data.</p>
|
||||||
<Button onClick={() => window.location.reload()}>{t('retry')}</Button>
|
<Button onClick={() => window.location.reload()}>{t("retry")}</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
|
<main className="flex-1 flex flex-col overflow-auto dark:bg-black p-2 shadow pb-0 ">
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 bg-white dark:bg-black p-4 rounded-xl ">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-foreground">{t('overview')}</h2>
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
|
{t("overview")}
|
||||||
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
className="text-primary-foreground"
|
className="text-primary-foreground"
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> {t('newService')}
|
<Plus className="w-4 h-4 mr-2" /> {t("newService")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AuthUser } from "@/services/authService";
|
import { AuthUser } from "@/services/authService";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText, Github, Twitter, MessageSquare, Bell, User, Settings, LogOut } from "lucide-react";
|
import {
|
||||||
|
Moon,
|
||||||
|
PanelLeft,
|
||||||
|
PanelLeftClose,
|
||||||
|
Sun,
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
Github,
|
||||||
|
Twitter,
|
||||||
|
MessageSquare,
|
||||||
|
Bell,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
CaseUpper,
|
||||||
|
BookA,
|
||||||
|
CaseSensitive,
|
||||||
|
CaseLower,
|
||||||
|
ALargeSmall,
|
||||||
|
} from "lucide-react";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import useDynamicFontSize from "@/contexts/FontSizeContext";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
@@ -20,7 +46,7 @@ export const Header = ({
|
|||||||
currentUser,
|
currentUser,
|
||||||
onLogout,
|
onLogout,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
toggleSidebar
|
toggleSidebar,
|
||||||
}: HeaderProps) => {
|
}: HeaderProps) => {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { language, setLanguage, t } = useLanguage();
|
const { language, setLanguage, t } = useLanguage();
|
||||||
@@ -55,27 +81,35 @@ export const Header = ({
|
|||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
|
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
|
||||||
let avatarUrl = '';
|
let avatarUrl = "";
|
||||||
if (currentUser?.avatar) {
|
if (currentUser?.avatar) {
|
||||||
// If it's a relative path from the public folder, make sure it's resolved properly
|
// If it's a relative path from the public folder, make sure it's resolved properly
|
||||||
if (currentUser.avatar.startsWith('/upload/profile/')) {
|
if (currentUser.avatar.startsWith("/upload/profile/")) {
|
||||||
avatarUrl = currentUser.avatar;
|
avatarUrl = currentUser.avatar;
|
||||||
} else {
|
} else {
|
||||||
avatarUrl = currentUser.avatar;
|
avatarUrl = currentUser.avatar;
|
||||||
}
|
}
|
||||||
console.log("Final avatar URL:", avatarUrl);
|
console.log("Final avatar URL:", avatarUrl);
|
||||||
}
|
}
|
||||||
|
const [fontSize, setFontSize] = useState(() => {
|
||||||
|
const savedFontSize = localStorage.getItem("fontSize");
|
||||||
|
return savedFontSize ? parseInt(savedFontSize, 10) : 14;
|
||||||
|
});
|
||||||
|
useDynamicFontSize(fontSize);
|
||||||
return (
|
return (
|
||||||
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
|
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
|
||||||
{/* Grid Pattern Overlay - Similar to StatusCards */}
|
{/* Grid Pattern Overlay - Similar to StatusCards */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<div
|
<div
|
||||||
className="w-full h-full"
|
className="w-full h-full dark:bg-black"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
|
backgroundImage: `linear-gradient(${
|
||||||
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
|
theme === "dark" ? "#ffffff10" : "#00000010"
|
||||||
backgroundSize: '20px 20px'
|
} 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, ${
|
||||||
|
theme === "dark" ? "#ffffff10" : "#00000010"
|
||||||
|
} 1px, transparent 1px)`,
|
||||||
|
backgroundSize: "20px 20px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full backdrop-blur-[1px]"></div>
|
<div className="w-full h-full backdrop-blur-[1px]"></div>
|
||||||
@@ -84,45 +118,100 @@ export const Header = ({
|
|||||||
|
|
||||||
{/* Header Content */}
|
{/* Header Content */}
|
||||||
<div className="flex items-center gap-4 z-10">
|
<div className="flex items-center gap-4 z-10">
|
||||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
<div
|
||||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
className={` p-1 h-8 w-8 -ml-2 rounded bg-gray-600 ${
|
||||||
|
!sidebarCollapsed ? "hidden" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/favicon_sidebar.ico"
|
||||||
|
alt="CheckCle"
|
||||||
|
className={`h-6 w-6 `}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<PanelLeft className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftClose className="h-5 w-5" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
<h1 className="font-medium text-xl">
|
||||||
|
{greeting},{" "}
|
||||||
|
{currentUser?.name || currentUser?.email?.split("@")[0] || "User"}{" "}
|
||||||
|
👋 ✨
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 z-10">
|
<div className="flex items-center space-x-4 z-10">
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
>
|
||||||
<span className="sr-only">{t("language")}</span>
|
<span className="sr-only">{t("language")}</span>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setLanguage("en")}
|
||||||
|
className={language === "en" ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setLanguage("km")}
|
||||||
|
className={language === "km" ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
{t("khmer")}
|
{t("khmer")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setLanguage("de")}
|
||||||
|
className={language === "de" ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
{t("Deutsch")}
|
{t("Deutsch")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setLanguage("ko")}
|
||||||
|
className={language === "ko" ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
{t("korean")}
|
{t("korean")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setLanguage("ja")}
|
||||||
|
className={language === "ja" ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
{t("japanese")}
|
{t("japanese")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setLanguage("zhcn")}
|
||||||
|
className={language === "zhcn" ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
{t("simplifiedChinese")}
|
{t("simplifiedChinese")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -144,7 +233,9 @@ export const Header = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="rounded-full w-8 h-8 border-border"
|
className="rounded-full w-8 h-8 border-border"
|
||||||
onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}
|
onClick={() =>
|
||||||
|
window.open("https://github.com/operacle/checkcle", "_blank")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="sr-only">GitHub</span>
|
<span className="sr-only">GitHub</span>
|
||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
@@ -173,30 +264,87 @@ export const Header = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
>
|
||||||
<span className="sr-only">{t("notifications")}</span>
|
<span className="sr-only">{t("notifications")}</span>
|
||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Fond Size */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
>
|
||||||
|
<span className="sr-only">{t("language")}</span>
|
||||||
|
<BookA className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setFontSize((prev) => prev + 1)}
|
||||||
|
className={`cursor-pointer`}
|
||||||
|
>
|
||||||
|
<span className={`text-[15px] font-semibold`}> Aa +</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setFontSize((prev) => prev - 1)}
|
||||||
|
className={`cursor-pointer`}
|
||||||
|
>
|
||||||
|
<span className={`text-[12px] font-semibold`}> Aa -</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* User Profile Dropdown */}
|
{/* User Profile Dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
|
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
{avatarUrl ? (
|
||||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
<AvatarImage
|
||||||
</AvatarFallback>}
|
src={avatarUrl}
|
||||||
|
alt={
|
||||||
|
currentUser?.name ||
|
||||||
|
currentUser?.email?.split("@")[0] ||
|
||||||
|
"User"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AvatarFallback className="bg-primary/20 text-primary">
|
||||||
|
{currentUser?.name?.[0] || currentUser?.email?.[0] || "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<div className="flex items-center gap-3 p-2">
|
<div className="flex items-center gap-3 p-2">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
{avatarUrl ? (
|
||||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
<AvatarImage
|
||||||
</AvatarFallback>}
|
src={avatarUrl}
|
||||||
|
alt={
|
||||||
|
currentUser?.name ||
|
||||||
|
currentUser?.email?.split("@")[0] ||
|
||||||
|
"User"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AvatarFallback className="bg-primary/20 text-primary">
|
||||||
|
{currentUser?.name?.[0] || currentUser?.email?.[0] || "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<span className="font-medium">{currentUser?.name || 'User'}</span>
|
<span className="font-medium">
|
||||||
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
|
{currentUser?.name || "User"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{currentUser?.email}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -209,7 +357,10 @@ export const Header = ({
|
|||||||
<span>{t("settings")}</span>
|
<span>{t("settings")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
|
<DropdownMenuItem
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-red-500 focus:text-red-500"
|
||||||
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>{t("logout")}</span>
|
<span>{t("logout")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
@@ -18,24 +23,26 @@ export const ServiceFilters = ({
|
|||||||
setFilter,
|
setFilter,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
servicesCount
|
servicesCount,
|
||||||
}: ServiceFiltersProps) => {
|
}: ServiceFiltersProps) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex justify-between items-center">
|
<div className="mb-6 flex justify-between items-center ">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h3 className="text-xl font-semibold mr-2 text-foreground">{t('currentlyMonitoring')}</h3>
|
<h3 className="text-xl font-semibold mr-2 text-foreground">
|
||||||
|
{t("currentlyMonitoring")}
|
||||||
|
</h3>
|
||||||
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
|
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
|
||||||
{servicesCount}
|
{servicesCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<Select value={filter} onValueChange={setFilter}>
|
<Select value={filter} onValueChange={setFilter}>
|
||||||
<SelectTrigger className="w-40 bg-card border-border">
|
<SelectTrigger className="w-40 bg-card border-border dark:bg-gray-800">
|
||||||
<SelectValue placeholder={t('allTypes')} />
|
<SelectValue placeholder={t("allTypes")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t('allTypes')}</SelectItem>
|
<SelectItem value="all">{t("allTypes")}</SelectItem>
|
||||||
<SelectItem value="HTTP">HTTP</SelectItem>
|
<SelectItem value="HTTP">HTTP</SelectItem>
|
||||||
<SelectItem value="PING">PING</SelectItem>
|
<SelectItem value="PING">PING</SelectItem>
|
||||||
<SelectItem value="TCP">TCP</SelectItem>
|
<SelectItem value="TCP">TCP</SelectItem>
|
||||||
@@ -45,7 +52,7 @@ export const ServiceFilters = ({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
className="w-72 bg-card border-border"
|
className="w-72 bg-card border-border"
|
||||||
placeholder={t('search')}
|
placeholder={t("search")}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||||
@@ -15,14 +14,21 @@ export const Sidebar = ({ collapsed }: SidebarProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'}
|
${
|
||||||
|
theme === "dark"
|
||||||
|
? "bg-black border-[#1e1e1e]"
|
||||||
|
: "bg-sidebars border-sidebar-border"
|
||||||
|
}
|
||||||
border-r flex flex-col h-full
|
border-r flex flex-col h-full
|
||||||
${collapsed ? 'w-16' : 'w-64'}
|
${collapsed ? "w-16" : "w-64"}
|
||||||
`}
|
${collapsed ? "hidden" : ""}`}
|
||||||
>
|
>
|
||||||
<SidebarHeader collapsed={collapsed} />
|
<SidebarHeader collapsed={collapsed} />
|
||||||
<MainNavigation collapsed={collapsed} />
|
<MainNavigation collapsed={collapsed} />
|
||||||
<SettingsPanel collapsed={collapsed} />
|
<SettingsPanel collapsed={collapsed} />
|
||||||
|
<div className="items-center ml-2 p-2 h-30 border-t ">
|
||||||
|
Version: latest
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { MenuItem } from "./MenuItem";
|
import { MenuItem } from "./MenuItem";
|
||||||
import { mainMenuItems } from "./navigationData";
|
import { mainMenuItems } from "./navigationData";
|
||||||
@@ -7,9 +6,15 @@ interface MainNavigationProps {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
|
export const MainNavigation: React.FC<MainNavigationProps> = ({
|
||||||
|
collapsed,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<nav className="my-2 mx-1 py-1 px-1">
|
<nav
|
||||||
|
className={`my-2 mx-1 py-1 px-1 dark:bg-black ${
|
||||||
|
collapsed ? "hidden" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{mainMenuItems.map((item) => (
|
{mainMenuItems.map((item) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -22,7 +21,7 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
|||||||
translationKey,
|
translationKey,
|
||||||
color,
|
color,
|
||||||
hasNavigation,
|
hasNavigation,
|
||||||
collapsed
|
collapsed,
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -44,18 +43,24 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
${collapsed ? 'p-3' : 'p-2 pl-3'}
|
${collapsed ? "p-3" : "p-2 pl-3"}
|
||||||
mb-1 rounded-lg
|
mb-1 rounded-lg
|
||||||
${isActive ? (theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent') : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`}
|
${
|
||||||
|
isActive
|
||||||
|
? theme === "dark"
|
||||||
|
? "bg-drak"
|
||||||
|
: "bg-sidebar-accent "
|
||||||
|
: `hover:${theme === "dark" ? "bg-gray-800" : "bg-sidebar-accent "}`
|
||||||
|
}
|
||||||
flex items-center
|
flex items-center
|
||||||
${collapsed ? 'justify-center' : ''}
|
${collapsed ? "justify-center" : ""}
|
||||||
cursor-pointer
|
cursor-pointer
|
||||||
`}
|
`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Icon className={`${mainIconSize} ${color}`} />
|
<Icon className={`${mainIconSize} ${color}`} />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">
|
<span className="ml-2.5 font-medium text-foreground tracking-wide ">
|
||||||
{t(translationKey)}
|
{t(translationKey)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { Settings, ChevronDown } from "lucide-react";
|
import { Settings, ChevronDown } from "lucide-react";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { settingsMenuItems } from "./navigationData";
|
import { settingsMenuItems } from "./navigationData";
|
||||||
|
|
||||||
@@ -17,19 +20,27 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>(
|
||||||
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
"general"
|
||||||
|
);
|
||||||
|
//const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||||
|
const [settingsPanelOpen, setSettingsPanelOpen] = useState(() => {
|
||||||
|
const savedValue = localStorage.getItem("settingsPanelOpen");
|
||||||
|
// Check if the saved value exists and is "true"
|
||||||
|
return savedValue === "true";
|
||||||
|
});
|
||||||
|
|
||||||
// Update active settings item based on URL
|
// Update active settings item based on URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname === '/settings') {
|
if (location.pathname === "/settings") {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const panel = params.get('panel');
|
const panel = params.get("panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
setActiveSettingsItem(panel);
|
setActiveSettingsItem(panel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [location]);
|
localStorage.setItem("settingsPanelOpen", settingsPanelOpen.toString());
|
||||||
|
}, [location, settingsPanelOpen]);
|
||||||
|
|
||||||
const handleSettingsItemClick = (item: string) => {
|
const handleSettingsItemClick = (item: string) => {
|
||||||
setActiveSettingsItem(item);
|
setActiveSettingsItem(item);
|
||||||
@@ -43,15 +54,27 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMenuItemClasses = (isActive: boolean) => {
|
const getMenuItemClasses = (isActive: boolean) => {
|
||||||
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
return `p-2 ${
|
||||||
|
isActive
|
||||||
|
? theme === "dark"
|
||||||
|
? "bg-[#1a1a1a]"
|
||||||
|
: "bg-sidebar-accent"
|
||||||
|
: `hover:${theme === "dark" ? "bg-[#1a1a1a]" : "bg-sidebar-accent"}`
|
||||||
|
} rounded-lg flex items-center `;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
const mainIconSize = "h-6 w-6";
|
const mainIconSize = "h-6 w-6";
|
||||||
return (
|
return (
|
||||||
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
|
||||||
<div
|
<div
|
||||||
onClick={(e) => handleMenuItemClick('/settings', e)}
|
className={`border-t ${
|
||||||
|
theme === "dark"
|
||||||
|
? "border-[#1e1e1e] bg-[#121212]"
|
||||||
|
: "border-sidebar-border bg-sidebar"
|
||||||
|
} p-4 flex justify-center ${collapsed ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => handleMenuItemClick("/settings", e)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||||
@@ -61,33 +84,59 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
<div
|
||||||
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
className={`flex-1 flex flex-col border-t ${
|
||||||
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
theme === "dark"
|
||||||
|
? "border-[#1e1e1e] bg-black"
|
||||||
|
: "border-sidebar-border bg-sidebars"
|
||||||
|
} p-4 ${collapsed ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
|
<Collapsible
|
||||||
|
open={settingsPanelOpen}
|
||||||
|
onOpenChange={setSettingsPanelOpen}
|
||||||
|
className="w-full flex flex-col flex-1"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${
|
||||||
|
theme === "dark" ? "hover:bg-[#1a1a1a]" : "hover:bg-sidebar-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
<span className="font-medium tracking-wide">
|
||||||
|
{t("settingPanel")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Settings className="h-4 w-4 mr-1" />
|
<Settings className="h-4 w-4 mr-1" />
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 transition-transform duration-200 ${
|
||||||
|
settingsPanelOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
<CollapsibleContent
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "bg-black" : "bg-sidebars"
|
||||||
|
} flex-1 flex flex-col`}
|
||||||
|
>
|
||||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="space-y-2 pr-4">
|
<div className="space-y-2 pr-4 cursor-pointer">
|
||||||
{settingsMenuItems.map((item) => (
|
{settingsMenuItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={getMenuItemClasses(activeSettingsItem === item.id)}
|
className={getMenuItemClasses(
|
||||||
|
activeSettingsItem === item.id
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
||||||
handleSettingsItemClick(item.id);
|
handleSettingsItemClick(item.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4 mr-2" />
|
<item.icon className="h-4 w-4 mr-2" />
|
||||||
<span className="text-sm">{t(item.translationKey)}</span>
|
<span className="text-base">{t(item.translationKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
@@ -10,13 +9,15 @@ export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
<div
|
||||||
<div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
|
className={`p-[15.7px] ${
|
||||||
<img
|
theme === "dark"
|
||||||
src="/favicon_sidebar.ico"
|
? "border-[#1e1e1e] bg-black"
|
||||||
alt="CheckCle"
|
: "border-sidebar-border bg-white"
|
||||||
className="h-6 w-6"
|
} border-b flex items-center ${collapsed ? "justify-center" : ""}`}
|
||||||
/>
|
>
|
||||||
|
<div className="h-8 w-8 rounded flex items-center justify-center mr-2 dark:bg-drak">
|
||||||
|
<img src="/favicon_sidebar.ico" alt="CheckCle" className="h-7 w-6" />
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
|
import {
|
||||||
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
|
useOperationalPages,
|
||||||
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
|
useDeleteOperationalPage,
|
||||||
import { OperationalPageCard } from './OperationalPageCard';
|
} from "@/hooks/useOperationalPage";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { CreateOperationalPageDialog } from "./CreateOperationalPageDialog";
|
||||||
import { Button } from '@/components/ui/button';
|
import { EditOperationalPageDialog } from "./EditOperationalPageDialog";
|
||||||
import { OperationalPageRecord } from '@/types/operational.types';
|
import { OperationalPageCard } from "./OperationalPageCard";
|
||||||
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { OperationalPageRecord } from "@/types/operational.types";
|
||||||
|
import { Activity, Plus, RefreshCw } from "lucide-react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -18,17 +21,26 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
export const OperationalPageContent = () => {
|
export const OperationalPageContent = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
const {
|
||||||
|
data: pages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isRefetching,
|
||||||
|
} = useOperationalPages();
|
||||||
const deleteMutation = useDeleteOperationalPage();
|
const deleteMutation = useDeleteOperationalPage();
|
||||||
|
|
||||||
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
|
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
|
const [pageToDelete, setPageToDelete] =
|
||||||
|
useState<OperationalPageRecord | null>(null);
|
||||||
|
|
||||||
const handleEdit = (page: OperationalPageRecord) => {
|
const handleEdit = (page: OperationalPageRecord) => {
|
||||||
setEditingPage(page);
|
setEditingPage(page);
|
||||||
@@ -37,10 +49,10 @@ export const OperationalPageContent = () => {
|
|||||||
|
|
||||||
const handleView = (page: OperationalPageRecord) => {
|
const handleView = (page: OperationalPageRecord) => {
|
||||||
if (page.custom_domain) {
|
if (page.custom_domain) {
|
||||||
window.open(`https://${page.custom_domain}`, '_blank');
|
window.open(`https://${page.custom_domain}`, "_blank");
|
||||||
} else {
|
} else {
|
||||||
// Navigate to the public status page route using the correct format
|
// Navigate to the public status page route using the correct format
|
||||||
window.open(`/public/${page.slug}`, '_blank');
|
window.open(`/public/${page.slug}`, "_blank");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +68,7 @@ export const OperationalPageContent = () => {
|
|||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setPageToDelete(null);
|
setPageToDelete(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting page:', error);
|
console.error("Error deleting page:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -68,7 +80,9 @@ export const OperationalPageContent = () => {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3>
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
Failed to load operational pages
|
||||||
|
</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
There was an error loading your operational pages. Please try again.
|
There was an error loading your operational pages. Please try again.
|
||||||
</p>
|
</p>
|
||||||
@@ -82,11 +96,13 @@ export const OperationalPageContent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="w-full container mx-auto px-4 py-8 ">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 ">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
Operational Pages
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage your public status pages and monitor service health
|
Manage your public status pages and monitor service health
|
||||||
</p>
|
</p>
|
||||||
@@ -99,7 +115,9 @@ export const OperationalPageContent = () => {
|
|||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
disabled={isRefetching}
|
disabled={isRefetching}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 mr-2 ${isRefetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<CreateOperationalPageDialog />
|
<CreateOperationalPageDialog />
|
||||||
@@ -135,9 +153,12 @@ export const OperationalPageContent = () => {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3>
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
No operational pages found
|
||||||
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Create your first operational page to start monitoring your services and communicate status to your users.
|
Create your first operational page to start monitoring your
|
||||||
|
services and communicate status to your users.
|
||||||
</p>
|
</p>
|
||||||
<CreateOperationalPageDialog />
|
<CreateOperationalPageDialog />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -167,12 +188,12 @@ export const OperationalPageContent = () => {
|
|||||||
<span className="font-medium">Total Pages:</span> {pages.length}
|
<span className="font-medium">Total Pages:</span> {pages.length}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Public Pages:</span>{' '}
|
<span className="font-medium">Public Pages:</span>{" "}
|
||||||
{pages.filter(p => p.is_public === 'true').length}
|
{pages.filter((p) => p.is_public === "true").length}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Operational:</span>{' '}
|
<span className="font-medium">Operational:</span>{" "}
|
||||||
{pages.filter(p => p.status === 'operational').length}
|
{pages.filter((p) => p.status === "operational").length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +212,9 @@ export const OperationalPageContent = () => {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
|
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone and will permanently remove the operational page and all its components.
|
Are you sure you want to delete "{pageToDelete?.title}"? This
|
||||||
|
action cannot be undone and will permanently remove the
|
||||||
|
operational page and all its components.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -201,7 +224,7 @@ export const OperationalPageContent = () => {
|
|||||||
className="bg-red-600 hover:bg-red-700"
|
className="bg-red-600 hover:bg-red-700"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Plus, MapPin, Activity, Wifi, WifiOff } from "lucide-react";
|
import { Plus, MapPin, Activity, Wifi, WifiOff } from "lucide-react";
|
||||||
import { regionalService } from "@/services/regionalService";
|
import { regionalService } from "@/services/regionalService";
|
||||||
import { RegionalService } from "@/types/regional.types";
|
import { RegionalService } from "@/types/regional.types";
|
||||||
@@ -15,24 +20,29 @@ export const RegionalMonitoringContent = () => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: regionalServices = [], isLoading, error } = useQuery({
|
const {
|
||||||
queryKey: ['regional-services'],
|
data: regionalServices = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["regional-services"],
|
||||||
queryFn: regionalService.getRegionalServices,
|
queryFn: regionalService.getRegionalServices,
|
||||||
refetchInterval: 30000, // Refetch every 30 seconds
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAgentAdded = () => {
|
const handleAgentAdded = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
queryClient.invalidateQueries({ queryKey: ["regional-services"] });
|
||||||
toast({
|
toast({
|
||||||
title: "Regional Agent Added",
|
title: "Regional Agent Added",
|
||||||
description: "The regional monitoring agent has been successfully configured.",
|
description:
|
||||||
|
"The regional monitoring agent has been successfully configured.",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAgent = async (id: string) => {
|
const handleDeleteAgent = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await regionalService.deleteRegionalService(id);
|
await regionalService.deleteRegionalService(id);
|
||||||
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
queryClient.invalidateQueries({ queryKey: ["regional-services"] });
|
||||||
toast({
|
toast({
|
||||||
title: "Agent Removed",
|
title: "Agent Removed",
|
||||||
description: "The regional monitoring agent has been removed.",
|
description: "The regional monitoring agent has been removed.",
|
||||||
@@ -46,14 +56,18 @@ export const RegionalMonitoringContent = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onlineAgents = regionalServices.filter(agent => agent.connection === 'online').length;
|
const onlineAgents = regionalServices.filter(
|
||||||
|
(agent) => agent.connection === "online"
|
||||||
|
).length;
|
||||||
const totalAgents = regionalServices.length;
|
const totalAgents = regionalServices.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6 ">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Regional Monitoring</h1>
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
Regional Monitoring
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage distributed monitoring agents across different regions
|
Manage distributed monitoring agents across different regions
|
||||||
</p>
|
</p>
|
||||||
@@ -85,23 +99,25 @@ export const RegionalMonitoringContent = () => {
|
|||||||
<Wifi className="h-4 w-4 text-green-600" />
|
<Wifi className="h-4 w-4 text-green-600" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-600">{onlineAgents}</div>
|
<div className="text-2xl font-bold text-green-600">
|
||||||
<p className="text-xs text-muted-foreground">
|
{onlineAgents}
|
||||||
Currently connected
|
</div>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">Currently connected</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Offline Agents</CardTitle>
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Offline Agents
|
||||||
|
</CardTitle>
|
||||||
<WifiOff className="h-4 w-4 text-red-600" />
|
<WifiOff className="h-4 w-4 text-red-600" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div>
|
<div className="text-2xl font-bold text-red-600">
|
||||||
<p className="text-xs text-muted-foreground">
|
{totalAgents - onlineAgents}
|
||||||
Disconnected agents
|
</div>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">Disconnected agents</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +149,8 @@ export const RegionalMonitoringContent = () => {
|
|||||||
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
|
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<h3 className="text-lg font-semibold mb-2">No Regional Agents</h3>
|
<h3 className="text-lg font-semibold mb-2">No Regional Agents</h3>
|
||||||
<p className="text-muted-foreground text-center mb-4">
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
Get started by adding your first regional monitoring agent to extend your monitoring coverage.
|
Get started by adding your first regional monitoring agent to
|
||||||
|
extend your monitoring coverage.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setAddDialogOpen(true)}>
|
<Button onClick={() => setAddDialogOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, CalendarClock, AlertCircle } from "lucide-react";
|
import { Plus, CalendarClock, AlertCircle } from "lucide-react";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab";
|
import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab";
|
||||||
import { IncidentManagementTab } from "./IncidentManagementTab";
|
import { IncidentManagementTab } from "./IncidentManagementTab";
|
||||||
import { CreateMaintenanceDialog } from './maintenance/CreateMaintenanceDialog';
|
import { CreateMaintenanceDialog } from "./maintenance/CreateMaintenanceDialog";
|
||||||
import { CreateIncidentDialog } from './incident/CreateIncidentDialog';
|
import { CreateIncidentDialog } from "./incident/CreateIncidentDialog";
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService';
|
import {
|
||||||
|
initMaintenanceNotifications,
|
||||||
|
stopMaintenanceNotifications,
|
||||||
|
} from "@/services/maintenance/maintenanceNotificationService";
|
||||||
|
|
||||||
export const ScheduleIncidentContent = () => {
|
export const ScheduleIncidentContent = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [activeTab, setActiveTab] = useState("maintenance");
|
const [activeTab, setActiveTab] = useState("maintenance");
|
||||||
const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false);
|
const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] =
|
||||||
const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = useState(false);
|
useState(false);
|
||||||
|
const [createIncidentDialogOpen, setCreateIncidentDialogOpen] =
|
||||||
|
useState(false);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0);
|
const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
@@ -48,8 +52,8 @@ export const ScheduleIncidentContent = () => {
|
|||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
toast({
|
toast({
|
||||||
title: t('success'),
|
title: t("success"),
|
||||||
description: t('maintenanceCreatedSuccess'),
|
description: t("maintenanceCreatedSuccess"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,24 +65,26 @@ export const ScheduleIncidentContent = () => {
|
|||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
toast({
|
toast({
|
||||||
title: t('success'),
|
title: t("success"),
|
||||||
description: t('incidentCreatedSuccess'),
|
description: t("incidentCreatedSuccess"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6">
|
<main className="flex-1 flex flex-col overflow-auto bg-background dark:bg-black p-2 ">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6 bg-white dark:bg-black p-3 h-full rounded-xl">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-2xl font-bold text-foreground">
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
{t('scheduleIncidentManagement')}
|
{t("scheduleIncidentManagement")}
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
className="text-primary-foreground"
|
className="text-primary-foreground"
|
||||||
onClick={handleCreateButtonClick}
|
onClick={handleCreateButtonClick}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
|
{activeTab === "maintenance"
|
||||||
|
? t("createMaintenanceWindow")
|
||||||
|
: t("createIncident")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,11 +96,11 @@ export const ScheduleIncidentContent = () => {
|
|||||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
<TabsTrigger value="maintenance">
|
<TabsTrigger value="maintenance">
|
||||||
<CalendarClock className="w-4 h-4 mr-2" />
|
<CalendarClock className="w-4 h-4 mr-2" />
|
||||||
{t('scheduledMaintenance')}
|
{t("scheduledMaintenance")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="incidents">
|
<TabsTrigger value="incidents">
|
||||||
<AlertCircle className="w-4 h-4 mr-2" />
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
{t('incidentManagement')}
|
{t("incidentManagement")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import {
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
DropdownMenu,
|
||||||
import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Eye,
|
||||||
|
Activity,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { Server } from "@/types/server.types";
|
import { Server } from "@/types/server.types";
|
||||||
import { ServerStatusBadge } from "./ServerStatusBadge";
|
import { ServerStatusBadge } from "./ServerStatusBadge";
|
||||||
import { OSTypeIcon } from "./OSTypeIcon";
|
import { OSTypeIcon } from "./OSTypeIcon";
|
||||||
@@ -23,7 +55,11 @@ interface ServerTableProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
|
export const ServerTable = ({
|
||||||
|
servers,
|
||||||
|
isLoading,
|
||||||
|
onRefresh,
|
||||||
|
}: ServerTableProps) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -34,7 +70,8 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const filteredServers = servers.filter(server =>
|
const filteredServers = servers.filter(
|
||||||
|
(server) =>
|
||||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
server.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
server.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
|
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
@@ -57,35 +94,38 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setPausingServers(prev => new Set(prev).add(serverId));
|
setPausingServers((prev) => new Set(prev).add(serverId));
|
||||||
|
|
||||||
// Only update the status field, preserving all other server configuration
|
// Only update the status field, preserving all other server configuration
|
||||||
const updateData = {
|
const updateData = {
|
||||||
status: isPaused ? "up" : "paused",
|
status: isPaused ? "up" : "paused",
|
||||||
last_checked: new Date().toISOString()
|
last_checked: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await pb.collection('servers').update(serverId, updateData);
|
await pb.collection("servers").update(serverId, updateData);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: isPaused ? "Server resumed" : "Server paused",
|
title: isPaused ? "Server resumed" : "Server paused",
|
||||||
description: `Monitoring ${isPaused ? 'resumed' : 'paused'} for ${server.name}`,
|
description: `Monitoring ${isPaused ? "resumed" : "paused"} for ${
|
||||||
|
server.name
|
||||||
|
}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
|
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
|
||||||
|
|
||||||
// Refresh the server list to show updated status
|
// Refresh the server list to show updated status
|
||||||
onRefresh();
|
onRefresh();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Error updating server status:', error);
|
// console.error('Error updating server status:', error);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: `Failed to ${isPaused ? 'resume' : 'pause'} server monitoring. Please try again.`,
|
description: `Failed to ${
|
||||||
|
isPaused ? "resume" : "pause"
|
||||||
|
} server monitoring. Please try again.`,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPausingServers(prev => {
|
setPausingServers((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(serverId);
|
newSet.delete(serverId);
|
||||||
return newSet;
|
return newSet;
|
||||||
@@ -110,7 +150,7 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
// Delete the server from the database
|
// Delete the server from the database
|
||||||
await pb.collection('servers').delete(selectedServer.id);
|
await pb.collection("servers").delete(selectedServer.id);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Server deleted",
|
title: "Server deleted",
|
||||||
@@ -123,7 +163,6 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
// Close the dialog
|
// Close the dialog
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setSelectedServer(null);
|
setSelectedServer(null);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Error deleting server:', error);
|
// console.error('Error deleting server:', error);
|
||||||
toast({
|
toast({
|
||||||
@@ -140,61 +179,61 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
subtitle,
|
subtitle,
|
||||||
type
|
type,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
type: 'cpu' | 'memory' | 'disk'
|
type: "cpu" | "memory" | "disk";
|
||||||
}) => {
|
}) => {
|
||||||
const getGradientColors = (type: string, value: number) => {
|
const getGradientColors = (type: string, value: number) => {
|
||||||
if (type === 'cpu') {
|
if (type === "cpu") {
|
||||||
if (value > 90) return 'from-red-500 to-red-600';
|
if (value > 90) return "from-red-500 to-red-600";
|
||||||
if (value > 75) return 'from-orange-500 to-orange-600';
|
if (value > 75) return "from-orange-500 to-orange-600";
|
||||||
if (value > 60) return 'from-yellow-500 to-yellow-600';
|
if (value > 60) return "from-yellow-500 to-yellow-600";
|
||||||
return 'from-green-500 to-green-600';
|
return "from-green-500 to-green-600";
|
||||||
}
|
}
|
||||||
if (type === 'memory') {
|
if (type === "memory") {
|
||||||
if (value > 90) return 'from-red-500 to-red-600';
|
if (value > 90) return "from-red-500 to-red-600";
|
||||||
if (value > 75) return 'from-yellow-500 to-yellow-600';
|
if (value > 75) return "from-yellow-500 to-yellow-600";
|
||||||
return 'from-blue-500 to-blue-600';
|
return "from-blue-500 to-blue-600";
|
||||||
}
|
}
|
||||||
if (type === 'disk') {
|
if (type === "disk") {
|
||||||
if (value > 95) return 'from-red-500 to-red-600';
|
if (value > 95) return "from-red-500 to-red-600";
|
||||||
if (value > 85) return 'from-yellow-500 to-yellow-600';
|
if (value > 85) return "from-yellow-500 to-yellow-600";
|
||||||
return 'from-orange-500 to-orange-600';
|
return "from-orange-500 to-orange-600";
|
||||||
}
|
}
|
||||||
return 'from-gray-500 to-gray-600';
|
return "from-gray-500 to-gray-600";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTextColor = (value: number) => {
|
const getTextColor = (value: number) => {
|
||||||
if (value > 90) return 'text-red-600 dark:text-red-400';
|
if (value > 90) return "text-red-600 dark:text-red-400";
|
||||||
if (value > 75) return 'text-orange-600 dark:text-orange-400';
|
if (value > 75) return "text-orange-600 dark:text-orange-400";
|
||||||
if (value > 60) return 'text-yellow-600 dark:text-yellow-400';
|
if (value > 60) return "text-yellow-600 dark:text-yellow-400";
|
||||||
return 'text-green-600 dark:text-green-400';
|
return "text-green-600 dark:text-green-400";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 min-w-[120px]">
|
<div className="space-y-2 min-w-[120px] -mt-20">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className={`text-sm font-semibold ${getTextColor(value)}`}>
|
<span className={`text-sm font-semibold ${getTextColor(value)}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{subtitle} </span>
|
||||||
{subtitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="w-full h-3 bg-muted/30 rounded-full overflow-hidden shadow-inner">
|
<div className="w-full h-3 bg-muted/30 rounded-full overflow-hidden shadow-inner">
|
||||||
<div
|
<div
|
||||||
className={`h-full bg-gradient-to-r ${getGradientColors(type, value)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
|
className={`h-full bg-gradient-to-r ${getGradientColors(
|
||||||
|
type,
|
||||||
|
value
|
||||||
|
)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
|
||||||
style={{ width: `${Math.min(value, 100)}%` }}
|
style={{ width: `${Math.min(value, 100)}%` }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse opacity-60" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse opacity-60" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -202,7 +241,7 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex-1 flex flex-col">
|
<Card className="flex-1 flex flex-col ">
|
||||||
<CardHeader className="flex-shrink-0">
|
<CardHeader className="flex-shrink-0">
|
||||||
<CardTitle>Servers</CardTitle>
|
<CardTitle>Servers</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -244,27 +283,107 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
<p className="text-muted-foreground">No servers found</p>
|
<p className="text-muted-foreground">No servers found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
|
<div
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "bg-gray-900" : "bg-white"
|
||||||
|
} rounded-lg border border-border shadow-sm`}
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
<TableHeader
|
||||||
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
className={`${
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Name</TableHead>
|
theme === "dark" ? "bg-gray-800" : "bg-gray-50"
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead>
|
}`}
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead>
|
>
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead>
|
<TableRow
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead>
|
className={`${
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead>
|
theme === "dark"
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead>
|
? "border-gray-700 hover:bg-gray-800"
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Uptime</TableHead>
|
: "border-gray-200 hover:bg-gray-100"
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Last Checked</TableHead>
|
}`}
|
||||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead>
|
>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
OS
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
IP Address
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
CPU
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
Memory
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
Disk
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
Uptime
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4`}
|
||||||
|
>
|
||||||
|
Last Checked
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className={`${
|
||||||
|
theme === "dark" ? "text-gray-300" : "text-gray-700"
|
||||||
|
} font-medium text-base py-4 text-right`}
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredServers.map((server) => {
|
{filteredServers.map((server) => {
|
||||||
const cpuUsage = server.cpu_usage || 0;
|
const cpuUsage = server.cpu_usage || 0;
|
||||||
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
|
const memoryUsage =
|
||||||
const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
|
server.ram_total > 0
|
||||||
|
? (server.ram_used / server.ram_total) * 100
|
||||||
|
: 0;
|
||||||
|
const diskUsage =
|
||||||
|
server.disk_total > 0
|
||||||
|
? (server.disk_used / server.disk_total) * 100
|
||||||
|
: 0;
|
||||||
const isPaused = server.status === "paused";
|
const isPaused = server.status === "paused";
|
||||||
const isProcessing = pausingServers.has(server.id);
|
const isProcessing = pausingServers.has(server.id);
|
||||||
|
|
||||||
@@ -285,7 +404,10 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<OSTypeIcon osType={server.os_type} />
|
<OSTypeIcon osType={server.os_type} />
|
||||||
<span className="text-sm truncate" title={server.os_type}>
|
<span
|
||||||
|
className="text-sm truncate"
|
||||||
|
title={server.os_type}
|
||||||
|
>
|
||||||
{server.os_type}
|
{server.os_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +429,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
<CustomProgressBar
|
<CustomProgressBar
|
||||||
value={memoryUsage}
|
value={memoryUsage}
|
||||||
label={`${memoryUsage.toFixed(1)}%`}
|
label={`${memoryUsage.toFixed(1)}%`}
|
||||||
subtitle={serverService.formatBytes(server.ram_total)}
|
subtitle={serverService.formatBytes(
|
||||||
|
server.ram_total
|
||||||
|
)}
|
||||||
type="memory"
|
type="memory"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -315,12 +439,17 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
<CustomProgressBar
|
<CustomProgressBar
|
||||||
value={diskUsage}
|
value={diskUsage}
|
||||||
label={`${diskUsage.toFixed(1)}%`}
|
label={`${diskUsage.toFixed(1)}%`}
|
||||||
subtitle={serverService.formatBytes(server.disk_total)}
|
subtitle={serverService.formatBytes(
|
||||||
|
server.disk_total
|
||||||
|
)}
|
||||||
type="disk"
|
type="disk"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm truncate" title={server.uptime}>
|
<div
|
||||||
|
className="text-sm truncate"
|
||||||
|
title={server.uptime}
|
||||||
|
>
|
||||||
{server.uptime}
|
{server.uptime}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -329,10 +458,17 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
{new Date(server.last_checked).toLocaleString()}
|
{new Date(server.last_checked).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
<TableCell
|
||||||
|
className="text-right"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
@@ -341,20 +477,36 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-[200px]">
|
<DropdownMenuContent
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(server.id); }}>
|
align="end"
|
||||||
|
className="w-[200px]"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewDetails(server.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View Server Detail
|
View Server Detail
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{server.docker === 'true' && (
|
{server.docker === "true" && (
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewContainers(server.id); }}>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewContainers(server.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Activity className="mr-2 h-4 w-4" />
|
<Activity className="mr-2 h-4 w-4" />
|
||||||
Container Monitoring
|
Container Monitoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePauseResume(server);
|
||||||
|
}}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
@@ -370,12 +522,20 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleEdit(server); }}>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(server);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => { e.stopPropagation(); handleDelete(server); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(server);
|
||||||
|
}}
|
||||||
className="text-red-600 focus:text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
@@ -406,19 +566,19 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
Are you sure you want to delete this server?
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete{' '}
|
This action cannot be undone. This will permanently delete{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{selectedServer?.name}
|
{selectedServer?.name}
|
||||||
</span>{' '}
|
</span>{" "}
|
||||||
and all of its monitoring data.
|
and all of its monitoring data.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDelete}
|
onClick={confirmDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Clock, TimerOff } from "lucide-react";
|
import { Clock, TimerOff } from "lucide-react";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface LastCheckedTimeProps {
|
interface LastCheckedTimeProps {
|
||||||
lastCheckedTime: string;
|
lastCheckedTime: string;
|
||||||
@@ -10,6 +11,7 @@ interface LastCheckedTimeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
|
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
// Format the time without seconds to display a static time
|
// Format the time without seconds to display a static time
|
||||||
const formatTimeWithoutSeconds = (timeString: string) => {
|
const formatTimeWithoutSeconds = (timeString: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +57,7 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
|
|||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{isPaused ? "Paused at " : ""}
|
{isPaused ? t("pausedAt") : ""}
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,14 +68,14 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1 text-xs">
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{isPaused ? "Monitoring Paused" : "Last Check Details"}
|
{isPaused ? t("monitoringPaused") : t("lastCheckDetails")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
|
{isPaused ? t("noAutomaticChecks") : t("checkedAt") + `${formattedTime}`}
|
||||||
</div>
|
</div>
|
||||||
{interval && !isPaused && (
|
{interval && !isPaused && (
|
||||||
<div>
|
<div>
|
||||||
Check interval: {formatInterval(interval)}
|
{t("checkInterval")}: {formatInterval(interval)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-gray-400 text-[10px]">
|
<div className="text-gray-400 text-[10px]">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
export function LoadingState() {
|
export function LoadingState() {
|
||||||
|
const { t } = useLanguage();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-background text-foreground">
|
<div className="flex items-center justify-center h-screen bg-background text-foreground">
|
||||||
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
|
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
|
||||||
@@ -7,8 +9,8 @@ export function LoadingState() {
|
|||||||
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
|
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
|
||||||
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
|
<h3 className="text-xl font-medium mb-1">{t("loadingServerData")}</h3>
|
||||||
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
|
<p className="text-muted-foreground">{t("retrievingYourInformation")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Service } from "@/types/service.types";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface ServiceEditDialogProps {
|
interface ServiceEditDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -19,6 +20,7 @@ interface ServiceEditDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
|
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -53,9 +55,9 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi
|
|||||||
}}>
|
}}>
|
||||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">Edit Service</DialogTitle>
|
<DialogTitle className="text-xl">{t("editService")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the details of your monitored service.
|
{t("editServiceDesc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{open && service && (
|
{open && service && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Service, UptimeData } from "@/types/service.types";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { formatDistanceStrict } from "date-fns";
|
import { formatDistanceStrict } from "date-fns";
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
interface ServiceStatsCardsProps {
|
interface ServiceStatsCardsProps {
|
||||||
service: Service;
|
service: Service;
|
||||||
@@ -11,6 +12,8 @@ interface ServiceStatsCardsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
|
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
|
||||||
|
|
||||||
|
const { t } = useLanguage();
|
||||||
// Calculate uptime percentage
|
// Calculate uptime percentage
|
||||||
const calculateUptimePercentage = () => {
|
const calculateUptimePercentage = () => {
|
||||||
if (uptimeData.length === 0) return 100;
|
if (uptimeData.length === 0) return 100;
|
||||||
@@ -109,41 +112,46 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
|
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
|
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">{t('responseTime')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-blue-700 dark:text-white">
|
<div className="text-2xl font-bold text-blue-700 dark:text-white">
|
||||||
{service.responseTime}ms
|
{service.responseTime}ms
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
|
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">{t('lastCheckedAt').replace('{datetime}', service.lastChecked)}</p>
|
||||||
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
|
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
|
||||||
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
|
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">{t('avg')}: {avgResponseTime}ms ({t('lastUpChecksCount').replace('{count}', String(upChecks.length))})</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
|
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
|
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">{t('uptime')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-700 dark:text-white">
|
<div className="text-2xl font-bold text-green-700 dark:text-white">
|
||||||
{uptimePercentage}%
|
{uptimePercentage}%
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
|
<p className="text-xs text-green-600 dark:text-green-200 mt-1">{t('basedOnlastChecksCount').replace('{count}', String(uptimeData.length))}</p>
|
||||||
<div className="flex flex-col space-y-1 mt-2">
|
<div className="flex flex-col space-y-1 mt-2">
|
||||||
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
|
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
|
||||||
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
|
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
|
||||||
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
|
<span>{t("totalUptime")}: {uptimeStats.totalUptimeFormatted}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
|
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
|
||||||
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
|
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
|
||||||
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
|
<span>{t("totalDowntime")}: {uptimeStats.totalDowntimeFormatted}</span>
|
||||||
</div>
|
</div>
|
||||||
{service.status !== "paused" && (
|
{service.status !== "paused" && (
|
||||||
<div className="flex items-center text-xs font-medium mt-1">
|
<div className="flex items-center text-xs font-medium mt-1">
|
||||||
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
|
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
|
||||||
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
|
{
|
||||||
|
service.status === "up" ?
|
||||||
|
t('upStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration)
|
||||||
|
:
|
||||||
|
t('downStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration)
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -153,16 +161,16 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp
|
|||||||
|
|
||||||
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
|
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
|
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">{t('monitoringSettings')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
|
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
|
||||||
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
|
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
|
||||||
<span>Checked every {service.interval} seconds</span>
|
<span>{t('monitoringSettingsInterval').replace('{interval}', String(service.interval))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
|
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
|
||||||
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
|
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
|
||||||
<span>{service.type} monitoring</span>
|
<span>{service.type} {t('monitoringSettingsType')}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export function ServicesPagination({
|
|||||||
</Select>
|
</Select>
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{totalItems > 0
|
{totalItems > 0
|
||||||
? `${startItem}-${endItem} of ${totalItems} ${t("services") || "services"}`
|
? t("servicesPagination", {"startItem": startItem, "endItem": endItem, "totalItems": totalItems})
|
||||||
: `0 ${t("services") || "services"}`
|
: t("servicesPaginationNoService")
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
|||||||
import { UptimeStatusItem } from './uptime/UptimeStatusItem';
|
import { UptimeStatusItem } from './uptime/UptimeStatusItem';
|
||||||
import { uptimeService } from '@/services/uptimeService';
|
import { uptimeService } from '@/services/uptimeService';
|
||||||
import { UptimeData } from '@/types/service.types';
|
import { UptimeData } from '@/types/service.types';
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
interface UptimeBarProps {
|
interface UptimeBarProps {
|
||||||
uptime: number;
|
uptime: number;
|
||||||
@@ -15,6 +16,7 @@ interface UptimeBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
|
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
// Calculate date range for last 20 checks with much more aggressive caching
|
// Calculate date range for last 20 checks with much more aggressive caching
|
||||||
const endDate = useMemo(() => new Date(), []);
|
const endDate = useMemo(() => new Date(), []);
|
||||||
const startDate = useMemo(() => {
|
const startDate = useMemo(() => {
|
||||||
@@ -23,19 +25,21 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
|
|||||||
return start;
|
return start;
|
||||||
}, [endDate, interval]);
|
}, [endDate, interval]);
|
||||||
|
|
||||||
// Fetch uptime data with very aggressive caching to reduce API calls
|
// Fetch uptime data with immediate display and background updates
|
||||||
const { data: uptimeData = [] } = useQuery({
|
const { data: uptimeData = [] } = useQuery({
|
||||||
queryKey: ['uptime-bar', serviceId, serviceType],
|
queryKey: ['uptime-bar', serviceId, serviceType],
|
||||||
queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType),
|
queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType),
|
||||||
enabled: !!serviceId,
|
enabled: !!serviceId,
|
||||||
staleTime: 30000, // Data is fresh for 30 seconds
|
staleTime: 300000, // Data stays fresh for 5 minutes - display immediately from cache
|
||||||
gcTime: 600000, // Keep in cache for 10 minutes
|
gcTime: 900000, // Keep in cache for 15 minutes
|
||||||
refetchInterval: 60000, // 1 minute polling
|
refetchInterval: 300000, // Reduced to 5 minute polling
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: 3,
|
retry: 1, // Reduce retries for faster failure
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
retryDelay: 1000, // Faster retry delay
|
||||||
|
refetchIntervalInBackground: true, // Allow background updates
|
||||||
|
placeholderData: (previousData) => previousData, // Keep showing previous data while refetching
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the uptime calculation to prevent unnecessary recalculations
|
// Memoize the uptime calculation to prevent unnecessary recalculations
|
||||||
@@ -84,7 +88,7 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Last 20 checks
|
{t('last20Checks')}
|
||||||
</span>
|
</span>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { MouseEvent } from "react";
|
import { MouseEvent } from "react";
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
interface ServiceFormActionsProps {
|
interface ServiceFormActionsProps {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
@@ -14,6 +15,8 @@ export function ServiceFormActions({
|
|||||||
onCancel,
|
onCancel,
|
||||||
submitLabel = "Create Service"
|
submitLabel = "Create Service"
|
||||||
}: ServiceFormActionsProps) {
|
}: ServiceFormActionsProps) {
|
||||||
|
|
||||||
|
const {t} = useLanguage();
|
||||||
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isSubmitting) {
|
if (!isSubmitting) {
|
||||||
@@ -29,7 +32,7 @@ export function ServiceFormActions({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -38,7 +41,7 @@ export function ServiceFormActions({
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Processing...
|
{t("processing")}...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
submitLabel
|
submitLabel
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
|
|||||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
|
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
|
||||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
|
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
|
||||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
|
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
|
||||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Error Message</TableHead>
|
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("errorMessage")}</TableHead>
|
||||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Details</TableHead>
|
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("details")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { IncidentTable } from "./IncidentTable";
|
|||||||
import { StatusFilter, PageSize } from "./types";
|
import { StatusFilter, PageSize } from "./types";
|
||||||
import { getStatusChangeEvents } from "./utils";
|
import { getStatusChangeEvents } from "./utils";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
|
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
|
||||||
// Get current theme
|
// Get current theme
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
@@ -65,7 +67,7 @@ export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] })
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<CardTitle className="text-card-foreground">
|
<CardTitle className="text-card-foreground">
|
||||||
<span>Incident History</span>
|
<span>{t("incidentHistory")}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
|
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PageSize } from "./types";
|
import { PageSize } from "./types";
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
interface TablePaginationProps {
|
interface TablePaginationProps {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
@@ -35,6 +36,7 @@ export function TablePagination({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
}: TablePaginationProps) {
|
}: TablePaginationProps) {
|
||||||
|
const {t} = useLanguage()
|
||||||
// Generate page numbers
|
// Generate page numbers
|
||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
@@ -58,7 +60,7 @@ export function TablePagination({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
<span className="text-sm text-muted-foreground">{t("rowsPerPage")}:</span>
|
||||||
<Select
|
<Select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onValueChange={(value) => onPageSizeChange(value as PageSize)}
|
onValueChange={(value) => onPageSizeChange(value as PageSize)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Service } from "@/types/service.types";
|
import { Service } from "@/types/service.types";
|
||||||
import { serviceService } from "@/services/serviceService";
|
import { serviceService } from "@/services/serviceService";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface ServiceRowActionsProps {
|
interface ServiceRowActionsProps {
|
||||||
service: Service;
|
service: Service;
|
||||||
@@ -31,6 +32,7 @@ export const ServiceRowActions = ({
|
|||||||
onMuteAlerts
|
onMuteAlerts
|
||||||
}: ServiceRowActionsProps) => {
|
}: ServiceRowActionsProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Handle pause/resume directly from dropdown
|
// Handle pause/resume directly from dropdown
|
||||||
const handlePauseResume = async (e: React.MouseEvent) => {
|
const handlePauseResume = async (e: React.MouseEvent) => {
|
||||||
@@ -122,7 +124,7 @@ export const ServiceRowActions = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
<span>View Detail</span>
|
<span>{t("viewDetail")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
|
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
|
||||||
@@ -131,12 +133,12 @@ export const ServiceRowActions = ({
|
|||||||
{service.status === "paused" ? (
|
{service.status === "paused" ? (
|
||||||
<>
|
<>
|
||||||
<Play className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
<span>Resume Monitoring</span>
|
<span>{t("resumeMonitoring")}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Pause className="h-4 w-4" />
|
<Pause className="h-4 w-4" />
|
||||||
<span>Pause Monitoring</span>
|
<span>{t("pauseMonitoring")}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -148,7 +150,7 @@ export const ServiceRowActions = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span>Edit</span>
|
<span>{t("edit")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
|
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
|
||||||
@@ -157,12 +159,12 @@ export const ServiceRowActions = ({
|
|||||||
{alertsMuted ? (
|
{alertsMuted ? (
|
||||||
<>
|
<>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
<span>Unmute Alerts</span>
|
<span>{t("unmuteAlerts")}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<BellOff className="h-4 w-4" />
|
<BellOff className="h-4 w-4" />
|
||||||
<span>Mute Alerts</span>
|
<span>{t("muteAlerts")}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -175,7 +177,7 @@ export const ServiceRowActions = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span>Delete</span>
|
<span>{t("delete")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
interface UptimeSummaryProps {
|
interface UptimeSummaryProps {
|
||||||
uptime: number;
|
uptime: number;
|
||||||
@@ -7,13 +8,14 @@ interface UptimeSummaryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => {
|
export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-xs mt-1">
|
<div className="flex items-center justify-between text-xs mt-1">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{Math.round(uptime)}% uptime
|
{Math.round(uptime)}% uptime
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Last 20 checks
|
{t('last20Checks')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -355,9 +355,9 @@ export const AboutSystem: React.FC = () => {
|
|||||||
<CardHeader className="bg-muted/50 pb-4">
|
<CardHeader className="bg-muted/50 pb-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Database className={`h-5 w-5 ${theme === 'dark' ? 'text-blue-400' : 'text-blue-600'}`} />
|
<Database className={`h-5 w-5 ${theme === 'dark' ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||||
<span>Update Schema</span>
|
<span>{t('updateSchema')}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="font-medium text-base">Automatic import collections schema</CardDescription>
|
<CardDescription className="font-medium text-base">{t('updateSchemaDesc')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pt-6">
|
<CardContent className="space-y-4 pt-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -368,7 +368,7 @@ export const AboutSystem: React.FC = () => {
|
|||||||
onCheckedChange={(checked) => setMergeFields(checked === true)}
|
onCheckedChange={(checked) => setMergeFields(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="merge-fields" className="text-sm font-medium">
|
<Label htmlFor="merge-fields" className="text-sm font-medium">
|
||||||
Merge fields with existing collections (safe - preserves data)
|
{t('mergeFieldsLabel')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +382,7 @@ export const AboutSystem: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Database className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
|
<Database className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
|
||||||
{isImporting ? 'Importing...' : 'Click to update Schema'}
|
{isImporting ? t('importing') : t('clickToUpdateSchema')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -403,7 +403,7 @@ export const AboutSystem: React.FC = () => {
|
|||||||
? 'text-green-800 dark:text-green-200'
|
? 'text-green-800 dark:text-green-200'
|
||||||
: 'text-red-800 dark:text-red-200'
|
: 'text-red-800 dark:text-red-200'
|
||||||
}`}>
|
}`}>
|
||||||
{importResult.success ? 'Import Successful' : 'Import Failed'}
|
{importResult.success ? t('importSuccessful') : t('importFailed')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-2 text-sm ${
|
<div className={`mt-2 text-sm ${
|
||||||
@@ -411,21 +411,21 @@ export const AboutSystem: React.FC = () => {
|
|||||||
? 'text-green-700 dark:text-green-300'
|
? 'text-green-700 dark:text-green-300'
|
||||||
: 'text-red-700 dark:text-red-300'
|
: 'text-red-700 dark:text-red-300'
|
||||||
}`}>
|
}`}>
|
||||||
{importResult.created > 0 && `${importResult.created} collections created`}
|
{importResult.created > 0 && t('collectionsCreatedCount').replace('{count}', String(importResult.created))}
|
||||||
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + `${importResult.updated} collections updated`}
|
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + t('collectionsUpdatedCount').replace('{count}', String(importResult.updated))}
|
||||||
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + `${importResult.skipped} collections skipped`}
|
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + t('collectionsSkippedCount').replace('{count}', String(importResult.skipped))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
<strong>Instructions:</strong>
|
<strong>{t('instructions')}:</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||||
<li><strong>Merge fields:</strong> Safely add new fields to existing collections, preserves all data</li>
|
<li><strong>{t('mergeFields')}:</strong> {t('instructionsMergeFields')}</li>
|
||||||
<li>System collections (starting with _) and users collection will be skipped automatically</li>
|
<li>{t('instructionsCollections')}</li>
|
||||||
<li>Only authenticated admins can perform schema imports</li>
|
<li>{t('instructionsImportAuth')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { TemplateType, templateTypeConfigs } from "@/services/templateService";
|
import { TemplateType, templateTypeConfigs } from "@/services/templateService";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
interface TemplateDialogProps {
|
interface TemplateDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -81,6 +82,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderPlaceholderGuide = () => {
|
const renderPlaceholderGuide = () => {
|
||||||
|
|
||||||
const config = templateTypeConfigs[selectedTemplateType];
|
const config = templateTypeConfigs[selectedTemplateType];
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ const DataRetentionSettings = () => {
|
|||||||
setLastCleanup(result.lastCleanup);
|
setLastCleanup(result.lastCleanup);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading retention settings:", error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to load retention settings",
|
description: "Failed to load retention settings",
|
||||||
@@ -72,7 +71,6 @@ const DataRetentionSettings = () => {
|
|||||||
description: "Data retention settings have been updated",
|
description: "Data retention settings have been updated",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving retention settings:", error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to save retention settings",
|
description: "Failed to save retention settings",
|
||||||
@@ -96,7 +94,6 @@ const DataRetentionSettings = () => {
|
|||||||
// Reload settings to get updated last cleanup time
|
// Reload settings to get updated last cleanup time
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during uptime cleanup:", error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to perform uptime data cleanup",
|
description: "Failed to perform uptime data cleanup",
|
||||||
@@ -120,7 +117,6 @@ const DataRetentionSettings = () => {
|
|||||||
// Reload settings to get updated last cleanup time
|
// Reload settings to get updated last cleanup time
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during server cleanup:", error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to perform server data cleanup",
|
description: "Failed to perform server data cleanup",
|
||||||
@@ -144,7 +140,6 @@ const DataRetentionSettings = () => {
|
|||||||
// Reload settings to get updated last cleanup time
|
// Reload settings to get updated last cleanup time
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during manual cleanup:", error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to perform database cleanup",
|
description: "Failed to perform database cleanup",
|
||||||
@@ -170,7 +165,7 @@ const DataRetentionSettings = () => {
|
|||||||
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
|
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
|
||||||
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<AlertDescription className="text-blue-700 dark:text-blue-300">
|
<AlertDescription className="text-blue-700 dark:text-blue-300">
|
||||||
<span className="font-medium">{t("permissionNotice")}</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
|
<span className="font-medium">Permission Notice:</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -210,10 +205,16 @@ const DataRetentionSettings = () => {
|
|||||||
min="1"
|
min="1"
|
||||||
max="365"
|
max="365"
|
||||||
value={settings.uptimeRetentionDays}
|
value={settings.uptimeRetentionDays}
|
||||||
onChange={(e) => setSettings(prev => ({
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === '' || isNaN(Number(value))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
uptimeRetentionDays: parseInt(e.target.value) || 30
|
uptimeRetentionDays: Number(value)
|
||||||
}))}
|
}));
|
||||||
|
}}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
@@ -229,10 +230,16 @@ const DataRetentionSettings = () => {
|
|||||||
min="1"
|
min="1"
|
||||||
max="365"
|
max="365"
|
||||||
value={settings.serverRetentionDays}
|
value={settings.serverRetentionDays}
|
||||||
onChange={(e) => setSettings(prev => ({
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === '' || isNaN(Number(value))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
serverRetentionDays: parseInt(e.target.value) || 30
|
serverRetentionDays: Number(value)
|
||||||
}))}
|
}));
|
||||||
|
}}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
@@ -250,52 +257,7 @@ const DataRetentionSettings = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-4">
|
<CardFooter className="flex justify-end">
|
||||||
<div className="flex flex-wrap gap-2 w-full">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleUptimeShrink}
|
|
||||||
disabled={isUptimeShrinking}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isUptimeShrinking ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Shrink Uptime Data
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleServerShrink}
|
|
||||||
disabled={isServerShrinking}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isServerShrinking ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Server className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Shrink Server Data
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleFullShrink}
|
|
||||||
disabled={isFullShrinking}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isFullShrinking ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Full Database Shrink
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end w-full">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -306,7 +268,6 @@ const DataRetentionSettings = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,15 +126,6 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
|
|||||||
<Mail className="h-5 w-5" />
|
<Mail className="h-5 w-5" />
|
||||||
{t("sendTestEmail", "settings")}
|
{t("sendTestEmail", "settings")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-4 top-4"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
@@ -203,9 +194,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
|
|||||||
{/* Info message */}
|
{/* Info message */}
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>{t("testEmailAlert", "settings")}</AlertDescription>
|
||||||
This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,7 +212,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isLoading ? t("sending", "settings") : t("send", "common")}
|
{isLoading ? t("sending", "settings") : t("send", "settings")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
+126
-4
@@ -27,6 +27,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Loader2, Copy } from "lucide-react";
|
import { Loader2, Copy } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface NotificationChannelDialogProps {
|
interface NotificationChannelDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -34,9 +35,10 @@ interface NotificationChannelDialogProps {
|
|||||||
editingConfig: AlertConfiguration | null;
|
editingConfig: AlertConfiguration | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const baseSchema = z.object({
|
const baseSchema = z.object({
|
||||||
notify_name: z.string().min(1, "Name is required"),
|
notify_name: z.string().min(1, "Name is required"),
|
||||||
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "webhook"]),
|
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook"]),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
service_id: z.string().default("global"),
|
service_id: z.string().default("global"),
|
||||||
template_id: z.string().optional(),
|
template_id: z.string().optional(),
|
||||||
@@ -81,6 +83,19 @@ const emailSchema = baseSchema.extend({
|
|||||||
const ntfySchema = baseSchema.extend({
|
const ntfySchema = baseSchema.extend({
|
||||||
notification_type: z.literal("ntfy"),
|
notification_type: z.literal("ntfy"),
|
||||||
ntfy_endpoint: z.string().url("Must be a valid NTFY endpoint URL"),
|
ntfy_endpoint: z.string().url("Must be a valid NTFY endpoint URL"),
|
||||||
|
api_token: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushoverSchema = baseSchema.extend({
|
||||||
|
notification_type: z.literal("pushover"),
|
||||||
|
api_token: z.string().min(1, "API token is required"),
|
||||||
|
user_key: z.string().min(1, "User key is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifiarrSchema = baseSchema.extend({
|
||||||
|
notification_type: z.literal("notifiarr"),
|
||||||
|
api_token: z.string().min(1, "API token is required"),
|
||||||
|
channel_id: z.string().min(1, "Channel ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const webhookSchema = baseSchema.extend({
|
const webhookSchema = baseSchema.extend({
|
||||||
@@ -89,10 +104,10 @@ const webhookSchema = baseSchema.extend({
|
|||||||
webhook_payload_template: z.string().optional(),
|
webhook_payload_template: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pushoverSchema = baseSchema.extend({
|
const gotifySchema = baseSchema.extend({
|
||||||
notification_type: z.literal("pushover"),
|
notification_type: z.literal("gotify"),
|
||||||
api_token: z.string().min(1, "API token is required"),
|
api_token: z.string().min(1, "API token is required"),
|
||||||
user_key: z.string().min(1, "User key is required"),
|
server_url: z.string().url("Must be a valid server URL"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSchema = z.discriminatedUnion("notification_type", [
|
const formSchema = z.discriminatedUnion("notification_type", [
|
||||||
@@ -104,6 +119,8 @@ const formSchema = z.discriminatedUnion("notification_type", [
|
|||||||
emailSchema,
|
emailSchema,
|
||||||
ntfySchema,
|
ntfySchema,
|
||||||
pushoverSchema,
|
pushoverSchema,
|
||||||
|
notifiarrSchema,
|
||||||
|
gotifySchema,
|
||||||
webhookSchema,
|
webhookSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -158,6 +175,19 @@ const notificationTypeOptions = [
|
|||||||
description: "Send push notifications via Pushover",
|
description: "Send push notifications via Pushover",
|
||||||
icon: "/upload/notification/pushover.png"
|
icon: "/upload/notification/pushover.png"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
value: "notifiarr",
|
||||||
|
label: "Notifiarr",
|
||||||
|
description: "Send notifications via Notifiarr",
|
||||||
|
icon: "/upload/notification/notifiarr.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gotify",
|
||||||
|
label: "Gotify",
|
||||||
|
description: "Send push notifications via Gotify",
|
||||||
|
icon: "/upload/notification/gotify.png"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "webhook",
|
value: "webhook",
|
||||||
label: "Webhook",
|
label: "Webhook",
|
||||||
@@ -566,6 +596,7 @@ export const NotificationChannelDialog = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{notificationType === "ntfy" && (
|
{notificationType === "ntfy" && (
|
||||||
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="ntfy_endpoint"
|
name="ntfy_endpoint"
|
||||||
@@ -582,6 +613,23 @@ export const NotificationChannelDialog = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="api_token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Token (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter NTFY API token" {...field} type="password" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional API token for authentication with NTFY server
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{notificationType === "pushover" && (
|
{notificationType === "pushover" && (
|
||||||
@@ -621,6 +669,80 @@ export const NotificationChannelDialog = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{notificationType === "notifiarr" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="api_token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your Notifiarr API token" {...field} type="password" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Your Notifiarr API token for sending notifications
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="channel_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Channel ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Discord Channel ID" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The Discord channel ID where notifications will be sent
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notificationType === "gotify" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="api_token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your Gotify API token" {...field} type="password" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Your Gotify application API token
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="server_url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://your-gotify-server.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The URL of your Gotify server
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{notificationType === "webhook" && (
|
{notificationType === "webhook" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const NotificationChannelList = ({
|
|||||||
case "signal": return "Signal";
|
case "signal": return "Signal";
|
||||||
case "google_chat": return "Google Chat";
|
case "google_chat": return "Google Chat";
|
||||||
case "email": return "Email";
|
case "email": return "Email";
|
||||||
|
case "pushover": return "Pushover";
|
||||||
|
case "notifiarr": return "Notifiarr";
|
||||||
case "webhook": return "Webhook";
|
case "webhook": return "Webhook";
|
||||||
default: return type || "Unknown";
|
default: return type || "Unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export const SSLCertificatesTable = () => {
|
|||||||
warning_threshold: updatedCertificate.warning_threshold,
|
warning_threshold: updatedCertificate.warning_threshold,
|
||||||
expiry_threshold: updatedCertificate.expiry_threshold,
|
expiry_threshold: updatedCertificate.expiry_threshold,
|
||||||
notification_channel: updatedCertificate.notification_channel,
|
notification_channel: updatedCertificate.notification_channel,
|
||||||
|
notification_id: updatedCertificate.notification_id,
|
||||||
|
template_id: updatedCertificate.template_id,
|
||||||
check_interval: updatedCertificate.check_interval,
|
check_interval: updatedCertificate.check_interval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,24 @@ import React, { useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, RefreshCw } from "lucide-react";
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { SSLCertificateStatusCards } from "./SSLCertificateStatusCards";
|
import { SSLCertificateStatusCards } from "./SSLCertificateStatusCards";
|
||||||
import { SSLCertificatesTable } from "./SSLCertificatesTable";
|
import { SSLCertificatesTable } from "./SSLCertificatesTable";
|
||||||
import { LoadingState } from "@/components/services/LoadingState";
|
import { LoadingState } from "@/components/services/LoadingState";
|
||||||
import { fetchSSLCertificates, addSSLCertificate, checkAndUpdateCertificate, refreshAllCertificates, deleteSSLCertificate } from "@/services/ssl";
|
import {
|
||||||
|
fetchSSLCertificates,
|
||||||
|
addSSLCertificate,
|
||||||
|
checkAndUpdateCertificate,
|
||||||
|
refreshAllCertificates,
|
||||||
|
deleteSSLCertificate,
|
||||||
|
} from "@/services/ssl";
|
||||||
import { AddSSLCertificateForm } from "./AddSSLCertificateForm";
|
import { AddSSLCertificateForm } from "./AddSSLCertificateForm";
|
||||||
import { EditSSLCertificateForm } from "./EditSSLCertificateForm";
|
import { EditSSLCertificateForm } from "./EditSSLCertificateForm";
|
||||||
import type { AddSSLCertificateDto, SSLCertificate } from "@/types/ssl.types";
|
import type { AddSSLCertificateDto, SSLCertificate } from "@/types/ssl.types";
|
||||||
@@ -21,18 +32,23 @@ export const SSLDomainContent = () => {
|
|||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||||
const [isRefreshingAll, setIsRefreshingAll] = useState(false);
|
const [isRefreshingAll, setIsRefreshingAll] = useState(false);
|
||||||
const [selectedCertificate, setSelectedCertificate] = useState<SSLCertificate | null>(null);
|
const [selectedCertificate, setSelectedCertificate] =
|
||||||
|
useState<SSLCertificate | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch SSL certificates with explicit error handling
|
// Fetch SSL certificates with explicit error handling
|
||||||
const { data: certificates = [], isLoading, error } = useQuery({
|
const {
|
||||||
queryKey: ['ssl-certificates'],
|
data: certificates = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["ssl-certificates"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await fetchSSLCertificates();
|
const result = await fetchSSLCertificates();
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('failedToLoadCertificates'));
|
toast.error(t("failedToLoadCertificates"));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,32 +60,34 @@ export const SSLDomainContent = () => {
|
|||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: addSSLCertificate,
|
mutationFn: addSSLCertificate,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
toast.success(t('sslCertificateAdded'));
|
toast.success(t("sslCertificateAdded"));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
|
toast.error(
|
||||||
}
|
error instanceof Error ? error.message : t("failedToAddCertificate")
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit certificate mutation - Updated to include notification_id and template_id
|
// Edit certificate mutation - Updated to include notification_id and template_id
|
||||||
const editMutation = useMutation({
|
const editMutation = useMutation({
|
||||||
mutationFn: async (certificate: SSLCertificate) => {
|
mutationFn: async (certificate: SSLCertificate) => {
|
||||||
|
|
||||||
// Create the update data object with new fields
|
// Create the update data object with new fields
|
||||||
const updateData = {
|
const updateData = {
|
||||||
warning_threshold: Number(certificate.warning_threshold),
|
warning_threshold: Number(certificate.warning_threshold),
|
||||||
expiry_threshold: Number(certificate.expiry_threshold),
|
expiry_threshold: Number(certificate.expiry_threshold),
|
||||||
notification_channel: certificate.notification_channel,
|
notification_channel: certificate.notification_channel,
|
||||||
notification_id: certificate.notification_id || '', // Multi notification channels
|
notification_id: certificate.notification_id || "", // Multi notification channels
|
||||||
template_id: certificate.template_id || '', // Alert template ID
|
template_id: certificate.template_id || "", // Alert template ID
|
||||||
check_interval: certificate.check_interval,
|
check_interval: certificate.check_interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Update certificate in the database using PocketBase directly
|
// Update certificate in the database using PocketBase directly
|
||||||
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
|
const updated = await pb
|
||||||
|
.collection("ssl_certificates")
|
||||||
|
.update(certificate.id, updateData);
|
||||||
|
|
||||||
// After updating the settings, refresh the certificate to ensure it's up to date
|
// After updating the settings, refresh the certificate to ensure it's up to date
|
||||||
// This will also check if notification needs to be sent based on updated thresholds
|
// This will also check if notification needs to be sent based on updated thresholds
|
||||||
@@ -78,33 +96,37 @@ export const SSLDomainContent = () => {
|
|||||||
return refreshedCert;
|
return refreshedCert;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
setSelectedCertificate(null);
|
setSelectedCertificate(null);
|
||||||
toast.success(t('sslCertificateUpdated'));
|
toast.success(t("sslCertificateUpdated"));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
|
toast.error(
|
||||||
}
|
error instanceof Error ? error.message : t("failedToUpdateCertificate")
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete certificate mutation
|
// Delete certificate mutation
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteSSLCertificate,
|
mutationFn: deleteSSLCertificate,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
toast.success(t('sslCertificateDeleted'));
|
toast.success(t("sslCertificateDeleted"));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
|
toast.error(
|
||||||
}
|
error instanceof Error ? error.message : t("failedToDeleteCertificate")
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh certificate mutation - Updated to remove individual toast notifications
|
// Refresh certificate mutation - Updated to remove individual toast notifications
|
||||||
const refreshMutation = useMutation({
|
const refreshMutation = useMutation({
|
||||||
mutationFn: checkAndUpdateCertificate,
|
mutationFn: checkAndUpdateCertificate,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
setRefreshingId(null);
|
setRefreshingId(null);
|
||||||
// Removed individual success toast notification
|
// Removed individual success toast notification
|
||||||
},
|
},
|
||||||
@@ -112,33 +134,40 @@ export const SSLDomainContent = () => {
|
|||||||
setRefreshingId(null);
|
setRefreshingId(null);
|
||||||
|
|
||||||
// Still refresh the data to show any partial information that was saved
|
// Still refresh the data to show any partial information that was saved
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
// Removed individual error toast notification
|
// Removed individual error toast notification
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh all certificates mutation
|
// Refresh all certificates mutation
|
||||||
const refreshAllMutation = useMutation({
|
const refreshAllMutation = useMutation({
|
||||||
mutationFn: refreshAllCertificates,
|
mutationFn: refreshAllCertificates,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
setIsRefreshingAll(false);
|
setIsRefreshingAll(false);
|
||||||
|
|
||||||
if (result.failed === 0) {
|
if (result.failed === 0) {
|
||||||
toast.success(t('allCertificatesRefreshed').replace('{count}', result.success.toString()));
|
toast.success(
|
||||||
|
t("allCertificatesRefreshed").replace(
|
||||||
|
"{count}",
|
||||||
|
result.success.toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.info(t('someCertificatesFailed')
|
toast.info(
|
||||||
.replace('{success}', result.success.toString())
|
t("someCertificatesFailed")
|
||||||
.replace('{failed}', result.failed.toString()));
|
.replace("{success}", result.success.toString())
|
||||||
|
.replace("{failed}", result.failed.toString())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(t('failedToCheckCertificate'));
|
toast.error(t("failedToCheckCertificate"));
|
||||||
setIsRefreshingAll(false);
|
setIsRefreshingAll(false);
|
||||||
|
|
||||||
// Still refresh the data to show any partial information
|
// Still refresh the data to show any partial information
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] });
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddCertificate = async (data: AddSSLCertificateDto) => {
|
const handleAddCertificate = async (data: AddSSLCertificateDto) => {
|
||||||
@@ -166,12 +195,14 @@ export const SSLDomainContent = () => {
|
|||||||
|
|
||||||
const handleRefreshAll = async () => {
|
const handleRefreshAll = async () => {
|
||||||
if (certificates.length === 0) {
|
if (certificates.length === 0) {
|
||||||
toast.info(t('noCertificatesToRefresh'));
|
toast.info(t("noCertificatesToRefresh"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRefreshingAll(true);
|
setIsRefreshingAll(true);
|
||||||
toast.info(t('startingRefreshAll').replace('{count}', certificates.length.toString()));
|
toast.info(
|
||||||
|
t("startingRefreshAll").replace("{count}", certificates.length.toString())
|
||||||
|
);
|
||||||
refreshAllMutation.mutate();
|
refreshAllMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,21 +213,29 @@ export const SSLDomainContent = () => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
||||||
<p>{t('failedToLoadCertificates')}</p>
|
<p>{t("failedToLoadCertificates")}</p>
|
||||||
<Button onClick={() => queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] })}>
|
<Button
|
||||||
{t('check')}
|
onClick={() =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("check")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
|
<main className="flex-1 flex flex-col overflow-auto bg-background pb-0 dark:bg-black p-2 ">
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 bg-white dark:bg-black p-6 rounded-xl">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-foreground">{t('sslDomainManagement')}</h2>
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
<p className="text-sm text-muted-foreground mt-1">{t('monitorSSLCertificates')}</p>
|
{t("sslDomainManagement")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t("monitorSSLCertificates")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -205,8 +244,12 @@ export const SSLDomainContent = () => {
|
|||||||
disabled={isRefreshingAll || refreshingId !== null}
|
disabled={isRefreshingAll || refreshingId !== null}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshingAll ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
{t('refreshAll')}
|
className={`w-4 h-4 mr-2 ${
|
||||||
|
isRefreshingAll ? "animate-spin" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t("refreshAll")}
|
||||||
{isRefreshingAll && (
|
{isRefreshingAll && (
|
||||||
<span className="absolute top-0 right-0 -mt-2 -mr-2 bg-primary text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
|
<span className="absolute top-0 right-0 -mt-2 -mr-2 bg-primary text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
|
||||||
...
|
...
|
||||||
@@ -217,7 +260,7 @@ export const SSLDomainContent = () => {
|
|||||||
className="text-primary-foreground"
|
className="text-primary-foreground"
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> {t('addDomain')}
|
<Plus className="w-4 h-4 mr-2" /> {t("addDomain")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +275,7 @@ export const SSLDomainContent = () => {
|
|||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('addSSLCertificate')}</DialogTitle>
|
<DialogTitle>{t("addSSLCertificate")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<AddSSLCertificateForm
|
<AddSSLCertificateForm
|
||||||
onSubmit={handleAddCertificate}
|
onSubmit={handleAddCertificate}
|
||||||
@@ -243,13 +286,16 @@ export const SSLDomainContent = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{selectedCertificate && (
|
{selectedCertificate && (
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={(open) => {
|
<Dialog
|
||||||
|
open={isEditDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
setIsEditDialogOpen(open);
|
setIsEditDialogOpen(open);
|
||||||
if (!open) setSelectedCertificate(null);
|
if (!open) setSelectedCertificate(null);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('editSSLCertificate')}</DialogTitle>
|
<DialogTitle>{t("editSSLCertificate")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<EditSSLCertificateForm
|
<EditSSLCertificateForm
|
||||||
certificate={selectedCertificate}
|
certificate={selectedCertificate}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// src/contexts/FontSizeContext.js
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const useDynamicFontSize = (size) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof size === "number") {
|
||||||
|
// Apply font size to the root HTML element
|
||||||
|
document.documentElement.style.fontSize = `${size}px`;
|
||||||
|
localStorage.setItem("fontSize", size.toString());
|
||||||
|
}
|
||||||
|
}, [size]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDynamicFontSize;
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
|
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
|
||||||
import { translations, Language, TranslationModule, TranslationKey } from "@/translations";
|
import { translations, Language, TranslationModule, TranslationKey } from "@/translations";
|
||||||
|
|
||||||
|
type TranslationVars = Record<string, string | number>;
|
||||||
|
|
||||||
type LanguageContextType = {
|
type LanguageContextType = {
|
||||||
language: Language;
|
language: Language;
|
||||||
setLanguage: (language: Language) => void;
|
setLanguage: (language: Language) => void;
|
||||||
t: <M extends TranslationModule>(key: string, module?: M) => string;
|
t: {
|
||||||
|
(key: string): string;
|
||||||
|
(key: string, vars: TranslationVars): string;
|
||||||
|
<M extends TranslationModule>(key: string, module: M): string;
|
||||||
|
<M extends TranslationModule>(key: string, module: M, vars: TranslationVars): string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ❗ Create the context with `undefined` to enforce provider usage
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
// ✅ Stable custom hook
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useLanguage = () => {
|
export const useLanguage = () => {
|
||||||
const context = useContext(LanguageContext);
|
const context = useContext(LanguageContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -23,33 +29,76 @@ export const LanguageProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [language, setLanguage] = useState<Language>("en");
|
const [language, setLanguage] = useState<Language>("en");
|
||||||
const fallbackLanguage: Language = "en";
|
const fallbackLanguage: Language = "en";
|
||||||
|
|
||||||
const t = useCallback(<M extends TranslationModule>(key: string, module?: M): string => {
|
const interpolate = (template: string, vars: TranslationVars): string => {
|
||||||
|
return template.replace(/{(\w+)}/g, (match, key) => {
|
||||||
|
return vars[key] !== undefined ? String(vars[key]) : match;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = useCallback(((
|
||||||
|
key: string,
|
||||||
|
moduleOrVars?: TranslationModule | TranslationVars,
|
||||||
|
vars?: TranslationVars
|
||||||
|
): string => {
|
||||||
|
let module: TranslationModule | undefined;
|
||||||
|
let variables: TranslationVars | undefined;
|
||||||
|
|
||||||
|
if (typeof moduleOrVars === "string") {
|
||||||
|
module = moduleOrVars;
|
||||||
|
variables = vars;
|
||||||
|
} else if (typeof moduleOrVars === "object") {
|
||||||
|
variables = moduleOrVars;
|
||||||
|
}
|
||||||
|
|
||||||
const langPack = translations[language];
|
const langPack = translations[language];
|
||||||
const fallbackPack = translations[fallbackLanguage];
|
const fallbackPack = translations[fallbackLanguage];
|
||||||
|
let translatedText = "";
|
||||||
|
|
||||||
if (module) {
|
if (module) {
|
||||||
const valCur = langPack?.[module]?.[key as TranslationKey<M>];
|
const moduleKey = key as TranslationKey<typeof module>;
|
||||||
if (typeof valCur === "string") return valCur;
|
const valCur = langPack?.[module]?.[moduleKey];
|
||||||
|
if (typeof valCur === "string") {
|
||||||
|
translatedText = valCur;
|
||||||
|
} else {
|
||||||
|
const valEn = fallbackPack?.[module]?.[moduleKey];
|
||||||
|
translatedText = typeof valEn === "string" ? valEn : key;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let found = false;
|
||||||
|
|
||||||
const valEn = fallbackPack?.[module]?.[key as TranslationKey<M>];
|
for (const mod of Object.keys(langPack) as TranslationModule[]) {
|
||||||
if (typeof valEn === "string") return valEn;
|
const moduleTranslations = langPack[mod];
|
||||||
|
if (moduleTranslations && key in moduleTranslations) {
|
||||||
return key;
|
const val = moduleTranslations[key as keyof typeof moduleTranslations];
|
||||||
|
if (typeof val === "string") {
|
||||||
|
translatedText = val;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mod in langPack) {
|
if (!found) {
|
||||||
const m = mod as TranslationModule;
|
for (const mod of Object.keys(fallbackPack) as TranslationModule[]) {
|
||||||
const val = langPack[m]?.[key as any];
|
const moduleTranslations = fallbackPack[mod];
|
||||||
if (typeof val === "string") return val;
|
if (moduleTranslations && key in moduleTranslations) {
|
||||||
|
const val = moduleTranslations[key as keyof typeof moduleTranslations];
|
||||||
|
if (typeof val === "string") {
|
||||||
|
translatedText = val;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mod in fallbackPack) {
|
if (!found) {
|
||||||
const m = mod as TranslationModule;
|
translatedText = key;
|
||||||
const val = fallbackPack[m]?.[key as any];
|
}
|
||||||
if (typeof val === "string") return val;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return key;
|
return variables ? interpolate(translatedText, variables) : translatedText;
|
||||||
}, [language]);
|
}) as LanguageContextType['t'], [language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||||
|
|||||||
@@ -1,38 +1,48 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
import React, { createContext, useContext, useState } from 'react';
|
type SideBar = boolean;
|
||||||
|
|
||||||
interface SidebarContextType {
|
interface SidebarContextType {
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: SideBar;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: React.Dispatch<React.SetStateAction<SideBar>>;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||||
|
interface SidebarProviderProps {
|
||||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
children: React.ReactNode;
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
}
|
||||||
|
export const SidebarProvider: React.FC<SidebarProviderProps> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
// Initialize the state
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState<SideBar>(() => {
|
||||||
|
const saved = localStorage.getItem("sidebarCollapsed");
|
||||||
|
return saved === "true";
|
||||||
|
});
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarCollapsed(prev => !prev);
|
setSidebarCollapsed((prev) => {
|
||||||
|
const newState = !prev;
|
||||||
|
localStorage.setItem("sidebarCollapsed", newState.toString());
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
toggleSidebar
|
toggleSidebar,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider value={value}>
|
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
|
||||||
{children}
|
|
||||||
</SidebarContext.Provider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A custom hook to consume the sidebar context.
|
||||||
export const useSidebar = () => {
|
export const useSidebar = () => {
|
||||||
const context = useContext(SidebarContext);
|
const context = useContext(SidebarContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useSidebar must be used within a SidebarProvider');
|
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -26,16 +25,21 @@ const InstanceMonitoring = () => {
|
|||||||
total: 0,
|
total: 0,
|
||||||
online: 0,
|
online: 0,
|
||||||
offline: 0,
|
offline: 0,
|
||||||
warning: 0
|
warning: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: servers = [], isLoading, error, refetch } = useQuery({
|
const {
|
||||||
queryKey: ['servers'],
|
data: servers = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["servers"],
|
||||||
queryFn: serverService.getServers,
|
queryFn: serverService.getServers,
|
||||||
refetchInterval: 30000 // Refetch every 30 seconds
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,7 +54,7 @@ const InstanceMonitoring = () => {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authService.logout();
|
authService.logout();
|
||||||
navigate('/login');
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAgentAdded = () => {
|
const handleAgentAdded = () => {
|
||||||
@@ -68,11 +72,14 @@ const InstanceMonitoring = () => {
|
|||||||
sidebarCollapsed={sidebarCollapsed}
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 flex flex-col items-center justify-center p-4 sm:p-6">
|
<main className="flex-1 flex flex-col items-center justify-center p-4 sm:p-6 ">
|
||||||
<div className="text-center max-w-md w-full">
|
<div className="text-center max-w-md w-full">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold mb-4">Error loading servers</h2>
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">
|
||||||
|
Error loading servers
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
|
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
|
||||||
Unable to fetch server data. Please check your connection and try again.
|
Unable to fetch server data. Please check your connection and
|
||||||
|
try again.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
@@ -88,20 +95,20 @@ const InstanceMonitoring = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground ">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 ">
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto dark:bg-black p-2 shadow">
|
||||||
<div className="p-4 sm:p-6 lg:p-8 space-y-6">
|
<div className="p-4 sm:p-2 lg:p-2 space-y-6 h-full bg-white dark:bg-black p-4 rounded-xl">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-2 ">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl lg:text-2xl font-bold text-foreground">
|
<h1 className="text-2xl lg:text-2xl font-bold text-foreground">
|
||||||
Instance Monitoring
|
Instance Monitoring
|
||||||
@@ -110,7 +117,10 @@ const InstanceMonitoring = () => {
|
|||||||
Monitor and manage your server instances in real-time
|
Monitor and manage your server instances in real-time
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setAddDialogOpen(true)} className="flex-shrink-0">
|
<Button
|
||||||
|
onClick={() => setAddDialogOpen(true)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Server Agent
|
Add Server Agent
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,13 +128,17 @@ const InstanceMonitoring = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards Section */}
|
{/* Stats Cards Section */}
|
||||||
<div>
|
<div className="p-2 ">
|
||||||
<ServerStatsCards stats={stats} />
|
<ServerStatsCards stats={stats} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Server Table Section */}
|
{/* Server Table Section */}
|
||||||
<div>
|
<div className="p-3">
|
||||||
<ServerTable servers={servers} isLoading={isLoading} onRefresh={handleRefresh} />
|
<ServerTable
|
||||||
|
servers={servers}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import { User } from "@/services/userService";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useSidebar } from "@/contexts/SidebarContext";
|
import { useSidebar } from "@/contexts/SidebarContext";
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
// Use shared sidebar state
|
// Use shared sidebar state
|
||||||
const { sidebarCollapsed, toggleSidebar } = useSidebar();
|
const { sidebarCollapsed, toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
@@ -84,12 +87,12 @@ const Profile = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p>Please login to view your profile</p>
|
<p>{t("loginToViewProfile")}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/login")}
|
||||||
className="mt-4 px-4 py-2 bg-primary text-white rounded"
|
className="mt-4 px-4 py-2 bg-primary text-white rounded"
|
||||||
>
|
>
|
||||||
Go to Login
|
{t("goToLogin")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +113,7 @@ const Profile = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
<span className="ml-2">Loading user data...</span>
|
<span className="ml-2">{t("loadingUserData")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
@@ -121,7 +124,7 @@ const Profile = () => {
|
|||||||
onClick={() => fetchUserData()}
|
onClick={() => fetchUserData()}
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
|
||||||
>
|
>
|
||||||
Retry
|
{t("retry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
@@ -7,7 +6,11 @@ import { Header } from "@/components/dashboard/Header";
|
|||||||
import { Sidebar } from "@/components/dashboard/Sidebar";
|
import { Sidebar } from "@/components/dashboard/Sidebar";
|
||||||
import { SSLDomainContent } from "@/components/ssl-domain/SSLDomainContent";
|
import { SSLDomainContent } from "@/components/ssl-domain/SSLDomainContent";
|
||||||
import { LoadingState } from "@/components/services/LoadingState";
|
import { LoadingState } from "@/components/services/LoadingState";
|
||||||
import { fetchSSLCertificates, shouldRunDailyCheck, checkAllCertificatesAndNotify } from "@/services/sslCertificateService";
|
import {
|
||||||
|
fetchSSLCertificates,
|
||||||
|
shouldRunDailyCheck,
|
||||||
|
checkAllCertificatesAndNotify,
|
||||||
|
} from "@/services/sslCertificateService";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { useSidebar } from "@/contexts/SidebarContext";
|
import { useSidebar } from "@/contexts/SidebarContext";
|
||||||
|
|
||||||
@@ -23,8 +26,13 @@ const SslDomain = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Fetch SSL certificates with error handling and debugging
|
// Fetch SSL certificates with error handling and debugging
|
||||||
const { data: certificates = [], isLoading, error, refetch } = useQuery({
|
const {
|
||||||
queryKey: ['ssl-certificates'],
|
data: certificates = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["ssl-certificates"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// console.log("Fetching SSL certificates from SslDomain page...");
|
// console.log("Fetching SSL certificates from SslDomain page...");
|
||||||
try {
|
try {
|
||||||
@@ -86,23 +94,25 @@ const SslDomain = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 ">
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center justify-center h-full p-6">
|
<div className="flex flex-col items-center justify-center h-full p-6 ">
|
||||||
<h2 className="text-xl font-bold mb-2">{t('failedToLoadCertificates')}</h2>
|
<h2 className="text-xl font-bold mb-2">
|
||||||
|
{t("failedToLoadCertificates")}
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{error instanceof Error ? error.message : t('unknown')}
|
{error instanceof Error ? error.message : t("unknown")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
>
|
>
|
||||||
{t('check')}
|
{t("check")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +124,7 @@ const SslDomain = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 ">
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface AlertConfiguration {
|
|||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
collectionName?: string;
|
collectionName?: string;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "webhook";
|
notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "notifiarr" | "gotify" | "webhook";
|
||||||
telegram_chat_id?: string;
|
telegram_chat_id?: string;
|
||||||
discord_webhook_url?: string;
|
discord_webhook_url?: string;
|
||||||
signal_number?: string;
|
signal_number?: string;
|
||||||
@@ -31,6 +31,7 @@ export interface AlertConfiguration {
|
|||||||
ntfy_endpoint?: string;
|
ntfy_endpoint?: string;
|
||||||
api_token?: string;
|
api_token?: string;
|
||||||
user_key?: string;
|
user_key?: string;
|
||||||
|
server_url?: string;
|
||||||
webhook_url?: string;
|
webhook_url?: string;
|
||||||
webhook_payload_template?: string;
|
webhook_payload_template?: string;
|
||||||
}
|
}
|
||||||
@@ -87,11 +88,20 @@ export const alertConfigService = {
|
|||||||
|
|
||||||
} else if (config.notification_type === "ntfy") {
|
} else if (config.notification_type === "ntfy") {
|
||||||
cleanConfig.ntfy_endpoint = config.ntfy_endpoint || "";
|
cleanConfig.ntfy_endpoint = config.ntfy_endpoint || "";
|
||||||
|
cleanConfig.api_token = config.api_token || "";
|
||||||
|
|
||||||
} else if (config.notification_type === "pushover") {
|
} else if (config.notification_type === "pushover") {
|
||||||
cleanConfig.api_token = config.api_token || "";
|
cleanConfig.api_token = config.api_token || "";
|
||||||
cleanConfig.user_key = config.user_key || "";
|
cleanConfig.user_key = config.user_key || "";
|
||||||
|
|
||||||
|
} else if (config.notification_type === "notifiarr") {
|
||||||
|
cleanConfig.api_token = config.api_token || "";
|
||||||
|
cleanConfig.channel_id = config.channel_id || "";
|
||||||
|
|
||||||
|
} else if (config.notification_type === "gotify") {
|
||||||
|
cleanConfig.api_token = config.api_token || "";
|
||||||
|
cleanConfig.server_url = config.server_url || "";
|
||||||
|
|
||||||
} else if (config.notification_type === "webhook") {
|
} else if (config.notification_type === "webhook") {
|
||||||
cleanConfig.webhook_url = config.webhook_url || "";
|
cleanConfig.webhook_url = config.webhook_url || "";
|
||||||
cleanConfig.webhook_payload_template = config.webhook_payload_template || "";
|
cleanConfig.webhook_payload_template = config.webhook_payload_template || "";
|
||||||
|
|||||||
@@ -16,4 +16,19 @@ export const aboutTranslations: AboutTranslations = {
|
|||||||
quickActionsDescription: "Access common monitoring operations and features quickly. Select an action below to get started.",
|
quickActionsDescription: "Access common monitoring operations and features quickly. Select an action below to get started.",
|
||||||
quickTips: "Quick Tips",
|
quickTips: "Quick Tips",
|
||||||
releasedOn: "Released On",
|
releasedOn: "Released On",
|
||||||
|
updateSchema: "Update Schema",
|
||||||
|
updateSchemaDesc: "Automatic import collections schema",
|
||||||
|
mergeFieldsLabel: "Merge fields with existing collections (safe - preserves data)",
|
||||||
|
importing: "Importing...",
|
||||||
|
clickToUpdateSchema: "Click to update Schema",
|
||||||
|
importSuccessful: "Import Successful",
|
||||||
|
importFailed: "Import Failed",
|
||||||
|
instructions: "Instructions",
|
||||||
|
mergeFields: "Merge fields",
|
||||||
|
instructionsMergeFields: "Safely add new fields to existing collections, preserves all data",
|
||||||
|
instructionsCollections: "System collections (starting with _) and users collection will be skipped automatically",
|
||||||
|
instructionsImportAuth: "Only authenticated admins can perform schema imports",
|
||||||
|
collectionsUpdatedCount: "{count} collections updated",
|
||||||
|
collectionsCreatedCount: "{count} collections created",
|
||||||
|
collectionsSkippedCount: "{count} collections created",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,4 +61,5 @@ export const incidentTranslations: IncidentTranslations = {
|
|||||||
affectedSystems: 'Affected Systems',
|
affectedSystems: 'Affected Systems',
|
||||||
enterAffectedSystems: 'Enter Affected Systems',
|
enterAffectedSystems: 'Enter Affected Systems',
|
||||||
separateSystemsWithComma: 'Enter affected systems seperated by comma',
|
separateSystemsWithComma: 'Enter affected systems seperated by comma',
|
||||||
|
createIncidentDesc: 'Create a new incident',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,4 +78,12 @@ export const maintenanceTranslations: MaintenanceTranslations = {
|
|||||||
selectNotificationChannel: 'Add a notification channel',
|
selectNotificationChannel: 'Add a notification channel',
|
||||||
enableNotificationsFirst: 'Enable notifications first to select channel',
|
enableNotificationsFirst: 'Enable notifications first to select channel',
|
||||||
updateMaintenance: 'Update Maintenance',
|
updateMaintenance: 'Update Maintenance',
|
||||||
|
loginToViewProfile: 'Please login to view your profile',
|
||||||
|
goToLogin: 'Go to Login',
|
||||||
|
loadingUserData: 'Loading user data...',
|
||||||
|
retry: 'Retry',
|
||||||
|
loadingServerData: 'Loading server data...',
|
||||||
|
retrievingYourInformation: 'Please wait while we retrieve your information...',
|
||||||
|
servicesPagination: '{startItem}-{endItem} of {totalItems} services',
|
||||||
|
servicesPaginationNoService: '0 services',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ServicesTranslations } from '../types/services';
|
|||||||
|
|
||||||
export const servicesTranslations: ServicesTranslations = {
|
export const servicesTranslations: ServicesTranslations = {
|
||||||
serviceStatus: "Service Status",
|
serviceStatus: "Service Status",
|
||||||
responseTime: "Response Time",
|
|
||||||
uptime: "Uptime",
|
uptime: "Uptime",
|
||||||
lastChecked: "Last Checked",
|
lastChecked: "Last Checked",
|
||||||
noServices: "No services match your filter criteria.",
|
noServices: "No services match your filter criteria.",
|
||||||
@@ -95,4 +94,46 @@ export const servicesTranslations: ServicesTranslations = {
|
|||||||
downServices: "DOWN SERVICES",
|
downServices: "DOWN SERVICES",
|
||||||
pausedServices: "PAUSED SERVICES",
|
pausedServices: "PAUSED SERVICES",
|
||||||
warningServices: "WARNING SERVICES",
|
warningServices: "WARNING SERVICES",
|
||||||
|
|
||||||
|
// ServiceRowActions.tsx
|
||||||
|
viewDetail: "View Detail",
|
||||||
|
resumeMonitoring: "Resume Monitoring",
|
||||||
|
pauseMonitoring: "Pause Monitoring",
|
||||||
|
|
||||||
|
|
||||||
|
//IncidentTable.tsx
|
||||||
|
responseTime: "Response Time",
|
||||||
|
errorMessage: "Error Message",
|
||||||
|
details: "Details",
|
||||||
|
unmuteAlerts: "Unmute Alerts",
|
||||||
|
muteAlerts: "Mute Alerts",
|
||||||
|
|
||||||
|
//LastCheckedTime.tsx
|
||||||
|
pausedAt: "Paused at ",
|
||||||
|
lastCheckDetails: "Last Check Details",
|
||||||
|
checkedAt: "Checked at ",
|
||||||
|
monitoringPaused: "Monitoring Paused",
|
||||||
|
noAutomaticChecks: "No automatic checks",
|
||||||
|
|
||||||
|
//ServiveEditDialog.tsx
|
||||||
|
editService: "Edit Service",
|
||||||
|
editServiceDesc: "Update the details of your monitored service.",
|
||||||
|
|
||||||
|
//ServiceStatsCards.tsx
|
||||||
|
lastCheckedAt: "Last checked at {datetime}",
|
||||||
|
avg: "Avg",
|
||||||
|
lastUpChecksCount: "last {count} up checks",
|
||||||
|
basedOnlastChecksCount: "Based on last {count} checks",
|
||||||
|
totalUptime: "Total Uptime",
|
||||||
|
totalDowntime: "Total Downtime",
|
||||||
|
monitoringSettings: "Monitoring Settings",
|
||||||
|
monitoringSettingsInterval: "Checked every {interval} seconds",
|
||||||
|
monitoringSettingsType: "monitoring",
|
||||||
|
upStatusDuration: "Up for {duration}",
|
||||||
|
downStatusDuration: "Down for {duration}",
|
||||||
|
|
||||||
|
incidentHistory: "Incident History",
|
||||||
|
processing: "Processing",
|
||||||
|
back: "Back",
|
||||||
|
last20Checks: "Last 20 checks",
|
||||||
};
|
};
|
||||||
@@ -37,7 +37,11 @@ export const settingsTranslations: SettingsTranslations = {
|
|||||||
selectCollection: "Select collection",
|
selectCollection: "Select collection",
|
||||||
toEmailAddress: "To email address",
|
toEmailAddress: "To email address",
|
||||||
enterEmailAddress: "Enter email address",
|
enterEmailAddress: "Enter email address",
|
||||||
|
send: "Send",
|
||||||
sending: "Sending...",
|
sending: "Sending...",
|
||||||
|
testEmailSettings: "Test Email Settings",
|
||||||
|
testEmailDescription: "Test whether the current Email Settings are available",
|
||||||
|
testEmailAlert: "This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.",
|
||||||
|
|
||||||
// General Settings - Actions and status
|
// General Settings - Actions and status
|
||||||
save: "Save Changes",
|
save: "Save Changes",
|
||||||
|
|||||||
@@ -14,4 +14,19 @@ export interface AboutTranslations {
|
|||||||
quickActionsDescription: string;
|
quickActionsDescription: string;
|
||||||
quickTips: string;
|
quickTips: string;
|
||||||
releasedOn: string;
|
releasedOn: string;
|
||||||
|
updateSchema: string;
|
||||||
|
updateSchemaDesc: string;
|
||||||
|
mergeFieldsLabel: string;
|
||||||
|
importing: string;
|
||||||
|
clickToUpdateSchema: string;
|
||||||
|
importSuccessful: string;
|
||||||
|
importFailed: string;
|
||||||
|
instructions: string;
|
||||||
|
mergeFields: string;
|
||||||
|
instructionsMergeFields: string;
|
||||||
|
instructionsCollections: string;
|
||||||
|
instructionsImportAuth: string;
|
||||||
|
collectionsUpdatedCount: string;
|
||||||
|
collectionsCreatedCount: string;
|
||||||
|
collectionsSkippedCount: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,4 +59,5 @@ export interface IncidentTranslations {
|
|||||||
affectedSystems: string;
|
affectedSystems: string;
|
||||||
enterAffectedSystems: string;
|
enterAffectedSystems: string;
|
||||||
separateSystemsWithComma: string;
|
separateSystemsWithComma: string;
|
||||||
|
createIncidentDesc: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,4 +76,12 @@ export interface MaintenanceTranslations {
|
|||||||
selectNotificationChannel: string;
|
selectNotificationChannel: string;
|
||||||
enableNotificationsFirst: string;
|
enableNotificationsFirst: string;
|
||||||
updateMaintenance: string;
|
updateMaintenance: string;
|
||||||
|
loginToViewProfile: string;
|
||||||
|
goToLogin: string;
|
||||||
|
loadingUserData: string;
|
||||||
|
retry: string;
|
||||||
|
loadingServerData: string;
|
||||||
|
retrievingYourInformation: string;
|
||||||
|
servicesPagination: string;
|
||||||
|
servicesPaginationNoService: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
export interface ServicesTranslations {
|
export interface ServicesTranslations {
|
||||||
|
|
||||||
serviceStatus: string;
|
serviceStatus: string;
|
||||||
responseTime: string;
|
|
||||||
uptime: string;
|
uptime: string;
|
||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
noServices: string;
|
noServices: string;
|
||||||
@@ -94,4 +93,45 @@ export interface ServicesTranslations {
|
|||||||
downServices: string;
|
downServices: string;
|
||||||
pausedServices: string;
|
pausedServices: string;
|
||||||
warningServices: string;
|
warningServices: string;
|
||||||
|
|
||||||
|
// ServiceRowActions.tsx
|
||||||
|
viewDetail: string;
|
||||||
|
resumeMonitoring: string;
|
||||||
|
pauseMonitoring: string;
|
||||||
|
unmuteAlerts: string;
|
||||||
|
muteAlerts: string;
|
||||||
|
|
||||||
|
//IncidentTable.tsx
|
||||||
|
responseTime: string;
|
||||||
|
errorMessage: string;
|
||||||
|
details: string;
|
||||||
|
|
||||||
|
//LastCheckedTime.tsx
|
||||||
|
pausedAt: string;
|
||||||
|
lastCheckDetails: string;
|
||||||
|
checkedAt: string;
|
||||||
|
monitoringPaused: string;
|
||||||
|
noAutomaticChecks: string;
|
||||||
|
|
||||||
|
//ServiveEditDialog.tsx
|
||||||
|
editService: string;
|
||||||
|
editServiceDesc: string;
|
||||||
|
|
||||||
|
//ServiceStatsCards.tsx
|
||||||
|
lastCheckedAt: string;
|
||||||
|
avg: string;
|
||||||
|
lastUpChecksCount: string;
|
||||||
|
basedOnlastChecksCount: string;
|
||||||
|
totalUptime: string;
|
||||||
|
totalDowntime: string;
|
||||||
|
monitoringSettings: string;
|
||||||
|
monitoringSettingsInterval: string;
|
||||||
|
monitoringSettingsType: string;
|
||||||
|
upStatusDuration: string;
|
||||||
|
downStatusDuration: string;
|
||||||
|
|
||||||
|
incidentHistory: string;
|
||||||
|
processing: string;
|
||||||
|
back: string;
|
||||||
|
last20Checks: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export interface SettingsTranslations {
|
|||||||
selectCollection: string;
|
selectCollection: string;
|
||||||
toEmailAddress: string;
|
toEmailAddress: string;
|
||||||
enterEmailAddress: string;
|
enterEmailAddress: string;
|
||||||
|
send: string;
|
||||||
sending: string;
|
sending: string;
|
||||||
|
testEmailSettings: string;
|
||||||
|
testEmailDescription: string;
|
||||||
|
testEmailAlert: string;
|
||||||
|
|
||||||
// General Settings - Actions and status
|
// General Settings - Actions and status
|
||||||
save: string;
|
save: string;
|
||||||
|
|||||||
@@ -16,4 +16,19 @@ export const aboutTranslations: AboutTranslations = {
|
|||||||
quickActionsDescription: "快速访问常用的监控操作和功能。选择下面的操作开始。",
|
quickActionsDescription: "快速访问常用的监控操作和功能。选择下面的操作开始。",
|
||||||
quickTips: "快速提示",
|
quickTips: "快速提示",
|
||||||
releasedOn: "发布于",
|
releasedOn: "发布于",
|
||||||
|
updateSchema: "更新架构",
|
||||||
|
updateSchemaDesc: "自动导入集合架构",
|
||||||
|
mergeFieldsLabel: "合并字段到现有集合(安全 - 保留数据)",
|
||||||
|
importing: "导入中...",
|
||||||
|
clickToUpdateSchema: "点击更新架构",
|
||||||
|
importSuccessful: "导入成功",
|
||||||
|
importFailed: "导入失败",
|
||||||
|
instructions: "说明",
|
||||||
|
mergeFields: "合并字段",
|
||||||
|
instructionsMergeFields: "安全地向现有集合添加新字段,保留所有数据",
|
||||||
|
instructionsCollections: "系统集合(以_开头的)和用户集合将被自动跳过",
|
||||||
|
instructionsImportAuth: "只有经过身份验证的管理员才能执行架构导入",
|
||||||
|
collectionsUpdatedCount: "已更新 {count} 个集合",
|
||||||
|
collectionsCreatedCount: "已创建 {count} 个集合",
|
||||||
|
collectionsSkippedCount: "已跳过 {count} 个集合",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,4 +53,13 @@ export const incidentTranslations: IncidentTranslations = {
|
|||||||
failedToUpdateStatus: '更新状态失败',
|
failedToUpdateStatus: '更新状态失败',
|
||||||
inProgress: '进行中',
|
inProgress: '进行中',
|
||||||
enterRootCause: '输入故障根源',
|
enterRootCause: '输入故障根源',
|
||||||
|
enterIncidentTitle: '输入事件标题',
|
||||||
|
enterIncidentDescription: '输入事件描述',
|
||||||
|
enterServiceId: '输入以逗号分隔的服务ID',
|
||||||
|
selectAssignedUser: '选择分配用户',
|
||||||
|
noAssignedUser: '该事件当前未分配给任何用户',
|
||||||
|
affectedSystems: '受影响系统',
|
||||||
|
enterAffectedSystems: '受影响系统',
|
||||||
|
separateSystemsWithComma: '输入受影响的系统,以逗号分隔',
|
||||||
|
createIncidentDesc: '创建一个新的事件',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,4 +65,25 @@ export const maintenanceTranslations: MaintenanceTranslations = {
|
|||||||
noScheduledMaintenance: '没有计划的维护',
|
noScheduledMaintenance: '没有计划的维护',
|
||||||
noMaintenanceWindows: '没有此时间段的维护窗口。通过点击“创建维护”按钮创建一个。',
|
noMaintenanceWindows: '没有此时间段的维护窗口。通过点击“创建维护”按钮创建一个。',
|
||||||
maintenanceCreatedSuccess: '维护窗口创建成功',
|
maintenanceCreatedSuccess: '维护窗口创建成功',
|
||||||
|
editMaintenanceWindow: '编辑维护',
|
||||||
|
editMaintenanceDesc: '编辑维护详情',
|
||||||
|
priorityDescription: '选择维护优先级',
|
||||||
|
statusDescription: '选择维护状态',
|
||||||
|
impactLevel: '影响级别',
|
||||||
|
impactLevelDescription: '选择影响级别',
|
||||||
|
selectAssignedUsers: '选择分配的用户',
|
||||||
|
assignedPersonnelDescription: '当前分配的用户',
|
||||||
|
mutedNotifications: '通知已禁用',
|
||||||
|
notificationsAreMuted: '此维护期间的通知当前已静音',
|
||||||
|
selectNotificationChannel: '添加通知渠道',
|
||||||
|
enableNotificationsFirst: '首先启用通知以选择频道',
|
||||||
|
updateMaintenance: '更新维护',
|
||||||
|
loginToViewProfile: '请登录查看您的个人资料',
|
||||||
|
goToLogin: '立即登录',
|
||||||
|
loadingUserData: '正在加载用户数据...',
|
||||||
|
retry: '重试',
|
||||||
|
loadingServerData: '正在加载服务器数据...',
|
||||||
|
retrievingYourInformation: '正在获取您的信息,请稍候...',
|
||||||
|
servicesPagination: '{startItem}-{endItem}, 共 {totalItems} 个服务',
|
||||||
|
servicesPaginationNoService: '0 服务',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ServicesTranslations } from '../types/services';
|
|||||||
|
|
||||||
export const servicesTranslations: ServicesTranslations = {
|
export const servicesTranslations: ServicesTranslations = {
|
||||||
serviceStatus: "服务状态",
|
serviceStatus: "服务状态",
|
||||||
responseTime: "响应时间",
|
|
||||||
uptime: "在线时间",
|
uptime: "在线时间",
|
||||||
lastChecked: "最后检查时间",
|
lastChecked: "最后检查时间",
|
||||||
noServices: "没有符合您筛选条件的服务。",
|
noServices: "没有符合您筛选条件的服务。",
|
||||||
@@ -95,4 +94,45 @@ export const servicesTranslations: ServicesTranslations = {
|
|||||||
downServices: "下线服务",
|
downServices: "下线服务",
|
||||||
pausedServices: "暂停服务",
|
pausedServices: "暂停服务",
|
||||||
warningServices: "告警服务",
|
warningServices: "告警服务",
|
||||||
|
|
||||||
|
// ServiceRowActions.tsx
|
||||||
|
viewDetail: "查看详情",
|
||||||
|
resumeMonitoring: "恢复监控",
|
||||||
|
pauseMonitoring: "暂停监控",
|
||||||
|
unmuteAlerts: "取消静音警报",
|
||||||
|
muteAlerts: "静音警报",
|
||||||
|
|
||||||
|
//IncidentTable.tsx
|
||||||
|
responseTime: "响应时间",
|
||||||
|
errorMessage: "错误信息",
|
||||||
|
details: "详情",
|
||||||
|
|
||||||
|
//LastCheckedTime.tsx
|
||||||
|
pausedAt: "暂停于 ",
|
||||||
|
lastCheckDetails: "最近检查详情",
|
||||||
|
checkedAt: "检查于 ",
|
||||||
|
monitoringPaused: "监控已暂停",
|
||||||
|
noAutomaticChecks: "无自动检查",
|
||||||
|
|
||||||
|
//ServiveEditDialog.tsx
|
||||||
|
editService: "编辑服务",
|
||||||
|
editServiceDesc: "更新您所监控服务的详细信息。",
|
||||||
|
|
||||||
|
//ServiceStatsCards.tsx
|
||||||
|
lastCheckedAt: "最新检查时间 {datetime}",
|
||||||
|
avg: "平均",
|
||||||
|
lastUpChecksCount: "最近 {count} 次检查",
|
||||||
|
basedOnlastChecksCount: "基于最近的 {count} 次检查",
|
||||||
|
totalUptime: "总在线时间",
|
||||||
|
totalDowntime: "总宕机时间",
|
||||||
|
monitoringSettings: "监控设置",
|
||||||
|
monitoringSettingsInterval: "每 {interval} 秒检查一次",
|
||||||
|
monitoringSettingsType: "监控",
|
||||||
|
upStatusDuration: "已在线 {duration}",
|
||||||
|
downStatusDuration: "已宕机 {duration}",
|
||||||
|
|
||||||
|
incidentHistory: "事件历史",
|
||||||
|
processing: "处理中",
|
||||||
|
back: "返回",
|
||||||
|
last20Checks: "最近 20 次检查",
|
||||||
};
|
};
|
||||||
@@ -37,7 +37,11 @@ export const settingsTranslations: SettingsTranslations = {
|
|||||||
selectCollection: "选择集合",
|
selectCollection: "选择集合",
|
||||||
toEmailAddress: "收件人邮箱地址",
|
toEmailAddress: "收件人邮箱地址",
|
||||||
enterEmailAddress: "输入收件人邮箱地址",
|
enterEmailAddress: "输入收件人邮箱地址",
|
||||||
|
send: "发送",
|
||||||
sending: "发送中...",
|
sending: "发送中...",
|
||||||
|
testEmailSettings: "测试邮箱配置",
|
||||||
|
testEmailDescription: "测试当前的邮箱设置是否可用",
|
||||||
|
testEmailAlert: "这将使用您配置的SMTP设置发送一封测试邮件。请确保SMTP已正确配置。",
|
||||||
|
|
||||||
// General Settings - Actions and status
|
// General Settings - Actions and status
|
||||||
save: "保存变更",
|
save: "保存变更",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ The roadmap is divided into the following stages:
|
|||||||
- [ ] ✅ Add ntfy push notifications
|
- [ ] ✅ Add ntfy push notifications
|
||||||
|
|
||||||
### 🚀 v1.6.0 – Feature Enhancements _(Target: Q3 2025)_
|
### 🚀 v1.6.0 – Feature Enhancements _(Target: Q3 2025)_
|
||||||
**Status:** 🔧 In Progress
|
**Status:** ✅ Completed
|
||||||
#### Tentative Features:
|
#### Tentative Features:
|
||||||
- [ ] ✅ Allow user to update the schema directly from the dashboard
|
- [ ] ✅ Allow user to update the schema directly from the dashboard
|
||||||
- [ ] ✅ Add Korean translations and update types. By @taking [#143](https://github.com/operacle/checkcle/pull/143)
|
- [ ] ✅ Add Korean translations and update types. By @taking [#143](https://github.com/operacle/checkcle/pull/143)
|
||||||
@@ -86,7 +86,17 @@ The roadmap is divided into the following stages:
|
|||||||
- [ ] ✅ sync German translations across incident, maintenance, services & settings. By @ghotso [#141](https://github.com/operacle/checkcle/pull/141)
|
- [ ] ✅ sync German translations across incident, maintenance, services & settings. By @ghotso [#141](https://github.com/operacle/checkcle/pull/141)
|
||||||
- [ ] ✅ fix(services-form): max retry attempts. By @ghotso [#147](https://github.com/operacle/checkcle/pull/147)
|
- [ ] ✅ fix(services-form): max retry attempts. By @ghotso [#147](https://github.com/operacle/checkcle/pull/147)
|
||||||
- [ ] ✅ Add Pushover notifications
|
- [ ] ✅ Add Pushover notifications
|
||||||
- [ ] 🔧 Enhance the Data retention feature
|
- [ ] ✅ fix(ssl): Ensure edit form saves notification_id and template_id in DB.
|
||||||
|
- [ ] ✅ Add Notifiarr notifications
|
||||||
|
- [ ] ✅ Add Gotify notifications
|
||||||
|
- [ ] ✅ Enhance the Data retention feature
|
||||||
|
|
||||||
|
|
||||||
|
### 🚀 v1.7.0 – Feature Enhancements _(Target: Q3 2025)_
|
||||||
|
**Status:** 🔧 In Progress
|
||||||
|
#### Tentative Features:
|
||||||
|
- [ ] 🔧 Fix for the response time dashboard filter (date/time) in the uptime detail page
|
||||||
|
- [ ] 🔧 Fix the server historical loading data
|
||||||
- [ ] 🔧 Improve docker agent for server monitoring
|
- [ ] 🔧 Improve docker agent for server monitoring
|
||||||
- [ ] 🔧 Improve the Server Agent on Unraid
|
- [ ] 🔧 Improve the Server Agent on Unraid
|
||||||
- [ ] 🔧 Server Windows Agent
|
- [ ] 🔧 Server Windows Agent
|
||||||
@@ -96,6 +106,7 @@ The roadmap is divided into the following stages:
|
|||||||
- [ ] 🔧 Server support with Multiple Network Interfaces
|
- [ ] 🔧 Server support with Multiple Network Interfaces
|
||||||
- [ ] 🔧 Improve the Operational status page
|
- [ ] 🔧 Improve the Operational status page
|
||||||
- [ ] 🔧 Server network bandwidth monitoring
|
- [ ] 🔧 Server network bandwidth monitoring
|
||||||
|
- [ ] 🔧 Grouping uptime services
|
||||||
- [ ] 🎯 Improve the Schedule and Incident for automation
|
- [ ] 🎯 Improve the Schedule and Incident for automation
|
||||||
- [ ] 🎯 Uptime Monitoring option for choose: HTTP/HTTPS. Add Option to Bypass SSL Verification in Uptime
|
- [ ] 🎯 Uptime Monitoring option for choose: HTTP/HTTPS. Add Option to Bypass SSL Verification in Uptime
|
||||||
- [ ] 🎯 Add 2FA support
|
- [ ] 🎯 Add 2FA support
|
||||||
@@ -106,7 +117,6 @@ The roadmap is divided into the following stages:
|
|||||||
|
|
||||||
These are community-suggested or experimental features under review:
|
These are community-suggested or experimental features under review:
|
||||||
|
|
||||||
- [ ] Grouping uptime services
|
|
||||||
- [ ] OIDC Connect | OAuth2 integration
|
- [ ] OIDC Connect | OAuth2 integration
|
||||||
- [ ] PWA support
|
- [ ] PWA support
|
||||||
- [ ] Server outbound and inbound traffic usage
|
- [ ] Server outbound and inbound traffic usage
|
||||||
@@ -125,12 +135,6 @@ To get involved:
|
|||||||
- Join us in shaping the roadmap via [Discussions](https://github.com/operacle/checkcle/discussions)
|
- Join us in shaping the roadmap via [Discussions](https://github.com/operacle/checkcle/discussions)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📅 Last Updated
|
|
||||||
_This roadmap was last updated on **July 26, 2025**._
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
With ❤️ from the CheckCle
|
With ❤️ from the CheckCle
|
||||||
[Website](https://checkcle.io) | [GitHub](https://github.com/operacle/checkcle)
|
[Website](https://checkcle.io) | [GitHub](https://github.com/operacle/checkcle)
|
||||||
|
|
||||||
|
|||||||
+20
-16
@@ -117,7 +117,7 @@ docker run -d \
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## 🌟 面向社区的 CheckCle
|
## 🌟 面向社区的 CheckCle
|
||||||
@@ -129,26 +129,30 @@ docker run -d \
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 赞助商
|
## 赞助商
|
||||||
🙏 我们非常感谢我们的赞助商。你们的贡献使我们能够维护基础设施(托管、域名)、运行测试,并继续开发有价值的功能。
|
我们将不再接受赞助形式。从今以后,仅接受以生态系统和社区合作形式提供的支持,例如云服务器、域名或托管资源等基础设施支持。
|
||||||
|
|
||||||
### 🥈 白银在线保障合作伙伴
|
如果您是科技公司且有意支持 CheckCle ,请直接通过邮箱 tolaleng@checkcle.io 联系作者。
|
||||||
|
|
||||||
<a href="https://github.com/michaelortnerit">
|
|
||||||
<img src="https://avatars.githubusercontent.com/u/135371107?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### 🧡 支持者
|
### 🧡 支持者
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<a href="https://github.com/samang-dauth">
|
<a href="https://github.com/gitbookio">
|
||||||
<img src="https://avatars.githubusercontent.com/u/4575656?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### 🤝 生态系统与社区合作伙伴
|
|
||||||
<a href="https://github.com/gitbookio">
|
|
||||||
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
|
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
|
||||||
width="75" height="75"
|
width="75" height="75"
|
||||||
style="border-radius: 50%; display: block;" />
|
style="border-radius: 50%;"
|
||||||
</a>
|
alt="GitBook Logo" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://www.cloudflare.com">
|
||||||
|
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
|
||||||
|
height="60"
|
||||||
|
alt="Cloudflare Logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://m.do.co/c/0c27ef82475f">
|
||||||
|
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
|
||||||
|
height="50"
|
||||||
|
alt="DigitalOcean Logo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package dataretention
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"service-operation/pocketbase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles HTTP requests for data retention operations
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new data retention handler
|
||||||
|
func NewHandler(pbClient *pocketbase.PocketBaseClient) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
service: NewService(pbClient),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings handles GET requests for data retention settings
|
||||||
|
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := h.service.GetDataSettings()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerCleanup handles POST requests to trigger manual cleanup
|
||||||
|
func (h *Handler) TriggerCleanup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.service.CleanupOldRecords()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRoutes sets up the HTTP routes for data retention
|
||||||
|
func (h *Handler) SetupRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/data-retention/settings", h.GetSettings)
|
||||||
|
mux.HandleFunc("/data-retention/cleanup", h.TriggerCleanup)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package dataretention
|
||||||
|
|
||||||
|
import (
|
||||||
|
"service-operation/pocketbase"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scheduler handles periodic data retention cleanup
|
||||||
|
type Scheduler struct {
|
||||||
|
service *Service
|
||||||
|
interval time.Duration
|
||||||
|
stopChan chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduler creates a new cleanup scheduler
|
||||||
|
func NewScheduler(pbClient *pocketbase.PocketBaseClient, interval time.Duration) *Scheduler {
|
||||||
|
return &Scheduler{
|
||||||
|
service: NewService(pbClient),
|
||||||
|
interval: interval,
|
||||||
|
stopChan: make(chan bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the periodic cleanup process
|
||||||
|
func (s *Scheduler) Start() {
|
||||||
|
// log.Printf("Starting data retention scheduler with interval: %v", s.interval)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(s.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run initial cleanup
|
||||||
|
go func() {
|
||||||
|
if err := s.service.ScheduleCleanup(); err != nil {
|
||||||
|
// log.Printf("Initial cleanup failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
go func() {
|
||||||
|
if err := s.service.ScheduleCleanup(); err != nil {
|
||||||
|
// log.Printf("Scheduled cleanup failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case <-s.stopChan:
|
||||||
|
// log.Println("Data retention scheduler stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the scheduler
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
// log.Println("Stopping data retention scheduler...")
|
||||||
|
s.stopChan <- true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOnce executes cleanup immediately (for manual triggers)
|
||||||
|
func (s *Scheduler) RunOnce() (*CleanupSummary, error) {
|
||||||
|
// log.Println("Running manual data retention cleanup...")
|
||||||
|
return s.service.CleanupOldRecords()
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package dataretention
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"service-operation/pocketbase"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service handles data retention operations
|
||||||
|
type Service struct {
|
||||||
|
pbClient *pocketbase.PocketBaseClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new data retention service
|
||||||
|
func NewService(pbClient *pocketbase.PocketBaseClient) *Service {
|
||||||
|
return &Service{
|
||||||
|
pbClient: pbClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDataSettings fetches the current data retention settings
|
||||||
|
func (s *Service) GetDataSettings() (*DataSettings, error) {
|
||||||
|
resp, err := s.pbClient.GetHTTPClient().Get(
|
||||||
|
fmt.Sprintf("%s/api/collections/data_settings/records", s.pbClient.GetBaseURL()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch data settings: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to fetch data settings, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response DataSettingsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode data settings response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(response.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no data settings found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response.Items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldRecords performs cleanup of old records based on retention settings
|
||||||
|
func (s *Service) CleanupOldRecords() (*CleanupSummary, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
settings, err := s.GetDataSettings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get data settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cleanup should run (only once per day)
|
||||||
|
if !s.shouldRunCleanup(settings) {
|
||||||
|
// log.Println("Skipping cleanup - less than 24 hours since last run")
|
||||||
|
return &CleanupSummary{
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: time.Now(),
|
||||||
|
Duration: 0,
|
||||||
|
Results: make([]CleanupResult, 0),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := &CleanupSummary{
|
||||||
|
StartTime: startTime,
|
||||||
|
Results: make([]CleanupResult, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define collections and their corresponding retention days
|
||||||
|
uptimeCollections := []string{"dns_data", "ping_data", "tcp_data", "uptime_data", "services_metrics"}
|
||||||
|
serverCollections := []string{"server_metrics", "docker_metrics"}
|
||||||
|
|
||||||
|
// Process collections in parallel for better performance
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
resultsChan := make(chan CleanupResult, len(uptimeCollections)+len(serverCollections))
|
||||||
|
|
||||||
|
// Cleanup uptime-related collections in parallel
|
||||||
|
if settings.UptimeRetentionDays > 0 {
|
||||||
|
cutoffDate := time.Now().AddDate(0, 0, -settings.UptimeRetentionDays)
|
||||||
|
// log.Printf("Cleaning uptime collections with %d days retention (cutoff: %s)",
|
||||||
|
// settings.UptimeRetentionDays, cutoffDate.Format("2006-01-02 15:04:05"))
|
||||||
|
for _, collection := range uptimeCollections {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(col string) {
|
||||||
|
defer wg.Done()
|
||||||
|
result := s.cleanupCollection(col, cutoffDate)
|
||||||
|
resultsChan <- result
|
||||||
|
}(collection)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// log.Println("Skipping uptime collections cleanup - retention days is 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup server-related collections in parallel
|
||||||
|
if settings.ServerRetentionDays > 0 {
|
||||||
|
cutoffDate := time.Now().AddDate(0, 0, -settings.ServerRetentionDays)
|
||||||
|
// log.Printf("Cleaning server collections with %d days retention (cutoff: %s)",
|
||||||
|
// settings.ServerRetentionDays, cutoffDate.Format("2006-01-02 15:04:05"))
|
||||||
|
for _, collection := range serverCollections {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(col string) {
|
||||||
|
defer wg.Done()
|
||||||
|
result := s.cleanupCollection(col, cutoffDate)
|
||||||
|
resultsChan <- result
|
||||||
|
}(collection)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// log.Println("Skipping server collections cleanup - retention days is 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all collections to complete and collect results
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultsChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect all results
|
||||||
|
for result := range resultsChan {
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
summary.TotalDeleted += result.RecordsDeleted
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.EndTime = time.Now()
|
||||||
|
summary.Duration = summary.EndTime.Sub(summary.StartTime)
|
||||||
|
|
||||||
|
// Update last cleanup time after successful completion
|
||||||
|
if err := s.updateLastCleanupTime(); err != nil {
|
||||||
|
// log.Printf("Warning: Failed to update last cleanup time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Printf("Data retention cleanup completed. Total records deleted: %d, Duration: %v",
|
||||||
|
// summary.TotalDeleted, summary.Duration)
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupCollection deletes records older than the cutoff date from a specific collection
|
||||||
|
func (s *Service) cleanupCollection(collection string, cutoffDate time.Time) CleanupResult {
|
||||||
|
result := CleanupResult{
|
||||||
|
Collection: collection,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format cutoff date for PocketBase filter (RFC3339 format)
|
||||||
|
cutoffStr := cutoffDate.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
|
||||||
|
// Get records to delete (balanced batch size for performance vs rate limits)
|
||||||
|
batchSize := 50
|
||||||
|
totalDeleted := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Always fetch page 1 since we're deleting records (pagination shifts after deletes)
|
||||||
|
filter := fmt.Sprintf("created < '%s'", cutoffStr)
|
||||||
|
encodedFilter := url.QueryEscape(filter)
|
||||||
|
requestURL := fmt.Sprintf("%s/api/collections/%s/records?page=1&perPage=%d&filter=%s",
|
||||||
|
s.pbClient.GetBaseURL(), collection, batchSize, encodedFilter)
|
||||||
|
|
||||||
|
resp, err := s.pbClient.GetHTTPClient().Get(requestURL)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to fetch records: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check if response is successful
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
result.Error = fmt.Sprintf("API request failed with status %d for collection %s", resp.StatusCode, collection)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"items"`
|
||||||
|
TotalItems int `json:"totalItems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to decode response for collection %s: %v", collection, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(response.Items) == 0 {
|
||||||
|
// log.Printf("No more old records found in collection %s", collection)
|
||||||
|
break // No more records to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Printf("Found %d old records in collection %s (batch), total items in DB: %d",
|
||||||
|
// len(response.Items), collection, response.TotalItems)
|
||||||
|
|
||||||
|
// Delete records with conservative rate limiting to avoid 429 errors
|
||||||
|
semaphore := make(chan struct{}, 3) // Further reduced to 3 concurrent deletes
|
||||||
|
var deleteWg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
batchDeleted := 0
|
||||||
|
rateLimitHit := false
|
||||||
|
|
||||||
|
for _, record := range response.Items {
|
||||||
|
deleteWg.Add(1)
|
||||||
|
go func(recordID string) {
|
||||||
|
defer deleteWg.Done()
|
||||||
|
semaphore <- struct{}{} // Acquire
|
||||||
|
defer func() { <-semaphore }() // Release
|
||||||
|
|
||||||
|
deleted := false
|
||||||
|
retryCount := 0
|
||||||
|
maxRetries := 5
|
||||||
|
|
||||||
|
for !deleted && retryCount < maxRetries {
|
||||||
|
if err := s.deleteRecord(collection, recordID); err != nil {
|
||||||
|
if retryCount < maxRetries-1 {
|
||||||
|
// Progressive backoff with longer delays for rate limits
|
||||||
|
baseWait := time.Duration(1<<retryCount) * 500 * time.Millisecond
|
||||||
|
if retryCount >= 2 {
|
||||||
|
// Extra penalty for persistent rate limits
|
||||||
|
baseWait += time.Duration(retryCount) * 2 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
rateLimitHit = true
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(baseWait)
|
||||||
|
retryCount++
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// log.Printf("Failed to delete record %s from %s after %d retries: %v",
|
||||||
|
// recordID, collection, maxRetries, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleted = true
|
||||||
|
mu.Lock()
|
||||||
|
batchDeleted++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increased delay between deletions with adaptive timing
|
||||||
|
mu.Lock()
|
||||||
|
currentRateLimitHit := rateLimitHit
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if currentRateLimitHit {
|
||||||
|
time.Sleep(150 * time.Millisecond) // Longer delay if rate limits detected
|
||||||
|
} else {
|
||||||
|
time.Sleep(75 * time.Millisecond) // Standard delay
|
||||||
|
}
|
||||||
|
}(record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWg.Wait()
|
||||||
|
totalDeleted += batchDeleted
|
||||||
|
|
||||||
|
// If we got fewer records than batch size, we're done
|
||||||
|
if len(response.Items) < batchSize {
|
||||||
|
// log.Printf("Finished processing collection %s - no more old records", collection)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// More conservative delay between batches with adaptive timing
|
||||||
|
if len(response.Items) > 0 {
|
||||||
|
mu.Lock()
|
||||||
|
currentRateLimitHit := rateLimitHit
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
baseDelay := 300 * time.Millisecond
|
||||||
|
if currentRateLimitHit {
|
||||||
|
baseDelay = 1 * time.Second // Much longer delay if rate limits were hit
|
||||||
|
// log.Printf("Processed batch for collection %s (%d deleted), rate limits detected - waiting longer before next batch...", collection, batchDeleted)
|
||||||
|
} else {
|
||||||
|
// log.Printf("Processed batch for collection %s (%d deleted), processing next batch...", collection, batchDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(baseDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.RecordsDeleted = totalDeleted
|
||||||
|
|
||||||
|
if totalDeleted > 0 {
|
||||||
|
// log.Printf("Deleted %d records from collection %s", totalDeleted, collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteRecord deletes a single record from a collection
|
||||||
|
func (s *Service) deleteRecord(collection, recordID string) error {
|
||||||
|
requestURL := fmt.Sprintf("%s/api/collections/%s/records/%s", s.pbClient.GetBaseURL(), collection, recordID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("DELETE", requestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.pbClient.GetHTTPClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to delete record, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleCleanup runs cleanup based on schedule (can be called periodically)
|
||||||
|
func (s *Service) ScheduleCleanup() error {
|
||||||
|
// log.Println("Starting scheduled data retention cleanup...")
|
||||||
|
|
||||||
|
_, err := s.CleanupOldRecords()
|
||||||
|
if err != nil {
|
||||||
|
// log.Printf("Scheduled cleanup failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Printf("Scheduled cleanup completed successfully. Summary: %+v", summary)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldRunCleanup checks if cleanup should run based on last cleanup time (once per day)
|
||||||
|
func (s *Service) shouldRunCleanup(settings *DataSettings) bool {
|
||||||
|
if settings.LastCleanup == "" {
|
||||||
|
// log.Println("No previous cleanup found, proceeding with cleanup...")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCleanup, err := time.Parse(time.RFC3339, settings.LastCleanup)
|
||||||
|
if err != nil {
|
||||||
|
// log.Printf("Failed to parse last cleanup time '%s', proceeding with cleanup: %v", settings.LastCleanup, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
timeSinceLastCleanup := time.Since(lastCleanup)
|
||||||
|
minInterval := 24 * time.Hour
|
||||||
|
|
||||||
|
if timeSinceLastCleanup < minInterval {
|
||||||
|
// log.Printf("Last cleanup was %v ago (less than 24h), skipping cleanup", timeSinceLastCleanup)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Printf("Last cleanup was %v ago, proceeding with cleanup", timeSinceLastCleanup)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLastCleanupTime updates the last cleanup timestamp in data settings
|
||||||
|
func (s *Service) updateLastCleanupTime() error {
|
||||||
|
settings, err := s.GetDataSettings()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get data settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last cleanup time
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
// Create the update payload
|
||||||
|
updateData := map[string]interface{}{
|
||||||
|
"last_cleanup": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(updateData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal update data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the record via PocketBase API
|
||||||
|
requestURL := fmt.Sprintf("%s/api/collections/data_settings/records/%s", s.pbClient.GetBaseURL(), settings.ID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("PATCH", requestURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create update request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.pbClient.GetHTTPClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update last cleanup time: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to update last cleanup time, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Printf("Updated last cleanup time to: %s", now)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package dataretention
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DataSettings represents the data retention configuration from PocketBase
|
||||||
|
type DataSettings struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CollectionId string `json:"collectionId"`
|
||||||
|
CollectionName string `json:"collectionName"`
|
||||||
|
RetentionDays int `json:"retention_days"`
|
||||||
|
ServerRetentionDays int `json:"server_retention_days"`
|
||||||
|
UptimeRetentionDays int `json:"uptime_retention_days"`
|
||||||
|
LastCleanup string `json:"last_cleanup"`
|
||||||
|
Backup string `json:"backup"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Updated string `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataSettingsResponse represents the response from PocketBase
|
||||||
|
type DataSettingsResponse struct {
|
||||||
|
Items []DataSettings `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"perPage"`
|
||||||
|
TotalItems int `json:"totalItems"`
|
||||||
|
TotalPages int `json:"totalPages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupResult represents the result of a cleanup operation
|
||||||
|
type CleanupResult struct {
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
RecordsDeleted int `json:"records_deleted"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupSummary represents the overall cleanup summary
|
||||||
|
type CleanupSummary struct {
|
||||||
|
TotalDeleted int `json:"total_deleted"`
|
||||||
|
Results []CleanupResult `json:"results"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"service-operation/config"
|
"service-operation/config"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
servermonitoring "service-operation/server-monitoring"
|
servermonitoring "service-operation/server-monitoring"
|
||||||
sslmonitoring "service-operation/ssl-monitoring"
|
sslmonitoring "service-operation/ssl-monitoring"
|
||||||
uptimemonitoring "service-operation/uptime-monitoring"
|
uptimemonitoring "service-operation/uptime-monitoring"
|
||||||
|
dataretention "service-operation/data-retention"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -35,6 +37,7 @@ func main() {
|
|||||||
var sslNotificationService *sslmonitoring.SSLMonitor
|
var sslNotificationService *sslmonitoring.SSLMonitor
|
||||||
var serverMonitoringService *servermonitoring.ServerMonitoringService
|
var serverMonitoringService *servermonitoring.ServerMonitoringService
|
||||||
var uptimeMonitoringService *uptimemonitoring.UptimeMonitor
|
var uptimeMonitoringService *uptimemonitoring.UptimeMonitor
|
||||||
|
var dataRetentionScheduler *dataretention.Scheduler
|
||||||
|
|
||||||
if cfg.PocketBaseEnabled {
|
if cfg.PocketBaseEnabled {
|
||||||
//log.Println("🔧 Initializing PocketBase client...")
|
//log.Println("🔧 Initializing PocketBase client...")
|
||||||
@@ -80,6 +83,12 @@ func main() {
|
|||||||
uptimeMonitoringService = uptimemonitoring.NewUptimeMonitor(pbClient)
|
uptimeMonitoringService = uptimemonitoring.NewUptimeMonitor(pbClient)
|
||||||
go uptimeMonitoringService.Start()
|
go uptimeMonitoringService.Start()
|
||||||
//log.Println("✅ Uptime monitoring started with notification support")
|
//log.Println("✅ Uptime monitoring started with notification support")
|
||||||
|
|
||||||
|
// Initialize and start data retention scheduler
|
||||||
|
//log.Println("🔧 Initializing data retention scheduler...")
|
||||||
|
dataRetentionScheduler = dataretention.NewScheduler(pbClient, 24*time.Hour) // Run daily
|
||||||
|
go dataRetentionScheduler.Start()
|
||||||
|
//log.Println("✅ Data retention scheduler started (daily cleanup)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +132,9 @@ func main() {
|
|||||||
log.Printf("✓Uptime monitoring enabled with notification support")
|
log.Printf("✓Uptime monitoring enabled with notification support")
|
||||||
}
|
}
|
||||||
log.Printf("✓Supported operations: ping, dns, tcp, http, ssl")
|
log.Printf("✓Supported operations: ping, dns, tcp, http, ssl")
|
||||||
|
if dataRetentionScheduler != nil {
|
||||||
|
log.Printf("✓Data retention scheduler enabled (daily cleanup)")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Setup graceful shutdown
|
// Setup graceful shutdown
|
||||||
@@ -154,6 +166,10 @@ func main() {
|
|||||||
log.Println("🛑 Stopping uptime monitoring...")
|
log.Println("🛑 Stopping uptime monitoring...")
|
||||||
uptimeMonitoringService.Stop()
|
uptimeMonitoringService.Stop()
|
||||||
}
|
}
|
||||||
|
if dataRetentionScheduler != nil {
|
||||||
|
log.Println("🛑 Stopping data retention scheduler...")
|
||||||
|
dataRetentionScheduler.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("✅ All services stopped gracefully")
|
log.Println("✅ All services stopped gracefully")
|
||||||
log.Println("🛑 === SERVICE OPERATION SERVER STOPPED ===")
|
log.Println("🛑 === SERVICE OPERATION SERVER STOPPED ===")
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GotifyService handles Gotify notifications
|
||||||
|
type GotifyService struct{}
|
||||||
|
|
||||||
|
// NewGotifyService creates a new Gotify notification service
|
||||||
|
func NewGotifyService() *GotifyService {
|
||||||
|
return &GotifyService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotifyPayload represents the payload for Gotify API
|
||||||
|
type GotifyPayload struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotifyResponse represents the response from Gotify API
|
||||||
|
type GotifyResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
AppID int `json:"appid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotification sends a notification via Gotify
|
||||||
|
func (gs *GotifyService) SendNotification(config *AlertConfiguration, message string) error {
|
||||||
|
// fmt.Printf("📱 [GOTIFY] Attempting to send notification...\n")
|
||||||
|
// fmt.Printf("📱 [GOTIFY] Config - API Token: %s, Server URL: %s, Notify Name: %s\n",
|
||||||
|
// maskToken(config.APIToken), config.ServerURL, config.NotifyName)
|
||||||
|
// fmt.Printf("📱 [GOTIFY] Message: %s\n", message)
|
||||||
|
|
||||||
|
if config.APIToken == "" || config.ServerURL == "" {
|
||||||
|
return fmt.Errorf("Gotify API token and server URL are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the payload
|
||||||
|
payload := GotifyPayload{
|
||||||
|
Message: message,
|
||||||
|
Title: config.NotifyName, // Use notify_name as the title
|
||||||
|
Priority: 5, // Default priority
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
// fmt.Printf("❌ [GOTIFY] JSON marshal error: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("📱 [GOTIFY] Payload: %s\n", string(jsonData))
|
||||||
|
// fmt.Printf("📱 [GOTIFY] Sending POST request to Gotify API...\n")
|
||||||
|
|
||||||
|
// Construct the Gotify API URL
|
||||||
|
apiURL := strings.TrimRight(config.ServerURL, "/") + "/message?token=" + config.APIToken
|
||||||
|
|
||||||
|
// Send the request to Gotify API
|
||||||
|
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
// fmt.Printf("❌ [GOTIFY] HTTP error: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
// fmt.Printf("❌ [GOTIFY] HTTP status error: %d\n", resp.StatusCode)
|
||||||
|
return fmt.Errorf("Gotify API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
var gotifyResp GotifyResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&gotifyResp); err != nil {
|
||||||
|
// fmt.Printf("❌ [GOTIFY] Response decode error: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("📱 [GOTIFY] API response: %+v\n", gotifyResp)
|
||||||
|
|
||||||
|
// Check if the message was created successfully
|
||||||
|
if gotifyResp.ID == 0 {
|
||||||
|
// fmt.Printf("❌ [GOTIFY] API error: No message ID returned\n")
|
||||||
|
return fmt.Errorf("Gotify API error: message not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("✅ [GOTIFY] Message sent successfully! ID: %d\n", gotifyResp.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendServerNotification sends a server-specific notification via Gotify
|
||||||
|
func (gs *GotifyService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
|
||||||
|
message := gs.generateServerMessage(payload, template, resourceType)
|
||||||
|
return gs.SendNotification(config, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendServiceNotification sends a service-specific notification via Gotify
|
||||||
|
func (gs *GotifyService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
|
||||||
|
message := gs.generateServiceMessage(payload, template)
|
||||||
|
return gs.SendNotification(config, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateServerMessage creates a message for server notifications using server template
|
||||||
|
func (gs *GotifyService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
|
||||||
|
var templateMessage string
|
||||||
|
|
||||||
|
// Select appropriate template message based on status and resource type
|
||||||
|
switch strings.ToLower(payload.Status) {
|
||||||
|
case "down":
|
||||||
|
templateMessage = template.DownMessage
|
||||||
|
case "up":
|
||||||
|
templateMessage = template.UpMessage
|
||||||
|
case "warning":
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
case "paused":
|
||||||
|
templateMessage = template.PausedMessage
|
||||||
|
default:
|
||||||
|
// Handle resource-specific messages
|
||||||
|
switch resourceType {
|
||||||
|
case "cpu":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreCPUMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.CPUMessage
|
||||||
|
}
|
||||||
|
case "ram", "memory":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreRAMMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.RAMMessage
|
||||||
|
}
|
||||||
|
case "disk":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreDiskMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.DiskMessage
|
||||||
|
}
|
||||||
|
case "network":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreNetworkMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.NetworkMessage
|
||||||
|
}
|
||||||
|
case "cpu_temp", "cpu_temperature":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreCPUTempMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.CPUTempMessage
|
||||||
|
}
|
||||||
|
case "disk_io":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreDiskIOMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.DiskIOMessage
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no template message found, use a default
|
||||||
|
if templateMessage == "" {
|
||||||
|
templateMessage = gs.generateDefaultServerMessage(payload, resourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gs.replacePlaceholders(templateMessage, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateServiceMessage creates a message for service notifications using service template
|
||||||
|
func (gs *GotifyService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
|
||||||
|
var templateMessage string
|
||||||
|
|
||||||
|
// Select appropriate template message based on status
|
||||||
|
switch strings.ToLower(payload.Status) {
|
||||||
|
case "up":
|
||||||
|
templateMessage = template.UpMessage
|
||||||
|
case "down":
|
||||||
|
templateMessage = template.DownMessage
|
||||||
|
case "maintenance":
|
||||||
|
templateMessage = template.MaintenanceMessage
|
||||||
|
case "incident":
|
||||||
|
templateMessage = template.IncidentMessage
|
||||||
|
case "resolved":
|
||||||
|
templateMessage = template.ResolvedMessage
|
||||||
|
case "warning":
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
default:
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no template message found, use a default
|
||||||
|
if templateMessage == "" {
|
||||||
|
templateMessage = gs.generateDefaultUptimeMessage(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gs.replacePlaceholders(templateMessage, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replacePlaceholders replaces all placeholders in the message with actual values
|
||||||
|
func (gs *GotifyService) replacePlaceholders(message string, payload *NotificationPayload) string {
|
||||||
|
// Replace basic placeholders
|
||||||
|
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
|
||||||
|
message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name
|
||||||
|
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
|
||||||
|
message = strings.ReplaceAll(message, "${host}", gs.safeString(payload.Host))
|
||||||
|
message = strings.ReplaceAll(message, "${hostname}", gs.safeString(payload.Hostname))
|
||||||
|
|
||||||
|
// Replace URL with fallback to host
|
||||||
|
url := gs.safeString(payload.URL)
|
||||||
|
if url == "N/A" && payload.Host != "" {
|
||||||
|
url = payload.Host
|
||||||
|
}
|
||||||
|
message = strings.ReplaceAll(message, "${url}", url)
|
||||||
|
|
||||||
|
// Replace domain
|
||||||
|
message = strings.ReplaceAll(message, "${domain}", gs.safeString(payload.Domain))
|
||||||
|
|
||||||
|
// Replace service type
|
||||||
|
if payload.ServiceType != "" {
|
||||||
|
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${service_type}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace region and agent info
|
||||||
|
message = strings.ReplaceAll(message, "${region_name}", gs.safeString(payload.RegionName))
|
||||||
|
message = strings.ReplaceAll(message, "${agent_id}", gs.safeString(payload.AgentID))
|
||||||
|
|
||||||
|
// Handle numeric fields safely
|
||||||
|
if payload.Port > 0 {
|
||||||
|
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${port}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.ResponseTime > 0 {
|
||||||
|
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${response_time}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Uptime > 0 {
|
||||||
|
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${uptime}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace server monitoring fields
|
||||||
|
message = strings.ReplaceAll(message, "${cpu_usage}", gs.safeString(payload.CPUUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${ram_usage}", gs.safeString(payload.RAMUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${disk_usage}", gs.safeString(payload.DiskUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${network_usage}", gs.safeString(payload.NetworkUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${cpu_temp}", gs.safeString(payload.CPUTemp))
|
||||||
|
message = strings.ReplaceAll(message, "${disk_io}", gs.safeString(payload.DiskIO))
|
||||||
|
message = strings.ReplaceAll(message, "${threshold}", gs.safeString(payload.Threshold))
|
||||||
|
|
||||||
|
// Replace SSL certificate fields
|
||||||
|
message = strings.ReplaceAll(message, "${certificate_name}", gs.safeString(payload.CertificateName))
|
||||||
|
message = strings.ReplaceAll(message, "${expiry_date}", gs.safeString(payload.ExpiryDate))
|
||||||
|
message = strings.ReplaceAll(message, "${days_left}", gs.safeString(payload.DaysLeft))
|
||||||
|
message = strings.ReplaceAll(message, "${issuer_cn}", gs.safeString(payload.IssuerCN))
|
||||||
|
message = strings.ReplaceAll(message, "${issuer}", gs.safeString(payload.IssuerCN)) // alias
|
||||||
|
|
||||||
|
// Replace error message - important for uptime services
|
||||||
|
message = strings.ReplaceAll(message, "${error_message}", gs.safeString(payload.ErrorMessage))
|
||||||
|
message = strings.ReplaceAll(message, "${error}", gs.safeString(payload.ErrorMessage))
|
||||||
|
message = strings.ReplaceAll(message, "${message}", gs.safeString(payload.Message))
|
||||||
|
|
||||||
|
// Replace time placeholders
|
||||||
|
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
|
||||||
|
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeString returns the string value or "N/A" if empty
|
||||||
|
func (gs *GotifyService) safeString(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
|
||||||
|
func (gs *GotifyService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
|
||||||
|
message := fmt.Sprintf("Service %s is %s.", payload.ServiceName, strings.ToUpper(payload.Status))
|
||||||
|
|
||||||
|
// Build formatted details
|
||||||
|
details := []string{}
|
||||||
|
|
||||||
|
// Add URL or host
|
||||||
|
if payload.URL != "" {
|
||||||
|
details = append(details, fmt.Sprintf("Host URL: %s", payload.URL))
|
||||||
|
} else if payload.Host != "" {
|
||||||
|
details = append(details, fmt.Sprintf("Host: %s", payload.Host))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add service type
|
||||||
|
if payload.ServiceType != "" {
|
||||||
|
details = append(details, fmt.Sprintf("Type: %s", strings.ToUpper(payload.ServiceType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add port if available
|
||||||
|
if payload.Port > 0 {
|
||||||
|
details = append(details, fmt.Sprintf("Port: %d", payload.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain if available
|
||||||
|
if payload.Domain != "" {
|
||||||
|
details = append(details, fmt.Sprintf("Domain: %s", payload.Domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add response time
|
||||||
|
if payload.ResponseTime > 0 {
|
||||||
|
details = append(details, fmt.Sprintf("Response time: %dms", payload.ResponseTime))
|
||||||
|
} else {
|
||||||
|
details = append(details, "Response time: N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add region info
|
||||||
|
if payload.RegionName != "" {
|
||||||
|
details = append(details, fmt.Sprintf("Region: %s", payload.RegionName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add agent info
|
||||||
|
if payload.AgentID != "" {
|
||||||
|
details = append(details, fmt.Sprintf("Agent: %s", payload.AgentID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add uptime if available
|
||||||
|
if payload.Uptime > 0 {
|
||||||
|
details = append(details, fmt.Sprintf("Uptime: %d%%", payload.Uptime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
details = append(details, fmt.Sprintf("Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
|
||||||
|
|
||||||
|
// Combine message with details
|
||||||
|
if len(details) > 0 {
|
||||||
|
message += "\n" + strings.Join(details, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDefaultServerMessage creates a default server message
|
||||||
|
func (gs *GotifyService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
|
||||||
|
return fmt.Sprintf("Server %s (%s) status: %s", payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ func NewNotificationManager(pbClient *pocketbase.PocketBaseClient) *Notification
|
|||||||
services["webhook"] = NewWebhookService()
|
services["webhook"] = NewWebhookService()
|
||||||
services["ntfy"] = NewNtfyService()
|
services["ntfy"] = NewNtfyService()
|
||||||
services["pushover"] = NewPushoverService()
|
services["pushover"] = NewPushoverService()
|
||||||
|
services["notifiarr"] = NewNotifiarrService()
|
||||||
|
services["gotify"] = NewGotifyService()
|
||||||
|
|
||||||
// log.Printf("✅ Notification services initialized: %v", getKeys(services))
|
// log.Printf("✅ Notification services initialized: %v", getKeys(services))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifiarrService handles Notifiarr notifications
|
||||||
|
type NotifiarrService struct{}
|
||||||
|
|
||||||
|
// NewNotifiarrService creates a new Notifiarr notification service
|
||||||
|
func NewNotifiarrService() *NotifiarrService {
|
||||||
|
return &NotifiarrService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifiarrPayload represents the payload for Notifiarr API
|
||||||
|
type NotifiarrPayload struct {
|
||||||
|
Notification NotifiarrNotification `json:"notification"`
|
||||||
|
Discord NotifiarrDiscord `json:"discord"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifiarrNotification struct {
|
||||||
|
Update bool `json:"update"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifiarrDiscord struct {
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
Ping NotifiarrPing `json:"ping,omitempty"`
|
||||||
|
Images NotifiarrImages `json:"images,omitempty"`
|
||||||
|
Text NotifiarrText `json:"text"`
|
||||||
|
IDs NotifiarrIDs `json:"ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifiarrPing struct {
|
||||||
|
PingUser int64 `json:"pingUser,omitempty"`
|
||||||
|
PingRole int64 `json:"pingRole,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifiarrImages struct {
|
||||||
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifiarrText struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Fields []any `json:"fields,omitempty"`
|
||||||
|
Footer string `json:"footer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifiarrIDs struct {
|
||||||
|
Channel int64 `json:"channel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotification sends a notification via Notifiarr
|
||||||
|
func (ns *NotifiarrService) SendNotification(config *AlertConfiguration, message string) error {
|
||||||
|
// fmt.Printf("📡 [NOTIFIARR] Attempting to send notification...\n")
|
||||||
|
// fmt.Printf("📡 [NOTIFIARR] Config - API Token present: %v\n", config.APIToken != "")
|
||||||
|
// fmt.Printf("📡 [NOTIFIARR] Message: %s\n", message)
|
||||||
|
|
||||||
|
if config.APIToken == "" {
|
||||||
|
return fmt.Errorf("notifiarr API token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse channel ID if provided
|
||||||
|
var channelID int64
|
||||||
|
if config.ChannelID != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(config.ChannelID, 10, 64); err == nil {
|
||||||
|
channelID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://notifiarr.com/api/v1/notification/passthrough/%s", config.APIToken)
|
||||||
|
// fmt.Printf("📡 [NOTIFIARR] API URL: %s\n", strings.Replace(url, config.APIToken, "[REDACTED]", 1))
|
||||||
|
|
||||||
|
// Create status-based color
|
||||||
|
color := ns.getStatusColor(message)
|
||||||
|
|
||||||
|
payload := NotifiarrPayload{
|
||||||
|
Notification: NotifiarrNotification{
|
||||||
|
Update: false,
|
||||||
|
Name: "This is an automated notification from CheckCle System",
|
||||||
|
Event: "",
|
||||||
|
},
|
||||||
|
Discord: NotifiarrDiscord{
|
||||||
|
Color: color,
|
||||||
|
Text: NotifiarrText{
|
||||||
|
Title: "Service Alert",
|
||||||
|
Description: message,
|
||||||
|
Footer: "CheckCle Monitoring System",
|
||||||
|
},
|
||||||
|
IDs: NotifiarrIDs{
|
||||||
|
Channel: channelID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("📡 [NOTIFIARR] Sending POST request...\n")
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
// fmt.Printf("❌ [NOTIFIARR] HTTP error: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check response status
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// fmt.Printf("❌ [NOTIFIARR] HTTP status error: %d\n", resp.StatusCode)
|
||||||
|
return fmt.Errorf("notifiarr API error: status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("✅ [NOTIFIARR] Message sent successfully!\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendServerNotification sends a server-specific notification via Notifiarr
|
||||||
|
func (ns *NotifiarrService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
|
||||||
|
message := ns.generateServerMessage(payload, template, resourceType)
|
||||||
|
return ns.SendNotification(config, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendServiceNotification sends a service-specific notification via Notifiarr
|
||||||
|
func (ns *NotifiarrService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
|
||||||
|
message := ns.generateServiceMessage(payload, template)
|
||||||
|
return ns.SendNotification(config, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateServerMessage creates a message for server notifications using server template
|
||||||
|
func (ns *NotifiarrService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
|
||||||
|
var templateMessage string
|
||||||
|
|
||||||
|
// Select appropriate template message based on status and resource type
|
||||||
|
switch strings.ToLower(payload.Status) {
|
||||||
|
case "down":
|
||||||
|
templateMessage = template.DownMessage
|
||||||
|
case "up":
|
||||||
|
templateMessage = template.UpMessage
|
||||||
|
case "warning":
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
case "paused":
|
||||||
|
templateMessage = template.PausedMessage
|
||||||
|
default:
|
||||||
|
// Handle resource-specific messages
|
||||||
|
switch resourceType {
|
||||||
|
case "cpu":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreCPUMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.CPUMessage
|
||||||
|
}
|
||||||
|
case "ram", "memory":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreRAMMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.RAMMessage
|
||||||
|
}
|
||||||
|
case "disk":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreDiskMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.DiskMessage
|
||||||
|
}
|
||||||
|
case "network":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreNetworkMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.NetworkMessage
|
||||||
|
}
|
||||||
|
case "cpu_temp", "cpu_temperature":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreCPUTempMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.CPUTempMessage
|
||||||
|
}
|
||||||
|
case "disk_io":
|
||||||
|
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||||
|
templateMessage = template.RestoreDiskIOMessage
|
||||||
|
} else {
|
||||||
|
templateMessage = template.DiskIOMessage
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no template message found, use a default
|
||||||
|
if templateMessage == "" {
|
||||||
|
templateMessage = ns.generateDefaultServerMessage(payload, resourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ns.replacePlaceholders(templateMessage, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateServiceMessage creates a message for service notifications using service template
|
||||||
|
func (ns *NotifiarrService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
|
||||||
|
var templateMessage string
|
||||||
|
|
||||||
|
// Select appropriate template message based on status
|
||||||
|
switch strings.ToLower(payload.Status) {
|
||||||
|
case "up":
|
||||||
|
templateMessage = template.UpMessage
|
||||||
|
case "down":
|
||||||
|
templateMessage = template.DownMessage
|
||||||
|
case "maintenance":
|
||||||
|
templateMessage = template.MaintenanceMessage
|
||||||
|
case "incident":
|
||||||
|
templateMessage = template.IncidentMessage
|
||||||
|
case "resolved":
|
||||||
|
templateMessage = template.ResolvedMessage
|
||||||
|
case "warning":
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
default:
|
||||||
|
templateMessage = template.WarningMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no template message found, use a default
|
||||||
|
if templateMessage == "" {
|
||||||
|
templateMessage = ns.generateDefaultUptimeMessage(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ns.replacePlaceholders(templateMessage, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replacePlaceholders replaces all placeholders in the message with actual values
|
||||||
|
func (ns *NotifiarrService) replacePlaceholders(message string, payload *NotificationPayload) string {
|
||||||
|
// Replace basic placeholders
|
||||||
|
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
|
||||||
|
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
|
||||||
|
message = strings.ReplaceAll(message, "${host}", ns.safeString(payload.Host))
|
||||||
|
message = strings.ReplaceAll(message, "${hostname}", ns.safeString(payload.Hostname))
|
||||||
|
|
||||||
|
// Replace URL with fallback to host
|
||||||
|
url := ns.safeString(payload.URL)
|
||||||
|
if url == "N/A" && payload.Host != "" {
|
||||||
|
url = payload.Host
|
||||||
|
}
|
||||||
|
message = strings.ReplaceAll(message, "${url}", url)
|
||||||
|
|
||||||
|
// Replace domain
|
||||||
|
message = strings.ReplaceAll(message, "${domain}", ns.safeString(payload.Domain))
|
||||||
|
|
||||||
|
// Replace service type
|
||||||
|
if payload.ServiceType != "" {
|
||||||
|
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${service_type}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace region and agent info
|
||||||
|
message = strings.ReplaceAll(message, "${region_name}", ns.safeString(payload.RegionName))
|
||||||
|
message = strings.ReplaceAll(message, "${agent_id}", ns.safeString(payload.AgentID))
|
||||||
|
|
||||||
|
// Handle numeric fields safely
|
||||||
|
if payload.Port > 0 {
|
||||||
|
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${port}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.ResponseTime > 0 {
|
||||||
|
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${response_time}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Uptime > 0 {
|
||||||
|
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
|
||||||
|
} else {
|
||||||
|
message = strings.ReplaceAll(message, "${uptime}", "N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace server monitoring fields
|
||||||
|
message = strings.ReplaceAll(message, "${cpu_usage}", ns.safeString(payload.CPUUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${ram_usage}", ns.safeString(payload.RAMUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${disk_usage}", ns.safeString(payload.DiskUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${network_usage}", ns.safeString(payload.NetworkUsage))
|
||||||
|
message = strings.ReplaceAll(message, "${cpu_temp}", ns.safeString(payload.CPUTemp))
|
||||||
|
message = strings.ReplaceAll(message, "${disk_io}", ns.safeString(payload.DiskIO))
|
||||||
|
message = strings.ReplaceAll(message, "${threshold}", ns.safeString(payload.Threshold))
|
||||||
|
|
||||||
|
// Replace error message - important for uptime services
|
||||||
|
message = strings.ReplaceAll(message, "${error_message}", ns.safeString(payload.ErrorMessage))
|
||||||
|
message = strings.ReplaceAll(message, "${error}", ns.safeString(payload.ErrorMessage))
|
||||||
|
|
||||||
|
// Replace time placeholders
|
||||||
|
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
|
||||||
|
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeString returns the string value or "N/A" if empty
|
||||||
|
func (ns *NotifiarrService) safeString(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStatusColor returns appropriate color for status
|
||||||
|
func (ns *NotifiarrService) getStatusColor(message string) string {
|
||||||
|
messageLower := strings.ToLower(message)
|
||||||
|
|
||||||
|
if strings.Contains(messageLower, "down") || strings.Contains(messageLower, "error") || strings.Contains(messageLower, "failed") {
|
||||||
|
return "FF0000" // Red
|
||||||
|
} else if strings.Contains(messageLower, "up") || strings.Contains(messageLower, "restored") || strings.Contains(messageLower, "resolved") {
|
||||||
|
return "00FF00" // Green
|
||||||
|
} else if strings.Contains(messageLower, "warning") || strings.Contains(messageLower, "alert") {
|
||||||
|
return "FFA500" // Orange
|
||||||
|
}
|
||||||
|
|
||||||
|
return "0099FF" // Blue (default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
|
||||||
|
func (ns *NotifiarrService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
|
||||||
|
// Status emoji mapping
|
||||||
|
statusEmoji := "🔵"
|
||||||
|
switch strings.ToLower(payload.Status) {
|
||||||
|
case "up":
|
||||||
|
statusEmoji = "🟢"
|
||||||
|
case "down":
|
||||||
|
statusEmoji = "🔴"
|
||||||
|
case "warning":
|
||||||
|
statusEmoji = "🟡"
|
||||||
|
case "maintenance", "paused":
|
||||||
|
statusEmoji = "🟠"
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status))
|
||||||
|
|
||||||
|
// Build formatted details
|
||||||
|
details := []string{}
|
||||||
|
|
||||||
|
// Add URL or host
|
||||||
|
if payload.URL != "" {
|
||||||
|
details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL))
|
||||||
|
} else if payload.Host != "" {
|
||||||
|
details = append(details, fmt.Sprintf(" - Host: %s", payload.Host))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add service type
|
||||||
|
if payload.ServiceType != "" {
|
||||||
|
details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add port if available
|
||||||
|
if payload.Port > 0 {
|
||||||
|
details = append(details, fmt.Sprintf(" - Port: %d", payload.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain if available
|
||||||
|
if payload.Domain != "" {
|
||||||
|
details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add response time
|
||||||
|
if payload.ResponseTime > 0 {
|
||||||
|
details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime))
|
||||||
|
} else {
|
||||||
|
details = append(details, " - Response time: N/A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add region info
|
||||||
|
if payload.RegionName != "" {
|
||||||
|
details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add agent info
|
||||||
|
if payload.AgentID != "" {
|
||||||
|
details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add uptime if available
|
||||||
|
if payload.Uptime > 0 {
|
||||||
|
details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
|
||||||
|
|
||||||
|
// Combine message with details
|
||||||
|
if len(details) > 0 {
|
||||||
|
message += "\n" + strings.Join(details, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDefaultServerMessage creates a default server message
|
||||||
|
func (ns *NotifiarrService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
|
||||||
|
statusEmoji := "🔵"
|
||||||
|
switch strings.ToLower(payload.Status) {
|
||||||
|
case "up":
|
||||||
|
statusEmoji = "🟢"
|
||||||
|
case "down":
|
||||||
|
statusEmoji = "🔴"
|
||||||
|
case "warning":
|
||||||
|
statusEmoji = "🟡"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s 🖥️ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ func (ns *NtfyService) SendNotification(config *AlertConfiguration, message stri
|
|||||||
req.Header.Set("Tags", "monitoring")
|
req.Header.Set("Tags", "monitoring")
|
||||||
req.Header.Set("Priority", "default")
|
req.Header.Set("Priority", "default")
|
||||||
|
|
||||||
|
// Add API token authentication if provided
|
||||||
|
if config.APIToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||||
|
}
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -112,6 +117,11 @@ func (ns *NtfyService) SendNotificationWithDetails(config *AlertConfiguration, m
|
|||||||
req.Header.Set("Tags", tags)
|
req.Header.Set("Tags", tags)
|
||||||
req.Header.Set("Priority", priority)
|
req.Header.Set("Priority", priority)
|
||||||
|
|
||||||
|
// Add API token authentication if provided
|
||||||
|
if config.APIToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+config.APIToken)
|
||||||
|
}
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type AlertConfiguration struct {
|
|||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
SlackWebhookURL string `json:"slack_webhook_url"`
|
SlackWebhookURL string `json:"slack_webhook_url"`
|
||||||
GoogleChatWebhookURL string `json:"google_chat_webhook_url"`
|
GoogleChatWebhookURL string `json:"google_chat_webhook_url"`
|
||||||
Enabled string `json:"enabled"` // String because PocketBase returns it as string
|
Enabled string `json:"enabled"`
|
||||||
EmailAddress string `json:"email_address"`
|
EmailAddress string `json:"email_address"`
|
||||||
EmailSenderName string `json:"email_sender_name"`
|
EmailSenderName string `json:"email_sender_name"`
|
||||||
SMTPServer string `json:"smtp_server"`
|
SMTPServer string `json:"smtp_server"`
|
||||||
@@ -66,6 +66,7 @@ type AlertConfiguration struct {
|
|||||||
NtfyEndpoint string `json:"ntfy_endpoint"`
|
NtfyEndpoint string `json:"ntfy_endpoint"`
|
||||||
APIToken string `json:"api_token"`
|
APIToken string `json:"api_token"`
|
||||||
UserKey string `json:"user_key"`
|
UserKey string `json:"user_key"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerNotificationTemplate represents a server notification template
|
// ServerNotificationTemplate represents a server notification template
|
||||||
|
|||||||
Reference in New Issue
Block a user